diff --git a/Directory.Build.props b/Directory.Build.props index 2558bfbe..45263613 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,7 +14,7 @@ MIT © Meta Vens and contributors true - 1.0.21 + 1.0.22 true diff --git a/src/GameCult.Mesh/CultMeshFrameBodies.cs b/src/GameCult.Mesh/CultMeshFrameBodies.cs index e01a0901..5c4d26d0 100644 --- a/src/GameCult.Mesh/CultMeshFrameBodies.cs +++ b/src/GameCult.Mesh/CultMeshFrameBodies.cs @@ -16,12 +16,16 @@ namespace GameCult.Mesh // Consumers own only read leases; mappings, capabilities, and slot selection remain CultMesh state. public sealed class CultMeshFrameBodyPublisher : IDisposable { - private const string CapabilityPrefix = "frame-v1-"; - private const int HeaderSize = 128; - private const int SequenceOffset = 8; - private const int ReaderCountOffset = 32; - private const int WriterCountOffset = 48; - private const int ContractHashOffset = 64; + internal const string CapabilityPrefix = "frame-v2-"; + internal const int HeaderSize = 512; + internal const int SequenceOffset = 8; + internal const int ReaderCountOffset = 32; + internal const int WriterCountOffset = 48; + internal const int ContractHashOffset = 64; + internal const int ByteLengthOffset = 128; + internal const int LeaseExpiresAtOffset = 144; + internal const int SemanticHashOffset = 192; + internal const int SemanticHashStride = 64; private readonly object _gate = new(); private readonly MemoryMappedFile _mapping; private readonly MemoryMappedViewAccessor _control; @@ -59,6 +63,8 @@ namespace GameCult.Mesh _control = _mapping.CreateViewAccessor(0, HeaderSize, MemoryMappedFileAccess.ReadWrite); _control.Write(0, slotByteLength); _control.Write(4, CultMeshFrameRegion.SlotCount); + for (var slot = 0; slot < CultMeshFrameRegion.SlotCount; slot++) + _control.Write(SequenceOffset + slot * sizeof(long), -1L); var contractHash = Encoding.ASCII.GetBytes(ContractHash(BodyId, SchemaId, LayoutVersion, Capacity, ProducerEpoch, SlotByteLength)); _control.WriteArray(ContractHashOffset, contractHash, 0, contractHash.Length); _mutex = new Mutex(false, MutexName(_token)); @@ -160,6 +166,10 @@ namespace GameCult.Mesh _control.ReadInt32(ReaderCountOffset + lease.SlotIndex * sizeof(int)) != 0) throw new InvalidOperationException("The CultMesh frame write reservation was lost before commit."); _control.Write(SequenceOffset + lease.SlotIndex * sizeof(long), lease.Sequence); + _control.Write(ByteLengthOffset + lease.SlotIndex * sizeof(int), byteLength); + _control.Write(LeaseExpiresAtOffset + lease.SlotIndex * sizeof(long), nowUtc.Add(_leaseDuration).ToUnixTimeMilliseconds()); + var hashBytes = Encoding.ASCII.GetBytes(semanticHash); + _control.WriteArray(SemanticHashOffset + lease.SlotIndex * SemanticHashStride, hashBytes, 0, hashBytes.Length); _control.Write(writerOffset, 0); }); var descriptor = Descriptor(lease.SlotIndex, byteLength, lease.Sequence, semanticHash, nowUtc); @@ -249,7 +259,7 @@ namespace GameCult.Mesh finally { mutex.Dispose(); } } - private static void ReleaseReader(string token, int slot) + internal static void ReleaseReader(string token, int slot) { try { @@ -281,16 +291,16 @@ namespace GameCult.Mesh } private void WithMutex(Action action) => WithMutex(_mutex, action); - private static void WithMutex(Mutex mutex, Action action) + internal static void WithMutex(Mutex mutex, Action action) { mutex.WaitOne(); try { action(); } finally { mutex.ReleaseMutex(); } } - private static long SlotOffset(int slot, int slotByteLength) => HeaderSize + (long)slot * slotByteLength; - private static string MappingName(string token) => "cultmesh-map-" + token; - private static string MutexName(string token) => "cultmesh-lock-" + token; - private static void ParseCapability(string capability, out string token, out int slot) + internal static long SlotOffset(int slot, int slotByteLength) => HeaderSize + (long)slot * slotByteLength; + internal static string MappingName(string token) => "cultmesh-map-" + token; + internal static string MutexName(string token) => "cultmesh-lock-" + token; + internal static void ParseCapability(string capability, out string token, out int slot) { var separator = capability.LastIndexOf('.'); if (separator <= CapabilityPrefix.Length || !int.TryParse(capability.Substring(separator + 1), out slot)) @@ -303,13 +313,180 @@ namespace GameCult.Mesh using (var random = RandomNumberGenerator.Create()) random.GetBytes(bytes); return BitConverter.ToString(bytes).Replace("-", string.Empty).ToLowerInvariant(); } - private static string ContractHash(string bodyId, string schemaId, int layoutVersion, int capacity, long epoch, int slotByteLength) => + internal static string ContractHash(string bodyId, string schemaId, int layoutVersion, int capacity, long epoch, int slotByteLength) => CultMeshBodyDescriptorValidator.ComputeSemanticHash( Encoding.UTF8.GetBytes($"{bodyId}\n{schemaId}\n{layoutVersion}\n{capacity}\n{epoch}\n{slotByteLength}")); private void ThrowIfDisposed() { if (_disposed) throw new ObjectDisposedException(nameof(CultMeshFrameBodyPublisher)); } private static string Require(string value, string name) => string.IsNullOrWhiteSpace(value) ? throw new ArgumentException("Value is required.", name) : value; } + /// + /// Retains one verified shared-memory frame mapping and leases only newer committed generations. + /// The control plane is needed for bootstrap and layout changes, not for every frame. + /// + public sealed class CultMeshMappedFrameBodyCursor : IDisposable + { + private readonly CultMeshBodyDescriptor _contract; + private readonly string _token; + private readonly MemoryMappedFile _mapping; + private readonly MemoryMappedViewAccessor _control; + private readonly Mutex _mutex; + private readonly int _slotByteLength; + private long _lastSequence = -1; + private bool _disposed; + + public static bool CanOpen(CultMeshBodyDescriptor descriptor) => + descriptor != null && + descriptor.TransportKind == CultMeshBodyTransportKind.SharedMemory && + descriptor.Synchronization == CultMeshBodySynchronization.TripleBuffer && + CultMeshFrameBodyPublisher.IsFrameCapability(descriptor.CapabilityToken); + + public CultMeshMappedFrameBodyCursor(CultMeshBodyDescriptor descriptor) + { + _contract = descriptor ?? throw new ArgumentNullException(nameof(descriptor)); + if (!CanOpen(descriptor)) + throw new ArgumentException("Descriptor is not a mapped CultMesh frame capability.", nameof(descriptor)); + CultMeshFrameBodyPublisher.ParseCapability(descriptor.CapabilityToken, out _token, out _); + try + { + _mapping = MemoryMappedFile.OpenExisting( + CultMeshFrameBodyPublisher.MappingName(_token), + MemoryMappedFileRights.ReadWrite); + _mutex = Mutex.OpenExisting(CultMeshFrameBodyPublisher.MutexName(_token)); + } + catch (Exception error) when (error is FileNotFoundException || error is WaitHandleCannotBeOpenedException) + { + throw new InvalidDataException("CultMesh frame capability is unavailable or revoked.", error); + } + try + { + _control = _mapping.CreateViewAccessor( + 0, + CultMeshFrameBodyPublisher.HeaderSize, + MemoryMappedFileAccess.ReadWrite); + _slotByteLength = _control.ReadInt32(0); + var contractHash = new byte[64]; + _control.ReadArray(CultMeshFrameBodyPublisher.ContractHashOffset, contractHash, 0, contractHash.Length); + var expected = CultMeshFrameBodyPublisher.ContractHash( + descriptor.BodyId, + descriptor.SchemaId, + descriptor.LayoutVersion, + descriptor.Capacity, + descriptor.ProducerEpoch, + _slotByteLength); + if (!string.Equals(Encoding.ASCII.GetString(contractHash), expected, StringComparison.Ordinal)) + throw new InvalidDataException("CultMesh frame cursor contract does not match its mapping."); + } + catch + { + _mapping.Dispose(); + _mutex.Dispose(); + throw; + } + } + + public long LastSequence => _lastSequence; + + public bool TryAcquireLatest(out ICultMeshBodyReadLease lease) => + TryAcquireLatest(DateTimeOffset.UtcNow, out lease); + + public bool TryAcquireLatest(DateTimeOffset nowUtc, out ICultMeshBodyReadLease lease) + { + if (_disposed) throw new ObjectDisposedException(nameof(CultMeshMappedFrameBodyCursor)); + var slot = -1; + var sequence = -1L; + var byteLength = 0; + var leaseExpiresAt = 0L; + var semanticHash = ""; + CultMeshFrameBodyPublisher.WithMutex(_mutex, () => + { + var slotCount = _control.ReadInt32(4); + for (var candidate = 0; candidate < slotCount; candidate++) + { + if (_control.ReadInt32(CultMeshFrameBodyPublisher.WriterCountOffset + candidate * sizeof(int)) != 0) + continue; + var candidateSequence = _control.ReadInt64( + CultMeshFrameBodyPublisher.SequenceOffset + candidate * sizeof(long)); + if (candidateSequence <= _lastSequence || candidateSequence <= sequence) + continue; + var candidateExpiry = _control.ReadInt64( + CultMeshFrameBodyPublisher.LeaseExpiresAtOffset + candidate * sizeof(long)); + var candidateLength = _control.ReadInt32( + CultMeshFrameBodyPublisher.ByteLengthOffset + candidate * sizeof(int)); + // A byte-length change means the control-plane layout contract changed. + // Do not expose that generation through a cursor bootstrapped against + // the previous layout; its new descriptor must arrive first. + if (candidateExpiry <= nowUtc.ToUnixTimeMilliseconds() || + candidateLength != _contract.ByteSize || candidateLength > _slotByteLength) + continue; + slot = candidate; + sequence = candidateSequence; + leaseExpiresAt = candidateExpiry; + byteLength = candidateLength; + } + if (slot < 0) return; + var hashBytes = new byte[CultMeshFrameBodyPublisher.SemanticHashStride]; + _control.ReadArray( + CultMeshFrameBodyPublisher.SemanticHashOffset + slot * CultMeshFrameBodyPublisher.SemanticHashStride, + hashBytes, + 0, + hashBytes.Length); + semanticHash = Encoding.ASCII.GetString(hashBytes); + var readerOffset = CultMeshFrameBodyPublisher.ReaderCountOffset + slot * sizeof(int); + _control.Write(readerOffset, checked(_control.ReadInt32(readerOffset) + 1)); + }); + if (slot < 0) + { + lease = null!; + return false; + } + + var descriptor = new CultMeshBodyDescriptor + { + BodyId = _contract.BodyId, + SchemaId = _contract.SchemaId, + LayoutVersion = _contract.LayoutVersion, + ByteSize = byteLength, + Capacity = _contract.Capacity, + ProducerEpoch = _contract.ProducerEpoch, + Sequence = sequence, + AccessMode = CultMeshBodyAccessMode.ReadOnly, + Synchronization = CultMeshBodySynchronization.TripleBuffer, + LeaseExpiresAtUnixMs = leaseExpiresAt, + TransportKind = CultMeshBodyTransportKind.SharedMemory, + CapabilityToken = _token + "." + slot, + SemanticHash = semanticHash + }; + try + { + var mapping = MemoryMappedFile.OpenExisting( + CultMeshFrameBodyPublisher.MappingName(_token), + MemoryMappedFileRights.ReadWrite); + lease = new CultMeshMappedBodyLease( + descriptor, + mapping, + CultMeshFrameBodyPublisher.SlotOffset(slot, _slotByteLength), + () => CultMeshFrameBodyPublisher.ReleaseReader(_token, slot)); + _lastSequence = sequence; + return true; + } + catch + { + CultMeshFrameBodyPublisher.ReleaseReader(_token, slot); + throw; + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _control.Dispose(); + _mapping.Dispose(); + _mutex.Dispose(); + } + } + /// A bounded producer lease over one directly writable shared-memory frame slot. public sealed unsafe class CultMeshFrameBodyWriteLease : IDisposable { diff --git a/src/GameCult.Mesh/README.md b/src/GameCult.Mesh/README.md index 2f80d345..8f118320 100644 --- a/src/GameCult.Mesh/README.md +++ b/src/GameCult.Mesh/README.md @@ -380,6 +380,40 @@ runtime-native storage: shared D3D12 textures, shared memory rings, platform GPU handles, DMA buffers, or paged CultCache fallbacks when a zero-copy transport is not available. +For entity SoA and other CPU-visible hot state, subscribe to the exact control +records and logical bodies the lowerer consumes. The subscription advertises +the transports the runtime can import; the provider's demand tracker selects +the cheapest mutually usable representation. A same-machine consumer normally +receives one shared-memory bootstrap descriptor, then observes later commits +through the mapped ring header. Per-frame CultNet documents are neither needed +nor desirable. + +```csharp +var initial = await subscriptions.SubscribeAsync( + "pilot-world", + recordKeys: new[] { entityLayoutRecord, bodyPublicationRecord }, + schemaIds: new[] { entityLayoutSchema, CultMeshBodyPublicationSchemaVersions.Publication }, + consumerRuntimeId: "eve-unity", + bodyIds: new[] { "eve:entity-soa:aetheria.daemon:pilot" }, + supportedBodyTransports: new[] + { + CultMeshBodyTransportKind.SharedMemory.ToString(), + CultMeshBodyTransportKind.Network.ToString() + }); + +var bootstrap = initial.OfType().Single(); +var mapped = bootstrap.Representations.Single(CultMeshMappedFrameBodyCursor.CanOpen); +using var cursor = new CultMeshMappedFrameBodyCursor(mapped); + +// Call from the consumer's normal update loop. The returned lease is read-only +// and must remain alive until the renderer has finished consuming that frame. +if (cursor.TryAcquireLatest(out var frame)) + Present(frame); +``` + +Snapshots remain bootstrap and recovery machinery. Reactive document changes +carry small control state; mapped or negotiated network bodies carry hot data. + ```csharp using GameCult.Mesh; diff --git a/src/GameCult.Networking/CultNetDatabaseSubscriptionClient.cs b/src/GameCult.Networking/CultNetDatabaseSubscriptionClient.cs index cfe349c5..d0a545c0 100644 --- a/src/GameCult.Networking/CultNetDatabaseSubscriptionClient.cs +++ b/src/GameCult.Networking/CultNetDatabaseSubscriptionClient.cs @@ -40,6 +40,10 @@ namespace GameCult.Networking /// Gets the local reactive cache receiving replicated documents. public CultCache Cache => _cache; + /// Completes if the retained transport pump terminates with an unrecoverable error. + public Task? BackgroundFailure => + (_client as RudpCultNetSchemaClient)?.BackgroundFailure; + /// /// Starts a remote subscription and waits until its initial matching snapshot is in the local cache. /// diff --git a/tests/GameCult.Mesh.Tests/CultMeshFrameBodyTests.cs b/tests/GameCult.Mesh.Tests/CultMeshFrameBodyTests.cs index 4ecbe339..2d69015d 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshFrameBodyTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshFrameBodyTests.cs @@ -30,6 +30,82 @@ public sealed class CultMeshFrameBodyTests lease.ReadByte(0).Should().Be(1); } + [Test] + public void MappedCursorObservesNewGenerationsWithoutNewControlDescriptors() + { + using var publisher = Publisher(); + publisher.TryPublish(new byte[] { 1, 2, 3 }, DateTimeOffset.UtcNow, out var bootstrap).Should().BeTrue(); + using var cursor = new CultMeshMappedFrameBodyCursor(bootstrap); + + cursor.TryAcquireLatest(out var first).Should().BeTrue(); + using (first) + { + first.Descriptor.Sequence.Should().Be(bootstrap.Sequence); + first.ReadByte(0).Should().Be(1); + } + cursor.TryAcquireLatest(out _).Should().BeFalse(); + + publisher.TryPublish(new byte[] { 9, 8, 7 }, DateTimeOffset.UtcNow, out _).Should().BeTrue(); + + cursor.TryAcquireLatest(out var second).Should().BeTrue(); + using (second) + { + second.Descriptor.Sequence.Should().Be(bootstrap.Sequence + 1); + second.Descriptor.ByteSize.Should().Be(3); + second.ReadByte(0).Should().Be(9); + } + cursor.TryAcquireLatest(out _).Should().BeFalse(); + } + + [Test] + public void MappedCursorWaitsForNewControlContractWhenFrameLengthChanges() + { + using var publisher = Publisher(); + publisher.TryPublish(new byte[] { 1 }, DateTimeOffset.UtcNow, out var bootstrap).Should().BeTrue(); + using var cursor = new CultMeshMappedFrameBodyCursor(bootstrap); + cursor.TryAcquireLatest(out var first).Should().BeTrue(); + first.Dispose(); + + publisher.TryPublish(new byte[] { 2, 3 }, DateTimeOffset.UtcNow, out _).Should().BeTrue(); + + cursor.TryAcquireLatest(out _).Should().BeFalse(); + } + + [Test] + public void MappedCursorReaderLeaseProtectsItsSlotUntilDisposed() + { + using var publisher = Publisher(); + publisher.TryPublish(new byte[] { 1 }, DateTimeOffset.UtcNow, out var bootstrap).Should().BeTrue(); + using var cursor = new CultMeshMappedFrameBodyCursor(bootstrap); + cursor.TryAcquireLatest(out var protectedRead).Should().BeTrue(); + + publisher.TryPublish(new byte[] { 2 }, DateTimeOffset.UtcNow, out _).Should().BeTrue(); + publisher.TryPublish(new byte[] { 3 }, DateTimeOffset.UtcNow, out _).Should().BeTrue(); + publisher.TryPublish(new byte[] { 4 }, DateTimeOffset.UtcNow, out var fourth).Should().BeTrue(); + + fourth.CapabilityToken.Should().NotBe(bootstrap.CapabilityToken); + protectedRead.ReadByte(0).Should().Be(1); + protectedRead.Dispose(); + } + + [Test] + public void MappedCursorIgnoresExpiredGenerations() + { + var publishedAt = DateTimeOffset.UtcNow; + using var publisher = new CultMeshFrameBodyPublisher( + "body", + "tests.frame.v2", + layoutVersion: 2, + capacity: 64, + producerEpoch: 9, + slotByteLength: 64, + leaseDuration: TimeSpan.FromMilliseconds(10)); + publisher.TryPublish(new byte[] { 42 }, publishedAt, out var bootstrap).Should().BeTrue(); + using var cursor = new CultMeshMappedFrameBodyCursor(bootstrap); + + cursor.TryAcquireLatest(publishedAt.AddMilliseconds(11), out _).Should().BeFalse(); + } + [TestCase("wrong", 2, 9)] [TestCase("tests.frame.v2", 1, 9)] [TestCase("tests.frame.v2", 2, 8)] diff --git a/unity/org.gamecult.cultlib/README.md b/unity/org.gamecult.cultlib/README.md index b7255bbe..be09e204 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.8` through Unity Package Manager with: +Install version `1.0.22` through Unity Package Manager with: ```text -https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.8 +https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.22 ``` 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 e69348f1..d13a4ef8 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 20990aa3..a55bee74 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 9a1e1997..caa426d0 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 bbb283d4..fb55549d 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 e4f2a5c8..75d4d505 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 af69cc78..96c3418c 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 d192ed78..85a1216f 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 bcc1f7ed..6faa3ece 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 f43687c3..919bf200 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 033fbee0..db888d0e 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 8c2e6811..3633a675 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.21", + "version": "1.0.22", "unity": "2021.3", "description": "CultCache, CultNet, and CultMesh managed runtime assemblies for Unity.", "license": "MIT",