diff --git a/Directory.Build.props b/Directory.Build.props
index 45263613..114bea2a 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -14,7 +14,7 @@
MIT
© Meta Vens and contributors
true
- 1.0.22
+ 1.0.23
true
diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs
index 4ec7940f..d19063ef 100644
--- a/src/GameCult.Mesh/CultMesh.cs
+++ b/src/GameCult.Mesh/CultMesh.cs
@@ -2852,6 +2852,40 @@ namespace GameCult.Mesh
return new CultMeshRudpEndpoint(host, uri.Port, $"rudp://{uriHost}:{uri.Port}");
}
+ ///
+ /// Opens one exact hot-body subscription and advertises every body plane this runtime can
+ /// actually open. The provider derives locality and publishes only the selected plane.
+ ///
+ public static Task> SubscribeHotBodyAsync(
+ CultNetDatabaseSubscriptionClient subscriptions,
+ IEnumerable supportedBodyTransports,
+ CultMeshHotBodySubscription subscription)
+ {
+ if (subscriptions == null) throw new ArgumentNullException(nameof(subscriptions));
+ if (supportedBodyTransports == null) throw new ArgumentNullException(nameof(supportedBodyTransports));
+ if (subscription == null) throw new ArgumentNullException(nameof(subscription));
+ var transports = supportedBodyTransports.Distinct().ToArray();
+ if (transports.Length == 0)
+ throw new ArgumentException("At least one readable body transport is required.", nameof(supportedBodyTransports));
+ return subscriptions.SubscribeAsync(
+ subscription.SubscriptionId,
+ recordKeys: subscription.RecordKeys,
+ schemaIds: subscription.SchemaIds,
+ consumerRuntimeId: subscription.ConsumerRuntimeId,
+ bodyIds: new[] { subscription.BodyId },
+ supportedBodyTransports: transports.Select(value => value.ToString()));
+ }
+
+ /// Opens an exact hot-body subscription using a resolver's readable planes.
+ public static Task> SubscribeHotBodyAsync(
+ CultNetDatabaseSubscriptionClient subscriptions,
+ CultMeshBodyPublicationResolver bodyResolver,
+ CultMeshHotBodySubscription subscription)
+ {
+ if (bodyResolver == null) throw new ArgumentNullException(nameof(bodyResolver));
+ return SubscribeHotBodyAsync(subscriptions, bodyResolver.SupportedTransports, subscription);
+ }
+
/// Resolves a CultMesh URI or RUDP endpoint into a concrete RUDP endpoint.
public static CultMeshRudpEndpoint ResolveRudpEndpoint(string endpointOrUri)
{
diff --git a/src/GameCult.Mesh/CultMeshBodies.cs b/src/GameCult.Mesh/CultMeshBodies.cs
index 4f2b3514..ff12e3b9 100644
--- a/src/GameCult.Mesh/CultMeshBodies.cs
+++ b/src/GameCult.Mesh/CultMeshBodies.cs
@@ -150,6 +150,11 @@ namespace GameCult.Mesh
private readonly Func _authorizeProducer;
private readonly Func? _authorizeNamedProducer;
+ /// Gets locally available body planes in fastest-local-first order.
+ public IReadOnlyList SupportedTransports => _adapters.Keys
+ .OrderByDescending(TransportPreference)
+ .ToArray();
+
public CultMeshBodyTransportService(
IEnumerable adapters,
Func authorizeProducer)
@@ -264,6 +269,13 @@ namespace GameCult.Mesh
left.AccessMode == right.AccessMode &&
left.Synchronization == right.Synchronization &&
string.Equals(left.SemanticHash, right.SemanticHash, StringComparison.Ordinal);
+
+ private static int TransportPreference(CultMeshBodyTransportKind transport) => transport switch
+ {
+ CultMeshBodyTransportKind.SharedMemory => 3,
+ CultMeshBodyTransportKind.SharedFileMapping => 2,
+ _ => 1
+ };
}
public sealed class CultMeshMappedBodyPublisher
diff --git a/src/GameCult.Mesh/CultMeshBodyDemand.cs b/src/GameCult.Mesh/CultMeshBodyDemand.cs
index 049d762a..7bfc230f 100644
--- a/src/GameCult.Mesh/CultMeshBodyDemand.cs
+++ b/src/GameCult.Mesh/CultMeshBodyDemand.cs
@@ -7,6 +7,57 @@ using GameCult.Networking;
namespace GameCult.Mesh
{
+ ///
+ /// One exact hot-body subscription. The view record, latest-publication record, consumer body
+ /// demand, and locally supported planes are one contract so callers cannot accidentally stream
+ /// body bytes through retained document snapshots.
+ ///
+ public sealed class CultMeshHotBodySubscription
+ {
+ public CultMeshHotBodySubscription(
+ string subscriptionId,
+ string consumerRuntimeId,
+ string viewRecordKey,
+ string viewSchemaId,
+ string bodyId,
+ IEnumerable? additionalRecordKeys = null,
+ IEnumerable? additionalSchemaIds = null)
+ {
+ SubscriptionId = Require(subscriptionId, nameof(subscriptionId));
+ ConsumerRuntimeId = Require(consumerRuntimeId, nameof(consumerRuntimeId));
+ BodyId = Require(bodyId, nameof(bodyId));
+ RecordKeys = new[]
+ {
+ Require(viewRecordKey, nameof(viewRecordKey)),
+ CultMeshBodyPublicationDocument.CreateLatestRecordKey(bodyId).Value
+ }
+ .Concat(additionalRecordKeys ?? Array.Empty())
+ .Where(value => !string.IsNullOrWhiteSpace(value))
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+ SchemaIds = new[]
+ {
+ Require(viewSchemaId, nameof(viewSchemaId)),
+ CultMeshBodyPublicationSchemaVersions.Publication
+ }
+ .Concat(additionalSchemaIds ?? Array.Empty())
+ .Where(value => !string.IsNullOrWhiteSpace(value))
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+ }
+
+ public string SubscriptionId { get; }
+ public string ConsumerRuntimeId { get; }
+ public string BodyId { get; }
+ public IReadOnlyList RecordKeys { get; }
+ public IReadOnlyList SchemaIds { get; }
+
+ private static string Require(string value, string parameterName) =>
+ string.IsNullOrWhiteSpace(value)
+ ? throw new ArgumentException("Value is required.", parameterName)
+ : value;
+ }
+
/// Derived transport work for one logical hot body.
public sealed class CultMeshBodyDemandPlan
{
diff --git a/src/GameCult.Mesh/CultMeshBodyPublications.cs b/src/GameCult.Mesh/CultMeshBodyPublications.cs
index 37f2955f..673465cc 100644
--- a/src/GameCult.Mesh/CultMeshBodyPublications.cs
+++ b/src/GameCult.Mesh/CultMeshBodyPublications.cs
@@ -1,5 +1,6 @@
using System;
using System.Globalization;
+using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
@@ -361,6 +362,9 @@ namespace GameCult.Mesh
public CultMeshBodyPublicationResolver(CultMeshBodyTransportService transport) =>
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
+ /// Gets the body planes this resolver can actually open.
+ public IReadOnlyList SupportedTransports => _transport.SupportedTransports;
+
public ICultMeshBodyReadLease ResolveReadOnly(
CultMeshBodyPublicationDocument publication,
CultMeshBodyValidationRequest request) =>
diff --git a/src/GameCult.Mesh/docs/reliability-migration-plan.md b/src/GameCult.Mesh/docs/reliability-migration-plan.md
index c52ea4dd..8b183dc6 100644
--- a/src/GameCult.Mesh/docs/reliability-migration-plan.md
+++ b/src/GameCult.Mesh/docs/reliability-migration-plan.md
@@ -918,6 +918,14 @@ stream demand merely by existing.
and server disposal all update the same typed demand projection. Stable peer
identity, not per-message wrapper identity, keys subscription lifetime.
+`CultMesh.SubscribeHotBodyAsync(...)` is the public construction path. Its
+`CultMeshHotBodySubscription` keeps the exact view record, the body's stable
+latest-publication record, consumer identity, and logical body demand in one
+contract. The local `CultMeshBodyTransportService` contributes only adapters it
+can actually open, ordered shared-memory first; the server combines those
+capabilities with observed peer locality. Applications do not hand-assemble
+transport-name arrays or request one snapshot per frame.
+
**Cut line:** local frame publication writes directly into a mapped write lease
and commits its descriptor. The compatibility byte-array publisher is copy-in
only. The current remote frame representation may still use the verified CDN
@@ -925,6 +933,12 @@ body path when remote demand exists; Phase 6 must replace that expiry-sensitive
traffic with a stream/datagram body plane rather than enlarging snapshot or CDN
timeouts.
+Reliable control messages larger than the RUDP receive mask explicitly
+acknowledge the packet just received. A retransmit older than the rolling
+32-packet summary can therefore always leave the sender queue; the rolling mask
+remains useful for piggyback acknowledgements but is not allowed to strand a
+fragment indefinitely.
+
### Gate
- Two local consumers map one verified CDN artifact without duplicate process
diff --git a/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs b/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs
index 0d8e4ff2..618f64df 100644
--- a/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs
+++ b/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs
@@ -163,16 +163,17 @@ namespace GameCult.Networking
{
var changeType = change.GetType();
var key = (CultRecordKey)(changeType.GetProperty("Key")?.GetValue(change) ?? new CultRecordKey(""));
- var schemaId = (string?)changeType.GetProperty("SchemaId")?.GetValue(change) ?? "";
if (request.RecordKeys is { Length: > 0 } && !request.RecordKeys.Contains(key.Value, StringComparer.Ordinal))
return null;
- if (request.SchemaIds is { Length: > 0 } && !request.SchemaIds.Contains(schemaId, StringComparer.Ordinal))
- return null;
var kind = (CultNetDatabaseChangeKind)(changeType.GetProperty("Kind")?.GetValue(change) ?? CultNetDatabaseChangeKind.Updated);
var document = changeType.GetProperty("Document")?.GetValue(change);
if (kind == CultNetDatabaseChangeKind.Removed || document == null)
{
+ var previous = changeType.GetProperty("PreviousDocument")?.GetValue(change);
+ var schemaId = ResolveWireSchemaId(previous, (string?)changeType.GetProperty("SchemaId")?.GetValue(change) ?? "");
+ if (!MatchesRequestedSchema(request.SchemaIds, previous, schemaId))
+ return null;
return new CultNetDatabaseChangeRawMessage
{
MessageId = Guid.NewGuid().ToString("N"),
@@ -183,15 +184,33 @@ namespace GameCult.Networking
};
}
+ var raw = CreateRawRecord(key, document);
+ if (!MatchesRequestedSchema(request.SchemaIds, document, raw.SchemaId))
+ return null;
return new CultNetDatabaseChangeRawMessage
{
MessageId = Guid.NewGuid().ToString("N"),
SubscriptionId = subscriptionId,
ChangeKind = kind == CultNetDatabaseChangeKind.Added ? "added" : "updated",
- Document = CreateRawRecord(key, document)
+ Document = raw
};
}
+ private string ResolveWireSchemaId(object? document, string fallback)
+ {
+ if (document == null) return fallback;
+ return _database.Documents.GetByDocumentType(document.GetType())?.SchemaId ?? fallback;
+ }
+
+ private bool MatchesRequestedSchema(string[]? requestedSchemaIds, object? document, string wireSchemaId)
+ {
+ if (requestedSchemaIds is not { Length: > 0 }) return true;
+ if (requestedSchemaIds.Contains(wireSchemaId, StringComparer.Ordinal)) return true;
+ return document != null && CultNetSchemaAliasMatching.MatchesAny(
+ requestedSchemaIds,
+ _database.Cache.Registry.GetRequired(document.GetType()));
+ }
+
private CultNetRawDocumentRecord CreateRawRecord(CultRecordKey key, object document)
{
var method = typeof(CultNetDocumentRegistry)
diff --git a/src/GameCult.Networking/CultNetTransport.cs b/src/GameCult.Networking/CultNetTransport.cs
index 06c6cf1a..8bdf020a 100644
--- a/src/GameCult.Networking/CultNetTransport.cs
+++ b/src/GameCult.Networking/CultNetTransport.cs
@@ -1141,9 +1141,14 @@ namespace GameCult.Networking
///
/// Creates a packet carrying the current acknowledgement state.
///
- public CultNetRudpPacket CreateAck()
+ public CultNetRudpPacket CreateAck(uint? acknowledgedSequence = null)
{
var (ack, ackMask) = AckState();
+ if (acknowledgedSequence.HasValue)
+ {
+ ack = acknowledgedSequence.Value;
+ ackMask = 0;
+ }
return new CultNetRudpPacket
{
PacketType = CultNetRudpPacketType.Ack,
@@ -1955,10 +1960,9 @@ namespace GameCult.Networking
delivered = _deliveredFrames.Count > 0 ? _deliveredFrames.Dequeue() : null;
if (packet.PacketType == CultNetRudpPacketType.Accept ||
- packet.PacketType == CultNetRudpPacketType.Data ||
- delivered != null)
+ packet.PacketType == CultNetRudpPacketType.Data)
{
- SendPacket(_session.CreateAck());
+ SendPacket(_session.CreateAck(packet.Sequence));
}
return true;
@@ -2108,6 +2112,10 @@ namespace GameCult.Networking
///
public bool Connected => Session.Connected;
///
+ /// Gets the number of reliable packets still awaiting acknowledgement.
+ ///
+ public int PendingReliablePacketCount => Session.PendingReliableSequences.Count;
+ ///
/// Gets the last transport-level remote disconnect reason, if one was received.
///
public byte[]? DisconnectReason { get; internal set; }
@@ -2349,9 +2357,9 @@ namespace GameCult.Networking
}
delivered = _deliveredFrames.Count > 0 ? _deliveredFrames.Dequeue() : null;
- if (packet.PacketType == CultNetRudpPacketType.Data || delivered != null)
+ if (packet.PacketType == CultNetRudpPacketType.Data)
{
- SendPacket(existingPeer.RemoteEndPoint, existingPeer.Session.CreateAck());
+ SendPacket(existingPeer.RemoteEndPoint, existingPeer.Session.CreateAck(packet.Sequence));
}
return true;
diff --git a/tests/GameCult.Mesh.Tests/CultMeshBodyTransportTests.cs b/tests/GameCult.Mesh.Tests/CultMeshBodyTransportTests.cs
index ca3e3045..154ee587 100644
--- a/tests/GameCult.Mesh.Tests/CultMeshBodyTransportTests.cs
+++ b/tests/GameCult.Mesh.Tests/CultMeshBodyTransportTests.cs
@@ -357,6 +357,30 @@ public sealed class CultMeshBodyTransportTests
plan.Consumers.Should().ContainSingle().Which.ConsumerRuntimeId.Should().Be("unknown");
}
+ [Test]
+ public void HotBodySubscriptionKeepsViewPublicationAndDemandInOneExactContract()
+ {
+ var subscription = new CultMeshHotBodySubscription(
+ "pilot-world",
+ "eve-unity",
+ "eve:view:pilot",
+ "gamecult.eve.entity_soa_view.v2",
+ "eve:body:pilot",
+ new[] { "eve:field:gravity" },
+ new[] { "gamecult.eve.fields_splats.v1" });
+
+ subscription.RecordKeys.Should().Equal(
+ "eve:view:pilot",
+ CultMeshBodyPublicationDocument.CreateLatestRecordKey("eve:body:pilot").Value,
+ "eve:field:gravity");
+ subscription.SchemaIds.Should().Equal(
+ "gamecult.eve.entity_soa_view.v2",
+ CultMeshBodyPublicationSchemaVersions.Publication,
+ "gamecult.eve.fields_splats.v1");
+ subscription.BodyId.Should().Be("eve:body:pilot");
+ subscription.ConsumerRuntimeId.Should().Be("eve-unity");
+ }
+
private static CultMeshBodyValidationRequest Request(CultMeshBodyDescriptor descriptor, DateTimeOffset now) => new()
{
BodyId = descriptor.BodyId,
diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs
index 8ec4e8d4..30162445 100644
--- a/tests/GameCult.Networking.Tests/NetworkingTests.cs
+++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs
@@ -771,6 +771,43 @@ namespace GameCult.Networking.Tests
Assert.That(session.DueResends(250), Is.Empty);
}
+ [Test]
+ public void RudpSession_ExplicitlyAcknowledgesRetransmitsOlderThanTheReceiveMask()
+ {
+ var sender = new CultNetRudpSession(new CultNetRudpSessionOptions
+ {
+ ConnectionId = 991,
+ InitialSequence = 1,
+ ResendDelayMs = 10
+ });
+ var receiver = new CultNetRudpSession(new CultNetRudpSessionOptions
+ {
+ ConnectionId = 991,
+ InitialSequence = 500
+ });
+ sender.Receive(new CultNetRudpPacket
+ { PacketType = CultNetRudpPacketType.Accept, ConnectionId = 991, Sequence = 400, ChannelId = "control" });
+ receiver.Receive(new CultNetRudpPacket
+ { PacketType = CultNetRudpPacketType.Accept, ConnectionId = 991, Sequence = 0, ChannelId = "control" });
+
+ var packets = sender.SendMany(
+ "snapshot",
+ Enumerable.Range(0, 80).Select(index => (byte)index).ToArray(),
+ new CultNetRudpSendOptions { Reliable = true, Ordered = true, NowMs = 0 },
+ maxFragmentBytes: 1);
+ foreach (var packet in packets)
+ receiver.Receive(packet);
+
+ sender.Receive(receiver.CreateAck());
+ Assert.That(sender.PendingReliableSequences.First(), Is.EqualTo(1u));
+
+ var oldRetransmit = sender.DueResends(10).Single(packet => packet.Sequence == 1);
+ receiver.Receive(oldRetransmit);
+ sender.Receive(receiver.CreateAck(oldRetransmit.Sequence));
+
+ Assert.That(sender.PendingReliableSequences, Does.Not.Contain(1u));
+ }
+
[Test]
public async Task RudpSession_ResendsAndAcknowledgementsCanRunConcurrently()
{
@@ -1544,6 +1581,104 @@ namespace GameCult.Networking.Tests
Assert.That(((NetworkSchemaNote)targetCache.Get(new CultRecordKey(recordKey))!).Text, Is.EqualTo("live"));
}
+ [Test]
+ public async Task DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding()
+ {
+ const string recordKey = "tests:subscription-client:wire-note";
+ const string wireSchema = "tests.wire_note.v1";
+ var sourceCache = new CultCache();
+ var sourceDocuments = new CultNetDocumentRegistry(sourceCache.Registry)
+ .Register(CultNetDocumentBinding.ForDocument(sourceCache.Registry, wireSchema));
+ using var sourceDatabase = new CultNetDatabase(sourceCache, new CultNetDatabaseOptions
+ {
+ DocumentRegistry = sourceDocuments
+ });
+ using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
+ {
+ RuntimeId = "database-wire-schema-subscription-server",
+ Socket = BindUdpSocket()
+ });
+ using var subscriptions = new CultNetDatabaseSubscriptionServer(server, sourceDatabase);
+ using var cancellation = new CancellationTokenSource();
+ var serverThread = new Thread(() =>
+ {
+ while (!cancellation.IsCancellationRequested)
+ {
+ _ = server.PollOnceAsync().GetAwaiter().GetResult();
+ Thread.Sleep(1);
+ }
+ }) { IsBackground = true };
+ serverThread.Start();
+
+ var targetCache = new CultCache();
+ var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry)
+ .Register(CultNetDocumentBinding.ForDocument(targetCache.Registry, wireSchema));
+ var transport = CultNetSchemaClients.CreateRudp("database-wire-schema-subscription-client");
+ using var client = new CultNetDatabaseSubscriptionClient(transport, targetCache, targetDocuments);
+ transport.Connect("127.0.0.1", server.LocalEndPoint.Port);
+ await WaitUntilAsync(() => transport.Connected, TimeSpan.FromSeconds(2));
+ await AwaitWithTimeout(
+ client.SubscribeAsync("wire-notes", recordKeys: [recordKey], schemaIds: [wireSchema]),
+ TimeSpan.FromSeconds(2));
+ var changed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ client.Changed += change => changed.TrySetResult(change);
+
+ await sourceDatabase.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote
+ {
+ Schema = "tests.networking_note.v1",
+ Text = "wire-live"
+ });
+ var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2));
+
+ cancellation.Cancel();
+ Assert.That(update.SchemaId, Is.EqualTo(wireSchema));
+ Assert.That(((NetworkSchemaNote)update.Document!).Text, Is.EqualTo("wire-live"));
+ }
+
+ [Test]
+ public void DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias()
+ {
+ var cache = new CultCache();
+ var documents = new CultNetDocumentRegistry(cache.Registry)
+ .Register(CultNetDocumentBinding.ForDocument(cache.Registry));
+ using var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
+ {
+ DocumentRegistry = documents
+ });
+ using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
+ {
+ RuntimeId = "database-schema-alias-subscription-server",
+ Socket = BindUdpSocket()
+ });
+ using var subscriptions = new CultNetDatabaseSubscriptionServer(server, database);
+ var descriptor = cache.Registry.GetRequired();
+ var change = new CultNetDatabaseChange(
+ CultNetDatabaseChangeKind.Added,
+ new CultRecordKey("tests:subscription-client:alias-note"),
+ descriptor.SchemaId,
+ database.Shards[0],
+ new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "alias-live" },
+ previousDocument: null);
+ var method = typeof(CultNetDatabaseSubscriptionServer).GetMethod(
+ "CreateChange",
+ BindingFlags.Instance | BindingFlags.NonPublic)!;
+
+ var message = (CultNetDatabaseChangeRawMessage?)method.Invoke(subscriptions, new object[]
+ {
+ change,
+ new CultNetDatabaseSubscribeMessage
+ {
+ SubscriptionId = "alias-notes",
+ SchemaIds = ["tests.networking_note.v1"],
+ RecordKeys = ["tests:subscription-client:alias-note"]
+ },
+ "alias-notes"
+ });
+
+ Assert.That(message, Is.Not.Null);
+ Assert.That(message!.Document!.SchemaId, Is.EqualTo(descriptor.SchemaId));
+ }
+
[Test]
public async Task RudpCultNetSchemaServer_DeliversLargeSnapshotResponse()
{
@@ -1607,9 +1742,14 @@ namespace GameCult.Networking.Tests
if (completed != responseCompletion.Task)
Assert.Fail($"Large snapshot timed out; server sent {server.Stats.BytesSent} bytes, received {server.Stats.BytesReceived} bytes, transports {server.Profile.Transports.Count()}.");
var response = await AwaitWithTimeout(responseCompletion.Task, TimeSpan.FromMilliseconds(1));
+ await WaitUntilAsync(
+ () => server.Peers.Count == 1 && server.Peers.Single().PendingReliablePacketCount == 0,
+ TimeSpan.FromSeconds(2));
cancellation.Cancel();
Assert.That(response.Documents, Has.Length.EqualTo(1));
Assert.That(response.Documents[0].Payload, Has.Length.EqualTo(128 * 1024));
+ Assert.That(server.Stats.BytesSent, Is.LessThan(512 * 1024),
+ "A lossless large response must not remain trapped in the reliable resend queue.");
}
[Test]
diff --git a/unity/org.gamecult.cultlib/README.md b/unity/org.gamecult.cultlib/README.md
index be09e204..28c73612 100644
--- a/unity/org.gamecult.cultlib/README.md
+++ b/unity/org.gamecult.cultlib/README.md
@@ -7,8 +7,8 @@ CultCache, CultNet, and CultMesh assemblies consumed through NuGet by normal
Build it with `scripts/build-unity-package.ps1`. Do not edit generated plugin
assemblies in the package output.
-Install version `1.0.22` through Unity Package Manager with:
+Install version `1.0.23` through Unity Package Manager with:
```text
-https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.22
+https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.23
```
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll
index d13a4ef8..79bf48c5 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb
index a55bee74..17d72db2 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll
index caa426d0..a06016f8 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb
index fb55549d..cdeae45c 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll
index 75d4d505..2a2596b7 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb
index 96c3418c..dcf27116 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll
index 85a1216f..adb73216 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb
index 6faa3ece..9fbfc4f4 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll
index 919bf200..7ff275e1 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb
index db888d0e..3d6c1c90 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb differ
diff --git a/unity/org.gamecult.cultlib/package.json b/unity/org.gamecult.cultlib/package.json
index 3633a675..3e6da006 100644
--- a/unity/org.gamecult.cultlib/package.json
+++ b/unity/org.gamecult.cultlib/package.json
@@ -1,7 +1,7 @@
{
"name": "org.gamecult.cultlib",
"displayName": "CultLib",
- "version": "1.0.22",
+ "version": "1.0.23",
"unity": "2021.3",
"description": "CultCache, CultNet, and CultMesh managed runtime assemblies for Unity.",
"license": "MIT",