## Why Delta world saves (only changed entities re-serialized during the freeze) need two things from content: `Serialize` must be pure, and an unchanged entity must produce identical bytes on every save. An audit of the tree found the places that break this today. This PR fixes them and adds a diagnostic that measures byte stability in a running world over real time. No wire format changes, no version bumps. ## What - **Guild**: `Serialize` ran the daily guildmaster recalculation (dictionary-order tie breaks, random picks, mutates another entity), war expiry, and the alliance leadership check on every save. That work now runs from `GuildMaintenanceTimer` (the former `WarTimer`, now started for both guild systems; the war and alliance checks are no-ops without the new guild system). - **BaseHouse**: the `DecayLevel` getter wrote `LastRefreshed` and advanced the dynamic decay stage on every read, so any undecayable house changed its persisted bytes whenever a sign, gump, or `[Props` looked at it. It is now a pure read; `UpdateDecay()` on the decay tick advances the stage and refreshes a house that leaves the undecayable state. - **Locked-down container contents decay through `DecayScheduler`** instead of a sweep inside `BaseHouse.Serialize`. `Item.CanDecay` now accepts a parent container whose new `Container.ContentsDecay` is true (locked down and not secure) when the content item is not itself locked down or secured; the `IsLockedDown`/`IsSecure` setters re-evaluate a container and its direct contents; `OnDecay` resolves the region from the world location. `BaseBoard`, `Aquarium` and `FishBowl` override `ContentsDecay` to false, matching the exclusions of the old sweep. Items already inside such containers are picked up at load, where every item re-evaluates its registration. The per-save O(all locked-down items) walk is gone; the work is now event driven. - **BraceletOfBinding**: the `Bound` getter nulled its backing field for a deleted target; it now returns null for a missing or deleted target without writing. - **`[SaveStability [delaySeconds=60] [sampleStride=1]`** (Administrator): hashes the serialized bytes of every entity in every registered entity persistence (`Persistence.EntityPersistences`, new), waits the delay in real time, hashes again, and reports per type how many records changed. Both passes are chunked under a 20 ms per-tick budget. Serializing twice inside one tick cannot see drift derived from `Core.Now`, which is frozen within a tick; the real-time wait is the point. On an idle shard the only legitimate churn is NPC movement and regeneration, so a static type with a high changed fraction is a serialization bug. `[SaveStability cancel` stops a run. Engine additions: `Container.ContentsDecay` / `UpdateContentsDecayRegistration()`, the `CanDecay`/`OnDecay` change above, and diagnostic-facing `IGenericEntityPersistence.Name`, `EntityCount`, `EnumerateEntities()`, and `Persistence.EntityPersistences`. ## Behaviour notes - Fealty recalculation now happens on the minute timer for both guild systems instead of at save time. - A house that becomes decayable is refreshed on its next decay tick rather than at the exact moment of transition (at most one minute later). - Contents of locked-down containers now decay on their own schedule (same `LastMoved + DecayTime` rule) instead of at the next world save after becoming due. ## Testing - New tests: `GuildSerializePurityTests`, `HouseDecayPurityTests`, `SaveStabilityTests` (stable entities hash identically after the clock advances; a clock-stamped record is reported by type only after the clock advances; the snapshot covers Items, Mobiles and Guilds). - `LockedDownContainerDecayTests` (Server.Tests): registration on drop, lock/secure/release transitions, excluded containers, nested containers, and decay on schedule through the scheduler. - Full suites: Server.Tests 855 passed, UOContent.Tests 765 passed.
208 lines
6.2 KiB
C#
208 lines
6.2 KiB
C#
/*************************************************************************
|
|
* ModernUO *
|
|
* Copyright 2019-2026 - ModernUO Development Team *
|
|
* Email: hi@modernuo.com *
|
|
* File: Persistence.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.IO;
|
|
using System.IO.MemoryMappedFiles;
|
|
|
|
namespace Server;
|
|
|
|
public abstract class Persistence
|
|
{
|
|
private static readonly SortedSet<Persistence> _registry = new(new PersistenceComparer());
|
|
|
|
public int Priority { get; }
|
|
|
|
public Persistence(int priority = 100)
|
|
{
|
|
Priority = priority;
|
|
_registry.Add(this);
|
|
}
|
|
|
|
public bool Register() => _registry.Add(this);
|
|
|
|
/// <summary>Every registered entity persistence (Items, Mobiles, Guilds, Accounts, ...), in priority order.</summary>
|
|
public static IEnumerable<IGenericEntityPersistence> EntityPersistences
|
|
{
|
|
get
|
|
{
|
|
foreach (var entry in _registry)
|
|
{
|
|
if (entry is IGenericEntityPersistence entityPersistence)
|
|
{
|
|
yield return entityPersistence;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public void Unregister() => _registry.Remove(this);
|
|
|
|
public static void Load(string path)
|
|
{
|
|
var typesDb = LoadTypes(path);
|
|
|
|
foreach (var entry in _registry)
|
|
{
|
|
(entry as IGenericEntityPersistence)?.DeserializeIndexes(path, typesDb);
|
|
}
|
|
|
|
// This should probably not be parallel since Mobiles must be loaded before Items
|
|
foreach (var entry in _registry)
|
|
{
|
|
entry.Deserialize(path, typesDb);
|
|
}
|
|
}
|
|
|
|
private static unsafe Dictionary<ulong, string> LoadTypes(string path)
|
|
{
|
|
var db = new Dictionary<ulong, string>();
|
|
|
|
var typesPath = Path.Combine(path, "SerializedTypes.db");
|
|
if (!File.Exists(typesPath))
|
|
{
|
|
return db;
|
|
}
|
|
|
|
using var mmf = MemoryMappedFile.CreateFromFile(typesPath, FileMode.Open);
|
|
using var accessor = mmf.CreateViewStream();
|
|
|
|
byte* ptr = null;
|
|
accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr);
|
|
var dataReader = new UnmanagedDataReader(ptr, accessor.Length);
|
|
|
|
var version = dataReader.ReadInt();
|
|
var count = dataReader.ReadInt();
|
|
|
|
for (var i = 0; i < count; ++i)
|
|
{
|
|
var hash = dataReader.ReadULong();
|
|
var typeName = dataReader.ReadStringRaw();
|
|
db[hash] = typeName;
|
|
}
|
|
|
|
accessor.SafeMemoryMappedViewHandle.ReleasePointer();
|
|
return db;
|
|
}
|
|
|
|
// Note: This is strictly on a background thread
|
|
internal static void WriteSnapshotAll(string path)
|
|
{
|
|
foreach (var p in _registry)
|
|
{
|
|
p.WriteSnapshot(path);
|
|
}
|
|
}
|
|
|
|
internal static void SerializeAll()
|
|
{
|
|
// Largest known payloads first (LPT scheduling). An indivisible multi-megabyte system
|
|
// serialized last extends the freeze by its entire duration; serialized first it overlaps
|
|
// the entity stream. Sizes come from the previous save, or the loaded files on first save.
|
|
var ordered = new Persistence[_registry.Count];
|
|
_registry.CopyTo(ordered);
|
|
Array.Sort(ordered, static (a, b) => GetEstimatedSize(b).CompareTo(GetEstimatedSize(a)));
|
|
|
|
foreach (var p in ordered)
|
|
{
|
|
// Chunks are persistence-homogeneous: publishing the partial chunk at each
|
|
// boundary lets workers attribute buffer-chunk records to their owner without
|
|
// any per-entity state.
|
|
World.SetChunkSourceOwner(p);
|
|
p.Serialize();
|
|
}
|
|
}
|
|
|
|
private static long GetEstimatedSize(Persistence p) => (p as GenericPersistence)?.EstimatedSize ?? 0;
|
|
|
|
internal static void PostWorldSaveAll()
|
|
{
|
|
foreach (var p in _registry)
|
|
{
|
|
p.PostWorldSave();
|
|
}
|
|
}
|
|
|
|
internal static void PostDeserializeAll()
|
|
{
|
|
foreach (var p in _registry)
|
|
{
|
|
p.PostDeserialize();
|
|
}
|
|
}
|
|
|
|
// Note: This should only be run on a background thread
|
|
public abstract void WriteSnapshot(string savePath);
|
|
|
|
public abstract void Serialize();
|
|
|
|
public abstract void Deserialize(string savePath, Dictionary<ulong, string> typesDb);
|
|
|
|
public virtual void PostWorldSave()
|
|
{
|
|
}
|
|
|
|
public virtual void PostDeserialize()
|
|
{
|
|
}
|
|
|
|
internal class PersistenceComparer : IComparer<Persistence>
|
|
{
|
|
public int Compare(Persistence x, Persistence y)
|
|
{
|
|
if (x == y)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
if (x == null)
|
|
{
|
|
return 1;
|
|
}
|
|
|
|
if (y == null)
|
|
{
|
|
return -1;
|
|
}
|
|
|
|
// First sort by priority
|
|
var cmp = x.Priority.CompareTo(y.Priority);
|
|
|
|
// Then alphabetically. We won't allow the same entry (by name) twice in the SortedSet
|
|
return cmp != 0 ? cmp : x.GetHashCode().CompareTo(y.GetHashCode());
|
|
}
|
|
}
|
|
|
|
public static void TraceException(Exception ex)
|
|
{
|
|
try
|
|
{
|
|
using var op = new StreamWriter("save-errors.log", true);
|
|
op.WriteLine("# {0}", Core.Now);
|
|
|
|
op.WriteLine(ex);
|
|
|
|
op.WriteLine();
|
|
op.WriteLine();
|
|
}
|
|
catch
|
|
{
|
|
// ignored
|
|
}
|
|
|
|
Console.WriteLine(ex);
|
|
}
|
|
}
|