mirror of
https://github.com/modernuo/ModernUO
synced 2026-08-11 22:23:06 -04:00
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.
577 lines
17 KiB
C#
577 lines
17 KiB
C#
/*************************************************************************
|
|
* ModernUO *
|
|
* Copyright 2019-2026 - ModernUO Development Team *
|
|
* Email: hi@modernuo.com *
|
|
* File: BufferWriter.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.Buffers.Binary;
|
|
using System.Collections;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Net;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Runtime.InteropServices;
|
|
using System.Text;
|
|
using Server.Text;
|
|
|
|
namespace Server;
|
|
|
|
public class BufferWriter : IGenericWriter
|
|
{
|
|
private readonly Encoding _encoding;
|
|
private readonly bool _prefixStrings;
|
|
|
|
private long _bytesWritten;
|
|
private long _index;
|
|
|
|
protected long Index
|
|
{
|
|
get => _index;
|
|
set
|
|
{
|
|
if (value < 0 || value > _buffer.Length)
|
|
{
|
|
// If you are receiving this exception and your value is too large, you may need to use `Resize`
|
|
// If you are receiving this exception and your value is negative, you probably used Seek incorrectly.
|
|
throw new ArgumentOutOfRangeException(nameof(value));
|
|
}
|
|
|
|
_index = value;
|
|
|
|
if (value > _bytesWritten)
|
|
{
|
|
_bytesWritten = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
private byte[] _buffer;
|
|
|
|
public BufferWriter(byte[] buffer, bool prefixStr)
|
|
{
|
|
_prefixStrings = prefixStr;
|
|
_encoding = TextEncoding.UTF8;
|
|
_buffer = buffer;
|
|
}
|
|
|
|
public BufferWriter(bool prefixStr) : this(0, prefixStr)
|
|
{
|
|
}
|
|
|
|
public BufferWriter(int count, bool prefixStr)
|
|
{
|
|
_prefixStrings = prefixStr;
|
|
_encoding = TextEncoding.UTF8;
|
|
_buffer = GC.AllocateUninitializedArray<byte>(count < 1 ? BufferSize : count);
|
|
}
|
|
|
|
public virtual long Position => _index;
|
|
|
|
protected virtual int BufferSize => 256;
|
|
|
|
public byte[] Buffer => _buffer;
|
|
|
|
public virtual void Close()
|
|
{
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public void Resize(int size)
|
|
{
|
|
_bytesWritten = Math.Max(_bytesWritten, _index);
|
|
|
|
// We shouldn't ever resize to a 0 length buffer. That is dangerous
|
|
if (size <= 0)
|
|
{
|
|
size = BufferSize;
|
|
}
|
|
|
|
if (size < _buffer.Length)
|
|
{
|
|
_bytesWritten = size;
|
|
}
|
|
|
|
var newBuffer = GC.AllocateUninitializedArray<byte>(size);
|
|
_buffer.AsSpan(0, Math.Min(size, _buffer.Length)).CopyTo(newBuffer);
|
|
_buffer = newBuffer;
|
|
}
|
|
|
|
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)]
|
|
private ref byte Reserve(int bytes)
|
|
{
|
|
if ((uint)(_index + bytes) > (uint)_buffer.Length)
|
|
{
|
|
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, int offset, int count) => Write(bytes.AsSpan(offset, count));
|
|
|
|
public virtual void Write(ReadOnlySpan<byte> bytes)
|
|
{
|
|
var length = bytes.Length;
|
|
|
|
while (_buffer.Length - _index < length)
|
|
{
|
|
Flush();
|
|
}
|
|
|
|
bytes.CopyTo(_buffer.AsSpan((int)_index));
|
|
_index += length;
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public virtual long Seek(long offset, SeekOrigin origin)
|
|
{
|
|
Debug.Assert(
|
|
origin != SeekOrigin.End || offset <= 0 && offset > -_buffer.Length,
|
|
"Attempting to seek to an invalid position using SeekOrigin.End"
|
|
);
|
|
Debug.Assert(
|
|
origin != SeekOrigin.Begin || offset >= 0 && offset < _buffer.Length,
|
|
"Attempting to seek to an invalid position using SeekOrigin.Begin"
|
|
);
|
|
Debug.Assert(
|
|
origin != SeekOrigin.Current || _index + offset >= 0 && _index + offset < _buffer.Length,
|
|
"Attempting to seek to an invalid position using SeekOrigin.Current"
|
|
);
|
|
|
|
_bytesWritten = Math.Max(_bytesWritten, _index);
|
|
|
|
return Index = Math.Max(0, origin switch
|
|
{
|
|
SeekOrigin.Current => _index + offset,
|
|
SeekOrigin.End => _bytesWritten + offset,
|
|
_ => offset // Begin
|
|
});
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public void Write(string value)
|
|
{
|
|
if (_prefixStrings)
|
|
{
|
|
if (value == null)
|
|
{
|
|
Write(false);
|
|
}
|
|
else
|
|
{
|
|
Write(true);
|
|
WriteRaw(value);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
WriteRaw(value);
|
|
}
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public void Write(long value)
|
|
{
|
|
if (!BitConverter.IsLittleEndian)
|
|
{
|
|
value = BinaryPrimitives.ReverseEndianness(value);
|
|
}
|
|
|
|
Unsafe.WriteUnaligned(ref Reserve(8), value);
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public void Write(ulong value)
|
|
{
|
|
if (!BitConverter.IsLittleEndian)
|
|
{
|
|
value = BinaryPrimitives.ReverseEndianness(value);
|
|
}
|
|
|
|
Unsafe.WriteUnaligned(ref Reserve(8), value);
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public void Write(int value)
|
|
{
|
|
if (!BitConverter.IsLittleEndian)
|
|
{
|
|
value = BinaryPrimitives.ReverseEndianness(value);
|
|
}
|
|
|
|
Unsafe.WriteUnaligned(ref Reserve(4), value);
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public void Write(uint value)
|
|
{
|
|
if (!BitConverter.IsLittleEndian)
|
|
{
|
|
value = BinaryPrimitives.ReverseEndianness(value);
|
|
}
|
|
|
|
Unsafe.WriteUnaligned(ref Reserve(4), value);
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public void Write(short value)
|
|
{
|
|
if (!BitConverter.IsLittleEndian)
|
|
{
|
|
value = BinaryPrimitives.ReverseEndianness(value);
|
|
}
|
|
|
|
Unsafe.WriteUnaligned(ref Reserve(2), value);
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public void Write(ushort value)
|
|
{
|
|
if (!BitConverter.IsLittleEndian)
|
|
{
|
|
value = BinaryPrimitives.ReverseEndianness(value);
|
|
}
|
|
|
|
Unsafe.WriteUnaligned(ref Reserve(2), value);
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public void Write(double value)
|
|
{
|
|
if (!BitConverter.IsLittleEndian)
|
|
{
|
|
value = BitConverter.Int64BitsToDouble(BinaryPrimitives.ReverseEndianness(BitConverter.DoubleToInt64Bits(value)));
|
|
}
|
|
|
|
Unsafe.WriteUnaligned(ref Reserve(8), value);
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public void Write(float value)
|
|
{
|
|
if (!BitConverter.IsLittleEndian)
|
|
{
|
|
value = BitConverter.Int32BitsToSingle(BinaryPrimitives.ReverseEndianness(BitConverter.SingleToInt32Bits(value)));
|
|
}
|
|
|
|
Unsafe.WriteUnaligned(ref Reserve(4), value);
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public void Write(byte value) => Reserve(1) = value;
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public void Write(sbyte value) => Reserve(1) = (byte)value;
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public void Write(bool value) => Reserve(1) = Unsafe.As<bool, byte>(ref 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));
|
|
}
|
|
}
|
|
|
|
[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 WriteEncodedInt(int value)
|
|
{
|
|
var v = (uint)value;
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
[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();
|
|
}
|
|
|
|
var written = _encoding.GetBytes(value, _buffer.AsSpan((int)(_index + prefixWidth)));
|
|
|
|
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)
|
|
}
|
|
}
|