ModernUO/Projects/Server/World/World.cs
Kamron Batman 2935eafe24
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.
2026-08-22 19:34:43 -07:00

591 lines
20 KiB
C#

/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: World.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Runtime.CompilerServices;
using System.Threading;
using Server.Guilds;
using Server.Logging;
using Server.Network;
namespace Server;
public enum WorldState
{
Initial,
Loading,
Running,
PendingSave,
Saving,
WritingSave
}
public static class World
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(World));
private static readonly ItemPersistence _itemPersistence = new();
private static readonly MobilePersistence _mobilePersistence = new();
private static readonly GenericEntityPersistence<BaseGuild> _guildPersistence = new("Guilds", 3, 1, 0x7FFFFFFF);
// All workers including the main thread's inline worker (last index), for heap lookups.
internal static SerializationThreadWorker[] _threadWorkers;
// How many of those own a thread (wake/sleep/exit applies only to these).
private static int _realWorkerCount;
private static readonly SerializationChunkSource _chunkSource = new();
private static readonly ManualResetEvent _diskWriteHandle = new(true);
private static string _tempSavePath; // Path to the temporary folder for the save
public const bool DirtyTrackingEnabled = false;
public const uint ItemOffset = 0x40000000;
public const uint MaxItemSerial = 0x7EEEEEEE;
public const uint MaxMobileSerial = ItemOffset - 1;
public const uint ResetVirtualSerial = MaxItemSerial;
public const uint MaxVirtualSerial = 0x7FFFFFFF;
private static uint _nextVirtualSerial = ResetVirtualSerial;
public static Serial NewMobile => _mobilePersistence.NewEntity;
public static Serial NewItem => _itemPersistence.NewEntity;
public static Serial NewGuild => _guildPersistence.NewEntity;
// Virtual things don't persist across saves
public static Serial NewVirtual
{
get
{
#if THREADGUARD
if (Thread.CurrentThread != Core.Thread)
{
logger.Error(
"Attempted to get a new virtual serial from the wrong thread!\n{StackTrace}",
new StackTrace()
);
}
#endif
var value = _nextVirtualSerial > MaxVirtualSerial ? ResetVirtualSerial : _nextVirtualSerial;
_nextVirtualSerial = value + 1;
return (Serial)value;
}
}
public static Dictionary<Serial, Item> Items => _itemPersistence.EntitiesBySerial;
public static Dictionary<Serial, Mobile> Mobiles => _mobilePersistence.EntitiesBySerial;
public static Dictionary<Serial, BaseGuild> Guilds => _guildPersistence.EntitiesBySerial;
public static bool UseMultiThreadedSaves { get; private set; }
public static string SavePath { get; private set; }
public static WorldState WorldState { get; private set; }
public static bool Saving => WorldState == WorldState.Saving;
/// <summary>
/// UTC time the current or most recent world save started. Written into save indexes so
/// 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;
public static void Configure()
{
var tempSavePath = ServerConfiguration.GetSetting("world.tempSavePath", "temp");
_tempSavePath = PathUtility.GetFullPath(tempSavePath);
var savePath = ServerConfiguration.GetOrUpdateSetting("world.savePath", "Saves");
SavePath = PathUtility.GetFullPath(savePath);
UseMultiThreadedSaves = ServerConfiguration.GetOrUpdateSetting("world.useMultithreadedSaves", true);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void WaitForWriteCompletion() => _diskWriteHandle.WaitOne();
public static void Broadcast(int hue, bool ascii, string text)
{
var length = OutgoingMessagePackets.GetMaxMessageLength(text.Length);
var buffer = stackalloc byte[length].InitializePacket();
foreach (var ns in NetState.Instances)
{
if (ns.Mobile == null)
{
continue;
}
length = OutgoingMessagePackets.CreateMessage(
buffer, Serial.MinusOne, -1, MessageType.Regular, hue, 3, ascii, "ENU", "System", text
);
if (length != buffer.Length)
{
buffer = buffer[..length]; // Adjust to the actual size
}
ns.Send(buffer);
}
NetState.FlushAll();
}
public static void BroadcastStaff(string text) => BroadcastStaff(0x35, false, text);
public static void BroadcastStaff(int hue, bool ascii, string text)
{
var length = OutgoingMessagePackets.GetMaxMessageLength(text.Length);
var buffer = stackalloc byte[length].InitializePacket();
foreach (var ns in NetState.Instances)
{
if (ns.Mobile == null || ns.Mobile.AccessLevel < AccessLevel.GameMaster)
{
continue;
}
length = OutgoingMessagePackets.CreateMessage(
buffer, Serial.MinusOne, -1, MessageType.Regular, hue, 3, ascii, "ENU", "System", text
);
if (length != buffer.Length)
{
buffer = buffer[..length]; // Adjust to the actual size
}
ns.Send(buffer);
}
NetState.FlushAll();
}
public static void Load()
{
if (WorldState != WorldState.Initial)
{
return;
}
WorldState = WorldState.Loading;
logger.Information("Loading world");
var watch = Stopwatch.StartNew();
Persistence.Load(SavePath);
EventSink.InvokeWorldLoad();
// Set the world to running before we process our queues
WorldState = WorldState.Running;
Persistence.PostDeserializeAll(); // Process safety queues
watch.Stop();
logger.Information("Loading world {Status} ({ItemCount} items, {MobileCount} mobiles) ({Duration:F2} seconds)",
"done",
Items.Count,
Mobiles.Count,
watch.Elapsed.TotalSeconds
);
// Create the serialization threads, plus an inline worker the main thread uses to
// join the drain after publishing work instead of idling until the workers finish.
_realWorkerCount = UseMultiThreadedSaves ? Math.Max(Environment.ProcessorCount - 1, 1) : 1;
_threadWorkers = new SerializationThreadWorker[_realWorkerCount + 1];
// The save we just loaded tells us how big the heaps need to be, so the first save
// doesn't pay copy-on-grow inside the freeze window. 25% headroom for world growth.
var heapSizeHint = (int)Math.Min(GetLoadedSaveSize() / _threadWorkers.Length * 5 / 4, 1024 * 1024 * 1024);
for (var i = 0; i < _realWorkerCount; i++)
{
_threadWorkers[i] = new SerializationThreadWorker(i, _chunkSource, heapSizeHint);
}
_threadWorkers[_realWorkerCount] =
SerializationThreadWorker.CreateInline(_realWorkerCount, _chunkSource, heapSizeHint);
}
private static long GetLoadedSaveSize()
{
try
{
if (!Directory.Exists(SavePath))
{
return 0;
}
var total = 0L;
foreach (var file in Directory.EnumerateFiles(SavePath, "*.bin", SearchOption.AllDirectories))
{
total += new FileInfo(file).Length;
}
return total;
}
catch
{
return 0;
}
}
public static void Save()
{
if (WorldState != WorldState.Running)
{
return;
}
WaitForWriteCompletion(); // Blocks Save until current disk flush is done.
_diskWriteHandle.Reset();
WorldState = WorldState.PendingSave;
ThreadPool.QueueUserWorkItem(Preserialize);
}
private static void Preserialize(object state)
{
try
{
// Allocate the heaps for the GC
foreach (var worker in _threadWorkers)
{
worker.AllocateHeap();
}
WakeSerializationThreads();
// Execute this synchronously so we don't have a race condition
Core.LoopContext.Post(() => Core.RequestSnapshot(PathUtility.EnsureRandomPath(_tempSavePath)));
}
catch (Exception ex)
{
logger.Error(ex, "Preparing to save world {Status}", "failed");
Persistence.TraceException(ex);
BroadcastStaff(0x35, true, "Preparing for a world save failed! Check the logs!");
}
}
internal static void Snapshot(string snapshotPath)
{
if (WorldState != WorldState.PendingSave)
{
return;
}
NetState.FlushAll();
WorldState = WorldState.Saving;
// The world is frozen from here: one anchor for the whole save. Written into save
// indexes so anchored timestamps can be re-based by the downtime at load.
SaveStartTime = Core.Now;
Broadcast(0x35, true, "The world is saving, please wait.");
logger.Information("Saving world");
var watch = Stopwatch.StartNew();
Exception exception = null;
try
{
if (string.IsNullOrEmpty(snapshotPath))
{
throw new ArgumentException("Snapshot path cannot be null or empty", nameof(snapshotPath));
}
Persistence.SerializeAll();
PauseSerializationThreads();
LogWorkerBalance();
EventSink.InvokeWorldSave();
}
catch (Exception ex)
{
exception = ex;
}
WorldState = WorldState.WritingSave;
ThreadPool.QueueUserWorkItem(WriteFiles, snapshotPath);
watch.Stop();
if (exception == null)
{
var duration = watch.Elapsed.TotalSeconds;
logger.Information("Saving world {Status} ({Duration:F2} seconds)", "done", duration);
Broadcast(0x35, true, $"World save completed in {duration:F2} seconds.");
}
else
{
logger.Error(exception, "Saving world {Status}", "failed");
Persistence.TraceException(exception);
BroadcastStaff(0x35, true, "World save failed! Check the logs!");
}
}
private static void WriteFiles(object state)
{
var snapshotPath = (string)state;
try
{
var watch = Stopwatch.StartNew();
logger.Information("Writing world save snapshot");
Persistence.WriteSnapshotAll(snapshotPath);
try
{
EventSink.InvokeWorldSavePostSnapshot(SavePath, snapshotPath);
PathUtility.MoveDirectoryContents(snapshotPath, SavePath);
Directory.SetLastWriteTimeUtc(SavePath, Core.Now);
}
catch (Exception ex)
{
Persistence.TraceException(ex);
}
watch.Stop();
logger.Information("Writing world save snapshot {Status} ({Duration:F2} seconds)", "done", watch.Elapsed.TotalSeconds);
}
catch (Exception ex)
{
logger.Error(ex, "Writing world save snapshot failed");
Persistence.TraceException(ex);
BroadcastStaff(0x35, true, "Writing world save snapshot failed! Check the logs!");
}
_diskWriteHandle.Set();
Core.LoopContext.Post(FinishWorldSave);
}
private static void FinishWorldSave()
{
WorldState = WorldState.Running;
Persistence.PostWorldSaveAll(); // Process decay and safety queues
MovementThrottle.ResetAllMovementTiming(); // Prevent post-save movement rejection bursts
// The snapshot is on disk; release the per-worker write logs so serialized
// entity references don't linger between saves.
for (var i = 0; i < _threadWorkers.Length; i++)
{
_threadWorkers[i].ReleaseWriteLogs();
}
}
// Debug-level output only exists in DEBUG builds (see LogFactory), so Release builds
// should not pay for the stat summing and argument boxing inside the freeze at all.
[Conditional("DEBUG")]
private static void LogWorkerBalance()
{
var totalEntities = 0L;
var totalBytes = 0L;
var minBytes = long.MaxValue;
var maxBytes = 0L;
for (var i = 0; i < _threadWorkers.Length; i++)
{
var worker = _threadWorkers[i];
totalEntities += worker.EntitiesSerialized;
var bytes = worker.BytesSerialized;
totalBytes += bytes;
minBytes = Math.Min(minBytes, bytes);
maxBytes = Math.Max(maxBytes, bytes);
}
logger.Debug(
"Serialized {EntityCount} entities ({ByteCount} bytes) across {WorkerCount} workers (min {MinBytes}, max {MaxBytes} bytes per worker)",
totalEntities,
totalBytes,
_threadWorkers.Length,
minBytes,
maxBytes
);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static void WakeSerializationThreads()
{
for (var i = 0; i < _realWorkerCount; i++)
{
_threadWorkers[i].Wake();
}
}
internal static void PauseSerializationThreads()
{
// Publish the partial chunk before the workers are told to finish draining.
_chunkSource.Flush();
// Join the drain: the main thread would otherwise idle here while workers finish.
_threadWorkers[_realWorkerCount].DrainInline();
for (var i = 0; i < _realWorkerCount; i++)
{
_threadWorkers[i].Sleep();
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static void SetChunkSourceOwner(Persistence owner) => _chunkSource.SetOwner(owner);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static void PushToCache(IGenericSerializable e) => _chunkSource.Push(e);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static void PushSingleToCache(GenericPersistence persistence) => _chunkSource.PushSingle(persistence);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static void PushSlotRangesToCache(ISlotRangeSource source, int slotCount) =>
_chunkSource.PushSlotRanges(source, slotCount);
public static void ExitSerializationThreads()
{
for (var i = 0; i < _realWorkerCount; i++)
{
_threadWorkers[i]?.Exit();
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Item FindItem(Serial serial, bool returnDeleted = false) => _itemPersistence.Find(serial, returnDeleted);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Mobile FindMobile(Serial serial, bool returnDeleted = false) =>
_mobilePersistence.Find(serial, returnDeleted);
// Legacy: Only used for retrieving Items and Mobiles.
public static void AddEntity(IEntity entity)
{
if (entity is Item item)
{
_itemPersistence.AddEntity(item);
}
else if (entity is Mobile mobile)
{
_mobilePersistence.AddEntity(mobile);
}
else
{
logger.Warning("Attempted to call World.AddEntity with '{Entity}'. Must be a mobile or item.", entity.GetType());
}
}
public static void RemoveEntity(IEntity entity)
{
if (entity is Item item)
{
_itemPersistence.RemoveEntity(item);
}
else if (entity is Mobile mobile)
{
_mobilePersistence.RemoveEntity(mobile);
}
else
{
logger.Warning($"Attempted to call World.RemoveEntity with '{entity.GetType()}'. Must be a mobile or item.");
return;
}
#if TRACK_LEAKS
EntityFinalizationTracker.TrackEntity(entity);
#endif
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static BaseGuild FindGuild(Serial serial) => _guildPersistence.Find(serial);
public static void AddGuild(BaseGuild guild) => _guildPersistence.AddEntity(guild);
public static void RemoveGuild(BaseGuild guild) => _guildPersistence.RemoveEntity(guild);
// Legacy: Only used for retrieving Items and Mobiles.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static IEntity FindEntity(Serial serial, bool returnDeleted = false) =>
FindEntity<IEntity>(serial, returnDeleted);
// Legacy: Only used for retrieving Items and Mobiles.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static T FindEntity<T>(Serial serial, bool returnDeleted = false)
where T : class, IEntity
{
if (serial.IsItem)
{
return _itemPersistence.Find(serial, returnDeleted) as T;
}
return _mobilePersistence.Find(serial, returnDeleted) as T;
}
private class ItemPersistence : GenericEntityPersistence<Item>
{
public ItemPersistence() : base("Items", 2, ItemOffset, MaxItemSerial)
{
}
public override void PostDeserialize()
{
base.PostDeserialize();
foreach (var item in EntitiesBySerial.Values)
{
if (item.Parent == null)
{
item.UpdateTotals();
}
item.ClearProperties();
item.UpdateDecayRegistration();
}
}
}
private class MobilePersistence : GenericEntityPersistence<Mobile>
{
public MobilePersistence() : base("Mobiles", 1, 1, MaxMobileSerial)
{
}
public override void PostDeserialize()
{
base.PostDeserialize();
foreach (var m in EntitiesBySerial.Values)
{
m.UpdateRegion(); // Is this really needed?
m.UpdateTotals();
m.ClearProperties();
}
}
}
}