perf(saves): eliminate world-save freeze bottlenecks (~9.5x faster freeze) (#2525)
Reduces the world-save freeze window from ~740ms to ~78ms (measured on a synthetic 10M-entity / 1.7GB world, 24 cores, through the real pipeline classes) by removing the per-entity handoff between the game loop and the serialization workers, fixing how large indivisible payloads are scheduled, rewriting the BufferWriter hot path, removing per-entity placement state entirely, and finally replacing the global serialized-types tracking with a per-file type table (idx v4) that also shrinks idx files by ~21% and speeds the background write phase. The pipeline has also been validated end-to-end on live-copy worlds in the multi-million-entity range, where the freeze is drain-bound (real `Serialize()` costs far more CPU per byte than synthetic writes) — the same structural wins hold, and entity/file round-trips are byte-clean across both load paths.
## The problem
The freeze window is `max(main-thread handoff, slowest worker drain)`:
1. **The producer was the bottleneck.** The main thread round-robined every entity through per-worker `ConcurrentQueue`s — two interlocked ops per entity, ~740ms of freeze floor at 10M entities before any serialization happened.
2. **Round-robin distributes count, not cost.** "Deep" systems (50MB generic persistence blobs) and "thick" entities (100K-item storage keys) landed on arbitrary workers, producing lopsided drain times on large worlds.
3. **Worst-case scheduling.** `GenericEntityPersistence.Serialize` pushed its self-payload *after* all entities, and generic persistences sort last in the registry — so the biggest indivisible blobs started serializing at the very end, extending the freeze by their entire duration.
## The fix
**Commit 1 — chunked handoff + LPT scheduling + heap pre-sizing:**
- Pooled 4096-entity chunks published to one shared queue; workers pull chunks and load-balance dynamically (a worker busy with a thick entity simply takes fewer chunks).
- `Persistence.SerializeAll` pushes systems largest-first (LPT) using the previous save's payload size (or loaded file size on first boot); self-payloads get dedicated single-entity chunks so they overlap the entity stream instead of ending it.
- Worker heaps pre-size from the loaded save's `.bin` totals, eliminating copy-on-grow inside the first save's freeze.
- `SpinWait` backoff in the drain loop (never `Sleep(1)`), per-worker balance stats logged in debug builds (the call site is compiled out of Release), 1MB snapshot write buffer.
**Commit 2 — workers iterate the dictionaries directly + main thread joins the drain:**
- `GenericEntityPersistence` publishes 4096-slot ranges over its dictionary's backing entries array; workers serialize occupied slots (`value != null`) directly through a `ShadowEntry<TValue>` struct mirroring the runtime's private `Entry` layout. Safe because the dictionary is frozen during `Saving` (mutations divert to the pending safety queues).
- The layout is **proven at startup before any code reads through it**: validation measures the true `Entry` stride via precise allocation accounting (guaranteeing all shadow reads are in-bounds), then verifies every key/value of a churned, resized, freelist-exercised dictionary — reading value slots as raw pointer bits only, never materializing a managed reference until the layout is proven. If a future runtime changes `Dictionary` internals, validation fails with a logged warning and saves fall back to the (fully maintained) enumerate-and-push path.
- The main thread joins the drain via an inline worker after publishing, instead of idling — worth a full worker share, proportionally more on low-core hosts.
**Commit 3 — 2.2x faster BufferWriter write path, single-pass short strings:**
- PGO already devirtualizes and inlines every `IGenericWriter.Write` callsite (interface vs concrete measured identical) — the real per-write cost was the non-inlinable `Index` setter (range-check throw path + per-write high-water tracking) plus span bounds checks. Writes now reserve capacity once, then do an unaligned store through a ref with a raw index increment; the high-water mark folds at Seek/Resize instead of per write.
- Class-level implementations of the hottest default interface methods keep nested writes inlined (a DIM re-dispatches on `this` even at a devirtualized callsite).
- Strings of 85 chars or fewer encode once into a stack scratch instead of walking the string twice (`GetByteCount` + `GetBytes`). Byte output is identical.
- Measured: 34.4 → 15.7 ns/entity on a generated-style write mix; end-to-end freeze ~99ms → ~74-82ms.
**Commit 4 — branch-free fallback push loop:**
- A bare `foreach { PushToCache(entity); }` runs at 2.3ns/entity; the same loop carrying a per-entity heavy-check runs 2.3x slower — the cost is the fatter loop body defeating tight-loop codegen. Entity-level >1MB payloads are rare enough to ride in shared chunks; system self-payloads (the large ones) are still explicitly scheduled largest-first.
**Commit 5 — drop the 9-byte per-entity placement state; snapshots write from worker segment logs:**
- Every `ISerializable` carried `SerializedThread/SerializedPosition/SerializedLength` so `WriteSnapshot` could gather each entity's bytes from the worker heaps in dictionary order. But the idx records absolute positions — bin order is free — so the snapshot is now written in worker-heap order and the join inverts: workers log segments (owner, slot range, heap start) plus one length per record as they serialize; positions are implicit because a worker's writes are contiguous, and identity comes from re-walking the same snapshot slots in the same order (stable until `PostWorldSave`).
- Chunks are persistence-homogeneous (the partial chunk publishes at each `SerializeAll` boundary) so segments route to files by owner with zero per-entity state. Self-payloads keep placement as three private fields on the handful of persistence instances.
- Net: 9 bytes (plus padding) of resident state removed from every item, mobile, guild, and account on every shard; three interface-property stores per entity leave the drain hot path (stamping dirtied one cache line per entity mid-freeze — the lengths log is one sequential stream); each segment's bytes hit the bin as a single span write instead of one copy per entity, speeding the background write phase; and `IGenericSerializable` shrinks to just `Serialize(IGenericWriter)`. Transient cost: ~4 bytes per entity in pooled per-worker logs, released after each write. The save format was unchanged at this point (idx v3, same loader); the v4 bump comes later in the branch.
**Commit 6 — staged file writes replace memory-mapped snapshot writing:**
- `MemoryMapFileWriter` is removed. `FileBufferWriter` composes through the full `BufferWriter` raw write path into a pooled staging block that drains to the file as large sequential positional writes (`RandomAccess.Write`); seeks flush the block and move the file offset, so backwards patches (the idx entity count) become small positional writes.
- Memory-mapped composition paid a soft page fault on every composed page plus unpredictable dirty-section teardown stalls at dispose — measured ~4x slower end-to-end than staged writes at snapshot sizes.
**Commits 7–11 — idx v4: per-file type table replaces SerializedTypes.db and all runtime type tracking:**
- Previously every `Write(Type)` from every worker enqueued into a shared `ConcurrentQueue<Type>` during the freeze (interlocked writes on a shared cache line, millions of mostly-duplicate entries), the background phase drained and deduped it all into a `HashSet`, and the snapshot recomputed `xxHash64(Type.FullName)` once per entity record (~5.4M redundant hashes per save on a large world) to write 9-byte tag+hash idx records plus a global `SerializedTypes.db`.
- The db's only real job was diagnostics: the string name behind "Type `<X>` was not found. Delete all of those types?" during idx loading. That map now lives in the idx itself: each `GenericEntityPersistence<T>` keeps an insertion-ordered `Type -> ushort` table, hydrated at `AddEntity` and on every deserialize path — one dictionary `TryAdd` per entity add on the game thread, amortized across gameplay, and provably immutable while the background writer reads it (adds divert to the pending queues during saves).
- idx v4 layout: the table (names only) is written before the records; records reference it by 2-byte index, shrinking from 33 to 26 bytes (−21%). The loader resolves each table name **once** (`FindTypeByHash(ComputeHash64(name))` — semantically identical to v3 resolution, `TypeAlias` included) into a constructor array, and each record becomes an array index instead of an 8-byte hash read plus dictionary probe. The unresolved-type prompt now surfaces once per type, with the name.
- Deleted outright: `World.SerializedTypes`, the drain/dedupe pass in `WriteFiles`, `BufferWriter`'s type tracking (its `Write(Type)` is now pure — payload format unchanged: tag byte + xxHash64), `FileBufferWriter`'s typeSet parameter, `Persistence.WriteSerializedTypesSnapshot`, and the adhoc db write. SerializedTypes.db is no longer produced.
- **Backward compatibility:** v0–v3 saves (including their SerializedTypes.db and legacy tdb files) load exactly as before, and every legacy load path hydrates the new table so the first v4 save after an upgrade is complete. Stale db files in existing save folders are simply ignored. Verified live: a v3 save boots, saves as v4 (Items.idx −20.2% on a dev world), and reloads with identical entity counts.
## Measured (synthetic 10M entities / 1.7GB, 64+64+32MB system blobs, 24x 2MB thick entities, dense write profile, 24 cores)
| Metric | Before | After |
|---|---|---|
| Steady-state freeze | ~740 ms | **~78 ms** |
| Main-thread publish cost | ~740 ms | **~0.1 ms** |
| Steady-state allocations | 0 | 0 (by iter 2) |
| Worker byte-load spread | 2x | ~1.15x |
The freeze is now bound by pure serialize throughput (payload / cores).
## Tests
- 779 Server.Tests + 501 UOContent.Tests pass.
- New across the branch: chunk fill/flush/owner-boundary tests, pool reuse/clear tests, an end-to-end multi-worker drain through the real wake/push/flush/pause protocol, a 50K-entry churn equivalence test for the shadow iteration re-walk (the exact pairing the snapshot writer relies on), byte-level BufferWriter output/position pins, `RuntimeLayoutIsSupported` so a silent fallback on a future runtime upgrade fails loudly in CI, and a full snapshot **round-trip test** that serializes 25K entities plus a self-payload through real workers, writes the idx/bin from the segment logs, and reloads them through the standard loader (now in v4 format).
- For idx v4 specifically: `FileBufferWriter` staging/drain/seek-patch and oversized-item tests, type-table registration tests, a hand-written v4 fixture proving an unresolvable type name skips only its own records through the console confirmation flow, and a hand-written **legacy v3 fixture** proving old saves still load and hydrate the type table for their next save.
## Trade-offs
- Chunk scheduling is nondeterministic, so worker heaps ratchet to each worker's max-ever draw rather than a fixed share. With slot ranges the balance is tight (~1.15x), so the effect is small; a shared slab pool remains an option if production shows retention creep.
- Entity-level heavy items inside slot ranges are serialized wherever they're encountered (no LPT for them); worst-case tail is one thick entity's serialize time (~ms). System self-payloads — the large ones — are still explicitly scheduled largest-first.
- Snapshot-write error granularity is per segment rather than per entity (heap-bounds bugs were the only thing the per-entity catch ever caught; idx metadata reads keep per-record granularity).
- idx v4 is a save-format version bump: old saves load unchanged through the preserved legacy paths, but saves written by this branch require this loader. Per-persistence type tables cap at 65,535 distinct entity types per boot (hard throw, orders of magnitude of headroom), and a type's table slot persists until restart even if its last entity is deleted — a few stale name entries per file, by design.
This commit is contained in:
parent
3cb077a79e
commit
e12cc5dd83
29 changed files with 2827 additions and 796 deletions
|
|
@ -443,9 +443,6 @@ public class ClientEnumeratorTests
|
||||||
public DateTime Created { get; set; }
|
public DateTime Created { get; set; }
|
||||||
public Serial Serial { get; }
|
public Serial Serial { get; }
|
||||||
public void Deserialize(IGenericReader reader) => throw new NotImplementedException();
|
public void Deserialize(IGenericReader reader) => throw new NotImplementedException();
|
||||||
public byte SerializedThread { get; set; }
|
|
||||||
public int SerializedPosition { get; set; }
|
|
||||||
public int SerializedLength { get; set; }
|
|
||||||
public void Serialize(IGenericWriter writer) => throw new NotImplementedException();
|
public void Serialize(IGenericWriter writer) => throw new NotImplementedException();
|
||||||
public bool Deleted { get; }
|
public bool Deleted { get; }
|
||||||
public void Delete() => throw new NotImplementedException();
|
public void Delete() => throw new NotImplementedException();
|
||||||
|
|
|
||||||
|
|
@ -52,10 +52,6 @@ public class AccountPacketTests
|
||||||
public Serial Serial { get; }
|
public Serial Serial { get; }
|
||||||
public void Deserialize(IGenericReader reader) => throw new NotImplementedException();
|
public void Deserialize(IGenericReader reader) => throw new NotImplementedException();
|
||||||
|
|
||||||
public byte SerializedThread { get; set; }
|
|
||||||
public int SerializedPosition { get; set; }
|
|
||||||
public int SerializedLength { get; set; }
|
|
||||||
|
|
||||||
public void Serialize(IGenericWriter writer) => throw new NotImplementedException();
|
public void Serialize(IGenericWriter writer) => throw new NotImplementedException();
|
||||||
|
|
||||||
public bool Deleted { get; }
|
public bool Deleted { get; }
|
||||||
|
|
|
||||||
310
Projects/Server.Tests/Tests/Serialization/BufferWriterTests.cs
Normal file
310
Projects/Server.Tests/Tests/Serialization/BufferWriterTests.cs
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
using System;
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pins BufferWriter's byte-level output and position semantics so the write path can be
|
||||||
|
/// optimized without behavioral drift.
|
||||||
|
/// </summary>
|
||||||
|
public class BufferWriterTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void PrimitivesAreLittleEndianAtExpectedOffsets()
|
||||||
|
{
|
||||||
|
var writer = new BufferWriter(new byte[256], true);
|
||||||
|
|
||||||
|
writer.Write((byte)0xAB);
|
||||||
|
writer.Write((sbyte)-5);
|
||||||
|
writer.Write(true);
|
||||||
|
writer.Write(false);
|
||||||
|
writer.Write((short)-12345);
|
||||||
|
writer.Write((ushort)54321);
|
||||||
|
writer.Write(-123456789);
|
||||||
|
writer.Write(3123456789u);
|
||||||
|
writer.Write(-1234567890123456789L);
|
||||||
|
writer.Write(12345678901234567890UL);
|
||||||
|
writer.Write(1234.5678d);
|
||||||
|
writer.Write(56.75f);
|
||||||
|
writer.Write((Serial)0x40000001u);
|
||||||
|
|
||||||
|
Assert.Equal(1 + 1 + 1 + 1 + 2 + 2 + 4 + 4 + 8 + 8 + 8 + 4 + 4, writer.Position);
|
||||||
|
|
||||||
|
var b = writer.Buffer;
|
||||||
|
Assert.Equal(0xAB, b[0]);
|
||||||
|
Assert.Equal(unchecked((byte)-5), b[1]);
|
||||||
|
Assert.Equal(1, b[2]);
|
||||||
|
Assert.Equal(0, b[3]);
|
||||||
|
Assert.Equal(-12345, BinaryPrimitives.ReadInt16LittleEndian(b.AsSpan(4)));
|
||||||
|
Assert.Equal(54321, BinaryPrimitives.ReadUInt16LittleEndian(b.AsSpan(6)));
|
||||||
|
Assert.Equal(-123456789, BinaryPrimitives.ReadInt32LittleEndian(b.AsSpan(8)));
|
||||||
|
Assert.Equal(3123456789u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12)));
|
||||||
|
Assert.Equal(-1234567890123456789L, BinaryPrimitives.ReadInt64LittleEndian(b.AsSpan(16)));
|
||||||
|
Assert.Equal(12345678901234567890UL, BinaryPrimitives.ReadUInt64LittleEndian(b.AsSpan(24)));
|
||||||
|
Assert.Equal(1234.5678d, BinaryPrimitives.ReadDoubleLittleEndian(b.AsSpan(32)));
|
||||||
|
Assert.Equal(56.75f, BinaryPrimitives.ReadSingleLittleEndian(b.AsSpan(40)));
|
||||||
|
Assert.Equal(0x40000001u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(44)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SeekEndUsesHighWaterMarkNotCurrentPosition()
|
||||||
|
{
|
||||||
|
var writer = new BufferWriter(new byte[256], true);
|
||||||
|
|
||||||
|
writer.Write(1L);
|
||||||
|
writer.Write(2L);
|
||||||
|
writer.Write(3L); // high water = 24
|
||||||
|
|
||||||
|
writer.Seek(4, SeekOrigin.Begin);
|
||||||
|
writer.Write(99); // position now 8, high water still 24
|
||||||
|
|
||||||
|
Assert.Equal(24, writer.Seek(0, SeekOrigin.End));
|
||||||
|
Assert.Equal(24, writer.Position);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SeekCurrentAndBeginBehave()
|
||||||
|
{
|
||||||
|
var writer = new BufferWriter(new byte[64], true);
|
||||||
|
|
||||||
|
writer.Write(0xDEADBEEF);
|
||||||
|
Assert.Equal(2, writer.Seek(2, SeekOrigin.Begin));
|
||||||
|
Assert.Equal(3, writer.Seek(1, SeekOrigin.Current));
|
||||||
|
|
||||||
|
writer.Write((byte)0x77);
|
||||||
|
Assert.Equal(0x77, writer.Buffer[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GrowthPreservesContentAndPosition()
|
||||||
|
{
|
||||||
|
var writer = new BufferWriter(new byte[16], true);
|
||||||
|
|
||||||
|
for (var i = 0; i < 100; i++)
|
||||||
|
{
|
||||||
|
writer.Write((long)i);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(800, writer.Position);
|
||||||
|
Assert.True(writer.Buffer.Length >= 800);
|
||||||
|
|
||||||
|
for (var i = 0; i < 100; i++)
|
||||||
|
{
|
||||||
|
Assert.Equal(i, BinaryPrimitives.ReadInt64LittleEndian(writer.Buffer.AsSpan(i * 8)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SpanWriteCrossesGrowthBoundary()
|
||||||
|
{
|
||||||
|
var writer = new BufferWriter(new byte[8], true);
|
||||||
|
|
||||||
|
Span<byte> payload = stackalloc byte[64];
|
||||||
|
for (var i = 0; i < payload.Length; i++)
|
||||||
|
{
|
||||||
|
payload[i] = (byte)(i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.Write((ushort)7);
|
||||||
|
writer.Write(payload);
|
||||||
|
|
||||||
|
Assert.Equal(66, writer.Position);
|
||||||
|
Assert.Equal(payload.ToArray(), writer.Buffer.AsSpan(2, 64).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, new byte[] { 0x00 })]
|
||||||
|
[InlineData(127, new byte[] { 0x7F })]
|
||||||
|
[InlineData(128, new byte[] { 0x80, 0x01 })]
|
||||||
|
[InlineData(0x3FFF, new byte[] { 0xFF, 0x7F })]
|
||||||
|
[InlineData(0x4000, new byte[] { 0x80, 0x80, 0x01 })]
|
||||||
|
[InlineData(0x1F_FFFF, new byte[] { 0xFF, 0xFF, 0x7F })]
|
||||||
|
[InlineData(0x20_0000, new byte[] { 0x80, 0x80, 0x80, 0x01 })]
|
||||||
|
[InlineData(0xFFF_FFFF, new byte[] { 0xFF, 0xFF, 0xFF, 0x7F })]
|
||||||
|
[InlineData(0x1000_0000, new byte[] { 0x80, 0x80, 0x80, 0x80, 0x01 })]
|
||||||
|
[InlineData(int.MaxValue, new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0x07 })]
|
||||||
|
[InlineData(-1, new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0x0F })]
|
||||||
|
public void EncodedIntMatchesFormat(int value, byte[] expected)
|
||||||
|
{
|
||||||
|
var writer = new BufferWriter(new byte[16], true);
|
||||||
|
|
||||||
|
((IGenericWriter)writer).WriteEncodedInt(value);
|
||||||
|
|
||||||
|
Assert.Equal(expected.Length, writer.Position);
|
||||||
|
Assert.Equal(expected, writer.Buffer.AsSpan(0, expected.Length).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PrefixedStringsWriteFlagLengthAndUtf8()
|
||||||
|
{
|
||||||
|
var writer = new BufferWriter(new byte[256], true);
|
||||||
|
|
||||||
|
writer.Write("héllo Ωorld");
|
||||||
|
var utf8 = Encoding.UTF8.GetBytes("héllo Ωorld");
|
||||||
|
|
||||||
|
var b = writer.Buffer;
|
||||||
|
Assert.Equal(1, b[0]); // not-null flag
|
||||||
|
Assert.Equal(utf8.Length, b[1]); // encoded length (small string = 1 byte)
|
||||||
|
Assert.Equal(utf8, b.AsSpan(2, utf8.Length).ToArray());
|
||||||
|
Assert.Equal(2 + utf8.Length, writer.Position);
|
||||||
|
|
||||||
|
writer.Write((string)null);
|
||||||
|
Assert.Equal(0, b[2 + utf8.Length]); // null flag
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(84)] // scratch path
|
||||||
|
[InlineData(85)] // scratch path boundary
|
||||||
|
[InlineData(86)] // two-pass path
|
||||||
|
[InlineData(300)] // two-pass, length prefix > 1 byte
|
||||||
|
public void StringPathsAgreeAcrossTheScratchBoundary(int chars)
|
||||||
|
{
|
||||||
|
// Mixed ASCII, 2-byte, 3-byte, and surrogate-pair (4-byte) content
|
||||||
|
var builder = new StringBuilder(chars);
|
||||||
|
for (var i = 0; builder.Length < chars; i++)
|
||||||
|
{
|
||||||
|
switch (i % 4)
|
||||||
|
{
|
||||||
|
case 0:
|
||||||
|
builder.Append('a');
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
builder.Append('é');
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
builder.Append('Ω');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (builder.Length + 2 <= chars)
|
||||||
|
{
|
||||||
|
builder.Append("𝔘"); // surrogate pair
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
builder.Append('z');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var value = builder.ToString();
|
||||||
|
Assert.Equal(chars, value.Length);
|
||||||
|
|
||||||
|
var writer = new BufferWriter(new byte[16], true); // forces growth through both paths
|
||||||
|
writer.Write(value);
|
||||||
|
|
||||||
|
var utf8 = Encoding.UTF8.GetBytes(value);
|
||||||
|
var b = writer.Buffer;
|
||||||
|
Assert.Equal(1, b[0]);
|
||||||
|
|
||||||
|
// decode the 7-bit encoded length prefix
|
||||||
|
var offset = 1;
|
||||||
|
var length = 0;
|
||||||
|
var shift = 0;
|
||||||
|
byte current;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
current = b[offset++];
|
||||||
|
length |= (current & 0x7F) << shift;
|
||||||
|
shift += 7;
|
||||||
|
} while ((current & 0x80) != 0);
|
||||||
|
|
||||||
|
Assert.Equal(utf8.Length, length);
|
||||||
|
Assert.Equal(utf8, b.AsSpan(offset, length).ToArray());
|
||||||
|
Assert.Equal(offset + length, writer.Position);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DateTimeWritesUtcTicksViaInterface()
|
||||||
|
{
|
||||||
|
var writer = new BufferWriter(new byte[64], true);
|
||||||
|
IGenericWriter iface = writer;
|
||||||
|
|
||||||
|
var utc = new DateTime(2026, 7, 13, 1, 2, 3, DateTimeKind.Utc);
|
||||||
|
var local = utc.ToLocalTime();
|
||||||
|
|
||||||
|
iface.Write(utc);
|
||||||
|
iface.Write(local); // must convert to UTC
|
||||||
|
|
||||||
|
Assert.Equal(utc.Ticks, BinaryPrimitives.ReadInt64LittleEndian(writer.Buffer.AsSpan(0)));
|
||||||
|
Assert.Equal(utc.Ticks, BinaryPrimitives.ReadInt64LittleEndian(writer.Buffer.AsSpan(8)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Point3DWritesThreeInts()
|
||||||
|
{
|
||||||
|
var writer = new BufferWriter(new byte[64], true);
|
||||||
|
IGenericWriter iface = writer;
|
||||||
|
|
||||||
|
iface.Write(new Point3D(100, -200, 30));
|
||||||
|
|
||||||
|
Assert.Equal(12, writer.Position);
|
||||||
|
Assert.Equal(100, BinaryPrimitives.ReadInt32LittleEndian(writer.Buffer.AsSpan(0)));
|
||||||
|
Assert.Equal(-200, BinaryPrimitives.ReadInt32LittleEndian(writer.Buffer.AsSpan(4)));
|
||||||
|
Assert.Equal(30, BinaryPrimitives.ReadInt32LittleEndian(writer.Buffer.AsSpan(8)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DecimalRoundTripsThroughReader()
|
||||||
|
{
|
||||||
|
var writer = new BufferWriter(new byte[64], true);
|
||||||
|
writer.Write(1234567.89012m);
|
||||||
|
|
||||||
|
IGenericReader reader = new BufferReader(writer.Buffer);
|
||||||
|
Assert.Equal(1234567.89012m, reader.ReadDecimal());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LongStringPrefixIsZeroPaddedToWorstCaseWidth()
|
||||||
|
{
|
||||||
|
// 100 ASCII chars: worst case 300 bytes -> 2-byte prefix; actual 100 bytes would
|
||||||
|
// canonically fit in 1. The prefix must be the non-minimal 2-byte form the readers
|
||||||
|
// decode identically: (100 | 0x80), 0x00.
|
||||||
|
var value = new string('a', 100);
|
||||||
|
var writer = new BufferWriter(new byte[1024], false);
|
||||||
|
writer.WriteRaw(value);
|
||||||
|
|
||||||
|
var b = writer.Buffer;
|
||||||
|
Assert.Equal((byte)(100 | 0x80), b[0]);
|
||||||
|
Assert.Equal(0, b[1]);
|
||||||
|
Assert.Equal(2 + 100, writer.Position);
|
||||||
|
|
||||||
|
IGenericReader reader = new BufferReader(writer.Buffer);
|
||||||
|
Assert.Equal(value, reader.ReadStringRaw());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(42)] // canonical 1-byte prefix (3 * 42 < 0x80)
|
||||||
|
[InlineData(100)] // padded prefix
|
||||||
|
[InlineData(10_000)] // multi-byte prefix, forces growth from a small buffer
|
||||||
|
public void ThreeBytePerCharContentRoundTrips(int chars)
|
||||||
|
{
|
||||||
|
// CJK content encodes at the UTF-8 worst case of 3 bytes per char - the case an
|
||||||
|
// undersized scratch would truncate or throw on.
|
||||||
|
var value = new string('二', chars);
|
||||||
|
var writer = new BufferWriter(new byte[16], true);
|
||||||
|
writer.Write(value);
|
||||||
|
|
||||||
|
Assert.Equal(Encoding.UTF8.GetByteCount(value), 3 * chars);
|
||||||
|
|
||||||
|
IGenericReader reader = new BufferReader(writer.Buffer);
|
||||||
|
Assert.Equal(value, reader.ReadString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SurrogatePairContentRoundTripsThroughRawPath()
|
||||||
|
{
|
||||||
|
// Surrogate pairs encode 2 chars into 4 bytes (2 bytes per char) - under the
|
||||||
|
// 3-bytes-per-char reservation, exercising written < maxLength with a padded prefix.
|
||||||
|
var value = string.Concat(Enumerable.Repeat("😀", 60)); // 120 chars, 240 bytes
|
||||||
|
var writer = new BufferWriter(new byte[16], false);
|
||||||
|
writer.WriteRaw(value);
|
||||||
|
|
||||||
|
IGenericReader reader = new BufferReader(writer.Buffer);
|
||||||
|
Assert.Equal(value, reader.ReadStringRaw());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Server.Tests;
|
||||||
|
|
||||||
|
public class FileBufferWriterTests
|
||||||
|
{
|
||||||
|
private static string TempFile() =>
|
||||||
|
Path.Combine(Path.GetTempPath(), $"muo-fbw-{Guid.NewGuid():N}.bin");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DrainsAcrossStagingBoundariesAndPatchesBackwards()
|
||||||
|
{
|
||||||
|
var path = TempFile();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Tiny staging block so every few records cross a drain; mirrors the idx
|
||||||
|
// pattern: version, count placeholder, records, backwards count patch.
|
||||||
|
using (var writer = new FileBufferWriter(path, expectedSize: 64))
|
||||||
|
{
|
||||||
|
writer.Write(3); // version
|
||||||
|
|
||||||
|
var countPosition = writer.Position;
|
||||||
|
writer.Write(0);
|
||||||
|
|
||||||
|
const int records = 1000;
|
||||||
|
for (var i = 0; i < records; i++)
|
||||||
|
{
|
||||||
|
writer.Write((ulong)i * 0x9E3779B97F4A7C15);
|
||||||
|
writer.Write(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
var end = writer.Position;
|
||||||
|
writer.Seek(countPosition, SeekOrigin.Begin);
|
||||||
|
writer.Write(records);
|
||||||
|
writer.Seek(0, SeekOrigin.End);
|
||||||
|
|
||||||
|
Assert.Equal(end, writer.Position);
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes = File.ReadAllBytes(path);
|
||||||
|
Assert.Equal(4 + 4 + 1000 * 12, bytes.Length);
|
||||||
|
|
||||||
|
IGenericReader reader = new BufferReader(bytes);
|
||||||
|
Assert.Equal(3, reader.ReadInt());
|
||||||
|
Assert.Equal(1000, reader.ReadInt());
|
||||||
|
|
||||||
|
for (var i = 0; i < 1000; i++)
|
||||||
|
{
|
||||||
|
Assert.Equal((ulong)i * 0x9E3779B97F4A7C15, reader.ReadULong());
|
||||||
|
Assert.Equal(i, reader.ReadInt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OversizedSingleItemGrowsTheStagingBlock()
|
||||||
|
{
|
||||||
|
var path = TempFile();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// A string whose worst-case reservation exceeds the staging block must grow
|
||||||
|
// the block instead of deadlocking the drain loop.
|
||||||
|
var value = new string('二', 500); // 1500 bytes utf8, staging 64
|
||||||
|
|
||||||
|
using (var writer = new FileBufferWriter(path, expectedSize: 64))
|
||||||
|
{
|
||||||
|
writer.Write(value);
|
||||||
|
writer.Write(0xC0FFEE);
|
||||||
|
}
|
||||||
|
|
||||||
|
IGenericReader reader = new BufferReader(File.ReadAllBytes(path));
|
||||||
|
Assert.Equal(value, reader.ReadString());
|
||||||
|
Assert.Equal(0xC0FFEE, reader.ReadInt());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SpanWritesCrossDrains()
|
||||||
|
{
|
||||||
|
var path = TempFile();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var payload = new byte[777];
|
||||||
|
new System.Random(0x5EED).NextBytes(payload);
|
||||||
|
|
||||||
|
using (var writer = new FileBufferWriter(path, expectedSize: 64))
|
||||||
|
{
|
||||||
|
writer.Write((ReadOnlySpan<byte>)payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(payload, File.ReadAllBytes(path));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TypeWritesUseTagAndHashFormat()
|
||||||
|
{
|
||||||
|
var path = TempFile();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var writer = new FileBufferWriter(path))
|
||||||
|
{
|
||||||
|
writer.Write(typeof(string));
|
||||||
|
writer.Write((Type)null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes = File.ReadAllBytes(path);
|
||||||
|
Assert.Equal(1 + 8 + 1, bytes.Length); // flag + hash + null flag
|
||||||
|
Assert.Equal(2, bytes[0]);
|
||||||
|
Assert.Equal(0, bytes[^1]);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Server.Tests;
|
||||||
|
|
||||||
|
internal class RoundTripEntity : ISerializable
|
||||||
|
{
|
||||||
|
public RoundTripEntity(Serial serial) => Serial = serial;
|
||||||
|
|
||||||
|
public Serial Serial { get; }
|
||||||
|
public DateTime Created { get; set; } = DateTime.UtcNow;
|
||||||
|
public bool Deleted => false;
|
||||||
|
|
||||||
|
public int Value { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
public void Delete()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Serialize(IGenericWriter writer)
|
||||||
|
{
|
||||||
|
writer.Write(Value);
|
||||||
|
writer.Write(Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Deserialize(IGenericReader reader)
|
||||||
|
{
|
||||||
|
Value = reader.ReadInt();
|
||||||
|
Name = reader.ReadString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Collection("Sequential Server Tests")]
|
||||||
|
public class GenericEntityPersistenceRoundTripTests
|
||||||
|
{
|
||||||
|
private const uint SelfPayloadMarker = 0xDEADBEEF;
|
||||||
|
|
||||||
|
private class RoundTripPersistence : GenericEntityPersistence<RoundTripEntity>
|
||||||
|
{
|
||||||
|
public bool SelfPayloadDeserialized { get; private set; }
|
||||||
|
|
||||||
|
public RoundTripPersistence(int priority) : base("RoundTrip", priority, 1, 0x7FFFFFFF)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Serialize(IGenericWriter writer) => writer.Write(SelfPayloadMarker);
|
||||||
|
|
||||||
|
public override void Deserialize(IGenericReader reader)
|
||||||
|
{
|
||||||
|
Assert.Equal(SelfPayloadMarker, reader.ReadUInt());
|
||||||
|
SelfPayloadDeserialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drives the real pipeline end to end: entities serialize on workers via slot-range
|
||||||
|
/// chunks (plus the persistence self-payload as a single), WriteSnapshot assembles the
|
||||||
|
/// idx/bin from the per-worker segment logs, and a fresh persistence loads it all back.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void SnapshotRoundTripsThroughWorkersAndSegmentLogs()
|
||||||
|
{
|
||||||
|
// The loader resolves types by hash through AssemblyHandler; make this test
|
||||||
|
// assembly visible for the duration of the test.
|
||||||
|
var previousAssemblies = AssemblyHandler.Assemblies;
|
||||||
|
AssemblyHandler.Assemblies = [.. previousAssemblies ?? [], typeof(RoundTripEntity).Assembly];
|
||||||
|
|
||||||
|
var source = new SerializationChunkSource();
|
||||||
|
var workers = new SerializationThreadWorker[3];
|
||||||
|
for (var i = 0; i < workers.Length; i++)
|
||||||
|
{
|
||||||
|
workers[i] = new SerializationThreadWorker(i, source);
|
||||||
|
workers[i].AllocateHeap();
|
||||||
|
}
|
||||||
|
|
||||||
|
var previousWorkers = World._threadWorkers;
|
||||||
|
World._threadWorkers = workers;
|
||||||
|
|
||||||
|
var persistence = new RoundTripPersistence(2000);
|
||||||
|
RoundTripPersistence loaded = null;
|
||||||
|
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), $"muo-roundtrip-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const int entityCount = 25_000;
|
||||||
|
var rng = new System.Random(0x5EED);
|
||||||
|
|
||||||
|
for (var i = 1; i <= entityCount; i++)
|
||||||
|
{
|
||||||
|
var serial = (Serial)(uint)i;
|
||||||
|
persistence.EntitiesBySerial[serial] = new RoundTripEntity(serial)
|
||||||
|
{
|
||||||
|
Value = rng.Next(),
|
||||||
|
Name = i % 5 == 0 ? null : $"entity-{i}"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
persistence.RegisterType(typeof(RoundTripEntity));
|
||||||
|
|
||||||
|
// Freeze: what Persistence.SerializeAll + GenericEntityPersistence.Serialize do,
|
||||||
|
// against this test's chunk source instead of the world's.
|
||||||
|
foreach (var worker in workers)
|
||||||
|
{
|
||||||
|
worker.Wake();
|
||||||
|
}
|
||||||
|
|
||||||
|
source.SetOwner(persistence);
|
||||||
|
source.PushSingle(persistence);
|
||||||
|
Assert.True(persistence.TrySnapshotEntries(out var slotCount));
|
||||||
|
source.PushSlotRanges(persistence, slotCount);
|
||||||
|
|
||||||
|
// Mirrors World.PauseSerializationThreads
|
||||||
|
source.Flush();
|
||||||
|
foreach (var worker in workers)
|
||||||
|
{
|
||||||
|
worker.Sleep();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background write phase: snapshot from the segment logs. v4 embeds the type
|
||||||
|
// table in the idx, so no SerializedTypes.db is produced or needed.
|
||||||
|
persistence.WriteSnapshot(dir);
|
||||||
|
|
||||||
|
persistence.PostWorldSave(); // releases the entries snapshot
|
||||||
|
|
||||||
|
// Load into a fresh persistence, like a server boot would.
|
||||||
|
loaded = new RoundTripPersistence(2001);
|
||||||
|
loaded.DeserializeIndexes(dir, null);
|
||||||
|
loaded.Deserialize(dir, null);
|
||||||
|
|
||||||
|
Assert.True(loaded.SelfPayloadDeserialized);
|
||||||
|
Assert.Equal(persistence.EntitiesBySerial.Count, loaded.EntitiesBySerial.Count);
|
||||||
|
|
||||||
|
foreach (var (serial, original) in persistence.EntitiesBySerial)
|
||||||
|
{
|
||||||
|
var entity = loaded.EntitiesBySerial[serial];
|
||||||
|
Assert.Equal(original.Value, entity.Value);
|
||||||
|
Assert.Equal(original.Name, entity.Name);
|
||||||
|
Assert.Equal(original.Created.Ticks, entity.Created.Ticks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading must hydrate the type table so the next save can write indexes.
|
||||||
|
Assert.True(loaded.TryGetTypeIndex(typeof(RoundTripEntity), out _));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
persistence.Unregister();
|
||||||
|
loaded?.Unregister();
|
||||||
|
|
||||||
|
foreach (var worker in workers)
|
||||||
|
{
|
||||||
|
worker.Exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
World._threadWorkers = previousWorkers;
|
||||||
|
AssemblyHandler.Assemblies = previousAssemblies;
|
||||||
|
Directory.Delete(dir, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Server.Tests;
|
||||||
|
|
||||||
|
[Collection("Sequential Server Tests")]
|
||||||
|
public class GenericEntityPersistenceTypeTableTests
|
||||||
|
{
|
||||||
|
private class TypeTablePersistence : GenericEntityPersistence<RoundTripEntity>
|
||||||
|
{
|
||||||
|
public TypeTablePersistence() : base("TypeTable", 3000, 1, 0x7FFFFFFF)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Serialize(IGenericWriter writer)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Deserialize(IGenericReader reader)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegisterTypeAssignsStableInsertionOrderedIndexes()
|
||||||
|
{
|
||||||
|
var persistence = new TypeTablePersistence();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
persistence.RegisterType(typeof(RoundTripEntity));
|
||||||
|
persistence.RegisterType(typeof(string));
|
||||||
|
persistence.RegisterType(typeof(RoundTripEntity)); // duplicate is a no-op
|
||||||
|
|
||||||
|
Assert.Equal(2, persistence.TypeTable.Count);
|
||||||
|
Assert.Same(typeof(RoundTripEntity), persistence.TypeTable[0]);
|
||||||
|
Assert.Same(typeof(string), persistence.TypeTable[1]);
|
||||||
|
|
||||||
|
Assert.True(persistence.TryGetTypeIndex(typeof(RoundTripEntity), out var first));
|
||||||
|
Assert.Equal(0, first);
|
||||||
|
Assert.True(persistence.TryGetTypeIndex(typeof(string), out var second));
|
||||||
|
Assert.Equal(1, second);
|
||||||
|
Assert.False(persistence.TryGetTypeIndex(typeof(int), out _));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
persistence.Unregister();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnresolvedTableEntrySkipsItsRecordsAfterConfirmation()
|
||||||
|
{
|
||||||
|
var previousAssemblies = AssemblyHandler.Assemblies;
|
||||||
|
AssemblyHandler.Assemblies = [.. previousAssemblies ?? [], typeof(RoundTripEntity).Assembly];
|
||||||
|
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), $"muo-typetable-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(Path.Combine(dir, "TypeTable"));
|
||||||
|
|
||||||
|
TypeTablePersistence loaded = null;
|
||||||
|
var previousIn = Console.In;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Hand-write a v4 idx: two table entries (one bogus), one record per type.
|
||||||
|
using (var idx = new FileBufferWriter(Path.Combine(dir, "TypeTable", "TypeTable.idx")))
|
||||||
|
{
|
||||||
|
idx.Write(4); // version
|
||||||
|
idx.Write(2); // type table count
|
||||||
|
idx.WriteRaw(typeof(RoundTripEntity).FullName); // index 0: resolvable
|
||||||
|
idx.WriteRaw("Server.Tests.DoesNotExistAnymore"); // index 1: bogus
|
||||||
|
idx.Write(2); // record count
|
||||||
|
idx.Write((ushort)0); // record 1: real type
|
||||||
|
idx.Write(1u); // serial
|
||||||
|
idx.Write(DateTime.UtcNow.Ticks);
|
||||||
|
idx.Write(0L); // position
|
||||||
|
idx.Write(4); // length
|
||||||
|
idx.Write((ushort)1); // record 2: bogus type
|
||||||
|
idx.Write(2u);
|
||||||
|
idx.Write(DateTime.UtcNow.Ticks);
|
||||||
|
idx.Write(4L);
|
||||||
|
idx.Write(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConstructorFor prompts on the console; answer "y" (delete those types).
|
||||||
|
Console.SetIn(new StringReader("y\n"));
|
||||||
|
|
||||||
|
loaded = new TypeTablePersistence();
|
||||||
|
loaded.DeserializeIndexes(dir, null);
|
||||||
|
|
||||||
|
Assert.Single(loaded.EntitiesBySerial);
|
||||||
|
Assert.True(loaded.EntitiesBySerial.ContainsKey((Serial)1u));
|
||||||
|
Assert.True(loaded.TryGetTypeIndex(typeof(RoundTripEntity), out _));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Console.SetIn(previousIn);
|
||||||
|
loaded?.Unregister();
|
||||||
|
AssemblyHandler.Assemblies = previousAssemblies;
|
||||||
|
Directory.Delete(dir, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LegacyV3IndexesStillLoadAndHydrateTheTypeTable()
|
||||||
|
{
|
||||||
|
var previousAssemblies = AssemblyHandler.Assemblies;
|
||||||
|
AssemblyHandler.Assemblies = [.. previousAssemblies ?? [], typeof(RoundTripEntity).Assembly];
|
||||||
|
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), $"muo-legacyidx-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(Path.Combine(dir, "TypeTable"));
|
||||||
|
|
||||||
|
TypeTablePersistence loaded = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var hash = AssemblyHandler.GetTypeHash(typeof(RoundTripEntity));
|
||||||
|
|
||||||
|
// Hand-write a v3 idx: records carry flag + 8-byte hash, resolved via typesDb.
|
||||||
|
using (var idx = new FileBufferWriter(Path.Combine(dir, "TypeTable", "TypeTable.idx")))
|
||||||
|
{
|
||||||
|
idx.Write(3); // version
|
||||||
|
idx.Write(1); // record count
|
||||||
|
idx.Write((byte)2); // xxHash3 flag
|
||||||
|
idx.Write(hash);
|
||||||
|
idx.Write(1u); // serial
|
||||||
|
idx.Write(DateTime.UtcNow.Ticks);
|
||||||
|
idx.Write(0L); // position
|
||||||
|
idx.Write(4); // length
|
||||||
|
}
|
||||||
|
|
||||||
|
var typesDb = new Dictionary<ulong, string> { [hash] = typeof(RoundTripEntity).FullName };
|
||||||
|
|
||||||
|
loaded = new TypeTablePersistence();
|
||||||
|
loaded.DeserializeIndexes(dir, typesDb);
|
||||||
|
|
||||||
|
Assert.Single(loaded.EntitiesBySerial);
|
||||||
|
Assert.True(loaded.EntitiesBySerial.ContainsKey((Serial)1u));
|
||||||
|
|
||||||
|
// Legacy loads must hydrate the table so the NEXT save can write v4 indexes.
|
||||||
|
Assert.True(loaded.TryGetTypeIndex(typeof(RoundTripEntity), out _));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
loaded?.Unregister();
|
||||||
|
AssemblyHandler.Assemblies = previousAssemblies;
|
||||||
|
Directory.Delete(dir, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,312 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Server.Tests;
|
||||||
|
|
||||||
|
// Constructing a persistence mutates the static registry (an unsynchronized SortedSet);
|
||||||
|
// tests that do so must share the sequential collection.
|
||||||
|
[Collection("Sequential Server Tests")]
|
||||||
|
public class SerializationChunkSourceTests
|
||||||
|
{
|
||||||
|
private class TestEntity : IGenericSerializable
|
||||||
|
{
|
||||||
|
public int PayloadSize { get; init; } = 16;
|
||||||
|
|
||||||
|
public void Serialize(IGenericWriter writer)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < PayloadSize; i++)
|
||||||
|
{
|
||||||
|
writer.Write((byte)(i & 0xFF));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestPersistence : GenericPersistence
|
||||||
|
{
|
||||||
|
public int PayloadSize { get; init; } = 16;
|
||||||
|
|
||||||
|
public TestPersistence() : base("ChunkSourceTest", 100)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public (byte Thread, int Position, int Length) Placement => (_selfThread, _selfPosition, _selfLength);
|
||||||
|
|
||||||
|
public override void Serialize(IGenericWriter writer)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < PayloadSize; i++)
|
||||||
|
{
|
||||||
|
writer.Write((byte)(i & 0xFF));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Deserialize(IGenericReader reader)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<TestEntity> Drain(SerializationChunkSource source)
|
||||||
|
{
|
||||||
|
var drained = new List<TestEntity>();
|
||||||
|
while (source.TryTake(out var chunk))
|
||||||
|
{
|
||||||
|
for (var i = 0; i < chunk.Count; i++)
|
||||||
|
{
|
||||||
|
drained.Add((TestEntity)chunk.Buffer[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
source.Return(chunk.Buffer, chunk.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
return drained;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PartialChunkIsNotVisibleUntilFlush()
|
||||||
|
{
|
||||||
|
var source = new SerializationChunkSource();
|
||||||
|
var entities = new List<TestEntity>();
|
||||||
|
|
||||||
|
for (var i = 0; i < 100; i++)
|
||||||
|
{
|
||||||
|
var e = new TestEntity();
|
||||||
|
entities.Add(e);
|
||||||
|
source.Push(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.False(source.TryTake(out _));
|
||||||
|
|
||||||
|
source.Flush();
|
||||||
|
|
||||||
|
var drained = Drain(source);
|
||||||
|
Assert.Equal(entities, drained);
|
||||||
|
|
||||||
|
// Flush again should publish nothing
|
||||||
|
source.Flush();
|
||||||
|
Assert.False(source.TryTake(out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FullChunkPublishesWithoutFlush()
|
||||||
|
{
|
||||||
|
var source = new SerializationChunkSource();
|
||||||
|
|
||||||
|
for (var i = 0; i < 4096; i++)
|
||||||
|
{
|
||||||
|
source.Push(new TestEntity());
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(source.TryTake(out var chunk));
|
||||||
|
Assert.Null(chunk.Single);
|
||||||
|
Assert.Equal(4096, chunk.Count);
|
||||||
|
source.Return(chunk.Buffer, chunk.Count);
|
||||||
|
|
||||||
|
// Nothing partial left behind
|
||||||
|
source.Flush();
|
||||||
|
Assert.False(source.TryTake(out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PushSingleDoesNotDisturbPartialChunk()
|
||||||
|
{
|
||||||
|
var source = new SerializationChunkSource();
|
||||||
|
var heavy = new TestPersistence();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var small1 = new TestEntity();
|
||||||
|
var small2 = new TestEntity();
|
||||||
|
|
||||||
|
source.Push(small1);
|
||||||
|
source.PushSingle(heavy); // published immediately as a single, ahead of the partial chunk
|
||||||
|
source.Push(small2);
|
||||||
|
|
||||||
|
Assert.True(source.TryTake(out var chunk));
|
||||||
|
Assert.Same(heavy, chunk.Single);
|
||||||
|
Assert.Equal(1, chunk.Count);
|
||||||
|
|
||||||
|
Assert.False(source.TryTake(out _));
|
||||||
|
source.Flush();
|
||||||
|
|
||||||
|
var drained = Drain(source);
|
||||||
|
Assert.Equal([small1, small2], drained);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
heavy.Unregister();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetOwnerPublishesPartialChunkOnChange()
|
||||||
|
{
|
||||||
|
var source = new SerializationChunkSource();
|
||||||
|
var ownerA = new TestPersistence();
|
||||||
|
var ownerB = new TestPersistence();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
source.SetOwner(ownerA);
|
||||||
|
source.Push(new TestEntity());
|
||||||
|
source.Push(new TestEntity());
|
||||||
|
|
||||||
|
// Same owner: partial chunk stays private to the producer.
|
||||||
|
source.SetOwner(ownerA);
|
||||||
|
Assert.False(source.TryTake(out _));
|
||||||
|
|
||||||
|
// Owner change publishes the partial chunk, keeping chunks persistence-homogeneous.
|
||||||
|
source.SetOwner(ownerB);
|
||||||
|
Assert.True(source.TryTake(out var chunk));
|
||||||
|
Assert.Same(ownerA, chunk.Owner);
|
||||||
|
Assert.Equal(2, chunk.Count);
|
||||||
|
source.Return(chunk.Buffer, chunk.Count);
|
||||||
|
|
||||||
|
source.Push(new TestEntity());
|
||||||
|
source.Flush();
|
||||||
|
Assert.True(source.TryTake(out chunk));
|
||||||
|
Assert.Same(ownerB, chunk.Owner);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ownerA.Unregister();
|
||||||
|
ownerB.Unregister();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReturnedBuffersAreClearedAndReused()
|
||||||
|
{
|
||||||
|
var source = new SerializationChunkSource();
|
||||||
|
|
||||||
|
for (var i = 0; i < 4096; i++)
|
||||||
|
{
|
||||||
|
source.Push(new TestEntity());
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(source.TryTake(out var chunk));
|
||||||
|
var buffer = chunk.Buffer;
|
||||||
|
source.Return(buffer, chunk.Count);
|
||||||
|
|
||||||
|
Assert.All(buffer, Assert.Null);
|
||||||
|
|
||||||
|
// Next fill rents the pooled buffer instead of allocating
|
||||||
|
source.Push(new TestEntity());
|
||||||
|
source.Flush();
|
||||||
|
Assert.True(source.TryTake(out var reused));
|
||||||
|
Assert.Same(buffer, reused.Buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WorkersDrainAllEntitiesAndLogSegments()
|
||||||
|
{
|
||||||
|
var source = new SerializationChunkSource();
|
||||||
|
var owner = new TestPersistence { PayloadSize = 512 * 1024 };
|
||||||
|
var workers = new SerializationThreadWorker[2];
|
||||||
|
for (var i = 0; i < workers.Length; i++)
|
||||||
|
{
|
||||||
|
workers[i] = new SerializationThreadWorker(i, source);
|
||||||
|
workers[i].AllocateHeap();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var entities = new List<TestEntity>();
|
||||||
|
for (var i = 0; i < 10_000; i++)
|
||||||
|
{
|
||||||
|
entities.Add(new TestEntity { PayloadSize = 16 + i % 64 });
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var worker in workers)
|
||||||
|
{
|
||||||
|
worker.Wake();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A large payload published as a dedicated single chunk (a persistence
|
||||||
|
// self-payload), interleaved with the bare entity stream.
|
||||||
|
source.SetOwner(owner);
|
||||||
|
for (var i = 0; i < entities.Count; i++)
|
||||||
|
{
|
||||||
|
if (i == 5000)
|
||||||
|
{
|
||||||
|
source.PushSingle(owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
source.Push(entities[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors World.PauseSerializationThreads
|
||||||
|
source.Flush();
|
||||||
|
foreach (var worker in workers)
|
||||||
|
{
|
||||||
|
worker.Sleep();
|
||||||
|
}
|
||||||
|
|
||||||
|
long totalEntities = 0;
|
||||||
|
long totalBytes = 0;
|
||||||
|
foreach (var worker in workers)
|
||||||
|
{
|
||||||
|
totalEntities += worker.EntitiesSerialized;
|
||||||
|
totalBytes += worker.BytesSerialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(entities.Count + 1, totalEntities);
|
||||||
|
|
||||||
|
// The self-payload recorded its placement on the persistence itself.
|
||||||
|
var (selfThread, selfPosition, selfLength) = owner.Placement;
|
||||||
|
Assert.Equal(owner.PayloadSize, selfLength);
|
||||||
|
Assert.InRange(selfThread, (byte)0, (byte)(workers.Length - 1));
|
||||||
|
var selfHeap = workers[selfThread].GetHeap(selfPosition, selfLength);
|
||||||
|
Assert.Equal(0, selfHeap[0]);
|
||||||
|
Assert.Equal((selfLength - 1) & 0xFF, selfHeap[^1]);
|
||||||
|
|
||||||
|
// Every entity appears exactly once in the worker segment logs, with a
|
||||||
|
// consistent span on that worker's heap: positions are implicit (contiguous
|
||||||
|
// writes), identity comes from the buffer-entities log.
|
||||||
|
var seen = new HashSet<TestEntity>();
|
||||||
|
long expectedBytes = owner.PayloadSize;
|
||||||
|
foreach (var e in entities)
|
||||||
|
{
|
||||||
|
expectedBytes += e.PayloadSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var worker in workers)
|
||||||
|
{
|
||||||
|
var lengths = worker.Lengths;
|
||||||
|
var bufferEntities = worker.BufferEntities;
|
||||||
|
|
||||||
|
foreach (var segment in worker.Segments)
|
||||||
|
{
|
||||||
|
Assert.Same(owner, segment.Owner);
|
||||||
|
Assert.Equal(-1, segment.SlotOffset);
|
||||||
|
|
||||||
|
var heapPos = (int)segment.HeapStart;
|
||||||
|
for (var i = 0; i < segment.RecordCount; i++)
|
||||||
|
{
|
||||||
|
var entity = (TestEntity)bufferEntities[segment.EntitiesStart + i];
|
||||||
|
var length = lengths[segment.LengthsStart + i];
|
||||||
|
|
||||||
|
Assert.True(seen.Add(entity));
|
||||||
|
Assert.Equal(entity.PayloadSize, length);
|
||||||
|
|
||||||
|
var heap = workers[Array.IndexOf(workers, worker)].GetHeap(heapPos, length);
|
||||||
|
Assert.Equal(0, heap[0]);
|
||||||
|
Assert.Equal((length - 1) & 0xFF, heap[^1]);
|
||||||
|
|
||||||
|
heapPos += length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(entities.Count, seen.Count);
|
||||||
|
Assert.Equal(expectedBytes, totalBytes);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
owner.Unregister();
|
||||||
|
foreach (var worker in workers)
|
||||||
|
{
|
||||||
|
worker.Exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Server.Tests;
|
||||||
|
|
||||||
|
[Collection("Sequential Server Tests")]
|
||||||
|
public class SerializationThreadWorkerHandshakeTests
|
||||||
|
{
|
||||||
|
// The pause handshake must tolerate a new cycle starting the moment _stopEvent is
|
||||||
|
// set (Exit right after Sleep). The watchdog turns a reintroduced deadlock into a
|
||||||
|
// failure instead of a hung run.
|
||||||
|
[Fact]
|
||||||
|
public void WakeSleepExitChurn_NeverDeadlocks()
|
||||||
|
{
|
||||||
|
Exception failure = null;
|
||||||
|
var done = new ManualResetEventSlim();
|
||||||
|
|
||||||
|
var churn = new Thread(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (var i = 0; i < 2000; i++)
|
||||||
|
{
|
||||||
|
var source = new SerializationChunkSource();
|
||||||
|
var worker = new SerializationThreadWorker(0, source);
|
||||||
|
worker.AllocateHeap();
|
||||||
|
|
||||||
|
worker.Wake();
|
||||||
|
worker.Sleep();
|
||||||
|
worker.Exit(); // Immediately after Sleep returns — the racy window.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
failure = e;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
done.Set();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
{
|
||||||
|
IsBackground = true,
|
||||||
|
Name = "Handshake Churn"
|
||||||
|
};
|
||||||
|
|
||||||
|
churn.Start();
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
done.Wait(TimeSpan.FromMinutes(2)),
|
||||||
|
"Worker pause/exit handshake deadlocked (owner blocked in Sleep or worker spinning)."
|
||||||
|
);
|
||||||
|
Assert.Null(failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Server.Tests;
|
||||||
|
|
||||||
|
// Constructing a persistence mutates the static registry (an unsynchronized SortedSet);
|
||||||
|
// tests that do so must share the sequential collection.
|
||||||
|
[Collection("Sequential Server Tests")]
|
||||||
|
public class ShadowDictionaryEntriesTests
|
||||||
|
{
|
||||||
|
private class TestEntity : ISerializable
|
||||||
|
{
|
||||||
|
public TestEntity(Serial serial) => Serial = serial;
|
||||||
|
|
||||||
|
public Serial Serial { get; }
|
||||||
|
public DateTime Created { get; set; }
|
||||||
|
public bool Deleted => false;
|
||||||
|
|
||||||
|
public void Delete()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Serialize(IGenericWriter writer)
|
||||||
|
{
|
||||||
|
writer.Write(Serial);
|
||||||
|
writer.Write(0xC0FFEE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Deserialize(IGenericReader reader)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TestEntity GetSlotValue(Array entries, int slot) =>
|
||||||
|
System.Runtime.CompilerServices.Unsafe.As<ShadowEntry<TestEntity>[]>(entries)[slot].Value;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RuntimeLayoutIsSupported()
|
||||||
|
{
|
||||||
|
// If this fails on a runtime upgrade, saves still work via the fallback path,
|
||||||
|
// but the parallel iteration fast path is silently lost — this test makes it loud.
|
||||||
|
Assert.True(ShadowDictionaryEntries.Supported);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SerializeRangeCoversExactlyTheLiveEntities()
|
||||||
|
{
|
||||||
|
var persistence = new GenericEntityPersistence<TestEntity>("ShadowTest", 1000, 1, 0x7FFFFFFF);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var rng = new System.Random(0xBEEF);
|
||||||
|
var dict = persistence.EntitiesBySerial;
|
||||||
|
|
||||||
|
// Heavy churn: adds, removes, and re-adds to exercise freelist reuse and resizes,
|
||||||
|
// leaving free slots scattered through the entries array.
|
||||||
|
var serials = new List<Serial>();
|
||||||
|
for (var i = 0; i < 50_000; i++)
|
||||||
|
{
|
||||||
|
var serial = (Serial)(uint)rng.Next(1, int.MaxValue);
|
||||||
|
if (dict.TryAdd(serial, new TestEntity(serial)))
|
||||||
|
{
|
||||||
|
serials.Add(serial);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i % 4 == 3)
|
||||||
|
{
|
||||||
|
var index = rng.Next(serials.Count);
|
||||||
|
dict.Remove(serials[index]);
|
||||||
|
serials.RemoveAt(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(persistence.TrySnapshotEntries(out var slotCount));
|
||||||
|
Assert.True(slotCount >= dict.Count);
|
||||||
|
|
||||||
|
var source = (ISlotRangeSource)persistence;
|
||||||
|
var writer = new BufferWriter(new byte[dict.Count * 16], true);
|
||||||
|
var lengths = new List<int>();
|
||||||
|
|
||||||
|
// Serialize in worker-sized slices, like the drain does.
|
||||||
|
var serialized = 0;
|
||||||
|
for (var offset = 0; offset < slotCount; offset += 4096)
|
||||||
|
{
|
||||||
|
serialized += source.SerializeRange(writer, lengths, offset, Math.Min(4096, slotCount - offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(dict.Count, serialized);
|
||||||
|
Assert.Equal(dict.Count, lengths.Count);
|
||||||
|
|
||||||
|
// Re-walk the same slots in the same order, pairing each occupied slot with the
|
||||||
|
// next logged length — exactly how the snapshot writer locates each record.
|
||||||
|
var entriesField = ShadowDictionaryEntries.GetEntriesField<TestEntity>();
|
||||||
|
var entries = (Array)entriesField.GetValue(dict);
|
||||||
|
|
||||||
|
var position = 0;
|
||||||
|
var lengthIndex = 0;
|
||||||
|
var matched = 0;
|
||||||
|
|
||||||
|
for (var slot = 0; slot < entries.Length; slot++)
|
||||||
|
{
|
||||||
|
// Occupancy is exactly the non-null values, same as production.
|
||||||
|
var entity = GetSlotValue(entries, slot);
|
||||||
|
if (entity == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var length = lengths[lengthIndex++];
|
||||||
|
Assert.Equal(8, length); // serial + int
|
||||||
|
|
||||||
|
var span = writer.Buffer.AsSpan(position, length);
|
||||||
|
Assert.Equal(entity.Serial, (Serial)System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(span));
|
||||||
|
|
||||||
|
position += length;
|
||||||
|
matched++;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(dict.Count, matched);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
persistence.Unregister();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SnapshotFailsGracefullyOnEmptyDictionary()
|
||||||
|
{
|
||||||
|
var persistence = new GenericEntityPersistence<TestEntity>("ShadowTestEmpty", 1001, 1, 0x7FFFFFFF);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.False(persistence.TrySnapshotEntries(out var slotCount));
|
||||||
|
Assert.Equal(0, slotCount);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
persistence.Unregister();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -52,10 +52,6 @@ public abstract class BaseGuild : ISerializable
|
||||||
[CommandProperty(AccessLevel.GameMaster, readOnly: true)]
|
[CommandProperty(AccessLevel.GameMaster, readOnly: true)]
|
||||||
public DateTime Created { get; set; } = Core.Now;
|
public DateTime Created { get; set; } = Core.Now;
|
||||||
|
|
||||||
public byte SerializedThread { get; set; }
|
|
||||||
public int SerializedPosition { get; set; }
|
|
||||||
public int SerializedLength { get; set; }
|
|
||||||
|
|
||||||
public abstract void Serialize(IGenericWriter writer);
|
public abstract void Serialize(IGenericWriter writer);
|
||||||
|
|
||||||
public abstract void Deserialize(IGenericReader reader);
|
public abstract void Deserialize(IGenericReader reader);
|
||||||
|
|
|
||||||
|
|
@ -115,10 +115,6 @@ public class Entity : IEntity
|
||||||
Timer.StartTimer(Delete);
|
Timer.StartTimer(Delete);
|
||||||
}
|
}
|
||||||
|
|
||||||
public byte SerializedThread { get; set; }
|
|
||||||
public int SerializedPosition { get; set; }
|
|
||||||
public int SerializedLength { get; set; }
|
|
||||||
|
|
||||||
public void Serialize(IGenericWriter writer)
|
public void Serialize(IGenericWriter writer)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -821,10 +821,6 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
[CommandProperty(AccessLevel.Counselor)]
|
[CommandProperty(AccessLevel.Counselor)]
|
||||||
public Serial Serial { get; }
|
public Serial Serial { get; }
|
||||||
|
|
||||||
public byte SerializedThread { get; set; }
|
|
||||||
public int SerializedPosition { get; set; }
|
|
||||||
public int SerializedLength { get; set; }
|
|
||||||
|
|
||||||
public virtual void Serialize(IGenericWriter writer)
|
public virtual void Serialize(IGenericWriter writer)
|
||||||
{
|
{
|
||||||
writer.Write(9); // version
|
writer.Write(9); // version
|
||||||
|
|
|
||||||
|
|
@ -2308,10 +2308,6 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
[CommandProperty(AccessLevel.Counselor)]
|
[CommandProperty(AccessLevel.Counselor)]
|
||||||
public Serial Serial { get; }
|
public Serial Serial { get; }
|
||||||
|
|
||||||
public byte SerializedThread { get; set; }
|
|
||||||
public int SerializedPosition { get; set; }
|
|
||||||
public int SerializedLength { get; set; }
|
|
||||||
|
|
||||||
public virtual void Serialize(IGenericWriter writer)
|
public virtual void Serialize(IGenericWriter writer)
|
||||||
{
|
{
|
||||||
writer.Write(37); // version
|
writer.Write(37); // version
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
*************************************************************************/
|
*************************************************************************/
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.IO.MemoryMappedFiles;
|
using System.IO.MemoryMappedFiles;
|
||||||
|
|
@ -27,9 +26,9 @@ public static class AdhocPersistence
|
||||||
/**
|
/**
|
||||||
* Serializes to memory.
|
* Serializes to memory.
|
||||||
*/
|
*/
|
||||||
public static IGenericWriter SerializeToBuffer(Action<IGenericWriter> serializer, ConcurrentQueue<Type> types = null)
|
public static IGenericWriter SerializeToBuffer(Action<IGenericWriter> serializer)
|
||||||
{
|
{
|
||||||
var saveBuffer = new BufferWriter(true, types);
|
var saveBuffer = new BufferWriter(true);
|
||||||
serializer(saveBuffer);
|
serializer(saveBuffer);
|
||||||
return saveBuffer;
|
return saveBuffer;
|
||||||
}
|
}
|
||||||
|
|
@ -55,22 +54,11 @@ public static class AdhocPersistence
|
||||||
{
|
{
|
||||||
var fullPath = PathUtility.GetFullPath(filePath, Core.BaseDirectory);
|
var fullPath = PathUtility.GetFullPath(filePath, Core.BaseDirectory);
|
||||||
PathUtility.EnsureDirectory(Path.GetDirectoryName(fullPath));
|
PathUtility.EnsureDirectory(Path.GetDirectoryName(fullPath));
|
||||||
HashSet<Type> typesSet = [];
|
|
||||||
var writer = new MemoryMapFileWriter(new FileStream(filePath, FileMode.Create), sizeHint, typesSet);
|
var writer = new FileBufferWriter(fullPath, sizeHint);
|
||||||
serializer(writer);
|
serializer(writer);
|
||||||
|
|
||||||
Task.Run(
|
Task.Run(() => writer.Dispose(), Core.ClosingTokenSource.Token);
|
||||||
() =>
|
|
||||||
{
|
|
||||||
var fs = writer.FileStream;
|
|
||||||
|
|
||||||
writer.Dispose();
|
|
||||||
fs.Dispose();
|
|
||||||
|
|
||||||
Persistence.WriteSerializedTypesSnapshot(Path.GetDirectoryName(fullPath), typesSet);
|
|
||||||
},
|
|
||||||
Core.ClosingTokenSource.Token
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static unsafe void Deserialize(string filePath, Action<IGenericReader> deserializer)
|
public static unsafe void Deserialize(string filePath, Action<IGenericReader> deserializer)
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,12 @@
|
||||||
*************************************************************************/
|
*************************************************************************/
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
using System.Buffers;
|
||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Net;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
@ -27,9 +29,9 @@ namespace Server;
|
||||||
|
|
||||||
public class BufferWriter : IGenericWriter
|
public class BufferWriter : IGenericWriter
|
||||||
{
|
{
|
||||||
private readonly ConcurrentQueue<Type> _types;
|
|
||||||
private readonly Encoding _encoding;
|
private readonly Encoding _encoding;
|
||||||
private readonly bool _prefixStrings;
|
private readonly bool _prefixStrings;
|
||||||
|
|
||||||
private long _bytesWritten;
|
private long _bytesWritten;
|
||||||
private long _index;
|
private long _index;
|
||||||
|
|
||||||
|
|
@ -56,27 +58,25 @@ public class BufferWriter : IGenericWriter
|
||||||
|
|
||||||
private byte[] _buffer;
|
private byte[] _buffer;
|
||||||
|
|
||||||
public BufferWriter(byte[] buffer, bool prefixStr, ConcurrentQueue<Type> types = null)
|
public BufferWriter(byte[] buffer, bool prefixStr)
|
||||||
{
|
{
|
||||||
_prefixStrings = prefixStr;
|
_prefixStrings = prefixStr;
|
||||||
_encoding = TextEncoding.UTF8;
|
_encoding = TextEncoding.UTF8;
|
||||||
_buffer = buffer;
|
_buffer = buffer;
|
||||||
_types = types;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public BufferWriter(bool prefixStr, ConcurrentQueue<Type> types = null) : this(0, prefixStr, types)
|
public BufferWriter(bool prefixStr) : this(0, prefixStr)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public BufferWriter(int count, bool prefixStr, ConcurrentQueue<Type> types = null)
|
public BufferWriter(int count, bool prefixStr)
|
||||||
{
|
{
|
||||||
_prefixStrings = prefixStr;
|
_prefixStrings = prefixStr;
|
||||||
_encoding = TextEncoding.UTF8;
|
_encoding = TextEncoding.UTF8;
|
||||||
_buffer = GC.AllocateUninitializedArray<byte>(count < 1 ? BufferSize : count);
|
_buffer = GC.AllocateUninitializedArray<byte>(count < 1 ? BufferSize : count);
|
||||||
_types = types;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public virtual long Position => Index;
|
public virtual long Position => _index;
|
||||||
|
|
||||||
protected virtual int BufferSize => 256;
|
protected virtual int BufferSize => 256;
|
||||||
|
|
||||||
|
|
@ -89,6 +89,8 @@ public class BufferWriter : IGenericWriter
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public void Resize(int size)
|
public void Resize(int size)
|
||||||
{
|
{
|
||||||
|
_bytesWritten = Math.Max(_bytesWritten, _index);
|
||||||
|
|
||||||
// We shouldn't ever resize to a 0 length buffer. That is dangerous
|
// We shouldn't ever resize to a 0 length buffer. That is dangerous
|
||||||
if (size <= 0)
|
if (size <= 0)
|
||||||
{
|
{
|
||||||
|
|
@ -107,13 +109,23 @@ public class BufferWriter : IGenericWriter
|
||||||
|
|
||||||
public virtual void Flush() => Resize(Math.Clamp(_buffer.Length * 2, BufferSize, _buffer.Length + 1024 * 1024 * 64));
|
public virtual void Flush() => Resize(Math.Clamp(_buffer.Length * 2, BufferSize, _buffer.Length + 1024 * 1024 * 64));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures capacity, returns a ref at the current position, and advances the index.
|
||||||
|
/// The capacity check proves the caller's unaligned store is in-bounds, and the index
|
||||||
|
/// only moves forward between Seek calls, so no per-write validation is needed. Growth
|
||||||
|
/// (Flush -> Resize) always adds at least BufferSize, covering any primitive width.
|
||||||
|
/// </summary>
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
private void FlushIfNeeded(int amount)
|
private ref byte Reserve(int bytes)
|
||||||
{
|
{
|
||||||
if (Index + amount > _buffer.Length)
|
if ((uint)(_index + bytes) > (uint)_buffer.Length)
|
||||||
{
|
{
|
||||||
Flush();
|
Flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ref var result = ref Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(_buffer), (nint)_index);
|
||||||
|
_index += bytes;
|
||||||
|
return ref result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public virtual void Write(byte[] bytes) => Write(bytes.AsSpan());
|
public virtual void Write(byte[] bytes) => Write(bytes.AsSpan());
|
||||||
|
|
@ -130,7 +142,7 @@ public class BufferWriter : IGenericWriter
|
||||||
}
|
}
|
||||||
|
|
||||||
bytes.CopyTo(_buffer.AsSpan((int)_index));
|
bytes.CopyTo(_buffer.AsSpan((int)_index));
|
||||||
Index += length;
|
_index += length;
|
||||||
}
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
|
@ -145,13 +157,15 @@ public class BufferWriter : IGenericWriter
|
||||||
"Attempting to seek to an invalid position using SeekOrigin.Begin"
|
"Attempting to seek to an invalid position using SeekOrigin.Begin"
|
||||||
);
|
);
|
||||||
Debug.Assert(
|
Debug.Assert(
|
||||||
origin != SeekOrigin.Current || Index + offset >= 0 && Index + offset < _buffer.Length,
|
origin != SeekOrigin.Current || _index + offset >= 0 && _index + offset < _buffer.Length,
|
||||||
"Attempting to seek to an invalid position using SeekOrigin.Current"
|
"Attempting to seek to an invalid position using SeekOrigin.Current"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
_bytesWritten = Math.Max(_bytesWritten, _index);
|
||||||
|
|
||||||
return Index = Math.Max(0, origin switch
|
return Index = Math.Max(0, origin switch
|
||||||
{
|
{
|
||||||
SeekOrigin.Current => Index + offset,
|
SeekOrigin.Current => _index + offset,
|
||||||
SeekOrigin.End => _bytesWritten + offset,
|
SeekOrigin.End => _bytesWritten + offset,
|
||||||
_ => offset // Begin
|
_ => offset // Begin
|
||||||
});
|
});
|
||||||
|
|
@ -169,107 +183,111 @@ public class BufferWriter : IGenericWriter
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Write(true);
|
Write(true);
|
||||||
InternalWriteString(value);
|
WriteRaw(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
InternalWriteString(value);
|
WriteRaw(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public void Write(long value)
|
public void Write(long value)
|
||||||
{
|
{
|
||||||
FlushIfNeeded(8);
|
if (!BitConverter.IsLittleEndian)
|
||||||
|
{
|
||||||
|
value = BinaryPrimitives.ReverseEndianness(value);
|
||||||
|
}
|
||||||
|
|
||||||
BinaryPrimitives.WriteInt64LittleEndian(_buffer.AsSpan((int)_index), value);
|
Unsafe.WriteUnaligned(ref Reserve(8), value);
|
||||||
Index += 8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public void Write(ulong value)
|
public void Write(ulong value)
|
||||||
{
|
{
|
||||||
FlushIfNeeded(8);
|
if (!BitConverter.IsLittleEndian)
|
||||||
|
{
|
||||||
|
value = BinaryPrimitives.ReverseEndianness(value);
|
||||||
|
}
|
||||||
|
|
||||||
BinaryPrimitives.WriteUInt64LittleEndian(_buffer.AsSpan((int)_index), value);
|
Unsafe.WriteUnaligned(ref Reserve(8), value);
|
||||||
Index += 8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public void Write(int value)
|
public void Write(int value)
|
||||||
{
|
{
|
||||||
FlushIfNeeded(4);
|
if (!BitConverter.IsLittleEndian)
|
||||||
|
{
|
||||||
|
value = BinaryPrimitives.ReverseEndianness(value);
|
||||||
|
}
|
||||||
|
|
||||||
BinaryPrimitives.WriteInt32LittleEndian(_buffer.AsSpan((int)_index), value);
|
Unsafe.WriteUnaligned(ref Reserve(4), value);
|
||||||
Index += 4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public void Write(uint value)
|
public void Write(uint value)
|
||||||
{
|
{
|
||||||
FlushIfNeeded(4);
|
if (!BitConverter.IsLittleEndian)
|
||||||
|
{
|
||||||
|
value = BinaryPrimitives.ReverseEndianness(value);
|
||||||
|
}
|
||||||
|
|
||||||
BinaryPrimitives.WriteUInt32LittleEndian(_buffer.AsSpan((int)_index), value);
|
Unsafe.WriteUnaligned(ref Reserve(4), value);
|
||||||
Index += 4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public void Write(short value)
|
public void Write(short value)
|
||||||
{
|
{
|
||||||
FlushIfNeeded(2);
|
if (!BitConverter.IsLittleEndian)
|
||||||
|
{
|
||||||
|
value = BinaryPrimitives.ReverseEndianness(value);
|
||||||
|
}
|
||||||
|
|
||||||
BinaryPrimitives.WriteInt16LittleEndian(_buffer.AsSpan((int)_index), value);
|
Unsafe.WriteUnaligned(ref Reserve(2), value);
|
||||||
Index += 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public void Write(ushort value)
|
public void Write(ushort value)
|
||||||
{
|
{
|
||||||
FlushIfNeeded(2);
|
if (!BitConverter.IsLittleEndian)
|
||||||
|
{
|
||||||
|
value = BinaryPrimitives.ReverseEndianness(value);
|
||||||
|
}
|
||||||
|
|
||||||
BinaryPrimitives.WriteUInt16LittleEndian(_buffer.AsSpan((int)_index), value);
|
Unsafe.WriteUnaligned(ref Reserve(2), value);
|
||||||
Index += 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public void Write(double value)
|
public void Write(double value)
|
||||||
{
|
{
|
||||||
FlushIfNeeded(8);
|
if (!BitConverter.IsLittleEndian)
|
||||||
|
{
|
||||||
|
value = BitConverter.Int64BitsToDouble(BinaryPrimitives.ReverseEndianness(BitConverter.DoubleToInt64Bits(value)));
|
||||||
|
}
|
||||||
|
|
||||||
BinaryPrimitives.WriteDoubleLittleEndian(_buffer.AsSpan((int)_index), value);
|
Unsafe.WriteUnaligned(ref Reserve(8), value);
|
||||||
Index += 8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public void Write(float value)
|
public void Write(float value)
|
||||||
{
|
{
|
||||||
FlushIfNeeded(4);
|
if (!BitConverter.IsLittleEndian)
|
||||||
|
{
|
||||||
|
value = BitConverter.Int32BitsToSingle(BinaryPrimitives.ReverseEndianness(BitConverter.SingleToInt32Bits(value)));
|
||||||
|
}
|
||||||
|
|
||||||
BinaryPrimitives.WriteSingleLittleEndian(_buffer.AsSpan((int)_index), value);
|
Unsafe.WriteUnaligned(ref Reserve(4), value);
|
||||||
Index += 4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public void Write(byte value)
|
public void Write(byte value) => Reserve(1) = value;
|
||||||
{
|
|
||||||
FlushIfNeeded(1);
|
|
||||||
_buffer[Index++] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public void Write(sbyte value)
|
public void Write(sbyte value) => Reserve(1) = (byte)value;
|
||||||
{
|
|
||||||
FlushIfNeeded(1);
|
|
||||||
_buffer[Index++] = (byte)value;
|
|
||||||
}
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public unsafe void Write(bool value)
|
public void Write(bool value) => Reserve(1) = Unsafe.As<bool, byte>(ref value);
|
||||||
{
|
|
||||||
FlushIfNeeded(1);
|
|
||||||
_buffer[Index++] = *(byte*)&value; // up to 30% faster to dereference the raw value on the stack
|
|
||||||
}
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public void Write(Serial serial) => Write(serial.Value);
|
public void Write(Serial serial) => Write(serial.Value);
|
||||||
|
|
@ -285,7 +303,6 @@ public class BufferWriter : IGenericWriter
|
||||||
{
|
{
|
||||||
Write((byte)0x2); // xxHash3 64bit
|
Write((byte)0x2); // xxHash3 64bit
|
||||||
Write(AssemblyHandler.GetTypeHash(type));
|
Write(AssemblyHandler.GetTypeHash(type));
|
||||||
_types?.Enqueue(type);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -299,18 +316,262 @@ public class BufferWriter : IGenericWriter
|
||||||
}
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
internal void InternalWriteString(string value)
|
public void WriteEncodedInt(int value)
|
||||||
{
|
{
|
||||||
var length = _encoding.GetByteCount(value);
|
var v = (uint)value;
|
||||||
|
|
||||||
((IGenericWriter)this).WriteEncodedInt(length);
|
// FAST PATH: 1 byte (0 to 127).
|
||||||
|
// This keeps the inlined code incredibly tiny at the call site.
|
||||||
|
if (v < 0x80)
|
||||||
|
{
|
||||||
|
Reserve(1) = (byte)v;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// SLOW PATH: Push to a non-inlined method to prevent code bloat.
|
||||||
|
WriteEncodedIntMultiByte(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
while (_buffer.Length - _index < length)
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||||
|
private void WriteEncodedIntMultiByte(uint v)
|
||||||
|
{
|
||||||
|
// We already know v >= 0x80. Unroll the loop entirely based on magnitude.
|
||||||
|
// This allows us to call Reserve() exactly ONE time.
|
||||||
|
|
||||||
|
if (v < 0x4000) // 2 bytes
|
||||||
|
{
|
||||||
|
ref byte ptr = ref Reserve(2);
|
||||||
|
ptr = (byte)(v | 0x80);
|
||||||
|
Unsafe.Add(ref ptr, 1) = (byte)(v >> 7);
|
||||||
|
}
|
||||||
|
else if (v < 0x200000) // 3 bytes
|
||||||
|
{
|
||||||
|
ref byte ptr = ref Reserve(3);
|
||||||
|
ptr = (byte)(v | 0x80);
|
||||||
|
Unsafe.Add(ref ptr, 1) = (byte)((v >> 7) | 0x80);
|
||||||
|
Unsafe.Add(ref ptr, 2) = (byte)(v >> 14);
|
||||||
|
}
|
||||||
|
else if (v < 0x10000000) // 4 bytes
|
||||||
|
{
|
||||||
|
ref byte ptr = ref Reserve(4);
|
||||||
|
ptr = (byte)(v | 0x80);
|
||||||
|
Unsafe.Add(ref ptr, 1) = (byte)((v >> 7) | 0x80);
|
||||||
|
Unsafe.Add(ref ptr, 2) = (byte)((v >> 14) | 0x80);
|
||||||
|
Unsafe.Add(ref ptr, 3) = (byte)(v >> 21);
|
||||||
|
}
|
||||||
|
else // 5 bytes (including all negative numbers due to logical shift)
|
||||||
|
{
|
||||||
|
ref byte ptr = ref Reserve(5);
|
||||||
|
ptr = (byte)(v | 0x80);
|
||||||
|
Unsafe.Add(ref ptr, 1) = (byte)((v >> 7) | 0x80);
|
||||||
|
Unsafe.Add(ref ptr, 2) = (byte)((v >> 14) | 0x80);
|
||||||
|
Unsafe.Add(ref ptr, 3) = (byte)((v >> 21) | 0x80);
|
||||||
|
Unsafe.Add(ref ptr, 4) = (byte)(v >> 28);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public void Write(DateTime value)
|
||||||
|
{
|
||||||
|
// If DateTimeKind is Unspecified, we can't assume it needs to be converted.
|
||||||
|
if (value.Kind == DateTimeKind.Local)
|
||||||
|
{
|
||||||
|
value = value.ToUniversalTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
Write(value.Ticks);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public void WriteDeltaTime(DateTime value)
|
||||||
|
{
|
||||||
|
if (value == DateTime.MinValue)
|
||||||
|
{
|
||||||
|
Write(long.MinValue);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value == DateTime.MaxValue)
|
||||||
|
{
|
||||||
|
Write(long.MaxValue);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.Kind == DateTimeKind.Local)
|
||||||
|
{
|
||||||
|
value = value.ToUniversalTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Technically supports negative deltas for times in the past
|
||||||
|
Write(value.Ticks - DateTime.UtcNow.Ticks);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public void Write(IPAddress value)
|
||||||
|
{
|
||||||
|
Span<byte> stack = stackalloc byte[16];
|
||||||
|
value.TryWriteBytes(stack, out var bytesWritten);
|
||||||
|
Write((byte)bytesWritten);
|
||||||
|
Write(stack[..bytesWritten]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public void Write(TimeSpan value) => Write(value.Ticks);
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public void Write(Point3D value)
|
||||||
|
{
|
||||||
|
Write(value.m_X);
|
||||||
|
Write(value.m_Y);
|
||||||
|
Write(value.m_Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public void Write(Point2D value)
|
||||||
|
{
|
||||||
|
Write(value.m_X);
|
||||||
|
Write(value.m_Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public void Write(Rectangle2D value)
|
||||||
|
{
|
||||||
|
Write(value.Start);
|
||||||
|
Write(value.End);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public void Write(Rectangle3D value)
|
||||||
|
{
|
||||||
|
Write(value.Start);
|
||||||
|
Write(value.End);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public void Write(Map value) => Write((byte)(value?.MapIndex ?? 0xFF));
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public void Write(Race value) => Write((byte)(value?.RaceIndex ?? 0xFF));
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public unsafe void WriteEnum<T>(T value) where T : unmanaged, Enum
|
||||||
|
{
|
||||||
|
switch (sizeof(T))
|
||||||
|
{
|
||||||
|
default:
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Argument of type {typeof(T)} is not a normal enum");
|
||||||
|
}
|
||||||
|
case 1:
|
||||||
|
{
|
||||||
|
Write(*(byte*)&value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 2:
|
||||||
|
{
|
||||||
|
Write(*(ushort*)&value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 4:
|
||||||
|
{
|
||||||
|
WriteEncodedInt(*(int*)&value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 8:
|
||||||
|
{
|
||||||
|
Write(*(ulong*)&value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public void Write(Guid guid)
|
||||||
|
{
|
||||||
|
Span<byte> stack = stackalloc byte[16];
|
||||||
|
guid.TryWriteBytes(stack);
|
||||||
|
Write(stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public void Write(BitArray bitArray)
|
||||||
|
{
|
||||||
|
var bitLength = bitArray.Length;
|
||||||
|
var byteLength = (bitLength + 7) / 8;
|
||||||
|
|
||||||
|
WriteEncodedInt(bitLength);
|
||||||
|
|
||||||
|
var arrayBuffer = ArrayPool<byte>.Shared.Rent(byteLength);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bitArray.CopyTo(arrayBuffer, 0);
|
||||||
|
Write(arrayBuffer.AsSpan(0, byteLength));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ArrayPool<byte>.Shared.Return(arrayBuffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public void Write(TextDefinition def)
|
||||||
|
{
|
||||||
|
if (def == null)
|
||||||
|
{
|
||||||
|
WriteEncodedInt(3);
|
||||||
|
}
|
||||||
|
else if (def.Number > 0)
|
||||||
|
{
|
||||||
|
WriteEncodedInt(1);
|
||||||
|
WriteEncodedInt(def.Number);
|
||||||
|
}
|
||||||
|
else if (def.String != null)
|
||||||
|
{
|
||||||
|
WriteEncodedInt(2);
|
||||||
|
Write(def.String);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
WriteEncodedInt(0); // Empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void WriteRaw(string value)
|
||||||
|
{
|
||||||
|
// Single pass, in place: reserve the UTF-8 worst case (3 bytes per char) plus a
|
||||||
|
// length prefix sized for that worst case, encode directly into the buffer, then
|
||||||
|
// write the actual byte count into the reserved prefix zero-padded to the same
|
||||||
|
// width. Readers accumulate 7-bit groups, so non-minimal prefixes decode
|
||||||
|
// identically — no second pass over the string, no scratch copy, no pooling.
|
||||||
|
var maxLength = value.Length * 3;
|
||||||
|
var prefixWidth = EncodedIntWidth(maxLength);
|
||||||
|
|
||||||
|
while (_buffer.Length - _index < prefixWidth + maxLength)
|
||||||
{
|
{
|
||||||
Flush();
|
Flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
// We don't use spans here since that incurs extra allocations for safety.
|
var written = _encoding.GetBytes(value, _buffer.AsSpan((int)(_index + prefixWidth)));
|
||||||
Index += _encoding.GetBytes(value, 0, value.Length, _buffer, (int)_index);
|
|
||||||
|
WriteEncodedIntPadded(written, prefixWidth);
|
||||||
|
_index += written;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private static int EncodedIntWidth(int value) =>
|
||||||
|
value < 0x80 ? 1 : value < 0x4000 ? 2 : value < 0x20_0000 ? 3 : value < 0x1000_0000 ? 4 : 5;
|
||||||
|
|
||||||
|
private void WriteEncodedIntPadded(int value, int width)
|
||||||
|
{
|
||||||
|
var v = (uint)value;
|
||||||
|
|
||||||
|
for (var i = 1; i < width; i++)
|
||||||
|
{
|
||||||
|
_buffer[_index++] = (byte)(v | 0x80);
|
||||||
|
v >>= 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
_buffer[_index++] = (byte)v; // fits in 7 bits because width >= EncodedIntWidth(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
138
Projects/Server/Serialization/FileBufferWriter.cs
Normal file
138
Projects/Server/Serialization/FileBufferWriter.cs
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
/*************************************************************************
|
||||||
|
* ModernUO *
|
||||||
|
* Copyright 2019-2026 - ModernUO Development Team *
|
||||||
|
* Email: hi@modernuo.com *
|
||||||
|
* File: FileBufferWriter.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.Buffers;
|
||||||
|
using System.IO;
|
||||||
|
using Microsoft.Win32.SafeHandles;
|
||||||
|
|
||||||
|
namespace Server;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A <see cref="BufferWriter"/> whose staging block drains to a file when full instead of
|
||||||
|
/// growing: the full raw write path (unrolled encoded ints, in-place strings) composes into
|
||||||
|
/// memory, and the file sees large sequential positional writes. Seeks flush the staging
|
||||||
|
/// block and move the file offset, so backwards patches (e.g. the idx entity count) become
|
||||||
|
/// small positional writes. Memory-mapped writing pays soft page faults on every composed
|
||||||
|
/// page and dirty-section teardown stalls at dispose — measured ~4x slower at snapshot sizes.
|
||||||
|
/// A single item larger than the staging block grows the block via the base resize path,
|
||||||
|
/// so oversized spans and strings remain correct.
|
||||||
|
/// </summary>
|
||||||
|
public class FileBufferWriter : BufferWriter, IDisposable
|
||||||
|
{
|
||||||
|
private const int MinStagingSize = 256;
|
||||||
|
private const int MaxStagingSize = 1024 * 1024; // 1MB write granularity for large files
|
||||||
|
|
||||||
|
private readonly SafeFileHandle _handle;
|
||||||
|
private readonly byte[] _rentedStaging;
|
||||||
|
private long _fileOffset; // file position where the staging block begins
|
||||||
|
private long _fileHighWater; // logical end of file across seeks
|
||||||
|
|
||||||
|
/// <param name="filePath">Destination file; created/truncated.</param>
|
||||||
|
/// <param name="expectedSize">
|
||||||
|
/// Expected total file size when known. Files at or under the staging cap never
|
||||||
|
/// drain until close; larger files stream through a pooled block at the cap. The
|
||||||
|
/// block comes from ArrayPool so sequential snapshot writers recycle one buffer
|
||||||
|
/// instead of dropping a large-object allocation per file per save.
|
||||||
|
/// </param>
|
||||||
|
public FileBufferWriter(string filePath, long expectedSize = MaxStagingSize)
|
||||||
|
: base(RentStaging(expectedSize), true)
|
||||||
|
{
|
||||||
|
_rentedStaging = Buffer;
|
||||||
|
_handle = File.OpenHandle(filePath, FileMode.Create, FileAccess.Write, FileShare.None, FileOptions.SequentialScan);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] RentStaging(long expectedSize) =>
|
||||||
|
ArrayPool<byte>.Shared.Rent((int)Math.Clamp(expectedSize, MinStagingSize, MaxStagingSize));
|
||||||
|
|
||||||
|
public override long Position => _fileOffset + Index;
|
||||||
|
|
||||||
|
public override void Flush()
|
||||||
|
{
|
||||||
|
if (Index > 0)
|
||||||
|
{
|
||||||
|
Drain();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Nothing staged and still not enough room: a single item larger than the
|
||||||
|
// staging block. Grow the block so the base write loops always make progress.
|
||||||
|
base.Flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Drain()
|
||||||
|
{
|
||||||
|
var length = (int)Index;
|
||||||
|
RandomAccess.Write(_handle, Buffer.AsSpan(0, length), _fileOffset);
|
||||||
|
_fileOffset += length;
|
||||||
|
|
||||||
|
if (_fileOffset > _fileHighWater)
|
||||||
|
{
|
||||||
|
_fileHighWater = _fileOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
Index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override long Seek(long offset, SeekOrigin origin)
|
||||||
|
{
|
||||||
|
var position = Position;
|
||||||
|
|
||||||
|
if (position > _fileHighWater)
|
||||||
|
{
|
||||||
|
_fileHighWater = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = origin switch
|
||||||
|
{
|
||||||
|
SeekOrigin.Current => position + offset,
|
||||||
|
SeekOrigin.End => _fileHighWater + offset,
|
||||||
|
_ => offset // Begin
|
||||||
|
};
|
||||||
|
|
||||||
|
if (target < 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Seek before start of file");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Index > 0)
|
||||||
|
{
|
||||||
|
Drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
_fileOffset = target;
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Close()
|
||||||
|
{
|
||||||
|
if (!_handle.IsClosed)
|
||||||
|
{
|
||||||
|
if (Index > 0)
|
||||||
|
{
|
||||||
|
Drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
_handle.Dispose();
|
||||||
|
|
||||||
|
// Safe even if an oversized item grew the staging block: growth replaced the
|
||||||
|
// base buffer with a fresh array, so the rented one is no longer referenced.
|
||||||
|
ArrayPool<byte>.Shared.Return(_rentedStaging);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => Close();
|
||||||
|
}
|
||||||
|
|
@ -28,13 +28,23 @@ namespace Server;
|
||||||
|
|
||||||
public interface IGenericEntityPersistence
|
public interface IGenericEntityPersistence
|
||||||
{
|
{
|
||||||
public void DeserializeIndexes(string savePath, Dictionary<ulong, string> typesDb);
|
void DeserializeIndexes(string savePath, Dictionary<ulong, string> typesDb);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPersistence where T : class, ISerializable
|
public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPersistence, ISlotRangeSource
|
||||||
|
where T : class, ISerializable
|
||||||
{
|
{
|
||||||
private static readonly ILogger logger = LogFactory.GetLogger(typeof(GenericEntityPersistence<T>));
|
private static readonly ILogger logger = LogFactory.GetLogger(typeof(GenericEntityPersistence<T>));
|
||||||
|
|
||||||
|
// Layout-validated direct access to EntitiesBySerial's entries array, letting workers
|
||||||
|
// iterate the dictionary in parallel during saves. Null when unsupported on this runtime.
|
||||||
|
private static readonly FieldInfo _entriesField =
|
||||||
|
ShadowDictionaryEntries.Supported ? ShadowDictionaryEntries.GetEntriesField<T>() : null;
|
||||||
|
|
||||||
|
// The entries array captured at freeze time. The dictionary cannot mutate while saving
|
||||||
|
// (adds/removes divert to the pending queues), so the array is stable until released.
|
||||||
|
private object _entriesSnapshot;
|
||||||
|
|
||||||
// Support legacy split file serialization
|
// Support legacy split file serialization
|
||||||
private static Dictionary<int, List<EntitySpan<T>>> _entities;
|
private static Dictionary<int, List<EntitySpan<T>>> _entities;
|
||||||
|
|
||||||
|
|
@ -44,6 +54,35 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
||||||
private readonly Dictionary<Serial, T> _pendingAdd = new();
|
private readonly Dictionary<Serial, T> _pendingAdd = new();
|
||||||
private readonly Dictionary<Serial, T> _pendingDelete = new();
|
private readonly Dictionary<Serial, T> _pendingDelete = new();
|
||||||
|
|
||||||
|
// Insertion-ordered table of every entity type added since boot. idx v4 records
|
||||||
|
// reference types by table index, so entries are never removed — a type whose
|
||||||
|
// entities were all deleted keeps its slot until restart. Only mutated on the game
|
||||||
|
// thread (AddEntity, deserialize); only read on the background writer thread during
|
||||||
|
// WritingSave, when AddEntity diverts to the pending queues.
|
||||||
|
private readonly Dictionary<Type, ushort> _typeIndexes = new();
|
||||||
|
private readonly List<Type> _typeTable = [];
|
||||||
|
|
||||||
|
internal IReadOnlyList<Type> TypeTable => _typeTable;
|
||||||
|
|
||||||
|
internal bool TryGetTypeIndex(Type type, out ushort index) => _typeIndexes.TryGetValue(type, out index);
|
||||||
|
|
||||||
|
internal void RegisterType(Type type)
|
||||||
|
{
|
||||||
|
ref var index = ref CollectionsMarshal.GetValueRefOrAddDefault(_typeIndexes, type, out var exists);
|
||||||
|
if (!exists)
|
||||||
|
{
|
||||||
|
if (_typeTable.Count > ushort.MaxValue)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"{Name} exceeded {ushort.MaxValue + 1} distinct entity types."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
index = (ushort)_typeTable.Count;
|
||||||
|
_typeTable.Add(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public Dictionary<Serial, T> EntitiesBySerial { get; } = new();
|
public Dictionary<Serial, T> EntitiesBySerial { get; } = new();
|
||||||
|
|
||||||
public GenericEntityPersistence(string name, int priority, uint minSerial, uint maxSerial) : this(
|
public GenericEntityPersistence(string name, int priority, uint minSerial, uint maxSerial) : this(
|
||||||
|
|
@ -63,81 +102,94 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
||||||
typeof(T).RegisterFindEntity(Find);
|
typeof(T).RegisterFindEntity(Find);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void WriteSnapshot(string savePath, HashSet<Type> typeSet)
|
public override void WriteSnapshot(string savePath)
|
||||||
{
|
{
|
||||||
var dir = Path.Combine(savePath, Name);
|
var dir = Path.Combine(savePath, Name);
|
||||||
PathUtility.EnsureDirectory(dir);
|
PathUtility.EnsureDirectory(dir);
|
||||||
|
|
||||||
var threads = World._threadWorkers;
|
var threads = World._threadWorkers;
|
||||||
|
|
||||||
using var binFs = new FileStream(Path.Combine(dir, $"{Name}.bin"), FileMode.Create, FileAccess.Write, FileShare.None);
|
// 1MB buffer: segments are written as large spans, but idx entries and skip splits
|
||||||
using var idxFs = new FileStream(Path.Combine(dir, $"{Name}.idx"), FileMode.Create);
|
// still benefit on the snapshot thread.
|
||||||
using var idx = new MemoryMapFileWriter(idxFs, 1024 * 1024, typeSet); // 1MB
|
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;
|
||||||
|
using var idx = new FileBufferWriter(Path.Combine(dir, $"{Name}.idx"), expectedIdxSize);
|
||||||
|
|
||||||
var binPosition = 0L;
|
var binPosition = 0L;
|
||||||
|
|
||||||
// Support for non-entity generic serialization.
|
// Support for non-entity generic serialization.
|
||||||
if (SerializedLength > 0)
|
if (_selfLength > 0)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
binFs.Write(threads[SerializedThread].GetHeap(SerializedPosition, SerializedLength));
|
binFs.Write(threads[_selfThread].GetHeap(_selfPosition, _selfLength));
|
||||||
}
|
}
|
||||||
catch (Exception error)
|
catch (Exception error)
|
||||||
{
|
{
|
||||||
logger.Error(
|
logger.Error(
|
||||||
error,
|
error,
|
||||||
"Error writing entity: (Thread: {Thread} - {Start} {Length})",
|
"Error writing self-payload: (Thread: {Thread} - {Start} {Length})",
|
||||||
SerializedThread,
|
_selfThread,
|
||||||
SerializedPosition,
|
_selfPosition,
|
||||||
SerializedLength
|
_selfLength
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
binPosition += SerializedLength;
|
binPosition += _selfLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
idx.Write(3); // Version
|
idx.Write(4); // Version
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// resolve constructors before reading them.
|
||||||
|
idx.Write(_typeTable.Count);
|
||||||
|
for (var i = 0; i < _typeTable.Count; i++)
|
||||||
|
{
|
||||||
|
idx.WriteRaw(_typeTable[i].FullName);
|
||||||
|
}
|
||||||
|
|
||||||
var countPosition = idx.Position;
|
var countPosition = idx.Position;
|
||||||
idx.Write(0);
|
idx.Write(0);
|
||||||
|
|
||||||
var entityCount = EntitiesBySerial.Count;
|
// The bin is written in worker-heap order, not dictionary order: each worker logged
|
||||||
foreach (var e in EntitiesBySerial.Values)
|
// (segment, record lengths) as it serialized, so records pair with their bytes by
|
||||||
|
// re-walking the same slots in the same order. The idx stores absolute positions,
|
||||||
|
// so the loader never cares about record order.
|
||||||
|
var entityCount = 0;
|
||||||
|
|
||||||
|
for (var t = 0; t < threads.Length; t++)
|
||||||
{
|
{
|
||||||
if (e is Item { SkipSerialization: true } or Mobile { SkipSerialization: true })
|
var worker = threads[t];
|
||||||
|
var segments = worker.Segments;
|
||||||
|
|
||||||
|
for (var s = 0; s < segments.Count; s++)
|
||||||
|
{
|
||||||
|
var segment = segments[s];
|
||||||
|
if (!ReferenceEquals(segment.Owner, this))
|
||||||
{
|
{
|
||||||
entityCount--;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var thread = e.SerializedThread;
|
|
||||||
var heapStart = e.SerializedPosition;
|
|
||||||
var heapLength = e.SerializedLength;
|
|
||||||
|
|
||||||
idx.Write(e.GetType());
|
|
||||||
idx.Write(e.Serial);
|
|
||||||
idx.Write(e.Created.Ticks);
|
|
||||||
idx.Write(binPosition);
|
|
||||||
idx.Write(heapLength);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
binFs.Write(threads[thread].GetHeap(heapStart, heapLength));
|
binPosition = WriteSegmentRecords(worker, in segment, idx, binFs, binPosition, ref entityCount);
|
||||||
}
|
}
|
||||||
catch (Exception error)
|
catch (Exception error)
|
||||||
{
|
{
|
||||||
logger.Error(
|
logger.Error(
|
||||||
error,
|
error,
|
||||||
"Error writing entity: {Entity} (Thread: {Thread} - {Start} {Length})",
|
"Error writing segment: (Thread: {Thread} - {Start}, {Records} records)",
|
||||||
e,
|
t,
|
||||||
thread,
|
segment.HeapStart,
|
||||||
heapStart,
|
segment.RecordCount
|
||||||
heapLength
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
binPosition += heapLength;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentPosition = idx.Position;
|
var currentPosition = idx.Position;
|
||||||
|
|
@ -146,14 +198,175 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
||||||
idx.Seek(currentPosition, SeekOrigin.Begin);
|
idx.Seek(currentPosition, SeekOrigin.Begin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private long WriteSegmentRecords(
|
||||||
|
SerializationThreadWorker worker, in SerializedSegment segment, FileBufferWriter idx, FileStream binFs,
|
||||||
|
long binPosition, ref int entityCount
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var lengths = worker.Lengths;
|
||||||
|
var lengthIndex = segment.LengthsStart;
|
||||||
|
|
||||||
|
var heapPos = (int)segment.HeapStart;
|
||||||
|
var spanStart = heapPos;
|
||||||
|
|
||||||
|
if (segment.SlotOffset >= 0)
|
||||||
|
{
|
||||||
|
// Re-walk the same slots the worker serialized; occupancy cannot have changed
|
||||||
|
// because dictionary mutations divert to the pending queues until PostWorldSave.
|
||||||
|
var entries = Unsafe.As<ShadowEntry<T>[]>(_entriesSnapshot);
|
||||||
|
ref var entry = ref Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(entries), segment.SlotOffset);
|
||||||
|
|
||||||
|
for (var i = 0; i < segment.SlotCount; i++, entry = ref Unsafe.Add(ref entry, 1))
|
||||||
|
{
|
||||||
|
var entity = entry.Value;
|
||||||
|
if (entity == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var length = lengths[lengthIndex++];
|
||||||
|
|
||||||
|
if (entity is Item { SkipSerialization: true } or Mobile { SkipSerialization: true })
|
||||||
|
{
|
||||||
|
// The bytes exist in the heap but are not part of the save: split the span.
|
||||||
|
if (heapPos > spanStart)
|
||||||
|
{
|
||||||
|
binFs.Write(worker.GetHeap(spanStart, heapPos - spanStart));
|
||||||
|
}
|
||||||
|
|
||||||
|
heapPos += length;
|
||||||
|
spanStart = heapPos;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
idx.Write(GetTypeIndex(entity));
|
||||||
|
idx.Write(entity.Serial);
|
||||||
|
idx.Write(entity.Created.Ticks);
|
||||||
|
idx.Write(binPosition);
|
||||||
|
idx.Write(length);
|
||||||
|
|
||||||
|
binPosition += length;
|
||||||
|
heapPos += length;
|
||||||
|
entityCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var bufferEntities = worker.BufferEntities;
|
||||||
|
|
||||||
|
for (var i = 0; i < segment.RecordCount; i++)
|
||||||
|
{
|
||||||
|
var entity = (T)bufferEntities[segment.EntitiesStart + i];
|
||||||
|
var length = lengths[lengthIndex++];
|
||||||
|
|
||||||
|
if (entity is Item { SkipSerialization: true } or Mobile { SkipSerialization: true })
|
||||||
|
{
|
||||||
|
if (heapPos > spanStart)
|
||||||
|
{
|
||||||
|
binFs.Write(worker.GetHeap(spanStart, heapPos - spanStart));
|
||||||
|
}
|
||||||
|
|
||||||
|
heapPos += length;
|
||||||
|
spanStart = heapPos;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
idx.Write(GetTypeIndex(entity));
|
||||||
|
idx.Write(entity.Serial);
|
||||||
|
idx.Write(entity.Created.Ticks);
|
||||||
|
idx.Write(binPosition);
|
||||||
|
idx.Write(length);
|
||||||
|
|
||||||
|
binPosition += length;
|
||||||
|
heapPos += length;
|
||||||
|
entityCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (heapPos > spanStart)
|
||||||
|
{
|
||||||
|
binFs.Write(worker.GetHeap(spanStart, heapPos - spanStart));
|
||||||
|
}
|
||||||
|
|
||||||
|
return binPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ushort GetTypeIndex(T entity)
|
||||||
|
{
|
||||||
|
// Every path into EntitiesBySerial registers the type first, so this cannot fire.
|
||||||
|
// If it ever does, the segment-level catch in WriteSnapshot logs it and moves on —
|
||||||
|
// the failed segment's records are dropped from the idx while binPosition rewinds,
|
||||||
|
// so treat any occurrence as a serious bug in an insertion path, not a bad entity.
|
||||||
|
if (!_typeIndexes.TryGetValue(entity.GetType(), out var typeIndex))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"{entity.GetType()} was serialized but never registered; entities must enter {Name} through AddEntity."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeIndex;
|
||||||
|
}
|
||||||
|
|
||||||
public override void Serialize()
|
public override void Serialize()
|
||||||
{
|
{
|
||||||
|
// Self-payload first so a large one overlaps the entity stream instead of ending it.
|
||||||
|
World.PushSingleToCache(this);
|
||||||
|
|
||||||
|
// Fast path: publish slot ranges of the dictionary's entries array so the workers
|
||||||
|
// iterate it directly in parallel — the main thread never touches the entities.
|
||||||
|
if (TrySnapshotEntries(out var slotCount))
|
||||||
|
{
|
||||||
|
World.PushSlotRangesToCache(this, slotCount);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: enumerate and hand off every entity from the main thread. Kept branch-free:
|
||||||
|
// a bare loop is ~2.3x faster than one carrying per-entity logic, and multi-megabyte
|
||||||
|
// entities are rare enough that riding inside a shared chunk is an acceptable tail.
|
||||||
foreach (var entity in EntitiesBySerial.Values)
|
foreach (var entity in EntitiesBySerial.Values)
|
||||||
{
|
{
|
||||||
World.PushToCache(entity);
|
World.PushToCache(entity);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
World.PushToCache(this);
|
internal bool TrySnapshotEntries(out int slotCount)
|
||||||
|
{
|
||||||
|
if (_entriesField != null && EntitiesBySerial.Count > 0 &&
|
||||||
|
_entriesField.GetValue(EntitiesBySerial) is Array entries)
|
||||||
|
{
|
||||||
|
_entriesSnapshot = entries;
|
||||||
|
slotCount = entries.Length;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
slotCount = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int ISlotRangeSource.SerializeRange(BufferWriter writer, List<int> lengths, int offset, int count)
|
||||||
|
{
|
||||||
|
// Layout proven at startup by ShadowDictionaryEntries.Supported; ranges are produced
|
||||||
|
// from the same array's length, so every read is in-bounds.
|
||||||
|
var entries = Unsafe.As<ShadowEntry<T>[]>(_entriesSnapshot);
|
||||||
|
var serialized = 0;
|
||||||
|
|
||||||
|
ref var entry = ref Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(entries), offset);
|
||||||
|
|
||||||
|
for (var i = 0; i < count; i++, entry = ref Unsafe.Add(ref entry, 1))
|
||||||
|
{
|
||||||
|
// Occupied slots are exactly the non-null values: Dictionary clears reference
|
||||||
|
// values on remove, and never-used capacity is zero-initialized.
|
||||||
|
var entity = entry.Value;
|
||||||
|
if (entity != null)
|
||||||
|
{
|
||||||
|
var start = writer.Position;
|
||||||
|
entity.Serialize(writer);
|
||||||
|
lengths.Add((int)(writer.Position - start));
|
||||||
|
serialized++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return serialized;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ConstructorInfo GetConstructorFor(string typeName, Type t, Type[] constructorTypes)
|
private static ConstructorInfo GetConstructorFor(string typeName, Type t, Type[] constructorTypes)
|
||||||
|
|
@ -214,7 +427,16 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
||||||
{
|
{
|
||||||
// Legacy didn't have the null flag check
|
// Legacy didn't have the null flag check
|
||||||
var typeName = dataReader.ReadStringRaw();
|
var typeName = dataReader.ReadStringRaw();
|
||||||
types.Add((ulong)i, GetConstructorFor(typeName, AssemblyHandler.FindTypeByName(typeName), ctorArguments));
|
var type = AssemblyHandler.FindTypeByName(typeName);
|
||||||
|
var ctor = GetConstructorFor(typeName, type, ctorArguments);
|
||||||
|
|
||||||
|
if (ctor != null)
|
||||||
|
{
|
||||||
|
// Keep the type table complete so the next (v4) save can index it.
|
||||||
|
RegisterType(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
types.Add((ulong)i, ctor);
|
||||||
}
|
}
|
||||||
|
|
||||||
accessor.SafeMemoryMappedViewHandle.ReleasePointer();
|
accessor.SafeMemoryMappedViewHandle.ReleasePointer();
|
||||||
|
|
@ -272,10 +494,17 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
||||||
|
|
||||||
var version = dataReader.ReadInt();
|
var version = dataReader.ReadInt();
|
||||||
|
|
||||||
|
if (version >= 4)
|
||||||
|
{
|
||||||
|
DeserializeIndexesV4(dataReader, entities);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
var ctors = version < 2 ? ReadTypes(Path.GetDirectoryName(filePath)) : [];
|
var ctors = version < 2 ? ReadTypes(Path.GetDirectoryName(filePath)) : [];
|
||||||
|
|
||||||
if (typesDb == null && ctors.Count == 0)
|
if (typesDb == null && ctors.Count == 0)
|
||||||
{
|
{
|
||||||
|
accessor.SafeMemoryMappedViewHandle.ReleasePointer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -306,7 +535,14 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
||||||
|
|
||||||
if (!ctors.TryGetValue(hash, out var ctor) && typesDb?.TryGetValue(hash, out var typeName) == true)
|
if (!ctors.TryGetValue(hash, out var ctor) && typesDb?.TryGetValue(hash, out var typeName) == true)
|
||||||
{
|
{
|
||||||
ctors[hash] = ctor = GetConstructorFor(typeName, AssemblyHandler.FindTypeByHash(hash), ctorArguments);
|
var type = AssemblyHandler.FindTypeByHash(hash);
|
||||||
|
ctors[hash] = ctor = GetConstructorFor(typeName, type, ctorArguments);
|
||||||
|
|
||||||
|
if (ctor != null)
|
||||||
|
{
|
||||||
|
// Keep the type table complete so the next (v4) save can index it.
|
||||||
|
RegisterType(type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var serial = (Serial)dataReader.ReadUInt();
|
var serial = (Serial)dataReader.ReadUInt();
|
||||||
|
|
@ -333,6 +569,7 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
||||||
EntitiesBySerial[serial] = entity;
|
EntitiesBySerial[serial] = entity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
accessor.SafeMemoryMappedViewHandle.ReleasePointer();
|
accessor.SafeMemoryMappedViewHandle.ReleasePointer();
|
||||||
|
|
||||||
|
|
@ -342,6 +579,55 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void DeserializeIndexesV4(UnmanagedDataReader dataReader, List<EntitySpan<T>> entities)
|
||||||
|
{
|
||||||
|
Type[] ctorArguments = [typeof(Serial)];
|
||||||
|
|
||||||
|
var typeCount = dataReader.ReadInt();
|
||||||
|
var ctors = new ConstructorInfo[typeCount];
|
||||||
|
|
||||||
|
for (var i = 0; i < typeCount; i++)
|
||||||
|
{
|
||||||
|
var typeName = dataReader.ReadStringRaw();
|
||||||
|
var type = AssemblyHandler.FindTypeByHash(HashUtility.ComputeHash64(typeName));
|
||||||
|
var ctor = GetConstructorFor(typeName, type, ctorArguments);
|
||||||
|
|
||||||
|
if (ctor != null)
|
||||||
|
{
|
||||||
|
// Keep the type table complete so the next save can index it.
|
||||||
|
RegisterType(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctors[i] = ctor;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ctorArgs = new object[1];
|
||||||
|
var count = dataReader.ReadInt();
|
||||||
|
|
||||||
|
for (var i = 0; i < count; ++i)
|
||||||
|
{
|
||||||
|
var ctor = ctors[dataReader.ReadUShort()];
|
||||||
|
var serial = (Serial)dataReader.ReadUInt();
|
||||||
|
var created = new DateTime(dataReader.ReadLong(), DateTimeKind.Utc);
|
||||||
|
var pos = dataReader.ReadLong();
|
||||||
|
var length = dataReader.ReadInt();
|
||||||
|
|
||||||
|
if (ctor == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctorArgs[0] = serial;
|
||||||
|
|
||||||
|
if (ctor.Invoke(ctorArgs) is T entity)
|
||||||
|
{
|
||||||
|
entity.Created = created;
|
||||||
|
entities.Add(new EntitySpan<T>(entity, pos, length));
|
||||||
|
EntitiesBySerial[serial] = entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public override void Deserialize(string savePath, Dictionary<ulong, string> typesDb)
|
public override void Deserialize(string savePath, Dictionary<ulong, string> typesDb)
|
||||||
{
|
{
|
||||||
var dataPath = Path.Combine(savePath, Name, $"{Name}.bin");
|
var dataPath = Path.Combine(savePath, Name, $"{Name}.bin");
|
||||||
|
|
@ -478,6 +764,8 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
||||||
|
|
||||||
public override void PostWorldSave()
|
public override void PostWorldSave()
|
||||||
{
|
{
|
||||||
|
// Release the snapshot so a between-saves resize doesn't pin the old array.
|
||||||
|
_entriesSnapshot = null;
|
||||||
ProcessSafetyQueues();
|
ProcessSafetyQueues();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -555,6 +843,7 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
||||||
case WorldState.PendingSave:
|
case WorldState.PendingSave:
|
||||||
case WorldState.Running:
|
case WorldState.Running:
|
||||||
{
|
{
|
||||||
|
RegisterType(entity.GetType());
|
||||||
ref var entityEntry = ref CollectionsMarshal.GetValueRefOrAddDefault(EntitiesBySerial, entity.Serial, out var exists);
|
ref var entityEntry = ref CollectionsMarshal.GetValueRefOrAddDefault(EntitiesBySerial, entity.Serial, out var exists);
|
||||||
if (exists)
|
if (exists)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,24 @@ public abstract class GenericPersistence : Persistence, IGenericSerializable
|
||||||
public string Name { get; }
|
public string Name { get; }
|
||||||
public string SaveFilePath { get; protected set; } // "<Folder>/<System>.bin"
|
public string SaveFilePath { get; protected set; } // "<Folder>/<System>.bin"
|
||||||
|
|
||||||
public byte SerializedThread { get; set; }
|
// Placement of the self-payload in the worker heaps for the most recent save. Only
|
||||||
public int SerializedPosition { get; set; }
|
// persistences carry placement state — entities are located through the per-worker
|
||||||
public int SerializedLength { get; set; }
|
// segment logs instead.
|
||||||
|
private protected byte _selfThread;
|
||||||
|
private protected int _selfPosition;
|
||||||
|
private protected int _selfLength;
|
||||||
|
|
||||||
|
internal void SetSelfPlacement(byte thread, int position, int length)
|
||||||
|
{
|
||||||
|
_selfThread = thread;
|
||||||
|
_selfPosition = position;
|
||||||
|
_selfLength = length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long _loadedFileLength;
|
||||||
|
|
||||||
|
// Scheduling estimate only: previous save's payload size, or the loaded file size before the first save.
|
||||||
|
internal long EstimatedSize => _selfLength > 0 ? _selfLength : _loadedFileLength;
|
||||||
|
|
||||||
public GenericPersistence(string name, int priority) : base(priority)
|
public GenericPersistence(string name, int priority) : base(priority)
|
||||||
{
|
{
|
||||||
|
|
@ -37,12 +52,14 @@ public abstract class GenericPersistence : Persistence, IGenericSerializable
|
||||||
|
|
||||||
public override void Serialize()
|
public override void Serialize()
|
||||||
{
|
{
|
||||||
World.PushToCache(this);
|
// Always a dedicated chunk: self-payloads can be arbitrarily large and must not
|
||||||
|
// ride inside a shared chunk where one worker would serialize them plus the chunk.
|
||||||
|
World.PushSingleToCache(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void WriteSnapshot(string savePath, HashSet<Type> typeSet)
|
public override void WriteSnapshot(string savePath)
|
||||||
{
|
{
|
||||||
if (SerializedLength == 0)
|
if (_selfLength == 0)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -55,11 +72,7 @@ public abstract class GenericPersistence : Persistence, IGenericSerializable
|
||||||
|
|
||||||
using var binFs = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.None);
|
using var binFs = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||||
|
|
||||||
var thread = SerializedThread;
|
binFs.Write(threads[_selfThread].GetHeap(_selfPosition, _selfLength));
|
||||||
var heapStart = SerializedPosition;
|
|
||||||
var heapLength = SerializedLength;
|
|
||||||
|
|
||||||
binFs.Write(threads[thread].GetHeap(heapStart, heapLength));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override unsafe void Deserialize(string savePath, Dictionary<ulong, string> typesDb)
|
public override unsafe void Deserialize(string savePath, Dictionary<ulong, string> typesDb)
|
||||||
|
|
@ -74,6 +87,7 @@ public abstract class GenericPersistence : Persistence, IGenericSerializable
|
||||||
}
|
}
|
||||||
|
|
||||||
var fileLength = file.Length;
|
var fileLength = file.Length;
|
||||||
|
_loadedFileLength = fileLength;
|
||||||
|
|
||||||
string error;
|
string error;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,5 @@ namespace Server;
|
||||||
|
|
||||||
public interface IGenericSerializable
|
public interface IGenericSerializable
|
||||||
{
|
{
|
||||||
byte SerializedThread { get; set; }
|
|
||||||
int SerializedPosition { get; set; }
|
|
||||||
int SerializedLength { get; set; }
|
|
||||||
|
|
||||||
void Serialize(IGenericWriter writer);
|
void Serialize(IGenericWriter writer);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
*************************************************************************/
|
*************************************************************************/
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Buffers;
|
|
||||||
using System.Collections;
|
using System.Collections;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
|
@ -39,168 +38,24 @@ public interface IGenericWriter
|
||||||
void Write(Serial serial);
|
void Write(Serial serial);
|
||||||
void Write(Type type);
|
void Write(Type type);
|
||||||
void Write(decimal value);
|
void Write(decimal value);
|
||||||
|
void WriteEncodedInt(int value);
|
||||||
void Write(DateTime value)
|
void Write(DateTime value);
|
||||||
{
|
void WriteDeltaTime(DateTime value);
|
||||||
// If DateTimeKind is Unspecified, we can't assume it needs to be converted.
|
void Write(IPAddress value);
|
||||||
if (value.Kind == DateTimeKind.Local)
|
void Write(TimeSpan value);
|
||||||
{
|
void Write(Point3D value);
|
||||||
value = value.ToUniversalTime();
|
void Write(Point2D value);
|
||||||
}
|
void Write(Rectangle2D value);
|
||||||
|
void Write(Rectangle3D value);
|
||||||
Write(value.Ticks);
|
void Write(Map value);
|
||||||
}
|
void Write(Race value);
|
||||||
void WriteDeltaTime(DateTime value)
|
|
||||||
{
|
|
||||||
if (value == DateTime.MinValue)
|
|
||||||
{
|
|
||||||
Write(long.MinValue);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value == DateTime.MaxValue)
|
|
||||||
{
|
|
||||||
Write(long.MaxValue);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.Kind == DateTimeKind.Local)
|
|
||||||
{
|
|
||||||
value = value.ToUniversalTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Technically supports negative deltas for times in the past
|
|
||||||
Write(value.Ticks - DateTime.UtcNow.Ticks);
|
|
||||||
}
|
|
||||||
void Write(IPAddress value)
|
|
||||||
{
|
|
||||||
Span<byte> stack = stackalloc byte[16];
|
|
||||||
value.TryWriteBytes(stack, out var bytesWritten);
|
|
||||||
Write((byte)bytesWritten);
|
|
||||||
Write(stack[..bytesWritten]);
|
|
||||||
}
|
|
||||||
|
|
||||||
void Write(TimeSpan value)
|
|
||||||
{
|
|
||||||
Write(value.Ticks);
|
|
||||||
}
|
|
||||||
|
|
||||||
void WriteEncodedInt(int value)
|
|
||||||
{
|
|
||||||
var v = (uint)value;
|
|
||||||
|
|
||||||
while (v >= 0x80)
|
|
||||||
{
|
|
||||||
Write((byte)(v | 0x80));
|
|
||||||
v >>= 7;
|
|
||||||
}
|
|
||||||
|
|
||||||
Write((byte)v);
|
|
||||||
}
|
|
||||||
|
|
||||||
void Write(Point3D value)
|
|
||||||
{
|
|
||||||
Write(value.m_X);
|
|
||||||
Write(value.m_Y);
|
|
||||||
Write(value.m_Z);
|
|
||||||
}
|
|
||||||
void Write(Point2D value)
|
|
||||||
{
|
|
||||||
Write(value.m_X);
|
|
||||||
Write(value.m_Y);
|
|
||||||
}
|
|
||||||
void Write(Rectangle2D value)
|
|
||||||
{
|
|
||||||
Write(value.Start);
|
|
||||||
Write(value.End);
|
|
||||||
}
|
|
||||||
void Write(Rectangle3D value)
|
|
||||||
{
|
|
||||||
Write(value.Start);
|
|
||||||
Write(value.End);
|
|
||||||
}
|
|
||||||
void Write(Map value) => Write((byte)(value?.MapIndex ?? 0xFF));
|
|
||||||
void Write(Race value) => Write((byte)(value?.RaceIndex ?? 0xFF));
|
|
||||||
void Write(byte[] bytes);
|
void Write(byte[] bytes);
|
||||||
void Write(byte[] bytes, int offset, int count);
|
void Write(byte[] bytes, int offset, int count);
|
||||||
void Write(ReadOnlySpan<byte> bytes);
|
void Write(ReadOnlySpan<byte> bytes);
|
||||||
unsafe void WriteEnum<T>(T value) where T : unmanaged, Enum
|
void WriteEnum<T>(T value) where T : unmanaged, Enum;
|
||||||
{
|
void Write(Guid guid);
|
||||||
switch (sizeof(T))
|
void Write(BitArray bitArray);
|
||||||
{
|
void Write(TextDefinition def);
|
||||||
default:
|
|
||||||
{
|
|
||||||
throw new ArgumentException($"Argument of type {typeof(T)} is not a normal enum");
|
|
||||||
}
|
|
||||||
case 1:
|
|
||||||
{
|
|
||||||
Write(*(byte*)&value);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 2:
|
|
||||||
{
|
|
||||||
Write(*(ushort*)&value);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 4:
|
|
||||||
{
|
|
||||||
WriteEncodedInt(*(int*)&value);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 8:
|
|
||||||
{
|
|
||||||
Write(*(ulong*)&value);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void Write(Guid guid)
|
|
||||||
{
|
|
||||||
Span<byte> stack = stackalloc byte[16];
|
|
||||||
guid.TryWriteBytes(stack);
|
|
||||||
Write(stack);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Write(BitArray bitArray)
|
|
||||||
{
|
|
||||||
var bitLength = bitArray.Length;
|
|
||||||
var byteLength = (bitLength + 7) / 8;
|
|
||||||
|
|
||||||
WriteEncodedInt(bitLength);
|
|
||||||
|
|
||||||
var arrayBuffer = ArrayPool<byte>.Shared.Rent(byteLength);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
bitArray.CopyTo(arrayBuffer, 0);
|
|
||||||
Write(arrayBuffer.AsSpan(0, byteLength));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
ArrayPool<byte>.Shared.Return(arrayBuffer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void Write(TextDefinition def)
|
|
||||||
{
|
|
||||||
if (def == null)
|
|
||||||
{
|
|
||||||
WriteEncodedInt(3);
|
|
||||||
}
|
|
||||||
else if (def.Number > 0)
|
|
||||||
{
|
|
||||||
WriteEncodedInt(1);
|
|
||||||
WriteEncodedInt(def.Number);
|
|
||||||
}
|
|
||||||
else if (def.String != null)
|
|
||||||
{
|
|
||||||
WriteEncodedInt(2);
|
|
||||||
Write(def.String);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
WriteEncodedInt(0); // Empty
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
long Seek(long offset, SeekOrigin origin);
|
long Seek(long offset, SeekOrigin origin);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,304 +0,0 @@
|
||||||
/*************************************************************************
|
|
||||||
* ModernUO *
|
|
||||||
* Copyright 2019-2026 - ModernUO Development Team *
|
|
||||||
* Email: hi@modernuo.com *
|
|
||||||
* File: MemoryMapFileWriter.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.Buffers.Binary;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.IO.MemoryMappedFiles;
|
|
||||||
using System.Runtime.CompilerServices;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Text;
|
|
||||||
using Server.Text;
|
|
||||||
|
|
||||||
namespace Server;
|
|
||||||
|
|
||||||
public unsafe class MemoryMapFileWriter : IGenericWriter, IDisposable
|
|
||||||
{
|
|
||||||
private readonly Encoding _encoding;
|
|
||||||
|
|
||||||
private readonly HashSet<Type> _types;
|
|
||||||
private readonly FileStream _fileStream;
|
|
||||||
private MemoryMappedFile _mmf;
|
|
||||||
private MemoryMappedViewAccessor _accessor;
|
|
||||||
private byte* _ptr;
|
|
||||||
private long _position;
|
|
||||||
private long _size;
|
|
||||||
|
|
||||||
public MemoryMapFileWriter(FileStream fileStream, long initialSize, HashSet<Type> types = null)
|
|
||||||
{
|
|
||||||
_types = types;
|
|
||||||
_fileStream = fileStream;
|
|
||||||
_encoding = TextEncoding.UTF8;
|
|
||||||
_size = Math.Max(initialSize, 1024);
|
|
||||||
|
|
||||||
ResizeMemoryMappedFile(initialSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
public long Position => _position;
|
|
||||||
|
|
||||||
public FileStream FileStream => _fileStream;
|
|
||||||
|
|
||||||
private void ResizeMemoryMappedFile(long newSize)
|
|
||||||
{
|
|
||||||
_accessor?.SafeMemoryMappedViewHandle.ReleasePointer();
|
|
||||||
_accessor?.Dispose();
|
|
||||||
_mmf?.Dispose();
|
|
||||||
|
|
||||||
// Do the actual resizing
|
|
||||||
_fileStream.SetLength(newSize);
|
|
||||||
|
|
||||||
_mmf = MemoryMappedFile.CreateFromFile(_fileStream, null, newSize, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, leaveOpen: true);
|
|
||||||
_accessor = _mmf.CreateViewAccessor();
|
|
||||||
_accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref _ptr);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void EnsureCapacity(long bytesToWrite)
|
|
||||||
{
|
|
||||||
var shouldResize = false;
|
|
||||||
while (_position + bytesToWrite > _size)
|
|
||||||
{
|
|
||||||
// Don't double forever, eventually we want to have a maximum, like 256MB at a time or something
|
|
||||||
_size += Math.Min(_size, 1024 * 1024 * 256);
|
|
||||||
shouldResize = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldResize)
|
|
||||||
{
|
|
||||||
ResizeMemoryMappedFile(_size);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
||||||
public void Write(byte[] bytes) => Write(bytes.AsSpan());
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
||||||
public void Write(byte[] bytes, int offset, int count) => Write(bytes.AsSpan(offset, count));
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
||||||
public void Write(ReadOnlySpan<byte> bytes)
|
|
||||||
{
|
|
||||||
var byteCount = bytes.Length;
|
|
||||||
EnsureCapacity(byteCount);
|
|
||||||
|
|
||||||
bytes.CopyTo(new Span<byte>(_ptr + _position, byteCount));
|
|
||||||
_position += byteCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
||||||
public virtual long Seek(long offset, SeekOrigin origin)
|
|
||||||
{
|
|
||||||
switch (origin)
|
|
||||||
{
|
|
||||||
case SeekOrigin.Begin:
|
|
||||||
{
|
|
||||||
if (offset > _size)
|
|
||||||
{
|
|
||||||
EnsureCapacity(offset);
|
|
||||||
}
|
|
||||||
|
|
||||||
_position = offset;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case SeekOrigin.Current:
|
|
||||||
{
|
|
||||||
EnsureCapacity(offset);
|
|
||||||
_position += offset;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case SeekOrigin.End:
|
|
||||||
{
|
|
||||||
if (_position + offset > _size)
|
|
||||||
{
|
|
||||||
EnsureCapacity(offset);
|
|
||||||
}
|
|
||||||
|
|
||||||
_position = _size + offset;
|
|
||||||
|
|
||||||
if (_position < 0)
|
|
||||||
{
|
|
||||||
Dispose();
|
|
||||||
throw new InvalidOperationException("Seek before start of file");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return _position;
|
|
||||||
}
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
||||||
public void Write(string value)
|
|
||||||
{
|
|
||||||
if (value == null)
|
|
||||||
{
|
|
||||||
Write(false);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Write(true);
|
|
||||||
WriteStringRaw(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
||||||
public void Write(long value)
|
|
||||||
{
|
|
||||||
EnsureCapacity(sizeof(long));
|
|
||||||
BinaryPrimitives.WriteInt64LittleEndian(new Span<byte>(_ptr + _position, sizeof(long)), value);
|
|
||||||
_position += sizeof(long);
|
|
||||||
}
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
||||||
public void Write(ulong value)
|
|
||||||
{
|
|
||||||
EnsureCapacity(sizeof(ulong));
|
|
||||||
BinaryPrimitives.WriteUInt64LittleEndian(new Span<byte>(_ptr + _position, sizeof(ulong)), value);
|
|
||||||
_position += sizeof(ulong);
|
|
||||||
}
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
||||||
public void Write(int value)
|
|
||||||
{
|
|
||||||
EnsureCapacity(sizeof(int));
|
|
||||||
BinaryPrimitives.WriteInt32LittleEndian(new Span<byte>(_ptr + _position, sizeof(int)), value);
|
|
||||||
_position += sizeof(int);
|
|
||||||
}
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
||||||
public void Write(uint value)
|
|
||||||
{
|
|
||||||
EnsureCapacity(sizeof(uint));
|
|
||||||
BinaryPrimitives.WriteUInt32LittleEndian(new Span<byte>(_ptr + _position, sizeof(uint)), value);
|
|
||||||
_position += sizeof(uint);
|
|
||||||
}
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
||||||
public void Write(short value)
|
|
||||||
{
|
|
||||||
EnsureCapacity(sizeof(short));
|
|
||||||
BinaryPrimitives.WriteInt16LittleEndian(new Span<byte>(_ptr + _position, sizeof(short)), value);
|
|
||||||
_position += sizeof(short);
|
|
||||||
}
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
||||||
public void Write(ushort value)
|
|
||||||
{
|
|
||||||
EnsureCapacity(sizeof(ushort));
|
|
||||||
BinaryPrimitives.WriteUInt16LittleEndian(new Span<byte>(_ptr + _position, sizeof(ushort)), value);
|
|
||||||
_position += sizeof(ushort);
|
|
||||||
}
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
||||||
public void Write(double value)
|
|
||||||
{
|
|
||||||
EnsureCapacity(sizeof(double));
|
|
||||||
BinaryPrimitives.WriteDoubleLittleEndian(new Span<byte>(_ptr + _position, sizeof(double)), value);
|
|
||||||
_position += sizeof(double);
|
|
||||||
}
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
||||||
public void Write(float value)
|
|
||||||
{
|
|
||||||
EnsureCapacity(sizeof(float));
|
|
||||||
BinaryPrimitives.WriteSingleLittleEndian(new Span<byte>(_ptr + _position, sizeof(float)), value);
|
|
||||||
_position += sizeof(float);
|
|
||||||
}
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
||||||
public void Write(byte value)
|
|
||||||
{
|
|
||||||
EnsureCapacity(1);
|
|
||||||
*(_ptr + _position++) = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
||||||
public void Write(sbyte value) => Write((byte)value);
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
||||||
public void Write(bool value) => Write(*(byte*)&value);
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
||||||
public void Write(Serial serial) => Write(serial.Value);
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
||||||
public void Write(Type type)
|
|
||||||
{
|
|
||||||
if (type == null)
|
|
||||||
{
|
|
||||||
Write((byte)0);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Write((byte)0x2); // xxHash3 64bit
|
|
||||||
Write(AssemblyHandler.GetTypeHash(type));
|
|
||||||
_types.Add(type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
||||||
public void Write(decimal value)
|
|
||||||
{
|
|
||||||
Span<int> buffer = stackalloc int[sizeof(decimal) / 4];
|
|
||||||
decimal.GetBits(value, buffer);
|
|
||||||
|
|
||||||
Write(MemoryMarshal.Cast<int, byte>(buffer));
|
|
||||||
}
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
||||||
public void WriteStringRaw(ReadOnlySpan<char> value)
|
|
||||||
{
|
|
||||||
var length = _encoding.GetByteCount(value);
|
|
||||||
|
|
||||||
EnsureCapacity(length + 5);
|
|
||||||
|
|
||||||
// WriteEncodedInt
|
|
||||||
var v = (uint)length;
|
|
||||||
|
|
||||||
while (v >= 0x80)
|
|
||||||
{
|
|
||||||
*(_ptr + _position++) = (byte)(v | 0x80);
|
|
||||||
v >>= 7;
|
|
||||||
}
|
|
||||||
*(_ptr + _position++) = (byte)v;
|
|
||||||
|
|
||||||
_encoding.GetBytes(value, new Span<byte>(_ptr + _position, length));
|
|
||||||
_position += length;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Dispose(bool disposing)
|
|
||||||
{
|
|
||||||
if (disposing)
|
|
||||||
{
|
|
||||||
_accessor.SafeMemoryMappedViewHandle.ReleasePointer();
|
|
||||||
_accessor.Dispose();
|
|
||||||
_mmf.Dispose();
|
|
||||||
|
|
||||||
// Truncate the file
|
|
||||||
_fileStream.SetLength(_position);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
Dispose(true);
|
|
||||||
GC.SuppressFinalize(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
~MemoryMapFileWriter()
|
|
||||||
{
|
|
||||||
Dispose(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -84,41 +84,35 @@ public abstract class Persistence
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: This is strictly on a background thread
|
// Note: This is strictly on a background thread
|
||||||
internal static void WriteSnapshotAll(string path, HashSet<Type> typeSet)
|
internal static void WriteSnapshotAll(string path)
|
||||||
{
|
{
|
||||||
foreach (var p in _registry)
|
foreach (var p in _registry)
|
||||||
{
|
{
|
||||||
p.WriteSnapshot(path, typeSet);
|
p.WriteSnapshot(path);
|
||||||
}
|
|
||||||
|
|
||||||
WriteSerializedTypesSnapshot(path, typeSet);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void WriteSerializedTypesSnapshot(string path, HashSet<Type> types)
|
|
||||||
{
|
|
||||||
var typesPath = Path.Combine(path, "SerializedTypes.db");
|
|
||||||
using var fs = new FileStream(typesPath, FileMode.Create);
|
|
||||||
using var writer = new MemoryMapFileWriter(fs, 1024 * 1024 * 4);
|
|
||||||
|
|
||||||
writer.Write(0); // version
|
|
||||||
writer.Write(types.Count);
|
|
||||||
|
|
||||||
foreach (var type in types)
|
|
||||||
{
|
|
||||||
var fullName = type.FullName;
|
|
||||||
writer.Write(HashUtility.ComputeHash64(fullName));
|
|
||||||
writer.WriteStringRaw(fullName);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static void SerializeAll()
|
internal static void SerializeAll()
|
||||||
{
|
{
|
||||||
foreach (var p in _registry)
|
// 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();
|
p.Serialize();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static long GetEstimatedSize(Persistence p) => (p as GenericPersistence)?.EstimatedSize ?? 0;
|
||||||
|
|
||||||
internal static void PostWorldSaveAll()
|
internal static void PostWorldSaveAll()
|
||||||
{
|
{
|
||||||
foreach (var p in _registry)
|
foreach (var p in _registry)
|
||||||
|
|
@ -136,7 +130,7 @@ public abstract class Persistence
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: This should only be run on a background thread
|
// Note: This should only be run on a background thread
|
||||||
public abstract void WriteSnapshot(string savePath, HashSet<Type> typeSet);
|
public abstract void WriteSnapshot(string savePath);
|
||||||
|
|
||||||
public abstract void Serialize();
|
public abstract void Serialize();
|
||||||
|
|
||||||
|
|
|
||||||
171
Projects/Server/Serialization/SerializationChunkSource.cs
Normal file
171
Projects/Server/Serialization/SerializationChunkSource.cs
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
/*************************************************************************
|
||||||
|
* ModernUO *
|
||||||
|
* Copyright 2019-2026 - ModernUO Development Team *
|
||||||
|
* Email: hi@modernuo.com *
|
||||||
|
* File: SerializationChunkSource.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.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace Server;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A range of backing-store slots that a serialization worker can serialize directly,
|
||||||
|
/// letting workers iterate a persistence's storage in parallel instead of the main thread
|
||||||
|
/// enumerating and handing off every entity. Implemented by
|
||||||
|
/// <see cref="GenericEntityPersistence{T}"/> over its dictionary's entries array.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISlotRangeSource
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes every occupied slot in [offset, offset + count) into the writer, appending
|
||||||
|
/// each record's byte length to <paramref name="lengths"/>. Returns the number serialized.
|
||||||
|
/// </summary>
|
||||||
|
int SerializeRange(BufferWriter writer, List<int> lengths, int offset, int count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single-producer/multi-consumer handoff between the game loop and the serialization
|
||||||
|
/// thread workers during a world save. The producer batches entities into pooled chunks
|
||||||
|
/// so the per-entity cost is a plain array store instead of a synchronized enqueue, and
|
||||||
|
/// workers pull whole chunks so they naturally load-balance: a worker busy with a thick
|
||||||
|
/// entity simply takes fewer chunks.
|
||||||
|
/// Persistence self-payloads are published as dedicated single-entity chunks so large
|
||||||
|
/// systems spread across workers instead of riding inside one chunk.
|
||||||
|
/// Persistences that support direct parallel iteration publish slot ranges instead of
|
||||||
|
/// filled chunks, removing the per-entity handoff from the freeze entirely.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SerializationChunkSource
|
||||||
|
{
|
||||||
|
// 4096 refs (32KB per chunk) keeps producer sync cost at one enqueue per 4096 entities
|
||||||
|
// while the drain tail stays sub-millisecond.
|
||||||
|
private const int ChunkCapacity = 4096;
|
||||||
|
|
||||||
|
internal readonly struct Chunk
|
||||||
|
{
|
||||||
|
public readonly GenericPersistence Single;
|
||||||
|
public readonly IGenericSerializable[] Buffer;
|
||||||
|
public readonly ISlotRangeSource Source;
|
||||||
|
public readonly Persistence Owner; // buffer chunks only; ranges use Source, singles record their own placement
|
||||||
|
public readonly int Offset;
|
||||||
|
public readonly int Count;
|
||||||
|
|
||||||
|
public Chunk(GenericPersistence single)
|
||||||
|
{
|
||||||
|
Single = single;
|
||||||
|
Count = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Chunk(IGenericSerializable[] buffer, int count, Persistence owner)
|
||||||
|
{
|
||||||
|
Buffer = buffer;
|
||||||
|
Count = count;
|
||||||
|
Owner = owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Chunk(ISlotRangeSource source, int offset, int count)
|
||||||
|
{
|
||||||
|
Source = source;
|
||||||
|
Offset = offset;
|
||||||
|
Count = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly ConcurrentQueue<Chunk> _chunks = new();
|
||||||
|
private readonly ConcurrentQueue<IGenericSerializable[]> _pool = new();
|
||||||
|
|
||||||
|
// Producer state - written only by the game loop thread.
|
||||||
|
private IGenericSerializable[] _current;
|
||||||
|
private int _count;
|
||||||
|
private Persistence _currentOwner;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Declares the owner of subsequently pushed entities. Publishes the partial chunk when
|
||||||
|
/// the owner changes, keeping buffer chunks persistence-homogeneous so workers can
|
||||||
|
/// attribute their serialized records to a persistence without any per-entity state.
|
||||||
|
/// </summary>
|
||||||
|
public void SetOwner(Persistence owner)
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(_currentOwner, owner))
|
||||||
|
{
|
||||||
|
Flush();
|
||||||
|
_currentOwner = owner;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public void Push(IGenericSerializable entity)
|
||||||
|
{
|
||||||
|
var current = _current ??= Rent();
|
||||||
|
|
||||||
|
// Ref store skips the bounds and array-covariance checks. Safe by construction:
|
||||||
|
// _count is producer-thread-only and always < ChunkCapacity here (reset on publish),
|
||||||
|
// and the array's element type is exactly IGenericSerializable.
|
||||||
|
Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(current), _count) = entity;
|
||||||
|
|
||||||
|
if (++_count == ChunkCapacity)
|
||||||
|
{
|
||||||
|
_chunks.Enqueue(new Chunk(current, ChunkCapacity, _currentOwner));
|
||||||
|
_current = null;
|
||||||
|
_count = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Publishes a persistence self-payload as a dedicated chunk regardless of its
|
||||||
|
/// estimated size — it can be large on the first save before an estimate exists.
|
||||||
|
/// The worker records the payload's placement on the persistence itself.
|
||||||
|
/// </summary>
|
||||||
|
public void PushSingle(GenericPersistence persistence) => _chunks.Enqueue(new Chunk(persistence));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Publishes slot ranges covering [0, slotCount) of a directly-iterable persistence.
|
||||||
|
/// Workers claim ranges like any other chunk, so the per-entity handoff cost disappears
|
||||||
|
/// and load balancing is unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public void PushSlotRanges(ISlotRangeSource source, int slotCount)
|
||||||
|
{
|
||||||
|
for (var offset = 0; offset < slotCount; offset += ChunkCapacity)
|
||||||
|
{
|
||||||
|
_chunks.Enqueue(new Chunk(source, offset, Math.Min(ChunkCapacity, slotCount - offset)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Publishes the partial chunk, if any. Must be called on the producer thread before
|
||||||
|
/// the workers are told to finish draining, or the tail of the stream is not serialized.
|
||||||
|
/// </summary>
|
||||||
|
public void Flush()
|
||||||
|
{
|
||||||
|
if (_count > 0)
|
||||||
|
{
|
||||||
|
_chunks.Enqueue(new Chunk(_current, _count, _currentOwner));
|
||||||
|
_current = null;
|
||||||
|
_count = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool TryTake(out Chunk chunk) => _chunks.TryDequeue(out chunk);
|
||||||
|
|
||||||
|
internal void Return(IGenericSerializable[] buffer, int count)
|
||||||
|
{
|
||||||
|
// Clear so pooled chunks don't keep entities reachable between saves.
|
||||||
|
Array.Clear(buffer, 0, count);
|
||||||
|
_pool.Enqueue(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IGenericSerializable[] Rent() =>
|
||||||
|
_pool.TryDequeue(out var buffer) ? buffer : new IGenericSerializable[ChunkCapacity];
|
||||||
|
}
|
||||||
|
|
@ -14,12 +14,44 @@
|
||||||
*************************************************************************/
|
*************************************************************************/
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Generic;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
|
||||||
namespace Server;
|
namespace Server;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One contiguous run of records a worker serialized into its heap from a single chunk.
|
||||||
|
/// Together with the worker's lengths log this replaces per-entity placement state:
|
||||||
|
/// positions are implicit (a worker's writes are contiguous), and identity comes from
|
||||||
|
/// re-walking the same slots for range segments, or from the entities log for buffer
|
||||||
|
/// segments. The snapshot writer routes segments to files by <see cref="Owner"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal readonly struct SerializedSegment
|
||||||
|
{
|
||||||
|
public readonly object Owner; // ISlotRangeSource for range segments, Persistence for buffer segments
|
||||||
|
public readonly int SlotOffset; // -1 when the segment came from a buffer chunk
|
||||||
|
public readonly int SlotCount;
|
||||||
|
public readonly long HeapStart;
|
||||||
|
public readonly int LengthsStart;
|
||||||
|
public readonly int RecordCount;
|
||||||
|
public readonly int EntitiesStart; // buffer segments only
|
||||||
|
|
||||||
|
public SerializedSegment(
|
||||||
|
object owner, int slotOffset, int slotCount, long heapStart, int lengthsStart, int recordCount,
|
||||||
|
int entitiesStart
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Owner = owner;
|
||||||
|
SlotOffset = slotOffset;
|
||||||
|
SlotCount = slotCount;
|
||||||
|
HeapStart = heapStart;
|
||||||
|
LengthsStart = lengthsStart;
|
||||||
|
RecordCount = recordCount;
|
||||||
|
EntitiesStart = entitiesStart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public class SerializationThreadWorker
|
public class SerializationThreadWorker
|
||||||
{
|
{
|
||||||
private const int MinHeapSize = 1024 * 1024; // 1MB
|
private const int MinHeapSize = 1024 * 1024; // 1MB
|
||||||
|
|
@ -27,22 +59,68 @@ public class SerializationThreadWorker
|
||||||
private readonly Thread _thread;
|
private readonly Thread _thread;
|
||||||
private readonly AutoResetEvent _startEvent; // Main thread tells the thread to start working
|
private readonly AutoResetEvent _startEvent; // Main thread tells the thread to start working
|
||||||
private readonly AutoResetEvent _stopEvent; // Main thread waits for the worker finish draining
|
private readonly AutoResetEvent _stopEvent; // Main thread waits for the worker finish draining
|
||||||
|
private readonly SerializationChunkSource _chunkSource;
|
||||||
|
private readonly int _heapSizeHint;
|
||||||
private bool _pause;
|
private bool _pause;
|
||||||
private bool _exit;
|
private bool _exit;
|
||||||
private bool _exited;
|
private bool _exited;
|
||||||
private byte[] _heap;
|
private byte[] _heap;
|
||||||
|
private long _entitiesSerialized;
|
||||||
|
private long _bytesSerialized;
|
||||||
|
|
||||||
private readonly ConcurrentQueue<IGenericSerializable> _entities;
|
// What this worker serialized where, logged during the drain and consumed by
|
||||||
|
// WriteSnapshot on the background writer thread. Cleared once the snapshot is on disk.
|
||||||
|
private readonly List<SerializedSegment> _segments = [];
|
||||||
|
private readonly List<int> _lengths = [];
|
||||||
|
private readonly List<IGenericSerializable> _bufferEntities = [];
|
||||||
|
|
||||||
public SerializationThreadWorker(int index)
|
internal List<SerializedSegment> Segments => _segments;
|
||||||
|
internal List<int> Lengths => _lengths;
|
||||||
|
internal List<IGenericSerializable> BufferEntities => _bufferEntities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Releases the write logs after the snapshot is written so serialized entity
|
||||||
|
/// references don't linger between saves. Capacity is retained: the logs regrow to
|
||||||
|
/// the same size every save.
|
||||||
|
/// </summary>
|
||||||
|
internal void ReleaseWriteLogs()
|
||||||
|
{
|
||||||
|
_segments.Clear();
|
||||||
|
_lengths.Clear();
|
||||||
|
_bufferEntities.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public SerializationThreadWorker(int index, SerializationChunkSource chunkSource, int heapSizeHint = 0)
|
||||||
|
: this(index, chunkSource, heapSizeHint, inline: false)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private SerializationThreadWorker(int index, SerializationChunkSource chunkSource, int heapSizeHint, bool inline)
|
||||||
{
|
{
|
||||||
_index = index;
|
_index = index;
|
||||||
|
_chunkSource = chunkSource;
|
||||||
|
_heapSizeHint = heapSizeHint;
|
||||||
|
|
||||||
|
if (!inline)
|
||||||
|
{
|
||||||
_startEvent = new AutoResetEvent(false);
|
_startEvent = new AutoResetEvent(false);
|
||||||
_stopEvent = new AutoResetEvent(false);
|
_stopEvent = new AutoResetEvent(false);
|
||||||
_entities = new ConcurrentQueue<IGenericSerializable>();
|
|
||||||
_thread = new Thread(Execute);
|
_thread = new Thread(Execute);
|
||||||
_thread.Start(this);
|
_thread.Start(this);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a worker with no thread of its own. The owner drains chunks inline via
|
||||||
|
/// <see cref="DrainInline"/> — used by the main thread to join the drain instead of
|
||||||
|
/// idling while the thread workers finish.
|
||||||
|
/// </summary>
|
||||||
|
public static SerializationThreadWorker CreateInline(int index, SerializationChunkSource chunkSource, int heapSizeHint = 0) =>
|
||||||
|
new(index, chunkSource, heapSizeHint, inline: true);
|
||||||
|
|
||||||
|
// Stats from the most recent save, for diagnosing load balance.
|
||||||
|
public long EntitiesSerialized => _entitiesSerialized;
|
||||||
|
public long BytesSerialized => _bytesSerialized;
|
||||||
|
|
||||||
public void Wake()
|
public void Wake()
|
||||||
{
|
{
|
||||||
|
|
@ -68,50 +146,139 @@ public class SerializationThreadWorker
|
||||||
Sleep();
|
Sleep();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void AllocateHeap() => _heap ??= GC.AllocateUninitializedArray<byte>(MinHeapSize); // 1MB
|
// Sized from the previous world load so the first save doesn't pay copy-on-grow during the freeze.
|
||||||
|
public void AllocateHeap() =>
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
_heap ??= GC.AllocateUninitializedArray<byte>(Math.Max(MinHeapSize, _heapSizeHint));
|
||||||
public void Push(IGenericSerializable entity) => _entities.Enqueue(entity);
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public ReadOnlySpan<byte> GetHeap(int start, int length) => _heap.AsSpan(start, length);
|
public ReadOnlySpan<byte> GetHeap(int start, int length) => _heap.AsSpan(start, length);
|
||||||
|
|
||||||
|
private long ProcessChunk(in SerializationChunkSource.Chunk chunk, BufferWriter writer)
|
||||||
|
{
|
||||||
|
if (chunk.Single != null)
|
||||||
|
{
|
||||||
|
// Self-payloads are written to their own file, so they record placement on the
|
||||||
|
// persistence itself instead of the segment logs.
|
||||||
|
var start = writer.Position;
|
||||||
|
chunk.Single.Serialize(writer);
|
||||||
|
chunk.Single.SetSelfPlacement((byte)_index, (int)start, (int)(writer.Position - start));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunk.Source != null)
|
||||||
|
{
|
||||||
|
var heapStart = writer.Position;
|
||||||
|
var lengthsStart = _lengths.Count;
|
||||||
|
var serialized = chunk.Source.SerializeRange(writer, _lengths, chunk.Offset, chunk.Count);
|
||||||
|
|
||||||
|
if (serialized > 0)
|
||||||
|
{
|
||||||
|
_segments.Add(
|
||||||
|
new SerializedSegment(chunk.Source, chunk.Offset, chunk.Count, heapStart, lengthsStart, serialized, -1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return serialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
var buffer = chunk.Buffer;
|
||||||
|
var count = chunk.Count;
|
||||||
|
|
||||||
|
var bufferHeapStart = writer.Position;
|
||||||
|
var bufferLengthsStart = _lengths.Count;
|
||||||
|
var entitiesStart = _bufferEntities.Count;
|
||||||
|
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
var e = buffer[i];
|
||||||
|
var start = writer.Position;
|
||||||
|
e.Serialize(writer);
|
||||||
|
_lengths.Add((int)(writer.Position - start));
|
||||||
|
_bufferEntities.Add(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
_segments.Add(
|
||||||
|
new SerializedSegment(chunk.Owner, -1, 0, bufferHeapStart, bufferLengthsStart, count, entitiesStart)
|
||||||
|
);
|
||||||
|
|
||||||
|
_chunkSource.Return(buffer, count);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drains chunks on the calling thread until the queue is empty, then returns.
|
||||||
|
/// Only valid on inline workers; the main thread calls this after publishing all work
|
||||||
|
/// so it contributes drain throughput instead of idling.
|
||||||
|
/// </summary>
|
||||||
|
public void DrainInline()
|
||||||
|
{
|
||||||
|
ReleaseWriteLogs();
|
||||||
|
|
||||||
|
var writer = new BufferWriter(_heap, true);
|
||||||
|
var entities = 0L;
|
||||||
|
|
||||||
|
while (_chunkSource.TryTake(out var chunk))
|
||||||
|
{
|
||||||
|
entities += ProcessChunk(in chunk, writer);
|
||||||
|
}
|
||||||
|
|
||||||
|
_heap = writer.Buffer;
|
||||||
|
_entitiesSerialized = entities;
|
||||||
|
_bytesSerialized = writer.Position;
|
||||||
|
|
||||||
|
writer.Close();
|
||||||
|
}
|
||||||
|
|
||||||
private static void Execute(object obj)
|
private static void Execute(object obj)
|
||||||
{
|
{
|
||||||
var worker = (SerializationThreadWorker)obj;
|
var worker = (SerializationThreadWorker)obj;
|
||||||
var threadIndex = (byte)worker._index;
|
|
||||||
|
|
||||||
var queue = worker._entities;
|
var chunkSource = worker._chunkSource;
|
||||||
var serializedTypes = World.SerializedTypes;
|
|
||||||
|
|
||||||
while (worker._startEvent.WaitOne())
|
while (worker._startEvent.WaitOne())
|
||||||
{
|
{
|
||||||
var writer = new BufferWriter(worker._heap, true, serializedTypes);
|
worker.ReleaseWriteLogs();
|
||||||
|
|
||||||
|
var writer = new BufferWriter(worker._heap, true);
|
||||||
|
var entities = 0L;
|
||||||
|
var spinner = new SpinWait();
|
||||||
|
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
var pauseRequested = Volatile.Read(ref worker._pause);
|
var pauseRequested = Volatile.Read(ref worker._pause);
|
||||||
if (queue.TryDequeue(out var e))
|
if (chunkSource.TryTake(out var chunk))
|
||||||
{
|
{
|
||||||
e.SerializedThread = threadIndex;
|
spinner.Reset();
|
||||||
var start = e.SerializedPosition = (int)writer.Position;
|
entities += worker.ProcessChunk(in chunk, writer);
|
||||||
e.Serialize(writer);
|
|
||||||
e.SerializedLength = (int)(writer.Position - start);
|
|
||||||
}
|
}
|
||||||
else if (pauseRequested) // Break when finished
|
else if (pauseRequested) // Break when finished
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Idle backoff instead of hammering the queue head while the producer works.
|
||||||
|
// sleep1Threshold: -1 keeps escalation at Yield/Sleep(0) and never Sleep(1),
|
||||||
|
// avoiding timer-resolution stalls at the end of the drain.
|
||||||
|
spinner.SpinOnce(-1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
worker._heap = writer.Buffer;
|
worker._heap = writer.Buffer;
|
||||||
|
worker._entitiesSerialized = entities;
|
||||||
|
worker._bytesSerialized = writer.Position;
|
||||||
|
|
||||||
writer.Close();
|
writer.Close();
|
||||||
|
|
||||||
worker._stopEvent.Set(); // Allow the main thread to continue now that we are finished
|
// The owning thread may start another pause cycle the moment _stopEvent is set
|
||||||
worker._pause = false;
|
// (Exit does exactly that). Clear _pause and sample the exit condition before
|
||||||
|
// signaling, or the new cycle's pause request is clobbered / its Sleep orphaned.
|
||||||
|
var exiting = Core.Closing || worker._exit;
|
||||||
|
Volatile.Write(ref worker._pause, false);
|
||||||
|
|
||||||
if (Core.Closing || worker._exit)
|
worker._stopEvent.Set(); // Allow the main thread to continue now that we are finished
|
||||||
|
|
||||||
|
if (exiting)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
197
Projects/Server/Serialization/ShadowDictionaryEntries.cs
Normal file
197
Projects/Server/Serialization/ShadowDictionaryEntries.cs
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
/*************************************************************************
|
||||||
|
* ModernUO *
|
||||||
|
* Copyright 2019-2026 - ModernUO Development Team *
|
||||||
|
* Email: hi@modernuo.com *
|
||||||
|
* File: ShadowDictionaryEntries.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.Reflection;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Server.Logging;
|
||||||
|
|
||||||
|
namespace Server;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mirrors the field layout of <c>Dictionary<Serial, TValue>.Entry</c> so serialization
|
||||||
|
/// workers can scan a dictionary's backing entries array directly, in parallel, without the
|
||||||
|
/// main thread enumerating and handing off every entity.
|
||||||
|
/// The CLR's auto-layout algorithm is deterministic for identical field sequences, so this
|
||||||
|
/// struct lays out identically to the runtime's private Entry struct — and
|
||||||
|
/// <see cref="ShadowDictionaryEntries.Supported"/> proves that empirically at startup before
|
||||||
|
/// any code reads through it. If validation fails on a future runtime, callers fall back to
|
||||||
|
/// the enumerate-and-push path.
|
||||||
|
/// A free or never-used slot always has a null value (Dictionary clears values of
|
||||||
|
/// reference-type TValue on remove to release references), so occupancy is exactly
|
||||||
|
/// <c>Value != null</c>.
|
||||||
|
/// </summary>
|
||||||
|
internal struct ShadowEntry<TValue>
|
||||||
|
{
|
||||||
|
// Field order must mirror S.P.CoreLib Dictionary<TKey,TValue>.Entry: hashCode, next, key, value
|
||||||
|
public uint HashCode;
|
||||||
|
public int Next;
|
||||||
|
public Serial Key;
|
||||||
|
public TValue Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class ShadowDictionaryEntries
|
||||||
|
{
|
||||||
|
private static readonly ILogger logger = LogFactory.GetLogger(typeof(ShadowDictionaryEntries));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when this runtime's Dictionary entry layout matches <see cref="ShadowEntry{TValue}"/>,
|
||||||
|
/// proven by validation at startup. All reference-type TValue instantiations share one
|
||||||
|
/// canonical layout, so a single validation covers every entity dictionary.
|
||||||
|
/// </summary>
|
||||||
|
internal static readonly bool Supported = Validate();
|
||||||
|
|
||||||
|
internal static FieldInfo GetEntriesField<TValue>() where TValue : class =>
|
||||||
|
typeof(Dictionary<Serial, TValue>).GetField("_entries", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||||
|
|
||||||
|
private sealed class ValidationValue
|
||||||
|
{
|
||||||
|
public int Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Validate()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var field = GetEntriesField<ValidationValue>();
|
||||||
|
if (field == null)
|
||||||
|
{
|
||||||
|
logger.Warning("Dictionary._entries not found; parallel save iteration disabled.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A compacting GC between snapshotting reference bits and scanning them can only
|
||||||
|
// produce a false negative, so retry a few times before falling back.
|
||||||
|
for (var attempt = 0; attempt < 3; attempt++)
|
||||||
|
{
|
||||||
|
if (RunValidationPass(field))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Warning("Dictionary entry layout mismatch; parallel save iteration disabled.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
logger.Warning(e, "Dictionary entry layout validation failed; parallel save iteration disabled.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Measures the true element stride of the runtime's Entry struct by allocating a large
|
||||||
|
/// array of it and reading the precise allocated byte count. Verifying the stride first
|
||||||
|
/// guarantees every subsequent shadow read is in-bounds of the entries array even if the
|
||||||
|
/// field layout were wrong.
|
||||||
|
/// </summary>
|
||||||
|
private static bool StrideMatches(Type entryType)
|
||||||
|
{
|
||||||
|
const int probeLength = 64 * 1024;
|
||||||
|
const int arrayHeaderSize = 24; // 64-bit: sync block + method table + length + padding
|
||||||
|
|
||||||
|
// Warm the reflection/allocation path so the measured delta contains only the probe.
|
||||||
|
Array.CreateInstance(entryType, 1);
|
||||||
|
|
||||||
|
var before = GC.GetAllocatedBytesForCurrentThread();
|
||||||
|
var probe = Array.CreateInstance(entryType, probeLength);
|
||||||
|
var delta = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||||
|
GC.KeepAlive(probe);
|
||||||
|
|
||||||
|
// Alignment slack is < 8 bytes on a 512KB+ allocation, so integer division is exact.
|
||||||
|
var actualStride = (delta - arrayHeaderSize) / probeLength;
|
||||||
|
|
||||||
|
return actualStride == Unsafe.SizeOf<ShadowEntry<ValidationValue>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Proves the layout without ever materializing a managed reference through the shadow
|
||||||
|
/// view: value slots are compared as raw pointer bits (nint). Only after the stride and
|
||||||
|
/// every key and value-pointer of a churned, resized, freelist-exercised dictionary match
|
||||||
|
/// is the layout trusted for typed reads.
|
||||||
|
/// </summary>
|
||||||
|
private static bool RunValidationPass(FieldInfo field)
|
||||||
|
{
|
||||||
|
const int count = 1000;
|
||||||
|
|
||||||
|
var dict = new Dictionary<Serial, ValidationValue>();
|
||||||
|
var rng = new System.Random(0x5EED);
|
||||||
|
var inserted = new List<Serial>(count);
|
||||||
|
|
||||||
|
// Adds with interleaved removes and re-adds: exercises resizes and freelist reuse so
|
||||||
|
// freed slots (which production skips via null values) are present in the array.
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
var serial = (Serial)(uint)rng.Next(1, int.MaxValue);
|
||||||
|
if (dict.TryAdd(serial, new ValidationValue { Id = i }))
|
||||||
|
{
|
||||||
|
inserted.Add(serial);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i % 3 == 2)
|
||||||
|
{
|
||||||
|
var victim = inserted[rng.Next(inserted.Count)];
|
||||||
|
if (dict.Remove(victim))
|
||||||
|
{
|
||||||
|
inserted.Remove(victim);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.GetValue(dict) is not Array entriesObj || !StrideMatches(entriesObj.GetType().GetElementType()!))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot expected key -> value-pointer-bits pairs before scanning, so no allocation
|
||||||
|
// happens between reading the real references and reading the shadow view.
|
||||||
|
var expected = new Dictionary<Serial, nint>(dict.Count);
|
||||||
|
foreach (var (key, value) in dict)
|
||||||
|
{
|
||||||
|
var v = value;
|
||||||
|
expected[key] = Unsafe.As<ValidationValue, nint>(ref v);
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries = Unsafe.As<ShadowEntry<ValidationValue>[]>(entriesObj);
|
||||||
|
var length = entriesObj.Length;
|
||||||
|
var matched = 0;
|
||||||
|
|
||||||
|
ref var entry = ref MemoryMarshal.GetArrayDataReference(entries);
|
||||||
|
|
||||||
|
for (var i = 0; i < length; i++, entry = ref Unsafe.Add(ref entry, 1))
|
||||||
|
{
|
||||||
|
// Read the value slot as pointer bits only — never as a reference — so a wrong
|
||||||
|
// field offset cannot fabricate a managed reference for the GC to trip over.
|
||||||
|
var bits = Unsafe.As<ValidationValue, nint>(ref entry.Value);
|
||||||
|
|
||||||
|
if (bits == 0)
|
||||||
|
{
|
||||||
|
continue; // free or never-used slot
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expected.TryGetValue(entry.Key, out var expectedBits) || bits != expectedBits)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
matched++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return matched == dict.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
*************************************************************************/
|
*************************************************************************/
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
|
@ -44,8 +43,11 @@ public static class World
|
||||||
private static readonly MobilePersistence _mobilePersistence = new();
|
private static readonly MobilePersistence _mobilePersistence = new();
|
||||||
private static readonly GenericEntityPersistence<BaseGuild> _guildPersistence = new("Guilds", 3, 1, 0x7FFFFFFF);
|
private static readonly GenericEntityPersistence<BaseGuild> _guildPersistence = new("Guilds", 3, 1, 0x7FFFFFFF);
|
||||||
|
|
||||||
private static int _threadId;
|
// All workers including the main thread's inline worker (last index), for heap lookups.
|
||||||
internal static SerializationThreadWorker[] _threadWorkers;
|
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 readonly ManualResetEvent _diskWriteHandle = new(true);
|
||||||
|
|
||||||
private static string _tempSavePath; // Path to the temporary folder for the save
|
private static string _tempSavePath; // Path to the temporary folder for the save
|
||||||
|
|
@ -195,48 +197,46 @@ public static class World
|
||||||
watch.Elapsed.TotalSeconds
|
watch.Elapsed.TotalSeconds
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create the serialization threads.
|
// Create the serialization threads, plus an inline worker the main thread uses to
|
||||||
var threadCount = UseMultiThreadedSaves ? Math.Max(Environment.ProcessorCount - 1, 1) : 1;
|
// join the drain after publishing work instead of idling until the workers finish.
|
||||||
_threadWorkers = new SerializationThreadWorker[threadCount];
|
_realWorkerCount = UseMultiThreadedSaves ? Math.Max(Environment.ProcessorCount - 1, 1) : 1;
|
||||||
|
_threadWorkers = new SerializationThreadWorker[_realWorkerCount + 1];
|
||||||
|
|
||||||
for (var i = 0; i < _threadWorkers.Length; i++)
|
// 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);
|
_threadWorkers[i] = new SerializationThreadWorker(i, _chunkSource, heapSizeHint);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
_threadWorkers[_realWorkerCount] =
|
||||||
* Duplicates can be weeded out asynchronously while flushing
|
SerializationThreadWorker.CreateInline(_realWorkerCount, _chunkSource, heapSizeHint);
|
||||||
* If performance becomes a problem, we need to build a dual mode concurrent array.
|
}
|
||||||
*
|
|
||||||
****************************************************** Proposal ******************************************************
|
private static long GetLoadedSaveSize()
|
||||||
* The structure is initialized with a large capacity to avoid unnecessary resizing.
|
{
|
||||||
* Write Mode:
|
try
|
||||||
* - Multiple threads can add a single, or a range of elements concurrently.
|
{
|
||||||
* - Elements can be Peeked, but there are no guarantees.
|
if (!Directory.Exists(SavePath))
|
||||||
* - To resize the internal array, replaced it with the next size up from an array pool.
|
{
|
||||||
* - The structure cannot be cleared in this mode.
|
return 0;
|
||||||
*
|
}
|
||||||
* Read Mode:
|
|
||||||
* - The array can be read from multiple threads using a ref struct enumerator.
|
var total = 0L;
|
||||||
* - Elements cannot be added or reassigned.
|
foreach (var file in Directory.EnumerateFiles(SavePath, "*.bin", SearchOption.AllDirectories))
|
||||||
* - Cleared by replacing the internal array with another one from the pool.
|
{
|
||||||
* - Note: Upon clearing, the existing array is not sent back to the pool until there are zero enumerators.
|
total += new FileInfo(file).Length;
|
||||||
*
|
}
|
||||||
* Enumeration:
|
|
||||||
* - Multiple threads can enumerate while in read mode. The enumerator will Interlocked.Increment a read counter.
|
return total;
|
||||||
* - Upon dispose of the enumerator, the read counter will be lowered with an Interlocked.Decrement
|
}
|
||||||
* - When the read counter reaches 0, if there is a cleared array, the array is sent back to the pool zeroed.
|
catch
|
||||||
*
|
{
|
||||||
* Notes:
|
return 0;
|
||||||
* - Elements can never be removed.
|
}
|
||||||
*
|
}
|
||||||
* How is this different from ConcurrentQueue?
|
|
||||||
* The functionality is very similar, except the constraints allow the implementation to be done without locks.
|
|
||||||
* Since this implementation uses pooled arrays, allocations will approach zero over time.
|
|
||||||
**********************************************************************************************************************
|
|
||||||
*/
|
|
||||||
public static ConcurrentQueue<Type> SerializedTypes { get; } = new();
|
|
||||||
|
|
||||||
public static void Save()
|
public static void Save()
|
||||||
{
|
{
|
||||||
|
|
@ -304,6 +304,7 @@ public static class World
|
||||||
|
|
||||||
Persistence.SerializeAll();
|
Persistence.SerializeAll();
|
||||||
PauseSerializationThreads();
|
PauseSerializationThreads();
|
||||||
|
LogWorkerBalance();
|
||||||
|
|
||||||
EventSink.InvokeWorldSave();
|
EventSink.InvokeWorldSave();
|
||||||
}
|
}
|
||||||
|
|
@ -332,8 +333,6 @@ public static class World
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly HashSet<Type> _typesSet = [];
|
|
||||||
|
|
||||||
private static void WriteFiles(object state)
|
private static void WriteFiles(object state)
|
||||||
{
|
{
|
||||||
var snapshotPath = (string)state;
|
var snapshotPath = (string)state;
|
||||||
|
|
@ -342,15 +341,7 @@ public static class World
|
||||||
var watch = Stopwatch.StartNew();
|
var watch = Stopwatch.StartNew();
|
||||||
logger.Information("Writing world save snapshot");
|
logger.Information("Writing world save snapshot");
|
||||||
|
|
||||||
// Dedupe the types
|
Persistence.WriteSnapshotAll(snapshotPath);
|
||||||
while (SerializedTypes.TryDequeue(out var type))
|
|
||||||
{
|
|
||||||
_typesSet.Add(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
Persistence.WriteSnapshotAll(snapshotPath, _typesSet);
|
|
||||||
|
|
||||||
_typesSet.Clear();
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -374,9 +365,6 @@ public static class World
|
||||||
BroadcastStaff(0x35, true, "Writing world save snapshot failed! Check the logs!");
|
BroadcastStaff(0x35, true, "Writing world save snapshot failed! Check the logs!");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear types
|
|
||||||
SerializedTypes.Clear();
|
|
||||||
|
|
||||||
_diskWriteHandle.Set();
|
_diskWriteHandle.Set();
|
||||||
Core.LoopContext.Post(FinishWorldSave);
|
Core.LoopContext.Post(FinishWorldSave);
|
||||||
}
|
}
|
||||||
|
|
@ -386,45 +374,84 @@ public static class World
|
||||||
WorldState = WorldState.Running;
|
WorldState = WorldState.Running;
|
||||||
Persistence.PostWorldSaveAll(); // Process decay and safety queues
|
Persistence.PostWorldSaveAll(); // Process decay and safety queues
|
||||||
MovementThrottle.ResetAllMovementTiming(); // Prevent post-save movement rejection bursts
|
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)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
internal static void WakeSerializationThreads()
|
internal static void WakeSerializationThreads()
|
||||||
{
|
{
|
||||||
for (var i = 0; i < _threadWorkers.Length; i++)
|
for (var i = 0; i < _realWorkerCount; i++)
|
||||||
{
|
{
|
||||||
_threadWorkers[i].Wake();
|
_threadWorkers[i].Wake();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
||||||
internal static void PauseSerializationThreads()
|
internal static void PauseSerializationThreads()
|
||||||
{
|
{
|
||||||
for (var i = 0; i < _threadWorkers.Length; i++)
|
// 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();
|
_threadWorkers[i].Sleep();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
internal static int GetThreadWorkerCount() => Math.Max(Environment.ProcessorCount - 1, 1);
|
internal static void SetChunkSourceOwner(Persistence owner) => _chunkSource.SetOwner(owner);
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
internal static void ResetRoundRobin() => _threadId = 0;
|
internal static void PushToCache(IGenericSerializable e) => _chunkSource.Push(e);
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
internal static void PushToCache(IGenericSerializable e)
|
internal static void PushSingleToCache(GenericPersistence persistence) => _chunkSource.PushSingle(persistence);
|
||||||
{
|
|
||||||
_threadWorkers[_threadId++].Push(e);
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
if (_threadId == _threadWorkers.Length)
|
internal static void PushSlotRangesToCache(ISlotRangeSource source, int slotCount) =>
|
||||||
{
|
_chunkSource.PushSlotRanges(source, slotCount);
|
||||||
_threadId = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void ExitSerializationThreads()
|
public static void ExitSerializationThreads()
|
||||||
{
|
{
|
||||||
for (var i = 0; i < _threadWorkers.Length; i++)
|
for (var i = 0; i < _realWorkerCount; i++)
|
||||||
{
|
{
|
||||||
_threadWorkers[i]?.Exit();
|
_threadWorkers[i]?.Exit();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -287,10 +287,6 @@ public partial class Account : IAccount, IComparable<Account>
|
||||||
|
|
||||||
public Serial Serial { get; set; }
|
public Serial Serial { get; set; }
|
||||||
|
|
||||||
public byte SerializedThread { get; set; }
|
|
||||||
public int SerializedPosition { get; set; }
|
|
||||||
public int SerializedLength { get; set; }
|
|
||||||
|
|
||||||
[AfterDeserialization(false)]
|
[AfterDeserialization(false)]
|
||||||
private void AfterDeserialization()
|
private void AfterDeserialization()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,6 @@ public abstract partial class BaseBOBEntry : IBOBEntry
|
||||||
|
|
||||||
public Serial Serial { get; }
|
public Serial Serial { get; }
|
||||||
|
|
||||||
public byte SerializedThread { get; set; }
|
|
||||||
public int SerializedPosition { get; set; }
|
|
||||||
public int SerializedLength { get; set; }
|
|
||||||
|
|
||||||
public bool Deleted { get; private set; }
|
public bool Deleted { get; private set; }
|
||||||
|
|
||||||
public BaseBOBEntry()
|
public BaseBOBEntry()
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,6 @@ public partial class EthicsEntity : ISerializable
|
||||||
|
|
||||||
public Serial Serial { get; }
|
public Serial Serial { get; }
|
||||||
|
|
||||||
public byte SerializedThread { get; set; }
|
|
||||||
public int SerializedPosition { get; set; }
|
|
||||||
public int SerializedLength { get; set; }
|
|
||||||
|
|
||||||
public bool Deleted { get; private set; }
|
public bool Deleted { get; private set; }
|
||||||
|
|
||||||
public void Delete()
|
public void Delete()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue