Add native QUIC realtime transport for Unity

This commit is contained in:
Meta Vens 2026-07-19 22:55:06 +01:00
commit 1d126f4a35
37 changed files with 1150 additions and 86 deletions

View file

@ -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")

View file

@ -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.

View file

@ -0,0 +1,377 @@
#include <windows.h>
#include <wincrypt.h>
#include <bcrypt.h>
#include <msquic.h>
#include <array>
#include <atomic>
#include <condition_variable>
#include <cstdint>
#include <cstring>
#include <deque>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
#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<uint8_t> 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<uint8_t, 32> certificate_pin{};
std::atomic<ClientState> state{ClientState::Connecting};
std::atomic<bool> closing{false};
std::mutex gate;
std::deque<std::vector<uint8_t>> received;
std::string error;
};
void SetFailure(Client* client, const std::string& message) {
if (client == nullptr || client->closing.load()) return;
{
std::lock_guard<std::mutex> 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<uint8_t>(value - '0');
else if (value >= 'a' && value <= 'f') nibble = static_cast<uint8_t>(value - 'a' + 10);
else if (value >= 'A' && value <= 'F') nibble = static_cast<uint8_t>(value - 'A' + 10);
else return false;
return true;
}
bool ParsePin(const char* value, std::array<uint8_t, 32>& 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<uint8_t>((high << 4) | low);
}
return true;
}
uint32_t ReadUInt32LittleEndian(const uint8_t* value) {
return static_cast<uint32_t>(value[0]) |
(static_cast<uint32_t>(value[1]) << 8) |
(static_cast<uint32_t>(value[2]) << 16) |
(static_cast<uint32_t>(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<size_t>(length);
if (bytes.size() < framed_length) return true;
std::vector<uint8_t> frame(length);
std::memcpy(frame.data(), bytes.data() + sizeof(uint32_t), length);
{
std::lock_guard<std::mutex> lock(stream->owner->gate);
stream->owner->received.emplace_back(std::move(frame));
}
bytes.erase(bytes.begin(), bytes.begin() + static_cast<std::ptrdiff_t>(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<StreamContext*>(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<const QUIC_BUFFER*>(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<Client*>(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<void*>(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<Client>();
if (!ParsePin(certificate_sha256, client->certificate_pin)) return -2;
QUIC_STATUS status = MsQuicOpenVersion(
kApiVersion,
reinterpret_cast<const void**>(&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(&registration_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<uint32_t>(sizeof(kAlpn) - 1),
reinterpret_cast<uint8_t*>(const_cast<char*>(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_FLAGS>(
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<int32_t>(ClientState::Failed);
return static_cast<int32_t>(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<std::mutex> 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<size_t>(INT32_MAX)) return -3;
*required_length = static_cast<int32_t>(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<std::mutex> lock(client->gate);
const size_t count = (std::min)(client->error.size(), static_cast<size_t>(destination_length - 1));
std::memcpy(destination, client->error.data(), count);
destination[count] = '\0';
return static_cast<int32_t>(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;
}

View file

@ -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

View file

@ -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)"

View file

@ -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);
}
/// <summary>
/// Creates receive-capable MsQuic sessions on Unity-compatible managed runtimes.
/// Commands and receipts remain on their typed control plane.
/// </summary>
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<ICultMeshRealtimeTransport> 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<CultMeshRealtimeFrame> 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);
}
}

View file

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<AssemblyName>GameCult.Mesh.Quic.Native</AssemblyName>
<RootNamespace>GameCult.Mesh.Quic.Native</RootNamespace>
<Description>Native MsQuic CultMesh realtime connector for Unity and other netstandard runtimes.</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\GameCult.Mesh\GameCult.Mesh.csproj" />
</ItemGroup>
</Project>

View file

@ -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);

View file

@ -0,0 +1,93 @@
using System;
using System.Buffers.Binary;
using System.IO;
using System.Text;
namespace GameCult.Mesh
{
/// <summary>
/// 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.
/// </summary>
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)
};
}
}
}

View file

@ -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

View file

@ -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<IOException>()
.WithMessage("*certificate*pin*");
}
private static async Task WaitUntilAsync(Func<bool> 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);
}
}

View file

@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<NoWarn>$(NoWarn);CA1416</NoWarn>
<NativeQuicOutput>$(MSBuildThisFileDirectory)..\..\artifacts\quic-native-test</NativeQuicOutput>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\GameCult.Mesh.Quic\GameCult.Mesh.Quic.csproj" />
<ProjectReference Include="..\..\src\GameCult.Mesh.Quic.Native\GameCult.Mesh.Quic.Native.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="NUnit" Version="4.*" />
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageReference Include="FluentAssertions" Version="8.9.0" />
</ItemGroup>
<Target Name="BuildNativeQuicTestRuntime" BeforeTargets="Build" Condition="'$(OS)' == 'Windows_NT'">
<Exec Command="&quot;$(SystemRoot)\System32\WindowsPowerShell\v1.0\powershell.exe&quot; -NoProfile -ExecutionPolicy Bypass -File &quot;$(MSBuildThisFileDirectory)..\..\scripts\build-quic-native.ps1&quot; -Configuration Debug -OutputDirectory &quot;$(NativeQuicOutput)&quot;" />
</Target>
<Target Name="CopyNativeQuicTestRuntime" AfterTargets="Build" Condition="'$(OS)' == 'Windows_NT'">
<ItemGroup>
<NativeQuicRuntime Include="$(NativeQuicOutput)\gamecult_mesh_quic_native.dll" />
<NativeQuicRuntime Include="$(NativeQuicOutput)\msquic.dll" />
</ItemGroup>
<Copy SourceFiles="@(NativeQuicRuntime)" DestinationFolder="$(OutDir)" />
</Target>
</Project>

View file

@ -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.

View file

@ -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",

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1e20e41f50c44a8686f75c1ff37a90bc

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 60ee118957cc478aa3143932787387d0

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c098a8f906144352a0eb7a5f47403c8d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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:

View file

@ -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:

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 145c68c2b4bb4adf8af4944b0f5c57b1
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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.

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 0f648ff709b44e29af0e8f284e661890
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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",