Ship authenticated remote transports in Unity package

This commit is contained in:
Meta Vens 2026-08-18 20:09:44 +02:00
commit 99458dbb57
28 changed files with 309 additions and 53 deletions

View file

@ -14,7 +14,7 @@
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<Copyright>© Meta Vens and contributors</Copyright>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<Version>1.0.34</Version>
<Version>1.0.35</Version>
<Version Condition="'$(CultLibPackageVersion)' != ''">$(CultLibPackageVersion)</Version>
<!-- Symbols/debugging -->

View file

@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<EveRoot Condition="'$(EveRoot)' == ''">$(MSBuildThisFileDirectory)..\..\..\Eve</EveRoot>
<UsePackageArtifacts Condition="'$(UsePackageArtifacts)' == ''">false</UsePackageArtifacts>
<CultLibPackageVersion Condition="'$(CultLibPackageVersion)' == ''">1.0.34</CultLibPackageVersion>
<CultLibPackageVersion Condition="'$(CultLibPackageVersion)' == ''">1.0.35</CultLibPackageVersion>
<EveSurfacePackageVersion Condition="'$(EveSurfacePackageVersion)' == ''">0.3.3</EveSurfacePackageVersion>
<RestoreAdditionalProjectSources Condition="'$(PackageFeed)' != ''">$(PackageFeed)</RestoreAdditionalProjectSources>
</PropertyGroup>

View file

