diff --git a/Directory.Build.props b/Directory.Build.props
index 595da325..10c8d749 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -14,7 +14,7 @@
MIT
© Meta Vens and contributors
true
- 1.0.34
+ 1.0.35
$(CultLibPackageVersion)
diff --git a/samples/eve-browser-network/EveBrowserNetworkSample.csproj b/samples/eve-browser-network/EveBrowserNetworkSample.csproj
index 5dde9995..50b41781 100644
--- a/samples/eve-browser-network/EveBrowserNetworkSample.csproj
+++ b/samples/eve-browser-network/EveBrowserNetworkSample.csproj
@@ -5,7 +5,7 @@
enable
$(MSBuildThisFileDirectory)..\..\..\Eve
false
- 1.0.34
+ 1.0.35
0.3.3
$(PackageFeed)
diff --git a/scripts/build-unity-package.ps1 b/scripts/build-unity-package.ps1
index 08366cc5..c0e0518f 100644
--- a/scripts/build-unity-package.ps1
+++ b/scripts/build-unity-package.ps1
@@ -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) {
diff --git a/scripts/verify-eve-getting-started.ps1 b/scripts/verify-eve-getting-started.ps1
index 26c69f14..4a87b19d 100644
--- a/scripts/verify-eve-getting-started.ps1
+++ b/scripts/verify-eve-getting-started.ps1
@@ -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") `
diff --git a/src/GameCult.Mesh/CultMeshClient.cs b/src/GameCult.Mesh/CultMeshClient.cs
index 19a01c54..230ce55b 100644
--- a/src/GameCult.Mesh/CultMeshClient.cs
+++ b/src/GameCult.Mesh/CultMeshClient.cs
@@ -27,8 +27,8 @@ namespace GameCult.Mesh
public IReadOnlyList? Connectors { get; set; }
///
- /// 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.
///
public IReadOnlyList? ContentConnectors { get; set; }
@@ -184,6 +184,7 @@ namespace GameCult.Mesh
},
options.ContentConnectors ?? new ICultMeshContentTransportConnector[]
{
+ new CultMeshHttpsContentTransportConnector(),
new CultMeshTcpContentTransportConnector()
},
options.RealtimeConnectors,
diff --git a/src/GameCult.Mesh/CultMeshHttpsContentTransport.cs b/src/GameCult.Mesh/CultMeshHttpsContentTransport.cs
new file mode 100644
index 00000000..d6427470
--- /dev/null
+++ b/src/GameCult.Mesh/CultMeshHttpsContentTransport.cs
@@ -0,0 +1,124 @@
+using System;
+using System.IO;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace GameCult.Mesh
+{
+ ///
+ /// 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.
+ ///
+ public sealed class CultMeshHttpsContentTransportConnector : ICultMeshContentTransportConnector
+ {
+ private readonly Func _createHandler;
+
+ public CultMeshHttpsContentTransportConnector(Func? 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 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(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;
+ }
+ }
+}
diff --git a/src/GameCult.Networking.WebSockets/CultNetWebSocketMessageIO.cs b/src/GameCult.Networking.WebSockets/CultNetWebSocketMessageIO.cs
new file mode 100644
index 00000000..de10a66e
--- /dev/null
+++ b/src/GameCult.Networking.WebSockets/CultNetWebSocketMessageIO.cs
@@ -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(payload), WebSocketMessageType.Binary, true, cancellationToken);
+
+ public static async Task 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(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();
+ }
+ }
+}
diff --git a/src/GameCult.Networking.WebSockets/CultNetWebSocketSchemaClient.cs b/src/GameCult.Networking.WebSockets/CultNetWebSocketSchemaClient.cs
index 0a233ab7..394fe207 100644
--- a/src/GameCult.Networking.WebSockets/CultNetWebSocketSchemaClient.cs
+++ b/src/GameCult.Networking.WebSockets/CultNetWebSocketSchemaClient.cs
@@ -37,7 +37,7 @@ public sealed class CultNetWebSocketSchemaClient : ICultNetUriSchemaClient, ICul
///
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
/// Connects to an exact WebSocket route.
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
///
public void SendCultNet(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
///
public void OnCultNet(Action 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));
}
}
diff --git a/src/GameCult.Networking.WebSockets/CultNetWebSocketSchemaServer.cs b/src/GameCult.Networking.WebSockets/CultNetWebSocketSchemaServer.cs
index 240311a1..fa2728d8 100644
--- a/src/GameCult.Networking.WebSockets/CultNetWebSocketSchemaServer.cs
+++ b/src/GameCult.Networking.WebSockets/CultNetWebSocketSchemaServer.cs
@@ -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 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();
- }
- }
-}
-
/// ASP.NET Core endpoint mapping for the CultNet WebSocket schema server.
public static class CultNetWebSocketEndpointRouteBuilderExtensions
{
diff --git a/src/GameCult.Networking.WebSockets/GameCult.Networking.WebSockets.csproj b/src/GameCult.Networking.WebSockets/GameCult.Networking.WebSockets.csproj
index 6ced9b42..3144d7fe 100644
--- a/src/GameCult.Networking.WebSockets/GameCult.Networking.WebSockets.csproj
+++ b/src/GameCult.Networking.WebSockets/GameCult.Networking.WebSockets.csproj
@@ -1,14 +1,18 @@
- net10.0
+ net10.0;netstandard2.1
enable
enable
CultNet schema-v0 WebSocket host adapter for browser and web runtimes
GameCult.Networking.WebSockets
game;networking;websocket;cultnet;cultmesh
-
+
+
+
+
+
diff --git a/tests/GameCult.Mesh.Tests/CultMeshAuthorityProofTests.cs b/tests/GameCult.Mesh.Tests/CultMeshAuthorityProofTests.cs
index 246e510a..42f02297 100644
--- a/tests/GameCult.Mesh.Tests/CultMeshAuthorityProofTests.cs
+++ b/tests/GameCult.Mesh.Tests/CultMeshAuthorityProofTests.cs
@@ -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(),
+ 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 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);
+ }
}
diff --git a/unity/org.gamecult.cultlib/Runtime/GameCult.CultLib.asmdef b/unity/org.gamecult.cultlib/Runtime/GameCult.CultLib.asmdef
index 100db084..bd13cb38 100644
--- a/unity/org.gamecult.cultlib/Runtime/GameCult.CultLib.asmdef
+++ b/unity/org.gamecult.cultlib/Runtime/GameCult.CultLib.asmdef
@@ -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",
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 0e9e063f..2fdc1ff9 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 56d08c92..1b4555df 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 98c10206..4d90585e 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 6213e7e8..345abea5 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 d1fe789e..d37534a3 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 27bf8a8a..fa1b1cb4 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 c7ef8430..a7c40f95 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 f2a26092..a73f4ef6 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 57024e4f..5675cc68 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 acac39f4..e9bcee25 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
new file mode 100644
index 00000000..233edd88
Binary files /dev/null and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.WebSockets.dll differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.WebSockets.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.WebSockets.dll.meta
new file mode 100644
index 00000000..3679db12
--- /dev/null
+++ b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.WebSockets.dll.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 78372006a7d6494ca38f58a78eb535a8
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll
index ee85d89f..eab06cce 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 09055758..bbacfe84 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/Runtime/Plugins/x86_64/gamecult_mesh_quic_native.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/gamecult_mesh_quic_native.dll
index 91133b5a..6805f175 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/gamecult_mesh_quic_native.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/gamecult_mesh_quic_native.dll differ
diff --git a/unity/org.gamecult.cultlib/package.json b/unity/org.gamecult.cultlib/package.json
index b0a00486..b957b18c 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.48",
+ "version": "1.0.49",
"unity": "2021.3",
"description": "CultCache, CultNet, and CultMesh managed runtime assemblies for Unity.",
"license": "MIT",