Give the bridge a Linux build the deploy host's own distribution can produce

There is no Linux MsQuic binary in any NuGet package, and the v2.5.9 GitHub
release carries only test bundles, so the Windows script's approach does not
transfer. What does exist is Microsoft's Debian 13 pool, which is the same
distribution Yggdrasil runs, and the msquic source tree at the matching tag for
the POSIX headers the NuGet omits.

`scripts/build-quic-native.sh` fetches the pinned `libmsquic_2.5.9_amd64.deb`
and the three headers `msquic.h`, `msquic_posix.h` and `quic_sal_stub.h`, and
verifies all four by SHA-256 the same way `build-quic-native.ps1:14` verifies
the NuGet. A digest that stops matching is a changed dependency, not a warning.

Two details the deb forced. It carries three library files and no licence text,
so the MIT licence is copied from the one this repo already committed for the
Windows plugin — the same text for both platforms. And it ships only
`libmsquic.so.2.5.9`, with no `libmsquic.so` for `-lmsquic` to resolve against,
so the script makes that link in the extracted tree rather than in the image.

The Linux CMake branch links `-fvisibility=hidden`, so only the twelve v2
exports leave the `.so` and the Windows-only v1 five are absent by construction
rather than by discipline, and sets `RPATH $ORIGIN` so the `libmsquic.so.2`
shipped beside the bridge resolves without being installed system-wide.

`build-quic-native.ps1`'s default output directory becomes
`artifacts/quic-native/win32-x64` so the two platforms' outputs sit beside each
other instead of on top of each other. Both existing callers —
`build-unity-package.ps1:80-82` and the Native.Tests csproj `:20-22` — pass an
explicit `-OutputDirectory`, so neither is affected.

Built and smoked in the workstation's Docker `debian:13` engine pinned by
digest: g++ 14.2.0, glibc 2.41, `libgamecult_mesh_quic_native.so` 85,456 bytes
with `NEEDED libmsquic.so.2` and `RUNPATH $ORIGIN`, and the same 15-check v2 FFI
smoke that runs on Windows passes under Node 24 on that image. That is a Linux
build on a Linux host and nothing more: Q6 ruled the release artifact comes from
the Actions job, and this one is the development loop.

A Linux host still needs MsQuic's own runtime dependencies present — libssl3t64,
libnuma1, libxdp1, libnl-route-3-200 — which is now written down, because the
first smoke run failed on a missing libxdp.so.1 and the reason was not obvious
from the error.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-16 21:04:14 +02:00
commit 23dc0939e0
3 changed files with 201 additions and 9 deletions

View file

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

View file

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

109
scripts/build-quic-native.sh Executable file
View file

@ -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}/||"