@ -9,9 +9,11 @@ $ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$projectPath = Join-Path $repoRoot "src\GameCult.Mesh\GameCult.Mesh.csproj"
$webSocketProjectPath = Join-Path $repoRoot "src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj"
$quicProjectPath = Join-Path $repoRoot "src\GameCult.Mesh.Quic.Native\GameCult.Mesh.Quic.Native.csproj"
$templateRoot = Join-Path $repoRoot "unity\org.gamecult.cultlib"
$publishRoot = Join-Path $repoRoot "artifacts\unity-publish"
$webSocketPublishRoot = Join-Path $repoRoot "artifacts\unity-websocket-publish"
$quicPublishRoot = Join-Path $repoRoot "artifacts\unity-quic-publish"
$quicNativeRoot = Join-Path $repoRoot "artifacts\unity-quic-native"
$outputRoot = if ([System.IO.Path]::IsPathRooted($OutputDirectory)) {
@ -27,11 +29,15 @@ if (Test-Path -LiteralPath $publishRoot) {
if (Test-Path -LiteralPath $quicPublishRoot) {
Remove-Item -LiteralPath $quicPublishRoot -Recurse -Force
}
if (Test-Path -LiteralPath $webSocketPublishRoot) {
Remove-Item -LiteralPath $webSocketPublishRoot -Recurse -Force
}
if (Test-Path -LiteralPath $outputRoot) {
Remove-Item -LiteralPath $outputRoot -Recurse -Force
}
$publishArguments = @("publish", $projectPath, "-c", $Configuration, "-o", $publishRoot)
$unityPackageVersion = (Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json).version
$publishArguments = @("publish", $projectPath, "-c", $Configuration, "-o", $publishRoot, "-p:CultLibPackageVersion=$unityPackageVersion")
if (-not [string]::IsNullOrWhiteSpace($NuGetConfig)) {
$publishArguments += "-p:RestoreConfigFile=$([IO.Path]::GetFullPath($NuGetConfig))"
}
@ -39,7 +45,15 @@ if (-not [string]::IsNullOrWhiteSpace($NuGetConfig)) {
if ($LASTEXITCODE -ne 0) {
throw "CultLib publish failed with exit code $LASTEXITCODE"
}
$quicPublishArguments = @("publish", $quicProjectPath, "-c", $Configuration, "-o", $quicPublishRoot)
$webSocketPublishArguments = @("publish", $webSocketProjectPath, "-c", $Configuration, "-f", "netstandard2.1", "-o", $webSocketPublishRoot, "-p:CultLibPackageVersion=$unityPackageVersion")
if (-not [string]::IsNullOrWhiteSpace($NuGetConfig)) {
$webSocketPublishArguments += "-p:RestoreConfigFile=$([IO.Path]::GetFullPath($NuGetConfig))"
}
& dotnet @webSocketPublishArguments
if ($LASTEXITCODE -ne 0) {
throw "CultLib WebSocket publish failed with exit code $LASTEXITCODE"
}
$quicPublishArguments = @("publish", $quicProjectPath, "-c", $Configuration, "-o", $quicPublishRoot, "-p:CultLibPackageVersion=$unityPackageVersion")
if (-not [string]::IsNullOrWhiteSpace($NuGetConfig)) {
$quicPublishArguments += "-p:RestoreConfigFile=$([IO.Path]::GetFullPath($NuGetConfig))"
}
@ -63,6 +77,7 @@ $expectedAssemblies = @(
"GameCult.Mesh.dll",
"GameCult.Mesh.Quic.Native.dll",
"GameCult.Networking.dll",
"GameCult.Networking.WebSockets.dll",
"Isopoh.Cryptography.Argon2.dll",
"Isopoh.Cryptography.Blake2b.dll",
"Isopoh.Cryptography.SecureArray.dll",
@ -88,6 +103,9 @@ foreach ($assembly in Get-ChildItem -LiteralPath $publishRoot -Filter "*.dll") {
foreach ($assembly in Get-ChildItem -LiteralPath $quicPublishRoot -Filter "*.dll") {
$publishedByName[$assembly.Name] = $assembly
}
foreach ($assembly in Get-ChildItem -LiteralPath $webSocketPublishRoot -Filter "*.dll") {
$publishedByName[$assembly.Name] = $assembly
}
foreach ($assemblyName in $expectedAssemblies) {
if (-not $publishedByName.ContainsKey($assemblyName)) {
throw "CultLib Unity package is missing expected assembly: $assemblyName"
@ -112,6 +130,26 @@ New-Item -ItemType Directory -Force -Path (Join-Path $outputRoot "Third Party No
Copy-Item -LiteralPath (Join-Path $quicNativeRoot "MSQUIC-LICENSE.txt") `
-Destination (Join-Path $outputRoot "Third Party Notices\MSQUIC-LICENSE.txt")
$meshPackagePath = Join-Path $pluginRoot "GameCult.Mesh.dll"
$meshPackageVersion = [Reflection.AssemblyName]::GetAssemblyName($meshPackagePath).Version.ToString()
if ($meshPackageVersion -ne "$unityPackageVersion.0") {
throw "Packaged GameCult.Mesh.dll version '$meshPackageVersion' does not match package '$unityPackageVersion'."
}
$meshPackageMetadata = [Text.Encoding]::UTF8.GetString([IO.File]::ReadAllBytes($meshPackagePath))
foreach ($requiredType in @(
"GameCult.Mesh.CultMeshAuthorityTrustPolicy",
"GameCult.Mesh.CultMeshRouteCertificate",
"GameCult.Mesh.CultMeshHttpsContentTransportConnector")) {
if (-not $meshPackageMetadata.Contains($requiredType.Split('.')[-1])) {
throw "Packaged GameCult.Mesh.dll is missing required API '$requiredType'."
}
}
$webSocketPackageMetadata = [Text.Encoding]::UTF8.GetString(
[IO.File]::ReadAllBytes((Join-Path $pluginRoot "GameCult.Networking.WebSockets.dll")))
if (-not $webSocketPackageMetadata.Contains("CultNetWebSocketSchemaClient")) {
throw "Packaged WebSocket assembly is missing the Unity-compatible CultNet client."
}
if ($UpdateTemplate) {
$templatePluginRoot = Join-Path $templateRoot "Runtime\Plugins"
foreach ($assemblyName in $expectedAssemblies) {

View file

@ -32,7 +32,7 @@ if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($sourceRevision)) {
throw "Could not determine the CultLib source revision for local package identity."
}
$verificationNonce = [guid]::NewGuid().ToString("N").Substring(0, 8)
$cultLibLocalVersion = "1.0.34-local.$sourceRevision.$verificationNonce"
$cultLibLocalVersion = "1.0.35-local.$sourceRevision.$verificationNonce"
$surfaceLocalVersion = "0.3.3-local.$sourceRevision.$verificationNonce"
try {
& (Join-Path $EveRoot "scripts\pack-dotnet-surface.ps1") `

View file

@ -27,8 +27,8 @@ namespace GameCult.Mesh
public IReadOnlyList<ICultMeshTransportConnector>? Connectors { get; set; }
/// <summary>
/// Gets or sets streaming content connectors. TCP content delivery is used by default;
/// applications may replace it or explicitly add legacy RUDP.
/// Gets or sets streaming content connectors. HTTPS and loopback TCP content
/// delivery are used by default; applications may replace them or explicitly add legacy RUDP.
/// </summary>
public IReadOnlyList<ICultMeshContentTransportConnector>? ContentConnectors { get; set; }
@ -184,6 +184,7 @@ namespace GameCult.Mesh
},
options.ContentConnectors ?? new ICultMeshContentTransportConnector[]
{
new CultMeshHttpsContentTransportConnector(),
new CultMeshTcpContentTransportConnector()
},
options.RealtimeConnectors,

View file

@ -0,0 +1,124 @@
using System;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace GameCult.Mesh
{
/// <summary>
/// Opens immutable content over an Odin-certified HTTPS route. Provider
/// identity comes from the verified route and TLS endpoint; the transfer
/// service remains responsible for checking the advertised chunk hash.
/// </summary>
public sealed class CultMeshHttpsContentTransportConnector : ICultMeshContentTransportConnector
{
private readonly Func<HttpMessageHandler> _createHandler;
public CultMeshHttpsContentTransportConnector(Func<HttpMessageHandler>? createHandler = null)
{
_createHandler = createHandler ?? (() => new HttpClientHandler());
}
public string ConnectorId => "https-content";
public int Priority => 0;
public bool CanConnect(CultMeshTransportCandidate candidate) =>
candidate != null &&
Uri.TryCreate(candidate.Endpoint, UriKind.Absolute, out var endpoint) &&
string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
public Task<ICultMeshContentTransport> ConnectAsync(
CultMeshTransportCandidate candidate,
CultMeshSessionTarget target,
CancellationToken cancellationToken = default)
{
if (candidate == null) throw new ArgumentNullException(nameof(candidate));
if (target == null) throw new ArgumentNullException(nameof(target));
if (!CanConnect(candidate))
throw new NotSupportedException($"HTTPS content connector does not support '{candidate.Endpoint}'.");
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult<ICultMeshContentTransport>(new CultMeshHttpsContentTransport(
candidate.Endpoint,
new HttpClient(_createHandler(), disposeHandler: true),
target,
candidate.Generation));
}
}
internal sealed class CultMeshHttpsContentTransport : ICultMeshContentTransport, ICultMeshVerifiedTransport
{
private readonly HttpClient _client;
private readonly CultMeshSessionTarget _target;
private readonly string _routeGeneration;
private bool _disposed;
public CultMeshHttpsContentTransport(
string endpoint,
HttpClient client,
CultMeshSessionTarget target,
string routeGeneration)
{
Endpoint = endpoint ?? throw new ArgumentNullException(nameof(endpoint));
_client = client ?? throw new ArgumentNullException(nameof(client));
_target = target ?? throw new ArgumentNullException(nameof(target));
_routeGeneration = routeGeneration ?? string.Empty;
}
public string TransportId => "https-content";
public string Endpoint { get; }
public bool IsVerifiedFor(
string verseId,
string authorityRuntimeId,
string protocolId,
string routeGeneration) =>
string.Equals(_target.VerseId, verseId, StringComparison.Ordinal) &&
string.Equals(_target.AuthorityRuntimeId, authorityRuntimeId, StringComparison.Ordinal) &&
string.Equals(CultMeshProtocols.Content.Value, protocolId, StringComparison.Ordinal) &&
string.Equals(_routeGeneration, routeGeneration, StringComparison.Ordinal);
public async Task CopyChunkToAsync(
CultMeshCdnChunkRef chunk,
Stream destination,
CancellationToken cancellationToken = default)
{
if (_disposed) throw new ObjectDisposedException(nameof(CultMeshHttpsContentTransport));
if (chunk == null) throw new ArgumentNullException(nameof(chunk));
if (destination == null) throw new ArgumentNullException(nameof(destination));
if (!destination.CanWrite) throw new ArgumentException("Content destination must be writable.", nameof(destination));
var requestUri = BuildChunkUri(Endpoint, chunk);
using var response = await _client.GetAsync(
requestUri,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
if (response.Content.Headers.ContentLength is long contentLength &&
contentLength != chunk.SizeBytes)
{
throw new InvalidDataException(
$"HTTPS content length {contentLength} did not match advertised chunk size {chunk.SizeBytes}.");
}
using var source = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
await source.CopyToAsync(destination, 81920, cancellationToken).ConfigureAwait(false);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_client.Dispose();
}
private static Uri BuildChunkUri(string endpoint, CultMeshCdnChunkRef chunk)
{
var builder = new UriBuilder(endpoint);
var separator = string.IsNullOrWhiteSpace(builder.Query) ? string.Empty : builder.Query.TrimStart('?') + "&";
builder.Query = separator +
"chunkHash=" + Uri.EscapeDataString(CultMeshCdn.NormalizeHash(chunk.ChunkHash, nameof(chunk.ChunkHash))) +
"&recordKey=" + Uri.EscapeDataString(chunk.RecordKey ?? string.Empty);
return builder.Uri;
}
}
}

View file

@ -0,0 +1,46 @@
using System;
using System.IO;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
namespace GameCult.Networking.WebSockets;
internal static class CultNetWebSocketMessageIO
{
public static Task SendAsync(WebSocket socket, byte[] payload, CancellationToken cancellationToken) =>
socket.SendAsync(new ArraySegment<byte>(payload), WebSocketMessageType.Binary, true, cancellationToken);
public static async Task<byte[]?> ReceiveAsync(
WebSocket socket,
int maxMessageBytes,
CancellationToken cancellationToken)
{
var segment = new byte[Math.Min(16 * 1024, maxMessageBytes)];
using var message = new MemoryStream();
while (true)
{
var result = await socket.ReceiveAsync(new ArraySegment<byte>(segment), cancellationToken)
.ConfigureAwait(false);
if (result.MessageType == WebSocketMessageType.Close) return null;
if (result.MessageType != WebSocketMessageType.Binary)
{
await socket.CloseAsync(
WebSocketCloseStatus.InvalidMessageType,
"CultNet requires binary MessagePack WebSocket messages.",
cancellationToken).ConfigureAwait(false);
return null;
}
if (message.Length + result.Count > maxMessageBytes)
{
await socket.CloseAsync(
WebSocketCloseStatus.MessageTooBig,
"CultNet message exceeds the configured maximum.",
cancellationToken).ConfigureAwait(false);
return null;
}
message.Write(segment, 0, result.Count);
if (result.EndOfMessage) return message.ToArray();
}
}
}

View file

@ -37,7 +37,7 @@ public sealed class CultNetWebSocketSchemaClient : ICultNetUriSchemaClient, ICul
/// <inheritdoc />
public void Connect(string host, int port)
{
ArgumentException.ThrowIfNullOrWhiteSpace(host);
if (string.IsNullOrWhiteSpace(host)) throw new ArgumentException("Host is required.", nameof(host));
if (port <= 0 || port > 65535) throw new ArgumentOutOfRangeException(nameof(port));
ConnectAsync(new Uri($"ws://{host}:{port}/cultmesh"), CancellationToken.None)
.GetAwaiter().GetResult();
@ -46,7 +46,7 @@ public sealed class CultNetWebSocketSchemaClient : ICultNetUriSchemaClient, ICul
/// <summary>Connects to an exact WebSocket route.</summary>
public async Task ConnectAsync(Uri endpoint, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(endpoint);
if (endpoint == null) throw new ArgumentNullException(nameof(endpoint));
if (endpoint.Scheme is not ("ws" or "wss"))
throw new ArgumentException("CultNet WebSocket endpoint must use ws:// or wss://.", nameof(endpoint));
ThrowIfDisposed();
@ -69,7 +69,7 @@ public sealed class CultNetWebSocketSchemaClient : ICultNetUriSchemaClient, ICul
/// <inheritdoc />
public void SendCultNet<T>(T message) where T : ICultNetSchemaMessage
{
ArgumentNullException.ThrowIfNull(message);
if (message == null) throw new ArgumentNullException(nameof(message));
ThrowIfDisposed();
var socket = _socket;
if (socket?.State != WebSocketState.Open)
@ -92,7 +92,7 @@ public sealed class CultNetWebSocketSchemaClient : ICultNetUriSchemaClient, ICul
/// <inheritdoc />
public void OnCultNet<T>(Action<T> callback) where T : ICultNetSchemaMessage
{
ArgumentNullException.ThrowIfNull(callback);
if (callback == null) throw new ArgumentNullException(nameof(callback));
ThrowIfDisposed();
lock (_handlerGate)
{
@ -161,6 +161,6 @@ public sealed class CultNetWebSocketSchemaClient : ICultNetUriSchemaClient, ICul
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_disposed) throw new ObjectDisposedException(nameof(CultNetWebSocketSchemaClient));
}
}

View file

@ -178,44 +178,6 @@ public sealed class CultNetWebSocketSchemaServer :
}
}
internal static class CultNetWebSocketMessageIO
{
public static Task SendAsync(WebSocket socket, byte[] payload, CancellationToken cancellationToken) =>
socket.SendAsync(payload, WebSocketMessageType.Binary, true, cancellationToken);
public static async Task<byte[]?> ReceiveAsync(
WebSocket socket,
int maxMessageBytes,
CancellationToken cancellationToken)
{
var segment = new byte[Math.Min(16 * 1024, maxMessageBytes)];
using var message = new MemoryStream();
while (true)
{
var result = await socket.ReceiveAsync(segment, cancellationToken).ConfigureAwait(false);
if (result.MessageType == WebSocketMessageType.Close) return null;
if (result.MessageType != WebSocketMessageType.Binary)
{
await socket.CloseAsync(
WebSocketCloseStatus.InvalidMessageType,
"CultNet requires binary MessagePack WebSocket messages.",
cancellationToken).ConfigureAwait(false);
return null;
}
if (message.Length + result.Count > maxMessageBytes)
{
await socket.CloseAsync(
WebSocketCloseStatus.MessageTooBig,
"CultNet message exceeds the configured maximum.",
cancellationToken).ConfigureAwait(false);
return null;
}
message.Write(segment, 0, result.Count);
if (result.EndOfMessage) return message.ToArray();
}
}
}
/// <summary>ASP.NET Core endpoint mapping for the CultNet WebSocket schema server.</summary>
public static class CultNetWebSocketEndpointRouteBuilderExtensions
{

View file

@ -1,14 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFrameworks>net10.0;netstandard2.1</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Description>CultNet schema-v0 WebSocket host adapter for browser and web runtimes</Description>
<PackageId>GameCult.Networking.WebSockets</PackageId>
<PackageTags>game;networking;websocket;cultnet;cultmesh</PackageTags>
</PropertyGroup>
<ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<ProjectReference Include="..\GameCult.Networking\GameCult.Networking.csproj" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.1'">
<ProjectReference Include="..\GameCult.Networking\GameCult.Networking.csproj" />
<Compile Remove="CultNetWebSocketSchemaServer.cs" />
</ItemGroup>
</Project>

View file

@ -1,6 +1,10 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
@ -173,6 +177,54 @@ public sealed class CultMeshAuthorityProofTests
connector.Attempts.Should().Equal(valid.Endpoint);
}
[Test]
public async Task AuthenticatedRemoteContentUsesCertifiedHttpsAndStreamsTheAdvertisedChunk()
{
using var odin = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var provider = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var payload = Encoding.UTF8.GetBytes("certified-content");
var route = CultMeshAuthorityProof.CreateSignedRoute(
"aetheria", "aetheria-daemon", "https://provider.example/cultmesh/content",
new[] { CultMeshProtocols.Content.Value }, 0, "content-generation",
CultMeshEcdsaP256PublicKey.From("provider-content", provider), "odin-content",
Now.AddMinutes(-1), Now.AddMinutes(5), odin);
var descriptor = new CultMeshVerseDescriptor(
"aetheria", "Aetheria", CultMeshVerseAuthorityModel.OperatorCluster,
new CultMeshVerseCompatibility("cultmesh.v0", "rules"),
authorityRoutes: new[] { route });
using var discovery = new CultMeshDiscoveryService(new[] { new SignedRouteSource(descriptor) });
var handler = new FixedContentHandler(payload);
using var manager = new CultMeshSessionManager(
discovery,
Array.Empty<ICultMeshTransportConnector>(),
new ICultMeshContentTransportConnector[]
{
new CultMeshHttpsContentTransportConnector(() => handler)
},
new CultMeshSessionManagerOptions
{
Trust = new CultMeshAuthorityTrustPolicy(
CultMeshAuthorityTrustMode.AuthenticatedRemote,
new[] { CultMeshEcdsaP256PublicKey.From("odin-content", odin) }),
Clock = new FixedClock(Now)
});
var session = await manager.ConnectContentAsync(
new CultMeshSessionTarget("aetheria", "aetheria-daemon"));
using var destination = new MemoryStream();
await session.CopyChunkToAsync(new CultMeshCdnChunkRef
{
ChunkHash = Convert.ToHexString(SHA256.HashData(payload)).ToLowerInvariant(),
RecordKey = "cdn:chunk:certified",
SizeBytes = payload.Length
}, destination);
destination.ToArray().Should().Equal(payload);
handler.RequestUri.Should().NotBeNull();
handler.RequestUri!.Scheme.Should().Be(Uri.UriSchemeHttps);
handler.RequestUri.Query.Should().Contain("chunkHash=").And.Contain("recordKey=");
}
private static CultMeshAuthorityRoute SignedRoute(ECDsa odin, ECDsa provider) =>
CultMeshAuthorityProof.CreateSignedRoute(
"aetheria",
@ -326,4 +378,30 @@ public sealed class CultMeshAuthorityProofTests
}
public void Dispose() { }
}
private sealed class FixedContentHandler : HttpMessageHandler
{
private readonly byte[] _payload;
public FixedContentHandler(byte[] payload) => _payload = payload;
public Uri? RequestUri { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
RequestUri = request.RequestUri;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(_payload)
});
}
}
private sealed class FixedClock : ICultMeshClock
{
public FixedClock(DateTimeOffset now) => UtcNow = now;
public DateTimeOffset UtcNow { get; }
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken = default) =>
Task.Delay(delay, cancellationToken);
}
}

View file

@ -14,6 +14,7 @@
"GameCult.Mesh.dll",
"GameCult.Mesh.Quic.Native.dll",
"GameCult.Networking.dll",
"GameCult.Networking.WebSockets.dll",
"Isopoh.Cryptography.Argon2.dll",
"Isopoh.Cryptography.Blake2b.dll",
"Isopoh.Cryptography.SecureArray.dll",

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 78372006a7d6494ca38f58a78eb535a8

View file

@ -1,7 +1,7 @@
{
"name": "org.gamecult.cultlib",
"displayName": "CultLib",
"version": "1.0.48",
"version": "1.0.49",
"unity": "2021.3",
"description": "CultCache, CultNet, and CultMesh managed runtime assemblies for Unity.",
"license": "MIT",