diff --git a/native/GameCult.Mesh.Quic.Native/README.md b/native/GameCult.Mesh.Quic.Native/README.md index 875ebb3b..b2a2ba23 100644 --- a/native/GameCult.Mesh.Quic.Native/README.md +++ b/native/GameCult.Mesh.Quic.Native/README.md @@ -1,10 +1,90 @@ # 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`. +One MsQuic body for every CultMesh host that is not .NET: two roles (provider +and consumer), two platforms (Windows x64 and Linux x64), and no callbacks +across the host boundary. -The build consumes Microsoft's pinned -`Microsoft.Native.Quic.MsQuic.Schannel` package. It does not contain -Aetheria-specific code. +The bridge owns MsQuic registration, configuration, listener, connection and +stream handles, TLS credential loading, the `cultmesh-state-v1` ALPN, the +1-byte-kind plus 4-byte-LE-length QUIC stream framing, and one event queue per +runtime. It owns transport and nothing else: it never decodes a CultMesh frame, +never decides delivery semantics, never coalesces, never reconnects, and in the +v2 path never decides whether to trust a certificate. Frame meaning stays in +`GameCult.Mesh` and in the TypeScript codec; trust stays with the host. + +## No host callbacks + +Every crossing is the host calling in, including the one blocking wait. MsQuic's +worker threads only ever push onto the queue and wake that wait, so no host +runtime is called from a thread it does not own. That is what lets one binary +serve a Node process through FFI and a Unity player through P/Invoke without +either one's threading rules leaking into the other. + +It is also why nothing here includes a Node header, a Unity header, or a +JavaScript engine: the bridge does not know what is on the other side. + +## Two ABIs + +**v2** is the runtime-neutral queue API, and the one new hosts should use: + + cultmesh_quic_runtime_open / runtime_close + cultmesh_quic_listener_open / listener_close + cultmesh_quic_connection_open / connection_certificate_complete / connection_shutdown + cultmesh_quic_stream_open / stream_send_frame / stream_shutdown + cultmesh_quic_next_event + cultmesh_quic_last_error + +`cultmesh_quic_event` is a fixed 64-byte struct described by layout rather than +by a shared header, so a host declares it in its own FFI. Its `type` is one of: +1 `listener_new_connection`, 2 `connection_connected`, 3 +`connection_certificate_received` (payload: the DER certificate), 4 +`connection_shutdown` (payload: a UTF-8 reason), 5 `stream_started` +(`stream_kind` taken from the stream's first byte), 6 `stream_frame` (payload: +one whole encoded frame, length prefix stripped), 7 `stream_send_complete`, 8 +`stream_shutdown`, 9 `listener_stopped`. + +`cultmesh_quic_next_event` returns 0 on timeout, 1 with the event filled and its +payload copied, 2 when the payload buffer is too small — the event stays at the +head of the queue and `out_required` is set, so the host can allocate and ask +again — and negative on a bad call. + +A client connection defers certificate validation: the bridge indicates the +certificate, emits event 3 carrying the DER, and answers MsQuic only when the +host calls `cultmesh_quic_connection_certificate_complete`. If the host never +answers, MsQuic's 10-second handshake timeout closes the connection and event 4 +reports it. Providers load their certificate as PKCS12; the advertised +`cert-sha256` pin is computed by the host from that same PKCS12, not here. + +**v1** is the five exports the shipped Unity package imports by name +(`cultmesh_quic_open`, `_state`, `_poll`, `_error`, `_close`). They are Windows +only, matching `CultMeshNativeQuicRealtimeTransport.CanConnect`, and are +implemented on top of a private v2 runtime holding one connection. They decide +nothing the v2 path does not, with one exception: a v1 connection carries a +SHA-256 pin that is compared here, in C, because that is the contract the +managed connector was built against. A v2 connection never carries one. + +## Building + +Windows x64, from the repo root: + + powershell -File scripts/build-quic-native.ps1 + +MsQuic comes from a pinned NuGet package verified by SHA-256, and the build uses +MSVC with `/Brepro` so a rebuild reproduces the committed Unity plugin DLL. + +Linux x64, on a Debian 13 host: + + scripts/build-quic-native.sh + +MsQuic comes from Microsoft's Debian 13 pool as a pinned `.deb`, and the POSIX +headers from the msquic source tree at the matching tag; all four downloads are +verified by SHA-256. The bridge is linked with `-fvisibility=hidden` so only the +v2 exports leave the `.so`, and with `RPATH $ORIGIN` so the `libmsquic.so.2` +shipped beside it resolves without being installed system-wide. + +A Linux host still needs MsQuic's own runtime dependencies present: +`libssl3t64`, `libnuma1`, `libxdp1`, `libnl-route-3-200`. + +Where an artifact was built is part of what it is. The Linux binary is linked +against Debian 13's glibc, so a build from anywhere else is a different artifact +whatever the file name says. diff --git a/scripts/build-quic-native.ps1 b/scripts/build-quic-native.ps1 index 68496180..f1b35787 100644 --- a/scripts/build-quic-native.ps1 +++ b/scripts/build-quic-native.ps1 @@ -1,7 +1,10 @@ param( [string] $Configuration = "Release", [string] $Architecture = "x64", - [string] $OutputDirectory = "artifacts\quic-native" + # Per-platform, so the Linux script's output sits beside this one's rather + # than on top of it. build-unity-package.ps1 and the Native.Tests csproj both + # pass an explicit -OutputDirectory, so neither is affected by this moving. + [string] $OutputDirectory = "artifacts\quic-native\win32-x64" ) $ErrorActionPreference = "Stop" diff --git a/scripts/build-quic-native.sh b/scripts/build-quic-native.sh new file mode 100755 index 00000000..1864caf3 --- /dev/null +++ b/scripts/build-quic-native.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Builds the CultMesh native QUIC bridge for Linux x64. +# +# The Windows counterpart (build-quic-native.ps1) takes MsQuic from a pinned +# NuGet package. There is no Linux binary in any MsQuic NuGet and the GitHub +# releases for v2.5.9 carry only test bundles, so the runtime here comes from +# Microsoft's Debian 13 pool and the headers come from the msquic source tree at +# the matching tag. Both are pinned by SHA-256 the same way the NuGet is: a +# digest that stops matching is a changed dependency, not a warning. +# +# This must run on a Debian 13 host (the container in .github/workflows, or the +# workstation's Docker engine for the development loop). The artifact is linked +# against that distribution's glibc, so where it was built is part of what it is. +# +# scripts/build-quic-native.sh [output-directory] +# +# Default output: artifacts/quic-native/linux-x64, which is git-ignored build +# output and never delivery. + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +version="2.5.9" +output_directory="${1:-${repo_root}/artifacts/quic-native/linux-x64}" +case "${output_directory}" in + /*) ;; + *) output_directory="${repo_root}/${output_directory}" ;; +esac + +cache_root="${repo_root}/artifacts/dependencies/msquic-linux-${version}" +deb_path="${cache_root}/libmsquic_${version}_amd64.deb" +extract_root="${cache_root}/package" +include_root="${cache_root}/include" +build_root="${repo_root}/artifacts/quic-native-build/linux-x64/Release" + +deb_url="https://packages.microsoft.com/debian/13/prod/pool/main/libm/libmsquic/libmsquic_${version}_amd64.deb" +deb_sha256="1baa61ade0b7b4a99f6dcb6b00d9aedb12b5566d00918a325be7425e878e51ba" +header_base="https://raw.githubusercontent.com/microsoft/msquic/v${version}/src/inc" + +# The three headers the POSIX build needs. msquic.h includes msquic_posix.h, +# which includes quic_sal_stub.h; the NuGet ships only the Windows trio. +header_names=("msquic.h" "msquic_posix.h" "quic_sal_stub.h") +header_sha256s=( + "c9abfdd02c45910649dd335d6bd82718e4ddd2fdb35fe550567c78f032551e0c" + "b285fa66b9c9bdc886c30ef92910da472692b25f5c6192416fb40f08f64e22ec" + "9b13328d9aec8807a754b2bc391b31b5d09b1c5f6cec064012051683ed169055" +) + +verify_sha256() { + local path="$1" expected="$2" + local actual + actual="$(sha256sum "${path}" | cut -d' ' -f1)" + if [[ "${actual}" != "${expected}" ]]; then + echo "Digest mismatch for ${path}" >&2 + echo " expected ${expected}" >&2 + echo " actual ${actual}" >&2 + exit 1 + fi +} + +mkdir -p "${cache_root}" "${include_root}" + +if [[ ! -f "${deb_path}" ]]; then + echo "Fetching ${deb_url}" + curl -fsSL -o "${deb_path}" "${deb_url}" +fi +verify_sha256 "${deb_path}" "${deb_sha256}" + +if [[ ! -f "${extract_root}/usr/lib/x86_64-linux-gnu/libmsquic.so.${version}" ]]; then + rm -rf "${extract_root}" + mkdir -p "${extract_root}" + dpkg-deb -x "${deb_path}" "${extract_root}" +fi + +for index in "${!header_names[@]}"; do + header="${header_names[${index}]}" + target="${include_root}/${header}" + if [[ ! -f "${target}" ]]; then + echo "Fetching ${header_base}/${header}" + curl -fsSL -o "${target}" "${header_base}/${header}" + fi + verify_sha256 "${target}" "${header_sha256s[${index}]}" +done + +library_directory="${extract_root}/usr/lib/x86_64-linux-gnu" +# CMake links `-lmsquic`, which wants a plain `libmsquic.so` to resolve against; +# the deb ships only the versioned file and its SONAME symlink. +ln -sf "libmsquic.so.${version}" "${library_directory}/libmsquic.so" + +cmake -S "${repo_root}/native/GameCult.Mesh.Quic.Native" -B "${build_root}" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DMSQUIC_INCLUDE_DIR="${include_root}" \ + -DMSQUIC_LIB_DIR="${library_directory}" +cmake --build "${build_root}" + +mkdir -p "${output_directory}" +cp "${build_root}/bin/libgamecult_mesh_quic_native.so" "${output_directory}/" +# The real file under the SONAME the bridge records, not the symlink: what ships +# beside the bridge has to be a file, because the package directory is copied. +cp "${library_directory}/libmsquic.so.${version}" "${output_directory}/libmsquic.so.2" +# The deb carries three library files and no licence text, so the MIT licence +# comes from the copy this repo already committed for the Windows plugin. It is +# the same text for both platforms. +cp "${repo_root}/unity/org.gamecult.cultlib/Third Party Notices/MSQUIC-LICENSE.txt" \ + "${output_directory}/MSQUIC-LICENSE.txt" + +echo "CultMesh native QUIC runtime: ${output_directory}" +ls -l "${output_directory}" +sha256sum "${output_directory}"/* | sed "s|${output_directory}/||"