diff --git a/native/GameCult.Mesh.Quic.Native/CMakeLists.txt b/native/GameCult.Mesh.Quic.Native/CMakeLists.txt new file mode 100644 index 00000000..2850951d --- /dev/null +++ b/native/GameCult.Mesh.Quic.Native/CMakeLists.txt @@ -0,0 +1,20 @@ +cmake_minimum_required(VERSION 3.24) +project(gamecult_mesh_quic_native LANGUAGES CXX) + +if(NOT WIN32) + message(FATAL_ERROR "The current CultMesh native QUIC connector supports Windows desktop only") +endif() +if(NOT DEFINED MSQUIC_ROOT) + message(FATAL_ERROR "MSQUIC_ROOT must point at the extracted Microsoft.Native.Quic.MsQuic.Schannel package build/native directory") +endif() + +add_library(gamecult_mesh_quic_native SHARED cultmesh_quic_native.cpp) +target_compile_features(gamecult_mesh_quic_native PRIVATE cxx_std_20) +target_compile_definitions(gamecult_mesh_quic_native PRIVATE GAMECULT_MESH_QUIC_NATIVE_EXPORTS WIN32_LEAN_AND_MEAN NOMINMAX) +target_include_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_ROOT}/include") +target_link_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_ROOT}/lib/x64") +target_link_libraries(gamecult_mesh_quic_native PRIVATE msquic crypt32 bcrypt) + +set_target_properties(gamecult_mesh_quic_native PROPERTIES + OUTPUT_NAME "gamecult_mesh_quic_native" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") diff --git a/native/GameCult.Mesh.Quic.Native/README.md b/native/GameCult.Mesh.Quic.Native/README.md new file mode 100644 index 00000000..875ebb3b --- /dev/null +++ b/native/GameCult.Mesh.Quic.Native/README.md @@ -0,0 +1,10 @@ +# GameCult.Mesh.Quic.Native + +This Windows desktop bridge keeps MsQuic's callback-heavy C API below the +managed runtime boundary. It accepts a discovery-authorized SHA-256 certificate +pin, negotiates `cultmesh-state-v1`, and emits complete CultMesh realtime frame +payloads. It owns transport only; frame meaning remains in `GameCult.Mesh`. + +The build consumes Microsoft's pinned +`Microsoft.Native.Quic.MsQuic.Schannel` package. It does not contain +Aetheria-specific code. diff --git a/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp b/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp new file mode 100644 index 00000000..8da12aed --- /dev/null +++ b/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp @@ -0,0 +1,377 @@ +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(GAMECULT_MESH_QUIC_NATIVE_EXPORTS) +#define CULTMESH_API extern "C" __declspec(dllexport) +#else +#define CULTMESH_API extern "C" __declspec(dllimport) +#endif + +namespace { + +constexpr uint32_t kApiVersion = 2; +constexpr uint64_t kConnectionCloseCode = 0x43554c54; +constexpr uint64_t kStreamAbortCode = 0x53544154; +constexpr uint8_t kReliableStream = 1; +constexpr uint8_t kLatestOnlyStream = 2; +constexpr uint32_t kMaximumEncodedFrameBytes = (64u * 1024u * 1024u) + 37u + (3u * 65535u); +constexpr char kAlpn[] = "cultmesh-state-v1"; + +enum class ClientState : int32_t { + Connecting = 0, + Connected = 1, + Failed = 2, + Closed = 3, +}; + +struct Client; + +struct StreamContext { + explicit StreamContext(Client* client) : owner(client) {} + Client* owner; + std::vector buffered; + uint8_t kind = 0; + bool has_kind = false; +}; + +struct Client { + const QUIC_API_TABLE* api = nullptr; + HQUIC registration = nullptr; + HQUIC configuration = nullptr; + HQUIC connection = nullptr; + std::array certificate_pin{}; + std::atomic state{ClientState::Connecting}; + std::atomic closing{false}; + std::mutex gate; + std::deque> received; + std::string error; +}; + +void SetFailure(Client* client, const std::string& message) { + if (client == nullptr || client->closing.load()) return; + { + std::lock_guard lock(client->gate); + if (client->error.empty()) client->error = message; + } + client->state.store(ClientState::Failed); +} + +bool ParseHexNibble(char value, uint8_t& nibble) { + if (value >= '0' && value <= '9') nibble = static_cast(value - '0'); + else if (value >= 'a' && value <= 'f') nibble = static_cast(value - 'a' + 10); + else if (value >= 'A' && value <= 'F') nibble = static_cast(value - 'A' + 10); + else return false; + return true; +} + +bool ParsePin(const char* value, std::array& pin) { + if (value == nullptr || std::strlen(value) != 64) return false; + for (size_t index = 0; index < pin.size(); ++index) { + uint8_t high = 0; + uint8_t low = 0; + if (!ParseHexNibble(value[index * 2], high) || !ParseHexNibble(value[(index * 2) + 1], low)) + return false; + pin[index] = static_cast((high << 4) | low); + } + return true; +} + +uint32_t ReadUInt32LittleEndian(const uint8_t* value) { + return static_cast(value[0]) | + (static_cast(value[1]) << 8) | + (static_cast(value[2]) << 16) | + (static_cast(value[3]) << 24); +} + +void FailStream(StreamContext* stream, const char* message) { + SetFailure(stream->owner, message); + if (stream->owner != nullptr && stream->owner->api != nullptr && stream->owner->connection != nullptr) + stream->owner->api->ConnectionShutdown( + stream->owner->connection, + QUIC_CONNECTION_SHUTDOWN_FLAG_SILENT, + kStreamAbortCode); +} + +bool ConsumeFrames(StreamContext* stream) { + auto& bytes = stream->buffered; + if (!stream->has_kind) { + if (bytes.empty()) return true; + stream->kind = bytes.front(); + bytes.erase(bytes.begin()); + stream->has_kind = true; + if (stream->kind != kReliableStream && stream->kind != kLatestOnlyStream) { + FailStream(stream, "CultMesh QUIC stream kind is invalid."); + return false; + } + } + + while (bytes.size() >= sizeof(uint32_t)) { + const uint32_t length = ReadUInt32LittleEndian(bytes.data()); + if (length == 0 || length > kMaximumEncodedFrameBytes) { + FailStream(stream, "CultMesh QUIC frame length is invalid."); + return false; + } + const size_t framed_length = sizeof(uint32_t) + static_cast(length); + if (bytes.size() < framed_length) return true; + std::vector frame(length); + std::memcpy(frame.data(), bytes.data() + sizeof(uint32_t), length); + { + std::lock_guard lock(stream->owner->gate); + stream->owner->received.emplace_back(std::move(frame)); + } + bytes.erase(bytes.begin(), bytes.begin() + static_cast(framed_length)); + if (stream->kind == kLatestOnlyStream && !bytes.empty()) { + FailStream(stream, "CultMesh latest-only QUIC stream carried multiple frames."); + return false; + } + } + return true; +} + +QUIC_STATUS QUIC_API StreamCallback(HQUIC stream_handle, void* context, QUIC_STREAM_EVENT* event) { + auto* stream = static_cast(context); + switch (event->Type) { + case QUIC_STREAM_EVENT_RECEIVE: + for (uint32_t index = 0; index < event->RECEIVE.BufferCount; ++index) { + const auto& buffer = event->RECEIVE.Buffers[index]; + stream->buffered.insert(stream->buffered.end(), buffer.Buffer, buffer.Buffer + buffer.Length); + } + ConsumeFrames(stream); + break; + case QUIC_STREAM_EVENT_PEER_SEND_SHUTDOWN: + if (!stream->buffered.empty()) + FailStream(stream, "CultMesh QUIC stream ended with a truncated frame."); + break; + case QUIC_STREAM_EVENT_SHUTDOWN_COMPLETE: + if (!event->SHUTDOWN_COMPLETE.AppCloseInProgress) + stream->owner->api->StreamClose(stream_handle); + delete stream; + break; + default: + break; + } + return QUIC_STATUS_SUCCESS; +} + +bool CertificateMatchesPin(Client* client, QUIC_CERTIFICATE* certificate) { + if (certificate == nullptr) return false; + const auto* encoded = reinterpret_cast(certificate); + if (encoded->Buffer == nullptr || encoded->Length == 0) return false; + BYTE digest[32]{}; + DWORD digest_length = sizeof(digest); + if (!CryptHashCertificate2( + BCRYPT_SHA256_ALGORITHM, + 0, + nullptr, + encoded->Buffer, + encoded->Length, + digest, + &digest_length) || digest_length != sizeof(digest)) + return false; + return std::memcmp(digest, client->certificate_pin.data(), sizeof(digest)) == 0; +} + +QUIC_STATUS QUIC_API ConnectionCallback(HQUIC connection, void* context, QUIC_CONNECTION_EVENT* event) { + auto* client = static_cast(context); + switch (event->Type) { + case QUIC_CONNECTION_EVENT_CONNECTED: + client->state.store(ClientState::Connected); + break; + case QUIC_CONNECTION_EVENT_PEER_STREAM_STARTED: { + auto* stream = new (std::nothrow) StreamContext(client); + if (stream == nullptr) return QUIC_STATUS_OUT_OF_MEMORY; + client->api->SetCallbackHandler( + event->PEER_STREAM_STARTED.Stream, + reinterpret_cast(StreamCallback), + stream); + break; + } + case QUIC_CONNECTION_EVENT_PEER_CERTIFICATE_RECEIVED: + if (!CertificateMatchesPin(client, event->PEER_CERTIFICATE_RECEIVED.Certificate)) { + SetFailure(client, "CultMesh QUIC provider certificate does not match the advertised SHA-256 pin."); + return QUIC_STATUS_BAD_CERTIFICATE; + } + return QUIC_STATUS_SUCCESS; + case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_TRANSPORT: + if (!client->closing.load()) + SetFailure(client, "CultMesh QUIC connection was shut down by the transport."); + break; + case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_PEER: + if (!client->closing.load()) + SetFailure(client, "CultMesh QUIC connection was shut down by the provider."); + break; + case QUIC_CONNECTION_EVENT_SHUTDOWN_COMPLETE: + if (client->closing.load()) client->state.store(ClientState::Closed); + break; + default: + break; + } + return QUIC_STATUS_SUCCESS; +} + +void CloseHandles(Client* client) { + if (client->connection != nullptr) { + client->api->ConnectionClose(client->connection); + client->connection = nullptr; + } + if (client->configuration != nullptr) { + client->api->ConfigurationClose(client->configuration); + client->configuration = nullptr; + } + if (client->registration != nullptr) { + client->api->RegistrationClose(client->registration); + client->registration = nullptr; + } + if (client->api != nullptr) { + MsQuicClose(client->api); + client->api = nullptr; + } +} + +} // namespace + +CULTMESH_API int32_t cultmesh_quic_open( + const char* host, + uint16_t port, + const char* certificate_sha256, + Client** result) { + if (host == nullptr || *host == '\0' || port == 0 || result == nullptr) return -1; + *result = nullptr; + auto client = std::make_unique(); + if (!ParsePin(certificate_sha256, client->certificate_pin)) return -2; + + QUIC_STATUS status = MsQuicOpenVersion( + kApiVersion, + reinterpret_cast(&client->api)); + if (QUIC_FAILED(status)) return status; + + const QUIC_REGISTRATION_CONFIG registration_config = { + "GameCult.Mesh.Quic.Native", + QUIC_EXECUTION_PROFILE_TYPE_REAL_TIME + }; + status = client->api->RegistrationOpen(®istration_config, &client->registration); + if (QUIC_FAILED(status)) { + CloseHandles(client.get()); + return status; + } + + QUIC_SETTINGS settings{}; + settings.IdleTimeoutMs = 30000; + settings.IsSet.IdleTimeoutMs = TRUE; + settings.KeepAliveIntervalMs = 5000; + settings.IsSet.KeepAliveIntervalMs = TRUE; + settings.PeerUnidiStreamCount = 1024; + settings.IsSet.PeerUnidiStreamCount = TRUE; + const QUIC_BUFFER alpn = { + static_cast(sizeof(kAlpn) - 1), + reinterpret_cast(const_cast(kAlpn)) + }; + status = client->api->ConfigurationOpen( + client->registration, + &alpn, + 1, + &settings, + sizeof(settings), + nullptr, + &client->configuration); + if (QUIC_FAILED(status)) { + CloseHandles(client.get()); + return status; + } + + QUIC_CREDENTIAL_CONFIG credentials{}; + credentials.Type = QUIC_CREDENTIAL_TYPE_NONE; + credentials.Flags = static_cast( + QUIC_CREDENTIAL_FLAG_CLIENT | + QUIC_CREDENTIAL_FLAG_INDICATE_CERTIFICATE_RECEIVED | + QUIC_CREDENTIAL_FLAG_DEFER_CERTIFICATE_VALIDATION | + QUIC_CREDENTIAL_FLAG_USE_PORTABLE_CERTIFICATES); + status = client->api->ConfigurationLoadCredential(client->configuration, &credentials); + if (QUIC_FAILED(status)) { + CloseHandles(client.get()); + return status; + } + + status = client->api->ConnectionOpen( + client->registration, + ConnectionCallback, + client.get(), + &client->connection); + if (QUIC_FAILED(status)) { + CloseHandles(client.get()); + return status; + } + status = client->api->ConnectionStart( + client->connection, + client->configuration, + QUIC_ADDRESS_FAMILY_UNSPEC, + host, + port); + if (QUIC_FAILED(status)) { + CloseHandles(client.get()); + return status; + } + + *result = client.release(); + return 0; +} + +CULTMESH_API int32_t cultmesh_quic_state(Client* client) { + if (client == nullptr) return static_cast(ClientState::Failed); + return static_cast(client->state.load()); +} + +CULTMESH_API int32_t cultmesh_quic_poll( + Client* client, + uint8_t* destination, + int32_t destination_length, + int32_t* required_length) { + if (client == nullptr || required_length == nullptr || destination_length < 0) return -1; + std::lock_guard lock(client->gate); + if (client->received.empty()) { + *required_length = 0; + return client->state.load() == ClientState::Failed ? -2 : 0; + } + const auto& frame = client->received.front(); + if (frame.size() > static_cast(INT32_MAX)) return -3; + *required_length = static_cast(frame.size()); + if (destination == nullptr || destination_length < *required_length) return 2; + std::memcpy(destination, frame.data(), frame.size()); + client->received.pop_front(); + return 1; +} + +CULTMESH_API int32_t cultmesh_quic_error( + Client* client, + char* destination, + int32_t destination_length) { + if (client == nullptr || destination == nullptr || destination_length <= 0) return -1; + std::lock_guard lock(client->gate); + const size_t count = (std::min)(client->error.size(), static_cast(destination_length - 1)); + std::memcpy(destination, client->error.data(), count); + destination[count] = '\0'; + return static_cast(count); +} + +CULTMESH_API void cultmesh_quic_close(Client* client) { + if (client == nullptr) return; + client->closing.store(true); + if (client->connection != nullptr && client->api != nullptr) + client->api->ConnectionShutdown(client->connection, QUIC_CONNECTION_SHUTDOWN_FLAG_SILENT, kConnectionCloseCode); + CloseHandles(client); + client->state.store(ClientState::Closed); + delete client; +} diff --git a/scripts/build-quic-native.ps1 b/scripts/build-quic-native.ps1 new file mode 100644 index 00000000..2de12bde --- /dev/null +++ b/scripts/build-quic-native.ps1 @@ -0,0 +1,54 @@ +param( + [string] $Configuration = "Release", + [string] $Architecture = "x64", + [string] $OutputDirectory = "artifacts\quic-native" +) + +$ErrorActionPreference = "Stop" +if ($Architecture -ne "x64") { + throw "The first CultMesh native QUIC package supports x64 Windows desktop only." +} + +$repoRoot = Split-Path -Parent $PSScriptRoot +$version = "2.5.9" +$expectedPackageSha256 = "9877A919E1AA73AA4800F1E8A06B6539021F376228910138EB04540CD956617B" +$cacheRoot = Join-Path $repoRoot "artifacts\dependencies\msquic-schannel-$version" +$packagePath = Join-Path $cacheRoot "Microsoft.Native.Quic.MsQuic.Schannel.$version.zip" +$extractRoot = Join-Path $cacheRoot "package" +$nativeRoot = Join-Path $extractRoot "build\native" +$buildRoot = Join-Path $repoRoot "artifacts\quic-native-build\$Architecture\$Configuration" +$outputRoot = if ([IO.Path]::IsPathRooted($OutputDirectory)) { + $OutputDirectory +} else { + Join-Path $repoRoot $OutputDirectory +} + +New-Item -ItemType Directory -Force -Path $cacheRoot | Out-Null +if (-not (Test-Path -LiteralPath $packagePath)) { + & curl.exe -L --fail --silent --show-error ` + -o $packagePath ` + "https://www.nuget.org/api/v2/package/Microsoft.Native.Quic.MsQuic.Schannel/$version" + if ($LASTEXITCODE -ne 0) { throw "MsQuic package download failed with exit code $LASTEXITCODE" } +} +if ((Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash -ne $expectedPackageSha256) { + throw "MsQuic package digest does not match the pinned release." +} +if (-not (Test-Path -LiteralPath (Join-Path $nativeRoot "include\msquic.h"))) { + if (Test-Path -LiteralPath $extractRoot) { Remove-Item -LiteralPath $extractRoot -Recurse -Force } + Expand-Archive -LiteralPath $packagePath -DestinationPath $extractRoot +} + +cmake -S (Join-Path $repoRoot "native\GameCult.Mesh.Quic.Native") -B $buildRoot -A x64 ` + "-DMSQUIC_ROOT=$($nativeRoot.Replace('\', '/'))" +if ($LASTEXITCODE -ne 0) { throw "CultMesh native QUIC configure failed with exit code $LASTEXITCODE" } +cmake --build $buildRoot --config $Configuration +if ($LASTEXITCODE -ne 0) { throw "CultMesh native QUIC build failed with exit code $LASTEXITCODE" } + +New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null +Copy-Item -LiteralPath (Join-Path $buildRoot "bin\$Configuration\gamecult_mesh_quic_native.dll") ` + -Destination $outputRoot -Force +Copy-Item -LiteralPath (Join-Path $nativeRoot "bin\x64\msquic.dll") -Destination $outputRoot -Force +Copy-Item -LiteralPath (Join-Path $extractRoot "LICENSE") -Destination (Join-Path $outputRoot "MSQUIC-LICENSE.txt") -Force + +Write-Host "CultMesh native QUIC runtime: $outputRoot" +Get-ChildItem -LiteralPath $outputRoot -File | Select-Object Name,Length diff --git a/scripts/build-unity-package.ps1 b/scripts/build-unity-package.ps1 index 9b8a5676..bb12e0c6 100644 --- a/scripts/build-unity-package.ps1 +++ b/scripts/build-unity-package.ps1 @@ -8,8 +8,11 @@ $ErrorActionPreference = "Stop" $repoRoot = Split-Path -Parent $PSScriptRoot $projectPath = Join-Path $repoRoot "src\GameCult.Mesh\GameCult.Mesh.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" +$quicPublishRoot = Join-Path $repoRoot "artifacts\unity-quic-publish" +$quicNativeRoot = Join-Path $repoRoot "artifacts\unity-quic-native" $outputRoot = if ([System.IO.Path]::IsPathRooted($OutputDirectory)) { $OutputDirectory } else { @@ -20,6 +23,9 @@ $pluginRoot = Join-Path $outputRoot "Runtime\Plugins" if (Test-Path -LiteralPath $publishRoot) { Remove-Item -LiteralPath $publishRoot -Recurse -Force } +if (Test-Path -LiteralPath $quicPublishRoot) { + Remove-Item -LiteralPath $quicPublishRoot -Recurse -Force +} if (Test-Path -LiteralPath $outputRoot) { Remove-Item -LiteralPath $outputRoot -Recurse -Force } @@ -28,6 +34,13 @@ dotnet publish $projectPath -c $Configuration -o $publishRoot if ($LASTEXITCODE -ne 0) { throw "CultLib publish failed with exit code $LASTEXITCODE" } +dotnet publish $quicProjectPath -c $Configuration -o $quicPublishRoot +if ($LASTEXITCODE -ne 0) { + throw "CultLib native QUIC managed publish failed with exit code $LASTEXITCODE" +} +& (Join-Path $PSScriptRoot "build-quic-native.ps1") ` + -Configuration $Configuration ` + -OutputDirectory $quicNativeRoot New-Item -ItemType Directory -Force -Path $pluginRoot | Out-Null Copy-Item -LiteralPath (Join-Path $templateRoot "package.json") -Destination $outputRoot @@ -39,6 +52,7 @@ $expectedAssemblies = @( "GameCult.Caching.MessagePack.dll", "GameCult.Logging.dll", "GameCult.Mesh.dll", + "GameCult.Mesh.Quic.Native.dll", "GameCult.Networking.dll", "Isopoh.Cryptography.Argon2.dll", "Isopoh.Cryptography.Blake2b.dll", @@ -62,6 +76,9 @@ $publishedByName = @{} foreach ($assembly in Get-ChildItem -LiteralPath $publishRoot -Filter "*.dll") { $publishedByName[$assembly.Name] = $assembly } +foreach ($assembly in Get-ChildItem -LiteralPath $quicPublishRoot -Filter "*.dll") { + $publishedByName[$assembly.Name] = $assembly +} foreach ($assemblyName in $expectedAssemblies) { if (-not $publishedByName.ContainsKey($assemblyName)) { throw "CultLib Unity package is missing expected assembly: $assemblyName" @@ -78,6 +95,13 @@ foreach ($assemblyName in $expectedAssemblies) { Copy-Item -LiteralPath $pdb -Destination $pluginRoot } } +$nativePluginRoot = Join-Path $pluginRoot "x86_64" +New-Item -ItemType Directory -Force -Path $nativePluginRoot | Out-Null +Copy-Item -LiteralPath (Join-Path $quicNativeRoot "gamecult_mesh_quic_native.dll") -Destination $nativePluginRoot +Copy-Item -LiteralPath (Join-Path $quicNativeRoot "msquic.dll") -Destination $nativePluginRoot +New-Item -ItemType Directory -Force -Path (Join-Path $outputRoot "Third Party Notices") | Out-Null +Copy-Item -LiteralPath (Join-Path $quicNativeRoot "MSQUIC-LICENSE.txt") ` + -Destination (Join-Path $outputRoot "Third Party Notices\MSQUIC-LICENSE.txt") if ($UpdateTemplate) { $templatePluginRoot = Join-Path $templateRoot "Runtime\Plugins" @@ -89,6 +113,16 @@ if ($UpdateTemplate) { Copy-Item -LiteralPath $pdb -Destination $templatePluginRoot -Force } } + $templateNativePluginRoot = Join-Path $templatePluginRoot "x86_64" + New-Item -ItemType Directory -Force -Path $templateNativePluginRoot | Out-Null + Copy-Item -LiteralPath (Join-Path $nativePluginRoot "gamecult_mesh_quic_native.dll") ` + -Destination $templateNativePluginRoot -Force + Copy-Item -LiteralPath (Join-Path $nativePluginRoot "msquic.dll") ` + -Destination $templateNativePluginRoot -Force + $templateNoticesRoot = Join-Path $templateRoot "Third Party Notices" + New-Item -ItemType Directory -Force -Path $templateNoticesRoot | Out-Null + Copy-Item -LiteralPath (Join-Path $outputRoot "Third Party Notices\MSQUIC-LICENSE.txt") ` + -Destination $templateNoticesRoot -Force Write-Host "Updated tracked Unity package assemblies: $templatePluginRoot" } @@ -96,3 +130,4 @@ $manifest = Get-Content -LiteralPath (Join-Path $outputRoot "package.json") -Raw Write-Host "CultLib Unity package: $outputRoot" Write-Host "Package: $($manifest.name)@$($manifest.version)" Write-Host "Managed assemblies: $($expectedAssemblies.Count)" +Write-Host "Native realtime: MsQuic Schannel 2.5.9 (Windows x64)" diff --git a/src/GameCult.Mesh.Quic.Native/CultMeshNativeQuicRealtimeTransport.cs b/src/GameCult.Mesh.Quic.Native/CultMeshNativeQuicRealtimeTransport.cs new file mode 100644 index 00000000..d687d5b6 --- /dev/null +++ b/src/GameCult.Mesh.Quic.Native/CultMeshNativeQuicRealtimeTransport.cs @@ -0,0 +1,257 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GameCult.Mesh; + +namespace GameCult.Mesh.Quic.Native +{ + public sealed class CultMeshNativeQuicRealtimeConnectorOptions + { + public TimeSpan HandshakeTimeout { get; set; } = TimeSpan.FromSeconds(10); + public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds(2); + } + + /// + /// Creates receive-capable MsQuic sessions on Unity-compatible managed runtimes. + /// Commands and receipts remain on their typed control plane. + /// + public sealed class CultMeshNativeQuicRealtimeTransportConnector : ICultMeshRealtimeTransportConnector + { + public const string Scheme = "cultmesh-state+quic"; + private readonly CultMeshNativeQuicRealtimeConnectorOptions _options; + + public CultMeshNativeQuicRealtimeTransportConnector( + CultMeshNativeQuicRealtimeConnectorOptions? options = null) + { + _options = options ?? new CultMeshNativeQuicRealtimeConnectorOptions(); + if (_options.HandshakeTimeout <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(options), "QUIC handshake timeout must be positive."); + if (_options.PollInterval <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(options), "QUIC poll interval must be positive."); + } + + public string ConnectorId => "msquic-native-realtime"; + public int Priority => 0; + + public bool CanConnect(CultMeshTransportCandidate candidate) => + candidate != null && RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && + TryParseEndpoint(candidate.Endpoint, out _, out _, out _); + + public async Task ConnectAsync( + CultMeshTransportCandidate candidate, + CultMeshEndpointId endpointId, + CancellationToken cancellationToken = default) + { + if (candidate == null) throw new ArgumentNullException(nameof(candidate)); + if (endpointId == null) throw new ArgumentNullException(nameof(endpointId)); + if (!TryParseEndpoint(candidate.Endpoint, out var host, out var port, out var pin)) + throw new NotSupportedException($"Native QUIC connector does not support '{candidate.Endpoint}'."); + + IntPtr handle; + int opened; + try + { + opened = NativeMethods.Open(host, checked((ushort)port), pin, out handle); + } + catch (DllNotFoundException error) + { + throw new PlatformNotSupportedException( + "CultMesh native QUIC requires gamecult_mesh_quic_native and msquic beside the managed runtime.", + error); + } + catch (EntryPointNotFoundException error) + { + throw new PlatformNotSupportedException("The installed CultMesh native QUIC bridge is incompatible.", error); + } + if (opened != 0 || handle == IntPtr.Zero) + throw new IOException($"CultMesh native QUIC initialization failed with status 0x{opened:X8}."); + + var transport = new CultMeshNativeQuicRealtimeTransport( + candidate.Endpoint, + handle, + _options.PollInterval); + try + { + await transport.WaitUntilConnectedAsync(_options.HandshakeTimeout, cancellationToken).ConfigureAwait(false); + return transport; + } + catch + { + transport.Dispose(); + throw; + } + } + + private static bool TryParseEndpoint( + string? endpoint, + out string host, + out int port, + out string certificatePin) + { + host = string.Empty; + port = 0; + certificatePin = string.Empty; + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) || + !string.Equals(uri.Scheme, Scheme, StringComparison.OrdinalIgnoreCase) || + string.IsNullOrWhiteSpace(uri.Host) || uri.Port <= 0) + return false; + var pin = ParseQueryValue(uri.Query, "cert-sha256"); + if (pin == null || pin.Length != 64) return false; + for (var index = 0; index < pin.Length; index++) + if (!Uri.IsHexDigit(pin[index])) return false; + host = uri.Host; + port = uri.Port; + certificatePin = pin.ToUpperInvariant(); + return true; + } + + private static string? ParseQueryValue(string query, string key) + { + foreach (var component in query.TrimStart('?').Split(new[] { '&' }, StringSplitOptions.RemoveEmptyEntries)) + { + var separator = component.IndexOf('='); + var candidateKey = separator < 0 ? component : component.Substring(0, separator); + if (!string.Equals(Uri.UnescapeDataString(candidateKey), key, StringComparison.OrdinalIgnoreCase)) + continue; + return separator < 0 ? string.Empty : Uri.UnescapeDataString(component.Substring(separator + 1)); + } + return null; + } + } + + internal sealed class CultMeshNativeQuicRealtimeTransport : ICultMeshRealtimeTransport + { + private readonly object _gate = new object(); + private readonly TimeSpan _pollInterval; + private IntPtr _handle; + private bool _disposed; + + public CultMeshNativeQuicRealtimeTransport(string endpoint, IntPtr handle, TimeSpan pollInterval) + { + Endpoint = endpoint; + _handle = handle; + _pollInterval = pollInterval; + } + + public string TransportId => "msquic-native-realtime"; + public string Endpoint { get; } + + public Task SendAsync(CultMeshRealtimeFrame frame, CancellationToken cancellationToken = default) => + Task.FromException(new NotSupportedException( + "The Unity-compatible native QUIC connector currently owns provider-to-client state only.")); + + public async Task ReceiveAsync(CancellationToken cancellationToken = default) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + byte[]? bytes = null; + lock (_gate) + { + ThrowIfDisposed(); + var state = NativeMethods.State(_handle); + if (state == 2) throw new IOException(ReadNativeError(_handle)); + var first = NativeMethods.Poll(_handle, IntPtr.Zero, 0, out var required); + if (first < 0) throw new IOException(ReadNativeError(_handle)); + if (first == 2 && required > 0) + { + bytes = new byte[required]; + var pinned = GCHandle.Alloc(bytes, GCHandleType.Pinned); + try + { + var second = NativeMethods.Poll( + _handle, + pinned.AddrOfPinnedObject(), + bytes.Length, + out var received); + if (second != 1 || received != bytes.Length) + throw new IOException("CultMesh native QUIC frame changed while it was acquired."); + } + finally + { + pinned.Free(); + } + } + } + if (bytes != null) return CultMeshRealtimeWireProtocol.DecodeFrame(bytes); + await Task.Delay(_pollInterval, cancellationToken).ConfigureAwait(false); + } + } + + internal async Task WaitUntilConnectedAsync(TimeSpan timeout, CancellationToken cancellationToken) + { + var deadline = DateTimeOffset.UtcNow + timeout; + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + lock (_gate) + { + ThrowIfDisposed(); + var state = NativeMethods.State(_handle); + if (state == 1) return; + if (state == 2) throw new IOException(ReadNativeError(_handle)); + } + if (DateTimeOffset.UtcNow >= deadline) + throw new TimeoutException($"CultMesh native QUIC handshake with '{Endpoint}' timed out."); + await Task.Delay(_pollInterval, cancellationToken).ConfigureAwait(false); + } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) return; + _disposed = true; + if (_handle != IntPtr.Zero) NativeMethods.Close(_handle); + _handle = IntPtr.Zero; + } + } + + private void ThrowIfDisposed() + { + if (_disposed || _handle == IntPtr.Zero) + throw new ObjectDisposedException(nameof(CultMeshNativeQuicRealtimeTransport)); + } + + private static string ReadNativeError(IntPtr handle) + { + var bytes = new byte[1024]; + var pinned = GCHandle.Alloc(bytes, GCHandleType.Pinned); + try + { + var count = NativeMethods.Error(handle, pinned.AddrOfPinnedObject(), bytes.Length); + return count > 0 + ? Encoding.UTF8.GetString(bytes, 0, count) + : "CultMesh native QUIC transport failed."; + } + finally + { + pinned.Free(); + } + } + } + + internal static class NativeMethods + { + private const string Library = "gamecult_mesh_quic_native"; + + [DllImport(Library, EntryPoint = "cultmesh_quic_open", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)] + internal static extern int Open(string host, ushort port, string certificateSha256, out IntPtr client); + + [DllImport(Library, EntryPoint = "cultmesh_quic_state", CallingConvention = CallingConvention.Cdecl)] + internal static extern int State(IntPtr client); + + [DllImport(Library, EntryPoint = "cultmesh_quic_poll", CallingConvention = CallingConvention.Cdecl)] + internal static extern int Poll(IntPtr client, IntPtr destination, int destinationLength, out int requiredLength); + + [DllImport(Library, EntryPoint = "cultmesh_quic_error", CallingConvention = CallingConvention.Cdecl)] + internal static extern int Error(IntPtr client, IntPtr destination, int destinationLength); + + [DllImport(Library, EntryPoint = "cultmesh_quic_close", CallingConvention = CallingConvention.Cdecl)] + internal static extern void Close(IntPtr client); + } +} diff --git a/src/GameCult.Mesh.Quic.Native/GameCult.Mesh.Quic.Native.csproj b/src/GameCult.Mesh.Quic.Native/GameCult.Mesh.Quic.Native.csproj new file mode 100644 index 00000000..5a9f2bc0 --- /dev/null +++ b/src/GameCult.Mesh.Quic.Native/GameCult.Mesh.Quic.Native.csproj @@ -0,0 +1,13 @@ + + + netstandard2.1 + enable + disable + GameCult.Mesh.Quic.Native + GameCult.Mesh.Quic.Native + Native MsQuic CultMesh realtime connector for Unity and other netstandard runtimes. + + + + + diff --git a/src/GameCult.Mesh.Quic/CultMeshQuicRealtimeTransport.cs b/src/GameCult.Mesh.Quic/CultMeshQuicRealtimeTransport.cs index 15b4a1c4..b9c6f3c6 100644 --- a/src/GameCult.Mesh.Quic/CultMeshQuicRealtimeTransport.cs +++ b/src/GameCult.Mesh.Quic/CultMeshQuicRealtimeTransport.cs @@ -6,7 +6,6 @@ using System.Net.Security; using System.Security.Authentication; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; -using System.Text; using System.Threading.Channels; namespace GameCult.Mesh.Quic; @@ -539,79 +538,18 @@ internal sealed class CultMeshRealtimeInbox internal static class CultMeshQuicRealtimeProtocol { - public static readonly SslApplicationProtocol ApplicationProtocol = new("cultmesh-state-v1"); - public const long ConnectionCloseCode = 0x43554c54; - public const long StreamAbortCode = 0x53544154; - public const byte ReliableStream = 1; - public const byte LatestOnlyStream = 2; - private const uint Magic = 0x31545343; - private const int FixedHeaderBytes = 37; - private const int MaximumFrameBytes = 64 * 1024 * 1024; + public static readonly SslApplicationProtocol ApplicationProtocol = + new(CultMeshRealtimeWireProtocol.ApplicationProtocolName); + public const long ConnectionCloseCode = CultMeshRealtimeWireProtocol.ConnectionCloseCode; + public const long StreamAbortCode = CultMeshRealtimeWireProtocol.StreamAbortCode; + public const byte ReliableStream = CultMeshRealtimeWireProtocol.ReliableStream; + public const byte LatestOnlyStream = CultMeshRealtimeWireProtocol.LatestOnlyStream; - public static byte[] EncodeFrame(CultMeshRealtimeFrame frame) - { - var channel = Encoding.UTF8.GetBytes(frame.ChannelId); - var schema = Encoding.UTF8.GetBytes(frame.SchemaId); - var body = Encoding.UTF8.GetBytes(frame.BodyId); - if (channel.Length > ushort.MaxValue || schema.Length > ushort.MaxValue || body.Length > ushort.MaxValue) - throw new InvalidDataException("Realtime frame identity exceeds the QUIC wire limit."); - if (frame.Payload.Length > MaximumFrameBytes) - throw new InvalidDataException("Realtime frame payload exceeds the QUIC wire limit."); + public static byte[] EncodeFrame(CultMeshRealtimeFrame frame) => + CultMeshRealtimeWireProtocol.EncodeFrame(frame); - var result = new byte[checked(FixedHeaderBytes + channel.Length + schema.Length + body.Length + frame.Payload.Length)]; - var span = result.AsSpan(); - BinaryPrimitives.WriteUInt32LittleEndian(span, Magic); - span[4] = (byte)frame.Delivery; - BinaryPrimitives.WriteInt64LittleEndian(span[5..], frame.ProducerEpoch); - BinaryPrimitives.WriteInt64LittleEndian(span[13..], frame.Sequence); - BinaryPrimitives.WriteUInt16LittleEndian(span[21..], (ushort)channel.Length); - BinaryPrimitives.WriteUInt16LittleEndian(span[23..], (ushort)schema.Length); - BinaryPrimitives.WriteUInt16LittleEndian(span[25..], (ushort)body.Length); - BinaryPrimitives.WriteInt32LittleEndian(span[27..], frame.Payload.Length); - BinaryPrimitives.WriteInt32LittleEndian(span[31..], FixedHeaderBytes); - BinaryPrimitives.WriteUInt16LittleEndian(span[35..], 1); - var offset = FixedHeaderBytes; - channel.CopyTo(span[offset..]); offset += channel.Length; - schema.CopyTo(span[offset..]); offset += schema.Length; - body.CopyTo(span[offset..]); offset += body.Length; - frame.Payload.Span.CopyTo(span[offset..]); - return result; - } - - public static CultMeshRealtimeFrame DecodeFrame(byte[] bytes) - { - if (bytes.Length < FixedHeaderBytes) throw new InvalidDataException("Realtime frame header is truncated."); - var span = bytes.AsSpan(); - if (BinaryPrimitives.ReadUInt32LittleEndian(span) != Magic) - throw new InvalidDataException("Realtime frame magic is invalid."); - if (BinaryPrimitives.ReadUInt16LittleEndian(span[35..]) != 1 || - BinaryPrimitives.ReadInt32LittleEndian(span[31..]) != FixedHeaderBytes) - throw new InvalidDataException("Realtime frame wire version is unsupported."); - var delivery = (CultMeshRealtimeDelivery)span[4]; - if (delivery is < CultMeshRealtimeDelivery.ReliableOrdered or > CultMeshRealtimeDelivery.Unreliable) - throw new InvalidDataException("Realtime frame delivery mode is invalid."); - var channelLength = BinaryPrimitives.ReadUInt16LittleEndian(span[21..]); - var schemaLength = BinaryPrimitives.ReadUInt16LittleEndian(span[23..]); - var bodyLength = BinaryPrimitives.ReadUInt16LittleEndian(span[25..]); - var payloadLength = BinaryPrimitives.ReadInt32LittleEndian(span[27..]); - var expected = checked(FixedHeaderBytes + channelLength + schemaLength + bodyLength + payloadLength); - if (payloadLength < 0 || expected != bytes.Length) - throw new InvalidDataException("Realtime frame length is invalid."); - var offset = FixedHeaderBytes; - var channel = Encoding.UTF8.GetString(span.Slice(offset, channelLength)); offset += channelLength; - var schema = Encoding.UTF8.GetString(span.Slice(offset, schemaLength)); offset += schemaLength; - var body = Encoding.UTF8.GetString(span.Slice(offset, bodyLength)); offset += bodyLength; - return new CultMeshRealtimeFrame - { - ChannelId = channel, - SchemaId = schema, - BodyId = body, - ProducerEpoch = BinaryPrimitives.ReadInt64LittleEndian(span[5..]), - Sequence = BinaryPrimitives.ReadInt64LittleEndian(span[13..]), - Delivery = delivery, - Payload = bytes.AsMemory(offset, payloadLength) - }; - } + public static CultMeshRealtimeFrame DecodeFrame(byte[] bytes) => + CultMeshRealtimeWireProtocol.DecodeFrame(bytes); public static async Task WriteFramedAsync(Stream stream, byte[] payload, CancellationToken cancellationToken) { @@ -628,7 +566,7 @@ internal static class CultMeshQuicRealtimeProtocol if (first == 0) return null; await ReadExactlyAsync(stream, header.AsMemory(1), cancellationToken).ConfigureAwait(false); var length = BinaryPrimitives.ReadInt32LittleEndian(header); - if (length <= 0 || length > MaximumFrameBytes + FixedHeaderBytes + (3 * ushort.MaxValue)) + if (length <= 0 || length > CultMeshRealtimeWireProtocol.MaximumEncodedFrameBytes) throw new InvalidDataException("Realtime QUIC frame length is invalid."); var payload = new byte[length]; await ReadExactlyAsync(stream, payload, cancellationToken).ConfigureAwait(false); diff --git a/src/GameCult.Mesh/CultMeshRealtimeWireProtocol.cs b/src/GameCult.Mesh/CultMeshRealtimeWireProtocol.cs new file mode 100644 index 00000000..d0925f12 --- /dev/null +++ b/src/GameCult.Mesh/CultMeshRealtimeWireProtocol.cs @@ -0,0 +1,93 @@ +using System; +using System.Buffers.Binary; +using System.IO; +using System.Text; + +namespace GameCult.Mesh +{ + /// + /// Stable CultMesh realtime frame encoding shared by managed and native QUIC connectors. + /// QUIC stream framing remains transport-owned; this codec owns only typed frame bytes. + /// + public static class CultMeshRealtimeWireProtocol + { + public const string ApplicationProtocolName = "cultmesh-state-v1"; + public const long ConnectionCloseCode = 0x43554c54; + public const long StreamAbortCode = 0x53544154; + public const byte ReliableStream = 1; + public const byte LatestOnlyStream = 2; + public const int MaximumFrameBytes = 64 * 1024 * 1024; + public const int MaximumEncodedFrameBytes = MaximumFrameBytes + 37 + (3 * ushort.MaxValue); + + private const uint Magic = 0x31545343; + private const int FixedHeaderBytes = 37; + + public static byte[] EncodeFrame(CultMeshRealtimeFrame frame) + { + if (frame == null) throw new ArgumentNullException(nameof(frame)); + frame.Validate(); + var channel = Encoding.UTF8.GetBytes(frame.ChannelId); + var schema = Encoding.UTF8.GetBytes(frame.SchemaId); + var body = Encoding.UTF8.GetBytes(frame.BodyId); + if (channel.Length > ushort.MaxValue || schema.Length > ushort.MaxValue || body.Length > ushort.MaxValue) + throw new InvalidDataException("Realtime frame identity exceeds the QUIC wire limit."); + if (frame.Payload.Length > MaximumFrameBytes) + throw new InvalidDataException("Realtime frame payload exceeds the QUIC wire limit."); + + var result = new byte[checked(FixedHeaderBytes + channel.Length + schema.Length + body.Length + frame.Payload.Length)]; + var span = result.AsSpan(); + BinaryPrimitives.WriteUInt32LittleEndian(span, Magic); + span[4] = (byte)frame.Delivery; + BinaryPrimitives.WriteInt64LittleEndian(span.Slice(5), frame.ProducerEpoch); + BinaryPrimitives.WriteInt64LittleEndian(span.Slice(13), frame.Sequence); + BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(21), (ushort)channel.Length); + BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(23), (ushort)schema.Length); + BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(25), (ushort)body.Length); + BinaryPrimitives.WriteInt32LittleEndian(span.Slice(27), frame.Payload.Length); + BinaryPrimitives.WriteInt32LittleEndian(span.Slice(31), FixedHeaderBytes); + BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(35), 1); + var offset = FixedHeaderBytes; + channel.CopyTo(span.Slice(offset)); offset += channel.Length; + schema.CopyTo(span.Slice(offset)); offset += schema.Length; + body.CopyTo(span.Slice(offset)); offset += body.Length; + frame.Payload.Span.CopyTo(span.Slice(offset)); + return result; + } + + public static CultMeshRealtimeFrame DecodeFrame(byte[] bytes) + { + if (bytes == null) throw new ArgumentNullException(nameof(bytes)); + if (bytes.Length < FixedHeaderBytes) throw new InvalidDataException("Realtime frame header is truncated."); + var span = bytes.AsSpan(); + if (BinaryPrimitives.ReadUInt32LittleEndian(span) != Magic) + throw new InvalidDataException("Realtime frame magic is invalid."); + if (BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(35)) != 1 || + BinaryPrimitives.ReadInt32LittleEndian(span.Slice(31)) != FixedHeaderBytes) + throw new InvalidDataException("Realtime frame wire version is unsupported."); + var delivery = (CultMeshRealtimeDelivery)span[4]; + if (delivery < CultMeshRealtimeDelivery.ReliableOrdered || delivery > CultMeshRealtimeDelivery.Unreliable) + throw new InvalidDataException("Realtime frame delivery mode is invalid."); + var channelLength = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(21)); + var schemaLength = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(23)); + var bodyLength = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(25)); + var payloadLength = BinaryPrimitives.ReadInt32LittleEndian(span.Slice(27)); + var expected = checked(FixedHeaderBytes + channelLength + schemaLength + bodyLength + payloadLength); + if (payloadLength < 0 || expected != bytes.Length) + throw new InvalidDataException("Realtime frame length is invalid."); + var offset = FixedHeaderBytes; + var channel = Encoding.UTF8.GetString(bytes, offset, channelLength); offset += channelLength; + var schema = Encoding.UTF8.GetString(bytes, offset, schemaLength); offset += schemaLength; + var body = Encoding.UTF8.GetString(bytes, offset, bodyLength); offset += bodyLength; + return new CultMeshRealtimeFrame + { + ChannelId = channel, + SchemaId = schema, + BodyId = body, + ProducerEpoch = BinaryPrimitives.ReadInt64LittleEndian(span.Slice(5)), + Sequence = BinaryPrimitives.ReadInt64LittleEndian(span.Slice(13)), + Delivery = delivery, + Payload = bytes.AsMemory(offset, payloadLength) + }; + } + } +} diff --git a/src/GameCult.Mesh/docs/transport-planes.md b/src/GameCult.Mesh/docs/transport-planes.md index 23b16e2e..219df88a 100644 --- a/src/GameCult.Mesh/docs/transport-planes.md +++ b/src/GameCult.Mesh/docs/transport-planes.md @@ -36,9 +36,18 @@ It maps reliable ordered frames to one persistent stream and latest-only frames to independent streams with keyed `(producer epoch, sequence)` pending-state replacement. Its `System.Net.Quic` adapter fails closed for unreliable frames because that API -does not expose QUIC datagrams. A native MsQuic connector must own that mode and -the generic Unity runtime boundary. No RUDP implementation is installed -implicitly in its place. +does not expose QUIC datagrams. `GameCult.Mesh.Quic.Native` owns the generic +Unity/client boundary through the MsQuic C API. The Unity package carries its +managed connector and the pinned Windows x64 Schannel runtime; consumers +register `CultMeshNativeQuicRealtimeTransportConnector`, then ask CultMesh for +the advertised realtime route. The connector currently receives provider state +and fails closed if used to send; commands and receipts remain on the typed TCP +control plane. No RUDP implementation is installed implicitly in its place. + +Local mapped bodies remain the first state plane. A consumer opens the +advertised mapping when it is actually reachable; otherwise it connects to the +advertised QUIC route. Latest-only receivers must replace pending generations +per body rather than queueing render debt. **Derived state:** connector choice, physical endpoint, connection health, and transport diagnostics. None of these grant provider authority or become content @@ -64,11 +73,10 @@ new seam does not pretend that migration has already occurred. ## Security and release gate -The current TCP connectors are a local-development transport and are plaintext. -Remote release requires authenticated encryption and binding the authenticated -peer to the advertised provider identity. Adding TLS must not move discovery, -authorization, content verification, or application semantics into the TCP -adapter. +The current TCP control and content connectors are plaintext and therefore +restricted to local or otherwise trusted deployment. Adding TLS must not move +discovery, authorization, content verification, or application semantics into +the TCP adapter. The QUIC adapter always uses TLS 1.3. Its certificate validation callback is given the advertised endpoint identity so the consumer can bind transport diff --git a/tests/GameCult.Mesh.Quic.Native.Tests/CultMeshNativeQuicRealtimeTransportTests.cs b/tests/GameCult.Mesh.Quic.Native.Tests/CultMeshNativeQuicRealtimeTransportTests.cs new file mode 100644 index 00000000..f46f833e --- /dev/null +++ b/tests/GameCult.Mesh.Quic.Native.Tests/CultMeshNativeQuicRealtimeTransportTests.cs @@ -0,0 +1,104 @@ +using System.Net; +using System.Net.Quic; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using FluentAssertions; +using GameCult.Mesh.Quic; +using GameCult.Mesh.Quic.Native; +using NUnit.Framework; + +namespace GameCult.Mesh.Quic.Native.Tests; + +[TestFixture] +public sealed class CultMeshNativeQuicRealtimeTransportTests +{ + [Test] + public async Task NativeConnectorAuthenticatesAndReceivesLatestOnlyFrame() + { + if (!OperatingSystem.IsWindows() || !QuicListener.IsSupported) + Assert.Ignore("Native MsQuic integration requires Windows QUIC support."); + using var certificate = CreateCertificate(); + await using var server = await CultMeshQuicRealtimeServer.ListenAsync(new CultMeshQuicRealtimeServerOptions + { + ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0), + ServerCertificate = certificate + }); + var pin = Convert.ToHexString(SHA256.HashData(certificate.RawData)); + var endpoint = $"cultmesh-state+quic://127.0.0.1:{server.LocalEndPoint.Port}?cert-sha256={pin}"; + var connector = new CultMeshNativeQuicRealtimeTransportConnector(); + + using var client = await connector.ConnectAsync( + new CultMeshTransportCandidate(endpoint), + CultMeshEndpointId.Parse("aetheria.local")); + var expected = new CultMeshRealtimeFrame + { + ChannelId = "aetheria.entities", + SchemaId = "gamecult.aetheria.entity_soa.v1", + BodyId = "aetheria.entities.current-zone", + ProducerEpoch = 17, + Sequence = 23, + Delivery = CultMeshRealtimeDelivery.LatestOnly, + Payload = new byte[] { 1, 3, 3, 7 } + }; + await WaitUntilAsync(() => server.ConnectionCount == 1); + await server.BroadcastAsync(expected); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var actual = await client.ReceiveAsync(timeout.Token); + + client.TransportId.Should().Be("msquic-native-realtime"); + actual.ChannelId.Should().Be(expected.ChannelId); + actual.SchemaId.Should().Be(expected.SchemaId); + actual.BodyId.Should().Be(expected.BodyId); + actual.ProducerEpoch.Should().Be(expected.ProducerEpoch); + actual.Sequence.Should().Be(expected.Sequence); + actual.Delivery.Should().Be(CultMeshRealtimeDelivery.LatestOnly); + actual.Payload.ToArray().Should().Equal(expected.Payload.ToArray()); + } + + [Test] + public async Task NativeConnectorRejectsWrongAdvertisedPin() + { + if (!OperatingSystem.IsWindows() || !QuicListener.IsSupported) + Assert.Ignore("Native MsQuic integration requires Windows QUIC support."); + using var certificate = CreateCertificate(); + await using var server = await CultMeshQuicRealtimeServer.ListenAsync(new CultMeshQuicRealtimeServerOptions + { + ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0), + ServerCertificate = certificate + }); + var connector = new CultMeshNativeQuicRealtimeTransportConnector(); + var endpoint = $"cultmesh-state+quic://127.0.0.1:{server.LocalEndPoint.Port}?cert-sha256={new string('0', 64)}"; + + var connect = () => connector.ConnectAsync( + new CultMeshTransportCandidate(endpoint), + CultMeshEndpointId.Parse("aetheria.local")); + + await connect.Should().ThrowAsync() + .WithMessage("*certificate*pin*"); + } + + private static async Task WaitUntilAsync(Func predicate) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!predicate()) + await Task.Delay(10, timeout.Token); + } + + private static X509Certificate2 CreateCertificate() + { + using var key = RSA.Create(2048); + var request = new CertificateRequest( + "CN=localhost", + key, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false)); + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false)); + request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); + using var generated = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddMinutes(-1), + DateTimeOffset.UtcNow.AddMinutes(10)); + return X509CertificateLoader.LoadPkcs12(generated.Export(X509ContentType.Pfx), null); + } +} diff --git a/tests/GameCult.Mesh.Quic.Native.Tests/GameCult.Mesh.Quic.Native.Tests.csproj b/tests/GameCult.Mesh.Quic.Native.Tests/GameCult.Mesh.Quic.Native.Tests.csproj new file mode 100644 index 00000000..fc232add --- /dev/null +++ b/tests/GameCult.Mesh.Quic.Native.Tests/GameCult.Mesh.Quic.Native.Tests.csproj @@ -0,0 +1,30 @@ + + + net10.0 + false + enable + enable + $(NoWarn);CA1416 + $(MSBuildThisFileDirectory)..\..\artifacts\quic-native-test + + + + + + + + + + + + + + + + + + + + + + diff --git a/unity/org.gamecult.cultlib/README.md b/unity/org.gamecult.cultlib/README.md index 74ecc835..bd5bb268 100644 --- a/unity/org.gamecult.cultlib/README.md +++ b/unity/org.gamecult.cultlib/README.md @@ -2,13 +2,21 @@ This package is generated from the CultLib .NET projects. It carries the same CultCache, CultNet, and CultMesh assemblies consumed through NuGet by normal -.NET applications, staged as Unity-compatible managed plugins. +.NET applications, staged as Unity-compatible managed plugins. Windows x64 +builds also receive the native MsQuic realtime connector and its Schannel +runtime; the Microsoft license is included under `Third Party Notices`. Build it with `scripts/build-unity-package.ps1`. Do not edit generated plugin assemblies in the package output. -Install version `1.0.24` through Unity Package Manager with: +Install version `1.0.36` through Unity Package Manager with: ```text -https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.24 +https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.36 ``` + +CultMesh chooses mapped memory for reachable same-machine bodies and advertised +QUIC for remote realtime state. Applications register the packaged +`CultMeshNativeQuicRealtimeTransportConnector`; they do not load or configure +MsQuic directly. Schemas, commands, receipts, and immutable content remain on +their typed control/content planes. diff --git a/unity/org.gamecult.cultlib/Runtime/GameCult.CultLib.asmdef b/unity/org.gamecult.cultlib/Runtime/GameCult.CultLib.asmdef index 6a2d7707..100db084 100644 --- a/unity/org.gamecult.cultlib/Runtime/GameCult.CultLib.asmdef +++ b/unity/org.gamecult.cultlib/Runtime/GameCult.CultLib.asmdef @@ -12,6 +12,7 @@ "GameCult.Caching.MessagePack.dll", "GameCult.Logging.dll", "GameCult.Mesh.dll", + "GameCult.Mesh.Quic.Native.dll", "GameCult.Networking.dll", "Isopoh.Cryptography.Argon2.dll", "Isopoh.Cryptography.Blake2b.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 e07f69d6..9cfcb1e1 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 bd175f2b..b7893d1a 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 550e931a..251eddf7 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 f431c1cb..9885f367 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 fb06f252..0ea237f8 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 9e4ff484..848a61b5 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 new file mode 100644 index 00000000..2c28b0fb Binary files /dev/null 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.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.dll.meta new file mode 100644 index 00000000..6b480da1 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.dll.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1e20e41f50c44a8686f75c1ff37a90bc 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 new file mode 100644 index 00000000..535ffbcf Binary files /dev/null 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.Quic.Native.pdb.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.pdb.meta new file mode 100644 index 00000000..74c502dc --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.pdb.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 60ee118957cc478aa3143932787387d0 diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll index 384a7e9c..5024debc 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 297760ad..feb471bb 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 53ed6524..dd68bd10 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 a0a42d8e..45da0cfd 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.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64.meta new file mode 100644 index 00000000..5046eeba --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c098a8f906144352a0eb7a5f47403c8d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: 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 new file mode 100644 index 00000000..9c0f132b Binary files /dev/null and b/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/gamecult_mesh_quic_native.dll differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/gamecult_mesh_quic_native.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/gamecult_mesh_quic_native.dll.meta new file mode 100644 index 00000000..cfeb2bd8 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/gamecult_mesh_quic_native.dll.meta @@ -0,0 +1,34 @@ +fileFormatVersion: 2 +guid: 9e0e0968e84b477f88232232ef261598 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 1 + settings: + CPU: x86_64 + DefaultValueInitialized: true + - first: + Standalone: Win + second: + enabled: 1 + settings: + CPU: x86_64 + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/msquic.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/msquic.dll new file mode 100644 index 00000000..ffd41afa Binary files /dev/null and b/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/msquic.dll differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/msquic.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/msquic.dll.meta new file mode 100644 index 00000000..789b12e6 --- /dev/null +++ b/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/msquic.dll.meta @@ -0,0 +1,34 @@ +fileFormatVersion: 2 +guid: 401ec45df4724af1bca3e78fd39f44de +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 1 + settings: + CPU: x86_64 + DefaultValueInitialized: true + - first: + Standalone: Win + second: + enabled: 1 + settings: + CPU: x86_64 + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.cultlib/Third Party Notices.meta b/unity/org.gamecult.cultlib/Third Party Notices.meta new file mode 100644 index 00000000..3715b97f --- /dev/null +++ b/unity/org.gamecult.cultlib/Third Party Notices.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 145c68c2b4bb4adf8af4944b0f5c57b1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.cultlib/Third Party Notices/MSQUIC-LICENSE.txt b/unity/org.gamecult.cultlib/Third Party Notices/MSQUIC-LICENSE.txt new file mode 100644 index 00000000..63447fd8 --- /dev/null +++ b/unity/org.gamecult.cultlib/Third Party Notices/MSQUIC-LICENSE.txt @@ -0,0 +1,21 @@ +Copyright (c) Microsoft Corporation. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/unity/org.gamecult.cultlib/Third Party Notices/MSQUIC-LICENSE.txt.meta b/unity/org.gamecult.cultlib/Third Party Notices/MSQUIC-LICENSE.txt.meta new file mode 100644 index 00000000..dabf2ce4 --- /dev/null +++ b/unity/org.gamecult.cultlib/Third Party Notices/MSQUIC-LICENSE.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 0f648ff709b44e29af0e8f284e661890 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.cultlib/package.json b/unity/org.gamecult.cultlib/package.json index e46e9f30..743a4920 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.35", + "version": "1.0.36", "unity": "2021.3", "description": "CultCache, CultNet, and CultMesh managed runtime assemblies for Unity.", "license": "MIT",