#W# Merge: Merged with upstream. Conflicts resolved.

This commit is contained in:
WarrentyExpired 2026-10-27 19:56:03 -04:00
commit f6f50470a9
276 changed files with 6742 additions and 8845 deletions

View file

@ -52,6 +52,12 @@ public static class MultiData
public static MultiComponentList GetComponents(int multiID) =>
_components.TryGetValue(multiID & 0x3FFF, out var mcl) ? mcl : MultiComponentList.Empty;
/// <summary>
/// Registers a footprint without multi files, for hosts that generate their own (tests).
/// </summary>
internal static void SetComponents(int multiID, MultiComponentList components) =>
_components[multiID & 0x3FFF] = components;
private static void LoadUOP(string path)
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);

View file

@ -1042,13 +1042,14 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
writer.Write((int)flags);
// 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);
// Absolute and never shifted at load, so an item that does not move serializes to the
// same bytes across saves and across restarts. Downtime credit for a decay countdown that
// was running at save time goes into DecayResetTime (CreditDowntimeToDecay).
writer.Write(LastMoved);
if (GetSaveFlag(flags, SaveFlag.DecayReset))
{
writer.WriteAnchoredTime(info.m_DecayReset);
writer.Write(info.m_DecayReset);
}
if (GetSaveFlag(flags, SaveFlag.Direction))
@ -2494,6 +2495,30 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
UpdateDecayRegistration();
}
/// <summary>
/// Credits the downtime before this load to a decay countdown that was still running when the
/// save started, so the shard being down does not age the item. <see cref="LastMoved" /> stays
/// the real time of the move; the credit goes into <see cref="DecayResetTime" />, which only
/// the items moved within their <see cref="DecayTime" /> of the save ever carry. Called once
/// per item after the world loads, when <see cref="DecayTime" /> is final. An item already
/// due at save time gets nothing: it is due either way.
/// </summary>
internal void CreditDowntimeToDecay(DateTime saveStartUtc, TimeSpan downtime)
{
if (downtime <= TimeSpan.Zero || !Decays)
{
return;
}
var reset = DecayResetTime;
var start = reset > LastMoved ? reset : LastMoved;
if (start + DecayTime > saveStartUtc)
{
DecayResetTime = start + downtime;
}
}
public void UpdateDecayRegistration()
{
DecayScheduler.Unregister(this);
@ -2856,7 +2881,9 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
if (version >= 11)
{
LastMoved = reader.ReadAnchoredTime();
// Written with WriteAnchoredTime before LastMoved stopped being shifted;
// the bytes are the same absolute ticks either way.
LastMoved = reader.ReadDateTime();
}
else if (version < 7)
{
@ -2878,7 +2905,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
if (version >= 10 && GetSaveFlag(flags, SaveFlag.DecayReset))
{
var reset = version >= 11 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime();
var reset = version >= 11 ? reader.ReadDateTime() : reader.ReadDeltaTime();
// Pre-v11 LastMoved was stored at whole-minute precision; keep the
// stamp only while it still extends the deadline.

View file

@ -68,7 +68,9 @@ public static class Localization
return false;
}
if (entries.Count == 0)
// The fallback table stays registered even when empty: _fallbackEntries points at it, and a
// replacement created by a later Add would be invisible to fallback lookups.
if (entries.Count == 0 && entries != _fallbackEntries)
{
_localizations.Remove(lang);
}

View file

@ -45,6 +45,10 @@ public static class Core
private static volatile bool _performSnapshot;
private static string _snapshotPath;
// 0 = relaunch in-process. Otherwise a restart exits with this code and leaves the relaunch to a
// supervisor; a spawned child loses the console and dies with a systemd unit or container.
private static int _restartExitCode;
// A backstop, not a latency control: the wheel's tick rate already bounds the sleep.
// Measured across 1/2/4/8ms; 2 is optimal.
private static int _eventLoopIdleWaitMs = 2;
@ -434,7 +438,7 @@ public static class Core
ConsoleInputHandler.ReadLine();
}
DoKill();
DoKill(_restartOnKill);
}
}
@ -468,27 +472,28 @@ public static class Core
HandleClosed();
if (restart && _restartExitCode != 0)
{
logger.Information("Restarting (exiting with code {ExitCode} for the supervisor)", _restartExitCode);
Environment.Exit(_restartExitCode);
}
if (restart)
{
try
{
logger.Information("Restarting");
if (IsWindows)
{
using var process = Process.Start("dotnet", $"{ApplicationAssembly.Location}");
}
else
{
using var process = new Process();
process.StartInfo = new ProcessStartInfo
{
FileName = "dotnet",
Arguments = $"{ApplicationAssembly.Location}",
UseShellExecute = true
};
process.Start();
// Relaunch through whichever host started us: the apphost (ModernUO.exe) runs directly,
// `dotnet ModernUO.dll` needs the assembly path. ArgumentList quotes paths with spaces.
var hostPath = Environment.ProcessPath ?? "dotnet";
var startInfo = new ProcessStartInfo(hostPath) { UseShellExecute = false };
if (Path.GetFileNameWithoutExtension(hostPath).Equals("dotnet", StringComparison.OrdinalIgnoreCase))
{
startInfo.ArgumentList.Add(ApplicationAssembly.Location);
}
using var process = Process.Start(startInfo);
logger.Information("Restart done");
}
catch (Exception e)
@ -630,6 +635,19 @@ public static class Core
_lateWakePercent = Math.Clamp(lateWakePercent, 0, 100);
// Unix truncates exit codes to a byte, so anything outside 1-255 cannot be told apart reliably.
var restartExitCode = ServerConfiguration.GetSetting("server.restartExitCode", 0);
if (restartExitCode is < 0 or > 255)
{
logger.Warning(
"server.restartExitCode {Value} is outside 1-255; restarts will relaunch the server directly",
restartExitCode
);
restartExitCode = 0;
}
_restartExitCode = restartExitCode;
var assemblyPath = Path.Join(BaseDirectory, AssembliesConfiguration);
// Load UOContent.dll

View file

@ -2349,9 +2349,11 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
{
writer.Write(38); // version
writer.WriteAnchoredTime(LastStrGain);
writer.WriteAnchoredTime(LastIntGain);
writer.WriteAnchoredTime(LastDexGain);
// Absolute and never shifted at load: the stat-gain cooldown is minutes long, so letting
// downtime count toward it costs nothing, and an idle mobile keeps its bytes across restarts.
writer.Write(LastStrGain);
writer.Write(LastIntGain);
writer.Write(LastDexGain);
byte hairflag = 0x00;
@ -6185,9 +6187,9 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
{
if (version >= 38)
{
LastStrGain = reader.ReadAnchoredTime();
LastIntGain = reader.ReadAnchoredTime();
LastDexGain = reader.ReadAnchoredTime();
LastStrGain = reader.ReadDateTime();
LastIntGain = reader.ReadDateTime();
LastDexGain = reader.ReadDateTime();
}
else
{

View file

@ -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();

View file

@ -39,6 +39,8 @@ public class BufferReader : IGenericReader
public TimeSpan AnchoredTimeShift { get; set; }
public DateTime AnchoredTimeOrigin { get; set; }
public BufferReader(byte[] buffer, Dictionary<ulong, string> typesDb = null, Encoding encoding = null)
{
_buffer = buffer;

View file

@ -409,9 +409,9 @@ public class BufferWriter : IGenericWriter
}
/// <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.
/// Writes the absolute value; <see cref="IGenericReader.ReadAnchoredTime" /> 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.
/// </summary>
public void WriteAnchoredTime(DateTime value)
{

View file

@ -529,6 +529,7 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
// 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;
World.LoadedSaveStartTime = anchor;
}
if (version >= 4)
@ -709,7 +710,8 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr);
var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb)
{
AnchoredTimeShift = _anchoredTimeShift
AnchoredTimeShift = _anchoredTimeShift,
AnchoredTimeOrigin = World.LoadedSaveStartTime
};
Deserialize(dataReader);

View file

@ -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);

View file

@ -65,6 +65,19 @@ public interface IGenericReader
/// </summary>
TimeSpan AnchoredTimeShift => TimeSpan.Zero;
/// <summary>
/// UTC start of the loaded save. <see cref="ReadAnchoredTime" /> leaves a value at or before
/// it unshifted. Default when unknown, which shifts every value.
/// </summary>
DateTime AnchoredTimeOrigin => default;
/// <summary>
/// Reads an anchored timestamp. A deadline still in the future when the save started is
/// shifted by <see cref="AnchoredTimeShift" />, 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.
/// </summary>
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;
}

View file

@ -45,6 +45,8 @@ public unsafe class UnmanagedDataReader : IGenericReader
public TimeSpan AnchoredTimeShift { get; set; }
public DateTime AnchoredTimeOrigin { get; set; }
/// <summary>
/// Read bits of data raw from a serialized file using Little-endian.
/// </summary>

View file

@ -37,10 +37,10 @@
<PackageReference Include="IORingGroup" Version="1.0.13" />
<PackageReference Include="CommunityToolkit.HighPerformance" Version="8.4.2" />
<PackageReference Include="LibDeflate.Bindings" Version="1.0.4" />
<PackageReference Include="System.IO.Hashing" Version="10.0.12" />
<PackageReference Include="System.IO.Hashing" Version="10.0.13" />
<PackageReference Include="ModernUO.Serialization.Annotations" Version="4.2.0" />
<PackageReference Include="ModernUO.Serialization.Generator" Version="4.2.0" PrivateAssets="all" />
<PackageReference Include="ModernUO.Serialization.Annotations" Version="4.3.0" />
<PackageReference Include="ModernUO.Serialization.Generator" Version="4.3.0" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="Migrations/*.v*.json" />

View file

@ -220,11 +220,17 @@ public class TileMatrix
public StaticTile[][][] GetStaticBlock(int x, int y)
{
if (x < 0 || y < 0 || x >= BlockWidth || y >= BlockHeight || DataStream == null || IndexStream == null)
if (x < 0 || y < 0 || x >= BlockWidth || y >= BlockHeight)
{
return _emptyStaticBlock;
}
// A map without static files (tests, generated maps) serves only what was injected.
if (DataStream == null || IndexStream == null)
{
return _staticTiles[x]?[y] ?? _emptyStaticBlock;
}
_staticTiles[x] ??= new StaticTile[BlockHeight][][][];
var tiles = _staticTiles[x][y];
@ -292,11 +298,17 @@ public class TileMatrix
public LandTile[] GetLandBlock(int x, int y)
{
if (x < 0 || y < 0 || x >= BlockWidth || y >= BlockHeight || MapStream == null)
if (x < 0 || y < 0 || x >= BlockWidth || y >= BlockHeight)
{
return _invalidLandBlock;
}
// A map without a map file (tests, generated maps) serves only what was injected.
if (MapStream == null)
{
return _landTiles[x]?[y] ?? _invalidLandBlock;
}
_landTiles[x] ??= new LandTile[BlockHeight][];
var tiles = _landTiles[x][y];

View file

@ -321,13 +321,14 @@ public partial class Timer
for (var i = 0; i < _ringSize; i++)
{
var node = t[i];
Timer next;
do
while (node != null)
{
next = node?._nextTimer;
node?.Stop();
} while (next != null);
// Stop() unlinks the node, so read its successor first.
var next = node._nextTimer;
node.Stop();
node = next;
}
}
}
}

View file

@ -108,6 +108,12 @@ public static class World
/// payloads, which carry no anchor of their own. Zero for saves that predate the anchor.
/// </summary>
public static TimeSpan LoadTimeShift { get; internal set; }
/// <summary>
/// UTC start time of the save being loaded, read from its index anchor. Default for saves
/// that predate the anchor.
/// </summary>
public static DateTime LoadedSaveStartTime { get; internal set; }
public static bool Running => WorldState is not WorldState.Loading and not WorldState.Initial;
public static bool Loading => WorldState == WorldState.Loading;
@ -674,6 +680,7 @@ public static class World
}
item.ClearProperties();
item.CreditDowntimeToDecay(LoadedSaveStartTime, LoadTimeShift);
item.UpdateDecayRegistration();
}
}