diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs
index 9d9584a5..0c980c61 100644
--- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs
+++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs
@@ -22,9 +22,11 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
/// Fault-probe stage emitted after all pages exist and before manifest finality.
public const string BeforeManifestCommitStage = "before-manifest-commit";
private const string LegacyIndexedFormatVersion = "cultcache.store.v2.directory-indexed";
- private const string IndexedFormatVersion = "cultcache.store.v3.directory-immutable-pages";
+ private const string LegacyMetadataPageFormatVersion = "cultcache.store.v3.directory-immutable-pages";
+ private const string IndexedFormatVersion = "cultcache.store.v4.directory-content-addressed-pages";
private readonly FileInfo _manifestFile;
private readonly DirectoryInfo _recordDirectory;
+ private readonly object _mutationGate = new();
private readonly ConcurrentDictionary _dirtyKeys = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary _deletedKeys = new(StringComparer.Ordinal);
private readonly HashSet _hydratedKeys = new(StringComparer.Ordinal);
@@ -33,6 +35,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
private CultSchemaCatalogEntry[] _durableCatalog = Array.Empty();
private bool _needsIndexUpgrade;
private bool _manifestUsesImmutablePages;
+ private bool _manifestUsesMetadataPages;
///
/// Gets or sets the predicate that selects indexed record payloads for hydration.
@@ -64,6 +67,12 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
///
public override void PullAll()
+ {
+ lock (_mutationGate)
+ PullAllCore();
+ }
+
+ private void PullAllCore()
{
var traceStartup = string.Equals(
Environment.GetEnvironmentVariable("CULTCACHE_TRACE_STARTUP_PHASES"),
@@ -91,10 +100,12 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
var reports = new List();
var loaded = new Dictionary(StringComparer.Ordinal);
var indexed = string.Equals(manifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal) ||
+ string.Equals(manifest.FormatVersion, LegacyMetadataPageFormatVersion, StringComparison.Ordinal) ||
string.Equals(manifest.FormatVersion, LegacyIndexedFormatVersion, StringComparison.Ordinal);
if (indexed)
{
_manifestUsesImmutablePages = string.Equals(manifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal);
+ _manifestUsesMetadataPages = string.Equals(manifest.FormatVersion, LegacyMetadataPageFormatVersion, StringComparison.Ordinal);
_needsIndexUpgrade = !_manifestUsesImmutablePages;
_durableIndex = manifest.Records
.Where(record => !string.IsNullOrWhiteSpace(record.Key))
@@ -156,17 +167,25 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
///
public override void PullSelected(Func selector)
+ {
+ lock (_mutationGate)
+ PullSelectedCore(selector);
+ }
+
+ private void PullSelectedCore(Func selector)
{
if (selector == null) throw new ArgumentNullException(nameof(selector));
var manifest = ReadManifest();
if (!string.Equals(manifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal) &&
+ !string.Equals(manifest.FormatVersion, LegacyMetadataPageFormatVersion, StringComparison.Ordinal) &&
!string.Equals(manifest.FormatVersion, LegacyIndexedFormatVersion, StringComparison.Ordinal))
{
- PullAll();
+ PullAllCore();
return;
}
_manifestUsesImmutablePages = string.Equals(manifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal);
+ _manifestUsesMetadataPages = string.Equals(manifest.FormatVersion, LegacyMetadataPageFormatVersion, StringComparison.Ordinal);
_needsIndexUpgrade = !_manifestUsesImmutablePages;
_durableCatalog = manifest.SchemaCatalog;
_durableIndex = manifest.Records
@@ -188,29 +207,42 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
///
public override bool ContainsDurableRecord(CultRecordKey key)
{
- return _durableIndex.ContainsKey(key.Value) || base.ContainsDurableRecord(key);
+ lock (_mutationGate)
+ return _durableIndex.ContainsKey(key.Value) || base.ContainsDurableRecord(key);
}
///
public override void Push(CultStoredDocument entry)
{
- Entries[entry.Key.Value] = entry;
- _dirtyKeys[entry.Key.Value] = true;
- _deletedKeys.TryRemove(entry.Key.Value, out _);
- IsDirty = true;
+ lock (_mutationGate)
+ {
+ Entries[entry.Key.Value] = entry;
+ _dirtyKeys[entry.Key.Value] = true;
+ _deletedKeys.TryRemove(entry.Key.Value, out _);
+ IsDirty = true;
+ }
}
///
public override void Delete(CultStoredDocument entry)
{
- Entries.TryRemove(entry.Key.Value, out _);
- _dirtyKeys.TryRemove(entry.Key.Value, out _);
- _deletedKeys[entry.Key.Value] = true;
- IsDirty = true;
+ lock (_mutationGate)
+ {
+ Entries.TryRemove(entry.Key.Value, out _);
+ _dirtyKeys.TryRemove(entry.Key.Value, out _);
+ _deletedKeys[entry.Key.Value] = true;
+ IsDirty = true;
+ }
}
///
public override void PushAll(bool soft = false)
+ {
+ lock (_mutationGate)
+ PushAllCore(soft);
+ }
+
+ private void PushAllCore(bool soft)
{
if (!IsDirty && !_needsIndexUpgrade)
{
@@ -219,11 +251,14 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
Directory.CreateDirectory(_manifestFile.DirectoryName!);
Directory.CreateDirectory(_recordDirectory.FullName);
+ using var commitLease = AcquireCommitLease();
var currentManifest = ReadManifest();
var currentManifestIsIndexed = string.Equals(currentManifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal) ||
+ string.Equals(currentManifest.FormatVersion, LegacyMetadataPageFormatVersion, StringComparison.Ordinal) ||
string.Equals(currentManifest.FormatVersion, LegacyIndexedFormatVersion, StringComparison.Ordinal);
var currentManifestUsesImmutablePages = string.Equals(currentManifest.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal);
+ var currentManifestUsesMetadataPages = string.Equals(currentManifest.FormatVersion, LegacyMetadataPageFormatVersion, StringComparison.Ordinal);
var currentIndex = currentManifestIsIndexed
? currentManifest.Records.ToDictionary(record => record.Key, record => record, StringComparer.Ordinal)
: new Dictionary(_durableIndex, StringComparer.Ordinal);
@@ -262,10 +297,13 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
document,
stored.Descriptor.DocumentType,
Registry));
+ var pagePayload = CultDocumentMessagePackSerialization.SerializePersistedRecord(record);
+ var indexRecord = ToContentAddressedIndexRecord(record, pagePayload);
+ currentIndex[key] = indexRecord;
FlushStageProbe?.Invoke(BeforeRecordPageWriteStage, pageWriteIndex++);
WriteFileAtomically(
- ImmutableRecordPath(ToIndexRecord(record)),
- CultDocumentMessagePackSerialization.SerializePersistedRecord(record));
+ ContentAddressedRecordPath(indexRecord),
+ pagePayload);
}
if (!currentManifestUsesImmutablePages)
@@ -274,10 +312,17 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
.Where(record => !_deletedKeys.ContainsKey(record.Key) && !_dirtyKeys.ContainsKey(record.Key))
.OrderBy(record => record.Key, StringComparer.Ordinal))
{
- var sourcePath = LegacyRecordPath(metadata.Key);
- var targetPath = ImmutableRecordPath(ToIndexRecord(metadata));
- if (File.Exists(sourcePath) && !File.Exists(targetPath))
- WriteFileAtomically(targetPath, ReadAllBytesShared(sourcePath));
+ var sourcePath = currentManifestUsesMetadataPages
+ ? MetadataRecordPath(metadata)
+ : LegacyRecordPath(metadata.Key);
+ if (!File.Exists(sourcePath))
+ continue;
+ var pagePayload = ReadAllBytesShared(sourcePath);
+ var indexRecord = ToContentAddressedIndexRecord(metadata, pagePayload);
+ currentIndex[metadata.Key] = indexRecord;
+ var targetPath = ContentAddressedRecordPath(indexRecord);
+ if (!File.Exists(targetPath))
+ WriteFileAtomically(targetPath, pagePayload);
}
}
@@ -285,9 +330,12 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
{
if (_deletedKeys.ContainsKey(pair.Key) || _dirtyKeys.ContainsKey(pair.Key))
continue;
+ var pagePayload = CultDocumentMessagePackSerialization.SerializePersistedRecord(pair.Value);
+ var indexRecord = ToContentAddressedIndexRecord(pair.Value, pagePayload);
+ currentIndex[pair.Key] = indexRecord;
WriteFileAtomically(
- ImmutableRecordPath(ToIndexRecord(pair.Value)),
- CultDocumentMessagePackSerialization.SerializePersistedRecord(pair.Value));
+ ContentAddressedRecordPath(indexRecord),
+ pagePayload);
}
FlushStageProbe?.Invoke(BeforeManifestCommitStage, pageWriteIndex);
@@ -296,6 +344,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
.ToArray());
_manifestUsesImmutablePages = true;
+ _manifestUsesMetadataPages = false;
DeleteUnreferencedRecordPages(currentIndex.Values);
_durableCatalog = targetCatalog;
@@ -384,11 +433,16 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
var started = tracePages ? Stopwatch.GetTimestamp() : 0L;
var metadata = records[index];
var path = _manifestUsesImmutablePages
- ? ImmutableRecordPath(metadata)
- : LegacyRecordPath(metadata.Key);
+ ? ContentAddressedRecordPath(metadata)
+ : _manifestUsesMetadataPages
+ ? MetadataRecordPath(metadata)
+ : LegacyRecordPath(metadata.Key);
if (!File.Exists(path))
return;
var pagePayload = ReadAllBytesShared(path);
+ if (_manifestUsesImmutablePages &&
+ !HashPayload(pagePayload).SequenceEqual(metadata.Payload))
+ throw new InvalidDataException($"Record page '{path}' does not match its committed content hash.");
var record = CultDocumentMessagePackSerialization.DeserializePersistedRecord(pagePayload);
if (!string.Equals(record.Key, metadata.Key, StringComparison.Ordinal))
throw new InvalidDataException($"Record page '{path}' contains key '{record.Key}', expected '{metadata.Key}'.");
@@ -527,6 +581,16 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
Payload = Array.Empty()
};
+ private static CultPersistedRecord ToContentAddressedIndexRecord(
+ CultPersistedRecord record,
+ byte[] pagePayload) => new()
+ {
+ Key = record.Key,
+ SchemaId = record.SchemaId,
+ StoredAt = record.StoredAt,
+ Payload = HashPayload(pagePayload)
+ };
+
private CultPersistedStoreSnapshot ReadManifest()
{
if (File.Exists(_manifestFile.FullName))
@@ -627,14 +691,28 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
private string LegacyRecordPath(string key) => Path.Combine(_recordDirectory.FullName, $"{HashKey(key)}.msgpack");
- private string ImmutableRecordPath(CultPersistedRecord metadata) => Path.Combine(
+ private string MetadataRecordPath(CultPersistedRecord metadata) => Path.Combine(
_recordDirectory.FullName,
$"{HashKey(metadata.Key + "\0" + metadata.SchemaId + "\0" + metadata.StoredAt)}.msgpack");
+ private string ContentAddressedRecordPath(CultPersistedRecord metadata)
+ {
+ if (metadata.Payload == null || metadata.Payload.Length != 32)
+ throw new InvalidDataException($"Record '{metadata.Key}' has no committed SHA-256 page identity.");
+ var hash = BitConverter.ToString(metadata.Payload).Replace("-", string.Empty).ToLowerInvariant();
+ return Path.Combine(_recordDirectory.FullName, $"{hash}.msgpack");
+ }
+
+ private static byte[] HashPayload(byte[] payload)
+ {
+ using var sha256 = SHA256.Create();
+ return sha256.ComputeHash(payload);
+ }
+
private void DeleteUnreferencedRecordPages(IEnumerable durableRecords)
{
var referenced = durableRecords
- .Select(ImmutableRecordPath)
+ .Select(ContentAddressedRecordPath)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var file in _recordDirectory.EnumerateFiles("*.msgpack"))
{
@@ -708,6 +786,29 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
return buffer.ToArray();
}
+ private FileStream AcquireCommitLease()
+ {
+ var lockPath = Path.Combine(_recordDirectory.FullName, ".commit.lock");
+ var started = Stopwatch.StartNew();
+ while (true)
+ {
+ try
+ {
+ return new FileStream(
+ lockPath,
+ FileMode.OpenOrCreate,
+ FileAccess.ReadWrite,
+ FileShare.None,
+ bufferSize: 1,
+ FileOptions.WriteThrough);
+ }
+ catch (IOException) when (started.Elapsed < TimeSpan.FromSeconds(30))
+ {
+ Thread.Sleep(10);
+ }
+ }
+ }
+
private static void ReplaceExistingFile(string sourcePath, string destinationPath)
{
for (var attempt = 0; attempt < 3; attempt++)
diff --git a/tests/GameCult.Caching.Tests/BackingStoreTests.cs b/tests/GameCult.Caching.Tests/BackingStoreTests.cs
index 66078f26..e43fa7b6 100644
--- a/tests/GameCult.Caching.Tests/BackingStoreTests.cs
+++ b/tests/GameCult.Caching.Tests/BackingStoreTests.cs
@@ -3,6 +3,7 @@ using System;
using System.IO;
using System.Linq;
using System.Reflection;
+using System.Threading;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
@@ -434,9 +435,10 @@ namespace GameCult.Caching.Tests
}
var manifest = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(filePath));
- Assert.That(manifest.FormatVersion, Is.EqualTo("cultcache.store.v3.directory-immutable-pages"));
+ Assert.That(manifest.FormatVersion, Is.EqualTo("cultcache.store.v4.directory-content-addressed-pages"));
Assert.That(manifest.Records, Has.Length.EqualTo(2));
- Assert.That(manifest.Records.All(record => record.Payload.Length == 0), Is.True);
+ Assert.That(manifest.Records.All(record => record.Payload.Length == 32), Is.True,
+ "the hot index carries only the SHA-256 page identity, never the document body");
var coldPath = Directory.GetFiles(recordsPath, "*.msgpack")
.Single(path => string.Equals(
@@ -469,7 +471,7 @@ namespace GameCult.Caching.Tests
var finalManifest = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(filePath));
Assert.That(finalManifest.Records, Has.Length.EqualTo(3));
Assert.That(finalManifest.Records.Any(record => record.Key == cold.Key.Value), Is.True);
- Assert.That(finalManifest.Records.All(record => record.Payload.Length == 0), Is.True);
+ Assert.That(finalManifest.Records.All(record => record.Payload.Length == 32), Is.True);
}
finally
{
@@ -713,6 +715,130 @@ namespace GameCult.Caching.Tests
}
}
+ [Test]
+ public async Task DirectoryMessagePackBackingStore_SameTimestampPayload_CannotOverwriteCommittedPageBeforeManifest()
+ {
+ var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc");
+ var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath);
+ try
+ {
+ const string storedAt = "2026-08-19T00:00:00.0000000+00:00";
+ var store = new DirectoryMessagePackBackingStore(filePath, recordsPath);
+ var cache = new CultCache();
+ cache.AddBackingStore(store);
+ var key = new CultRecordKey("record:atomic:same-timestamp");
+ var descriptor = CultDocumentRegistry.Shared.GetRequired();
+ store.Push(new CultStoredDocument(
+ key,
+ storedAt,
+ descriptor,
+ new NamedTestEntry { Name = "same", Value = "old" }));
+ store.PushAll();
+
+ store.Push(new CultStoredDocument(
+ key,
+ storedAt,
+ descriptor,
+ new NamedTestEntry { Name = "same", Value = "new" }));
+ store.FlushStageProbe = (stage, _) =>
+ {
+ if (string.Equals(stage, DirectoryMessagePackBackingStore.BeforeManifestCommitStage, StringComparison.Ordinal))
+ throw new IOException("injected crash before same-timestamp manifest commit");
+ };
+ Assert.That(() => store.PushAll(), Throws.TypeOf());
+
+ using var reopened = await CultCacheMessagePack.OpenAsync(
+ filePath,
+ new CultCacheOpenOptions { UseDirectoryStore = true });
+ Assert.That(reopened.Get(key)?.Value, Is.EqualTo("old"));
+ }
+ finally
+ {
+ if (File.Exists(filePath)) File.Delete(filePath);
+ if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true);
+ }
+ }
+
+ [Test]
+ public async Task DirectoryMessagePackBackingStore_MutationDuringFlush_RemainsDirtyAndCommitsNextGeneration()
+ {
+ var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc");
+ var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath);
+ try
+ {
+ var store = new DirectoryMessagePackBackingStore(filePath, recordsPath);
+ var cache = new CultCache();
+ cache.AddBackingStore(store);
+ var first = await cache.UpsertAsync(new NamedTestEntry { Name = "first", Value = "one" });
+ using var flushEntered = new ManualResetEventSlim();
+ using var writerAttempting = new ManualResetEventSlim();
+ store.FlushStageProbe = (stage, _) =>
+ {
+ if (!string.Equals(stage, DirectoryMessagePackBackingStore.BeforeManifestCommitStage, StringComparison.Ordinal))
+ return;
+ flushEntered.Set();
+ Assert.That(writerAttempting.Wait(TimeSpan.FromSeconds(5)), Is.True);
+ };
+
+ var flush = Task.Run(() => store.PushAll());
+ var writer = Task.Run(async () =>
+ {
+ Assert.That(flushEntered.Wait(TimeSpan.FromSeconds(5)), Is.True);
+ writerAttempting.Set();
+ return await cache.UpsertAsync(new NamedTestEntry { Name = "second", Value = "two" });
+ });
+ await flush;
+ var second = await writer;
+
+ Assert.That(store.IsDirty, Is.True, "the mutation blocked behind flush must remain pending");
+ store.FlushStageProbe = null;
+ store.PushAll();
+ using var reopened = await CultCacheMessagePack.OpenAsync(
+ filePath,
+ new CultCacheOpenOptions { UseDirectoryStore = true });
+ Assert.That(reopened.Get(first.Key)?.Value, Is.EqualTo("one"));
+ Assert.That(reopened.Get(second.Key)?.Value, Is.EqualTo("two"));
+ }
+ finally
+ {
+ if (File.Exists(filePath)) File.Delete(filePath);
+ if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true);
+ }
+ }
+
+ [Test]
+ public async Task DirectoryMessagePackBackingStore_ConcurrentInstances_MergeUnderOneCommitLease()
+ {
+ var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc");
+ var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath);
+ try
+ {
+ var firstStore = new DirectoryMessagePackBackingStore(filePath, recordsPath);
+ var firstCache = new CultCache();
+ firstCache.AddBackingStore(firstStore);
+ var secondStore = new DirectoryMessagePackBackingStore(filePath, recordsPath);
+ var secondCache = new CultCache();
+ secondCache.AddBackingStore(secondStore);
+ var first = await firstCache.UpsertAsync(new NamedTestEntry { Name = "first-process", Value = "one" });
+ var second = await secondCache.UpsertAsync(new NamedTestEntry { Name = "second-process", Value = "two" });
+
+ await Task.WhenAll(
+ Task.Run(() => firstStore.PushAll()),
+ Task.Run(() => secondStore.PushAll()));
+
+ using var reopened = await CultCacheMessagePack.OpenAsync(
+ filePath,
+ new CultCacheOpenOptions { UseDirectoryStore = true });
+ Assert.That(reopened.Get(first.Key)?.Value, Is.EqualTo("one"));
+ Assert.That(reopened.Get(second.Key)?.Value, Is.EqualTo("two"));
+ }
+ finally
+ {
+ if (File.Exists(filePath)) File.Delete(filePath);
+ if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true);
+ }
+ }
+
[Test]
public async Task DirectoryMessagePackBackingStore_PullsOnlyExternalRecordDeltas()
{
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 dd41e76d..b279cd09 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 353d991f..b2446e7f 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 5a897407..92231494 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 978cd4cb..888adde0 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 b2d7efa5..be0edb36 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 5aaeb6b1..84bb0c96 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.Quic.Native.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.dll
index 2e4576da..47771673 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.dll differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.pdb
index c53382fa..31760e9e 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.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 42fbe73e..6d06333d 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 5971d0e3..2b0311d9 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.WebSockets.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.WebSockets.dll
index e5c84a7f..3b414164 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.WebSockets.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.WebSockets.dll differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll
index ac971864..a6163a0a 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 89bbf59e..5e79480f 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 b695c7b6..ad99d1ba 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.51",
+ "version": "1.0.52",
"unity": "2021.3",
"description": "CultCache, CultNet, and CultMesh managed runtime assemblies for Unity.",
"license": "MIT",