Add native QUIC realtime transport for Unity
This commit is contained in:
parent
1742473845
commit
1d126f4a35
37 changed files with 1150 additions and 86 deletions
20
native/GameCult.Mesh.Quic.Native/CMakeLists.txt
Normal file
20
native/GameCult.Mesh.Quic.Native/CMakeLists.txt
Normal 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")
|
||||
10
native/GameCult.Mesh.Quic.Native/README.md
Normal file
10
native/GameCult.Mesh.Quic.Native/README.md
Normal 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.
|
||||
377
native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp
Normal file
377
native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp
Normal 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(®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<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;
|
||||
}
|
||||
54
scripts/build-quic-native.ps1
Normal file
54
scripts/build-quic-native.ps1
Normal 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
|
||||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
93
src/GameCult.Mesh/CultMeshRealtimeWireProtocol.cs
Normal file
93
src/GameCult.Mesh/CultMeshRealtimeWireProtocol.cs
Normal 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)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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=""$(SystemRoot)\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "$(MSBuildThisFileDirectory)..\..\scripts\build-quic-native.ps1" -Configuration Debug -OutputDirectory "$(NativeQuicOutput)"" />
|
||||
</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>
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 1e20e41f50c44a8686f75c1ff37a90bc
|
||||
Binary file not shown.
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 60ee118957cc478aa3143932787387d0
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
8
unity/org.gamecult.cultlib/Runtime/Plugins/x86_64.meta
Normal file
8
unity/org.gamecult.cultlib/Runtime/Plugins/x86_64.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: c098a8f906144352a0eb7a5f47403c8d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Binary file not shown.
|
|
@ -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:
|
||||
BIN
unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/msquic.dll
Normal file
BIN
unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/msquic.dll
Normal file
Binary file not shown.
|
|
@ -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:
|
||||
8
unity/org.gamecult.cultlib/Third Party Notices.meta
Normal file
8
unity/org.gamecult.cultlib/Third Party Notices.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 145c68c2b4bb4adf8af4944b0f5c57b1
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 0f648ff709b44e29af0e8f284e661890
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue