Compare commits

..
219 changed files with 3776 additions and 19298 deletions

13
.gitattributes vendored
View file

@ -1,15 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
# Shell scripts keep LF in the working tree as well as in the index. Left to
# `text=auto`, a Windows clone writes them with carriage returns, and the
# documented Linux path then dies inside the container in under a second: the
# shebang reads as the interpreter `bash\r`, which does not exist. Nothing about
# that failure names line endings, and the same clone builds and tests fine
# everywhere else, so it reads as a broken image rather than a broken checkout.
#
# A clone checked out before this rule keeps its carriage returns, because the
# blob is unchanged and git has no reason to rewrite the file. The one-line
# repair is in native/GameCult.Mesh.Quic.Native/README.md, beside the container
# path it unblocks.
*.sh text eol=lf

View file

@ -95,46 +95,3 @@ jobs:
dotnet test tests/GameCult.Mesh.Tests/GameCult.Mesh.Tests.csproj
--filter FullyQualifiedName~Document_OneHundredThousandDistinctLeaseCyclesLeaveNoResourcesOrCallbacks
--logger "console;verbosity=minimal"
# The TypeScript side of the CultMesh authority rules. This job is the
# only one that runs on both Windows and Linux for main, codex/** and
# pull requests: cultnet-interop.yml is windows-latest and main-only, so
# until these steps existed the shared verifier's tests never ran on
# Linux at all, and the browser client's never ran in CI on any OS.
# packages/cultnet-ts verifies both vector files; cultmesh-browser proves
# the browser client reads the shared rules rather than its own.
- name: Verify the CultMesh authority rules with the TypeScript verifier
run: npm run test --workspace packages/cultnet-ts
- name: Verify the browser client against the shared TypeScript verifier
run: npm run test --workspace packages/cultmesh-browser
# The realtime frame codec. Its byte parity rests on two vector files,
# contracts/cultmesh/realtime-frame-vectors.json written by the C#
# reference and decoded by this step, and realtime-frame-vectors.ts-written.json
# written by this codec and decoded by the C# step below. Neither half ran
# in CI on any OS until these two steps existed, so a codec that agreed
# only with itself would have gone green.
- name: Verify the realtime frame codec with the TypeScript codec
run: npm run test --workspace packages/cultmesh-ts
# Both directions of the CultMesh authority vectors: the C#-written
# file (contracts/cultmesh/authority-route-vectors.json) and the
# TypeScript-signed file (authority-route-vectors.ts-signed.json) are
# verified by the C# reference here; the TypeScript side verifies both
# in the step above. CULTMESH_WRITE_VECTORS is never set in CI: the
# committed files are the contract.
- name: Verify the CultMesh authority vectors with the C# reference
run: >-
dotnet test tests/GameCult.Mesh.Tests/GameCult.Mesh.Tests.csproj
--filter FullyQualifiedName~AuthorityProof
--logger "console;verbosity=minimal"
# The other half of the frame parity: the C# reference decodes and
# re-encodes the vector file the TypeScript codec wrote. A fixture
# authored on one side alone would only agree with itself.
- name: Verify the realtime frame vectors with the C# reference
run: >-
dotnet test tests/GameCult.Mesh.Tests/GameCult.Mesh.Tests.csproj
--filter FullyQualifiedName~RealtimeWireProtocol
--logger "console;verbosity=minimal"

View file

@ -65,32 +65,6 @@ jobs:
"KOTLIN_JAVA_HOME=$env:JAVA_HOME" | Out-File -FilePath $env:GITHUB_ENV -Append
"$kotlinHome\bin" | Out-File -FilePath $env:GITHUB_PATH -Append
- name: Verify CultNet schema contracts stayed consolidated
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$stray = git ls-files '*.schema.json' | Where-Object { $_ -notmatch '^contracts/cultnet/' }
# -I skips binary files: unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll
# still embeds the retired namespace until it is rebuilt (REC-032 D4, deferred).
$oldNamespace = git grep -I -lE 'GameCult/cultnet-ts/(integration/)?contracts' -- ':!.voidbot'
if ($LASTEXITCODE -eq 2) {
throw "git grep failed"
}
if ($stray) {
Write-Host "Schema files found outside contracts/cultnet/:"
$stray | ForEach-Object { Write-Host " $_" }
}
if ($oldNamespace) {
Write-Host "Files still referencing the retired GameCult/cultnet-ts contracts namespace:"
$oldNamespace | ForEach-Object { Write-Host " $_" }
}
if ($stray -or $oldNamespace) {
throw "CultNet schema contracts drifted out of contracts/cultnet/ (REC-032). See CultLib/contracts/cultnet/ and rewrite any `$id back to https://github.com/GameCult/CultLib/contracts/cultnet/."
}
# git grep exits 1 when nothing matches, and the runner's pwsh wrapper
# ends the step with that $LASTEXITCODE; a clean tree must pass.
exit 0
- name: Install Node dependencies
run: npm install --no-package-lock

View file

@ -42,12 +42,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
# fetch-depth: 0 so the semver check below can see every previously
# published tag, not just the one that triggered this run.
- name: Check out CultLib
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node
uses: actions/setup-node@v4
@ -68,19 +64,6 @@ jobs:
exit 1
fi
# docs/semver-policy.md: a breaking change under too small a bump, or a
# version that skips or reverses the previous release, fails here
# instead of shipping with a number that lies.
- name: Verify semver policy
if: startsWith(github.ref, 'refs/tags/cultcache-ts-v')
run: |
tagged="${GITHUB_REF_NAME#cultcache-ts-v}"
node scripts/check-changelog-semver.mjs \
--package "@gamecult/cultcache-ts" \
--changelog packages/cultcache-ts/CHANGELOG.md \
--version "$tagged" \
--tag-prefix cultcache-ts
- name: Build and test
working-directory: packages/cultcache-ts
run: npm test
@ -122,13 +105,9 @@ jobs:
echo "run=false" >> "$GITHUB_OUTPUT"
fi
# fetch-depth: 0 so the semver check below can see every previously
# published tag, not just the one that triggered this run.
- name: Check out CultLib
if: steps.selected.outputs.run == 'true'
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
if: steps.selected.outputs.run == 'true'
@ -136,12 +115,6 @@ jobs:
with:
python-version: "3.12"
- name: Set up Node
if: steps.selected.outputs.run == 'true'
uses: actions/setup-node@v4
with:
node-version: 24.x
- name: Verify the tag matches the manifest version
if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/')
run: |
@ -152,19 +125,6 @@ jobs:
exit 1
fi
# docs/semver-policy.md: a breaking change under too small a bump, or a
# version that skips or reverses the previous release, fails here
# instead of shipping with a number that lies.
- name: Verify semver policy
if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/')
run: |
tagged="${GITHUB_REF_NAME#${{ matrix.package }}-v}"
node scripts/check-changelog-semver.mjs \
--package "${{ matrix.package }}" \
--changelog packages/${{ matrix.package }}/CHANGELOG.md \
--version "$tagged" \
--tag-prefix "${{ matrix.package }}"
# Lower layers install from source so a release of one package is tested
# against the sibling revision it was cut with, not whatever PyPI has.
- name: Install and test

View file

@ -1,44 +0,0 @@
{
"nowUnixMilliseconds": 1800000000000,
"odinRoot": {
"keyId": "odin-vector",
"x": "shJLGtmRrW6D87VQKv0xYQekLIfEkeN7dVawwpmIKWM=",
"y": "iR6Gx3aVCghzSyo7TqXqr+1397L73KDNZuzN2cZ55IE="
},
"route": {
"verseId": "aetheria",
"authorityRuntimeId": "aetheria-daemon",
"endpoint": "wss://provider.example/mesh",
"protocolIds": [
"cultmesh.content.v1",
"cultmesh.documents.v1"
],
"priority": 7,
"generation": "vector-generation-1",
"certificate": {
"providerKey": {
"keyId": "provider-vector",
"x": "6XIvgx3W1q7lJFEV0yYbeKWNFgssq54BHBuh9gCwcH8=",
"y": "oi1FiKraueLic+VemzOx18MNwG6thmZMUWeqNw6vNOk="
},
"odinKeyId": "odin-vector",
"issuedAtUnixMilliseconds": 1799999940000,
"expiresAtUnixMilliseconds": 1800003600000,
"signature": "ZHuELFuCNNfbJt1T2mx1D9ZHXyxBedacDetmryMYhWKQxfROncltymkmL5KXl+1h8tDK6NytJCGy+7mYlb/DMA=="
}
},
"sessionProof": {
"request": {
"schemaVersion": "cultmesh.session_open.v2",
"messageId": "vector-message-1",
"sourceRuntimeId": "vector-consumer",
"verseId": "aetheria",
"authorityRuntimeId": "aetheria-daemon",
"protocolId": "cultmesh.documents.v1",
"routeGeneration": "vector-generation-1",
"clientNonce": "VczqjH8NdBaOb+rh8xb6P0xZjWD1el2ReNB8Vbp52ko="
},
"providerKeyId": "provider-vector",
"providerSignature": "6kGxSu/pwcR4lzh/7Av9nhblVIwIwVFzB7kY34vVvkpaFAbAxt8mWqIyZDTftE28G1g+NL4Ea41C3QsuImmkaA=="
}
}

View file

@ -1,45 +0,0 @@
{
"nowUnixMilliseconds": 1800000000000,
"odinRoot": {
"keyId": "odin-ts-vector",
"x": "lqw9TE0EyuDU5Ghe8SafIbBBYkhHMUF7HXJQUc0nXsw=",
"y": "nUj+Xhakih97JiJcTUNMHM9JIo7MVQaauuAkCXph+tw="
},
"route": {
"verseId": "aetheria",
"authorityRuntimeId": "aetheria-daemon",
"endpoint": "cultmesh-state+quic://provider.example:4433/?cert-sha256=ABCDEF",
"protocolIds": [
"cultmesh.documents.v1",
"cultmesh.content.v1",
"cultmesh.realtime.v1"
],
"priority": 13,
"generation": "ts-generation-ünïcode-∞-1",
"certificate": {
"providerKey": {
"keyId": "provider-ts-vector",
"x": "JKyjtWnoE5fhSWbeqr188SbReQWCjssf5mSMEa5pV+E=",
"y": "vtduzojDpbT49sbxBSsBQ+f3PG3rm+CUk/MTy2MM/ws="
},
"odinKeyId": "odin-ts-vector",
"issuedAtUnixMilliseconds": 1799999940000,
"expiresAtUnixMilliseconds": 1800003600000,
"signature": "NS5a7X8fNqTi1CdpPAcJaMmEsozwFRoygTW4z+JlhHFq7uaZcb4G7NwoEpPqo/Fv+UA7pNl5N+qUMuSD/oAq1Q=="
}
},
"sessionProof": {
"request": {
"schemaVersion": "cultmesh.session_open.v2",
"messageId": "ts-vector-message-1",
"sourceRuntimeId": "ts-vector-consumer",
"verseId": "aetheria",
"authorityRuntimeId": "aetheria-daemon",
"protocolId": "cultmesh.documents.v1",
"routeGeneration": "ts-generation-ünïcode-∞-1",
"clientNonce": "HICtAmV++1MarssY04Tkyn1j9qVEcOMp/P5Ce1M0Yec="
},
"providerKeyId": "provider-ts-vector",
"providerSignature": "OgJHkmw3fhPRS5TXxZm94yZvkWAV5TPDuvG+/zIVlnU7OFENZ0Nn0Pdx0IT/vDfEWXD+8/+sCZVFIFwa4tUjsg=="
}
}

View file

@ -1,177 +0,0 @@
{
"frames": [
{
"label": "reliable-ordered, small payload",
"fields": {
"channelId": "aetheria.zone-1",
"schemaId": "cultmesh.realtime.v1",
"bodyId": "body-7",
"producerEpoch": 1,
"sequence": 2,
"delivery": "reliable-ordered",
"payload": "AH+A/yo="
},
"encoding": "Q1NUMQABAAAAAAAAAAIAAAAAAAAADwAUAAYABQAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03AH+A/yo=",
"encodedLength": 83,
"sha256OfEncoding": "d04539b99790560e5e570208d92d3ac25f85909a36bfeb190c2080288cc9f1d4"
},
{
"label": "latest-only, small payload",
"fields": {
"channelId": "aetheria.zone-1",
"schemaId": "cultmesh.realtime.v1",
"bodyId": "body-7",
"producerEpoch": 3,
"sequence": 4,
"delivery": "latest-only",
"payload": "AQID"
},
"encoding": "Q1NUMQEDAAAAAAAAAAQAAAAAAAAADwAUAAYAAwAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03AQID",
"encodedLength": 81,
"sha256OfEncoding": "2c875718cf4968979f3cd4083ba2b279e8c03815ad3447c66037ee4530b2814a"
},
{
"label": "unreliable, small payload",
"fields": {
"channelId": "aetheria.zone-1",
"schemaId": "cultmesh.realtime.v1",
"bodyId": "body-7",
"producerEpoch": 5,
"sequence": 6,
"delivery": "unreliable",
"payload": "/g=="
},
"encoding": "Q1NUMQIFAAAAAAAAAAYAAAAAAAAADwAUAAYAAQAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03/g==",
"encodedLength": 79,
"sha256OfEncoding": "b932adf333a0ca73d3a9077e15cf9062070a3084a50a3a30c3baa53312d864b1"
},
{
"label": "empty payload",
"fields": {
"channelId": "aetheria.zone-1",
"schemaId": "cultmesh.realtime.v1",
"bodyId": "body-7",
"producerEpoch": 0,
"sequence": 0,
"delivery": "reliable-ordered",
"payload": ""
},
"encoding": "Q1NUMQAAAAAAAAAAAAAAAAAAAAAADwAUAAYAAAAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03",
"encodedLength": 78,
"sha256OfEncoding": "06c0362f2b395bf7855522190f6a59c45c46947ce7a9a4b8be115cf54a48f419"
},
{
"label": "multi-byte identities",
"fields": {
"channelId": "aetheria.zöne-∞",
"schemaId": "cultmesh.realtime.€1",
"bodyId": "bødy-\uD83D\uDD25",
"producerEpoch": 11,
"sequence": 12,
"delivery": "latest-only",
"payload": "cGF5bG9hZC3iiJ4="
},
"encoding": "Q1NUMQELAAAAAAAAAAwAAAAAAAAAEgAWAAoACwAAACUAAAABAGFldGhlcmlhLnrDtm5lLeKInmN1bHRtZXNoLnJlYWx0aW1lLuKCrDFiw7hkeS3wn5SlcGF5bG9hZC3iiJ4=",
"encodedLength": 98,
"sha256OfEncoding": "90c6ae8f1362d9e000aa57682612bb1d7606cabe2cd0c0969fdd07b816f44b1c"
},
{
"label": "epoch and sequence at long.MaxValue",
"fields": {
"channelId": "aetheria.zone-1",
"schemaId": "cultmesh.realtime.v1",
"bodyId": "body-7",
"producerEpoch": 9223372036854775807,
"sequence": 9223372036854775807,
"delivery": "unreliable",
"payload": "Wg=="
},
"encoding": "Q1NUMQL/////////f/////////9/DwAUAAYAAQAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03Wg==",
"encodedLength": 79,
"sha256OfEncoding": "208068a668b48dc1abde9b3d7f025e7f7b6de6c480faea25c1383b6de9fd37ca"
},
{
"label": "identities at exactly 65535 UTF-8 bytes",
"fields": {
"channelId": {
"repeat": "ä€",
"byteLength": 65535
},
"schemaId": {
"repeat": "ۊ",
"byteLength": 65535
},
"bodyId": {
"repeat": "ñ€",
"byteLength": 65535
},
"producerEpoch": 13,
"sequence": 14,
"delivery": "reliable-ordered",
"payload": "ESI="
},
"encoding": null,
"encodedLength": 196644,
"sha256OfEncoding": "e3c1a353b606e86b35169efb875e76349dcfcb2d036973ee3bbe3c601fbda3e1"
},
{
"label": "64 MiB payload",
"fields": {
"channelId": "aetheria.zone-1",
"schemaId": "cultmesh.realtime.v1",
"bodyId": "body-7",
"producerEpoch": 15,
"sequence": 16,
"delivery": "reliable-ordered",
"payload": {
"ramp": 67108864
}
},
"encoding": null,
"encodedLength": 67108942,
"sha256OfEncoding": "4d99183bdb450af854d87a81ba86a3a4c92ecc1b208675d916f1b75a9f1e7928"
}
],
"malformed": [
{
"label": "truncated header",
"encoding": "Q1NUMQABAAAAAAAAAAIAAAAAAAAADwAUAAYAAwAAACUAAAAB",
"message": "Realtime frame header is truncated."
},
{
"label": "wrong magic",
"encoding": "vFNUMQABAAAAAAAAAAIAAAAAAAAADwAUAAYAAwAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03CQkJ",
"message": "Realtime frame magic is invalid."
},
{
"label": "wire version 2",
"encoding": "Q1NUMQABAAAAAAAAAAIAAAAAAAAADwAUAAYAAwAAACUAAAACAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03CQkJ",
"message": "Realtime frame wire version is unsupported."
},
{
"label": "header size 36",
"encoding": "Q1NUMQABAAAAAAAAAAIAAAAAAAAADwAUAAYAAwAAACQAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03CQkJ",
"message": "Realtime frame wire version is unsupported."
},
{
"label": "delivery 3",
"encoding": "Q1NUMQMBAAAAAAAAAAIAAAAAAAAADwAUAAYAAwAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03CQkJ",
"message": "Realtime frame delivery mode is invalid."
},
{
"label": "delivery 255",
"encoding": "Q1NUMf8BAAAAAAAAAAIAAAAAAAAADwAUAAYAAwAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03CQkJ",
"message": "Realtime frame delivery mode is invalid."
},
{
"label": "payload length -1",
"encoding": "Q1NUMQABAAAAAAAAAAIAAAAAAAAADwAUAAYAAwAAgCUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03CQkJ",
"message": "Realtime frame length is invalid."
},
{
"label": "length sum off by one",
"encoding": "Q1NUMQABAAAAAAAAAAIAAAAAAAAADwAUAAYAAwAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03CQkJAA==",
"message": "Realtime frame length is invalid."
}
]
}

View file

@ -1,177 +0,0 @@
{
"frames": [
{
"label": "ts reliable-ordered, small payload",
"fields": {
"channelId": "ts.zone-a",
"schemaId": "cultmesh.realtime.v1",
"bodyId": "ts-body-1",
"producerEpoch": 21,
"sequence": 22,
"delivery": "reliable-ordered",
"payload": "AAH+/w=="
},
"encoding": "Q1NUMQAVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xAAH+/w==",
"encodedLength": 79,
"sha256OfEncoding": "68c264a42ce34e540e9dbee31dab5b73dec98c767f0b44591ce6a477cd17fc04"
},
{
"label": "ts latest-only, small payload",
"fields": {
"channelId": "ts.zone-a",
"schemaId": "cultmesh.realtime.v1",
"bodyId": "ts-body-1",
"producerEpoch": 23,
"sequence": 24,
"delivery": "latest-only",
"payload": "f4A="
},
"encoding": "Q1NUMQEXAAAAAAAAABgAAAAAAAAACQAUAAkAAgAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xf4A=",
"encodedLength": 77,
"sha256OfEncoding": "3d4f49f4aa2c1f132c3a09ee83555c13e0bc036d636524a972e2829cf417b959"
},
{
"label": "ts unreliable, small payload",
"fields": {
"channelId": "ts.zone-a",
"schemaId": "cultmesh.realtime.v1",
"bodyId": "ts-body-1",
"producerEpoch": 25,
"sequence": 26,
"delivery": "unreliable",
"payload": "pQ=="
},
"encoding": "Q1NUMQIZAAAAAAAAABoAAAAAAAAACQAUAAkAAQAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xpQ==",
"encodedLength": 76,
"sha256OfEncoding": "2c2faec604850959d44ee65d87d2ea59462b667c5d0db8565a8c159d75301851"
},
{
"label": "ts empty payload",
"fields": {
"channelId": "ts.zone-a",
"schemaId": "cultmesh.realtime.v1",
"bodyId": "ts-body-1",
"producerEpoch": 0,
"sequence": 0,
"delivery": "latest-only",
"payload": ""
},
"encoding": "Q1NUMQEAAAAAAAAAAAAAAAAAAAAACQAUAAkAAAAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0x",
"encodedLength": 75,
"sha256OfEncoding": "6d2a04b399f32db4086e728194249b0279330ffa5517cdd82228f691a0f10708"
},
{
"label": "ts multi-byte identities",
"fields": {
"channelId": "ts.zøne-∂",
"schemaId": "cultmesh.realtime.µ1",
"bodyId": "ts-bödy-🧊",
"producerEpoch": 27,
"sequence": 28,
"delivery": "unreliable",
"payload": "dHMtcGF5bG9hZC3iiII="
},
"encoding": "Q1NUMQIbAAAAAAAAABwAAAAAAAAADAAVAA0ADgAAACUAAAABAHRzLnrDuG5lLeKIgmN1bHRtZXNoLnJlYWx0aW1lLsK1MXRzLWLDtmR5LfCfp4p0cy1wYXlsb2FkLeKIgg==",
"encodedLength": 97,
"sha256OfEncoding": "d5d22458bd50a446c7bdce4e2598fe8e5e7bfac3646d8fcd7215363c7c5c8ca1"
},
{
"label": "ts epoch and sequence at long.MaxValue",
"fields": {
"channelId": "ts.zone-a",
"schemaId": "cultmesh.realtime.v1",
"bodyId": "ts-body-1",
"producerEpoch": 9223372036854775807,
"sequence": 9223372036854775807,
"delivery": "reliable-ordered",
"payload": "Bw=="
},
"encoding": "Q1NUMQD/////////f/////////9/CQAUAAkAAQAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xBw==",
"encodedLength": 76,
"sha256OfEncoding": "2abb67a876dc4ed006f33708b4c4dda55a90a6f95da7352749225195b10d45ba"
},
{
"label": "ts identities at exactly 65535 UTF-8 bytes",
"fields": {
"channelId": {
"repeat": "ü☃",
"byteLength": 65535
},
"schemaId": {
"repeat": "☃ü",
"byteLength": 65535
},
"bodyId": {
"repeat": "ç☃",
"byteLength": 65535
},
"producerEpoch": 29,
"sequence": 30,
"delivery": "latest-only",
"payload": "M0Q="
},
"encoding": null,
"encodedLength": 196644,
"sha256OfEncoding": "8bd4cd4c5232339f76b8e394eb93b66e8d14302398ecce2ae5bdb78070ef783a"
},
{
"label": "ts 64 MiB payload",
"fields": {
"channelId": "ts.zone-a",
"schemaId": "cultmesh.realtime.v1",
"bodyId": "ts-body-1",
"producerEpoch": 31,
"sequence": 32,
"delivery": "unreliable",
"payload": {
"ramp": 67108864
}
},
"encoding": null,
"encodedLength": 67108939,
"sha256OfEncoding": "f2c20e0b47a7681fbaeff6fcba024363542805de4cd95eeb7fa9f125ba646af0"
}
],
"malformed": [
{
"label": "ts truncated header",
"encoding": "Q1NUMQAVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAACUAAAAB",
"message": "Realtime frame header is truncated."
},
{
"label": "ts wrong magic",
"encoding": "Q1OrMQAVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xAAH+/w==",
"message": "Realtime frame magic is invalid."
},
{
"label": "ts wire version 2",
"encoding": "Q1NUMQAVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAACUAAAACAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xAAH+/w==",
"message": "Realtime frame wire version is unsupported."
},
{
"label": "ts header size 38",
"encoding": "Q1NUMQAVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAACYAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xAAH+/w==",
"message": "Realtime frame wire version is unsupported."
},
{
"label": "ts delivery 3",
"encoding": "Q1NUMQMVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xAAH+/w==",
"message": "Realtime frame delivery mode is invalid."
},
{
"label": "ts delivery 128",
"encoding": "Q1NUMYAVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xAAH+/w==",
"message": "Realtime frame delivery mode is invalid."
},
{
"label": "ts payload length -1",
"encoding": "Q1NUMQAVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAgCUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xAAH+/w==",
"message": "Realtime frame length is invalid."
},
{
"label": "ts length sum off by one",
"encoding": "Q1NUMQAVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xAAH+/wA=",
"message": "Realtime frame length is invalid."
}
]
}

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,5 @@
# NuGet Packaging
See `docs/semver-policy.md` for what a version bump on these packages is
required to mean and how a release is checked against that before it ships.
CultLib publishes the managed dependency graph as separate packages. The leaf
package is `GameCult.Mesh`; its NuGet dependencies preserve the CultMesh,
CultNet, and CultCache ownership boundaries.
@ -22,5 +19,5 @@ closure through UPM rather than NuGet restore.
Install the Unity package from the immutable release tag:
```text
https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.57
https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.46
```

View file

@ -1,213 +0,0 @@
# Semantic Versioning Policy
Operator ruling, 2026-09-17: **semantic versioning is policy** for every
package CultLib publishes, strictly. A release that removes or changes public
surface incompatibly is a major bump, full stop — not a judgment call left to
the person cutting the release.
This document says what "breaking" means for each kind of surface this repo
actually ships, and how a release is checked against it. See
`docs/nuget-packaging.md` for how the managed/Unity packaging mechanics work;
this document is the versioning contract those mechanics must satisfy.
## The scar
`org.gamecult.cultlib` 1.0.60 removed `CultInspectorAssetPath` — public API —
and shipped as a patch bump. Its own changelog entry is titled "Breaking"; the
version number said otherwise. A reviewer relying on the number alone would
have missed it. This is recorded as history, not fixed by renumbering: `1.0.60`
stays `1.0.60`. This policy, and the check below, exist so the next one is
caught before it ships instead of written up afterward.
## Packages in scope
| Package | Kind | Tag prefix | Published by |
| --- | --- | --- | --- |
| `org.gamecult.cultlib` | Unity UPM | `cultlib-unity` | `scripts/build-unity-package.ps1`, consumed by git tag |
| `org.gamecult.caching.unity` | Unity UPM | `caching-unity` | tagged directly (no build script; see "Coverage gaps") |
| `org.gamecult.cultmath` | Unity UPM | `cultmath-unity` | `packages/cultmath/scripts/build-unity-package.ps1`, consumed by git tag |
| `@gamecult/cultcache-ts` | npm | `cultcache-ts` | `.github/workflows/publish-packages.yml` |
| `cultcache-py` | PyPI | `cultcache-py` | `.github/workflows/publish-packages.yml` |
| `cultnet-py` | PyPI | `cultnet-py` | `.github/workflows/publish-packages.yml` |
| `cultmesh-py` | PyPI | `cultmesh-py` | `.github/workflows/publish-packages.yml` |
Rust crates are consumed as pinned git revisions (see `publish-packages.yml`'s
own header comment) and are out of scope for tagged-release semver policing;
a `Cargo.toml` version still exists and should follow this policy by
convention, but nothing in this repo checks it mechanically today.
Tag naming stays `<package>-v<version>` for every package above, matching the
prefixes already in use.
## What counts as breaking
Semver's ordinary rule — additive is minor or patch, anything a consumer's
existing code or data can no longer rely on is major — applies per surface as
follows.
### Managed public API (`org.gamecult.cultlib`, NuGet packages, `cultmath`)
Breaking: removing or renaming a public type/member, changing a public
member's signature or observable behavior in an incompatible way, tightening
a public contract (new required parameter, narrowed accepted input),
or moving a type across assemblies/namespaces without a compatible forwarder.
Not breaking: adding a new public member, type, or overload; widening an
accepted type; deprecating (marking obsolete) without removing.
### Unity package inspector attributes (`org.gamecult.cultlib`,
`org.gamecult.caching.unity`)
These ship as ordinary public API by the rule above, but get their own
callout because they are easy to mistake for "just editor tooling": an
attribute class, its constructor shape, and the serialized value shape it
reads/writes are all public surface a consumer's `[CultInspectorX(...)]`
usage and serialized scene/prefab data depend on. Removing or renaming an
attribute, or changing what it expects a annotated member's persisted value to
look like (as `CultInspectorAssetPath` to `CultInspectorAssetGuid` did in
1.0.60) is breaking.
### Persisted wire format / store compatibility (CultCache)
Governed by `src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md`
and `cultcache-persistence-format.md` — read those for the authoritative
rules. In semver terms: anything those documents classify as **hard
rejection** (type change, reference/value change, one-vs-many change, target
schema change, name/index lookup semantics change, a persisted schema with no
compatible local candidate) is breaking for any package that reads or writes
the format. Anything classified as **soft-migratable drift** (compatible
schema id drift, an ignorable missing slot, a defaultable missing slot) is not
breaking on its own, but changing what counts as soft-migratable — loosening
or tightening the drift rules themselves — is breaking for whichever package
owns that classification logic.
### CultNet protocol surface
Breaking: a wire-incompatible change to a CultNet message, frame, or
handshake shape; removing or repurposing a message type or field that another
runtime's implementation depends on; changing routing, authority, or trust
semantics such that a peer built against the previous protocol version
misbehaves rather than cleanly failing. Additive, backward-compatible framing
changes (a new optional field a receiver may ignore) are not breaking.
### Native bridge exported functions (`GameCult.Mesh.Quic.Native`)
Breaking: removing or changing the signature/ABI of a `CULTMESH_API`
(`extern "C"`) exported function, or changing an exported function's
behavior such that the existing managed P/Invoke caller (`GameCult.Mesh.Quic.Native`'s
C# side) or Rust caller (`cultmath-core`'s equivalent `#[no_mangle] extern "C"`
surface) no longer gets what it asked for. Swapping the underlying TLS/QUIC
implementation (as 1.0.60 did, Schannel to OpenSSL MsQuic) is not breaking
*by itself* as long as the exported v1 functions keep their signatures and
behavior — 1.0.60's actual breaking change was the inspector attribute
removal, not the bridge swap, and its changelog correctly filed the bridge
swap under "Changed" rather than "Breaking".
## Pre-1.0 packages
A package at `0.y.z` (currently `org.gamecult.cultmath`, at `0.2.x`) uses the
standard pre-1.0 convention: `MAJOR` stays `0` during initial development, so
`MINOR` carries what `MAJOR` means once the package reaches `1.0.0`. Concretely:
- a breaking change bumps `MINOR` (and resets `PATCH` to `0`);
- a non-breaking change bumps `PATCH`.
Leaving `0.y.z` for `1.0.0` is a deliberate, separate decision (the API is now
considered stable), not something the checker infers on its own.
## What a release changelog entry must contain
Every published package keeps a `CHANGELOG.md` beside its manifest
(`unity/org.gamecult.cultlib/CHANGELOG.md` and
`src/GameCult.Unity/Assets/Caching/CHANGELOG.md` are the existing precedent).
Each release adds a `## [<version>]` section containing:
- a category subsection per change (`### Added`, `### Changed`, `### Fixed`,
etc., following Keep a Changelog style);
- **`### Breaking`** — this exact heading, this exact spelling — for any
change that meets the "what counts as breaking" rules above. This heading
is not decorative: the CI check below greps for it by name.
- enough detail that a consumer can tell what to change, in the style of the
1.0.60 entry (name the removed/changed symbol and its replacement).
A version with no changelog entry at all fails the check below, the same as
a mislabelled one — an undocumented release is not a smaller sin than a
mislabelled one.
## Enforcement
`scripts/check-changelog-semver.mjs` is the mechanical check. Given a
package name, its `CHANGELOG.md` path, the version being released, and a git
tag prefix, it:
1. resolves the previous published version from the highest existing
`<prefix>-v*` tag older than the version being released (or treats the
release as a package's first if none exists);
2. requires a `## [<version>]` changelog entry to exist at all;
3. classifies the version bump (major/minor/patch) against the previous
version, refusing anything that is not exactly the next version in some
lane — a skip, a reversal, or a repeat are all refused, not just wrong
bump sizes;
4. if the changelog entry has a `### Breaking` section, requires the bump to
be at least major (or, pre-1.0, at least minor);
5. fails loudly, naming the package, the previous and new version, and (when
relevant) the changelog heading that triggered the requirement.
Run it directly: `node scripts/check-changelog-semver.mjs --package <name>
--changelog <path> --version <x.y.z> --tag-prefix <prefix>`. Its own tests
live in `scripts/check-changelog-semver.test.mjs` (`node --test
scripts/check-changelog-semver.test.mjs`).
It is wired into:
- `.github/workflows/publish-packages.yml`, right after each job's existing
"tag matches manifest version" check, for `cultcache-ts`, `cultcache-py`,
`cultnet-py`, and `cultmesh-py`;
- `scripts/build-unity-package.ps1`, for `org.gamecult.cultlib`;
- `packages/cultmath/scripts/build-unity-package.ps1`, for
`org.gamecult.cultmath`;
- `scripts/verify-caching-unity-release.ps1`, for `org.gamecult.caching.unity`.
### Coverage gaps
`publish-packages.yml` only triggers on `cultcache-ts-v*`, `cultcache-py-v*`,
`cultnet-py-v*`, and `cultmesh-py-v*` tag pushes — it does not build or gate
the Unity UPM packages at all; those are consumed directly by git tag
(`unity/org.gamecult.cultlib`, `packages/cultmath/unity/org.gamecult.cultmath`,
`src/GameCult.Unity/Assets/Caching`), so a workflow trigger on their tags
cannot be added to that file without also giving it a reason to check out and
build Unity content it currently has no job for. The check is instead wired
into the scripts that already run at Unity-package release time:
`build-unity-package.ps1` and `packages/cultmath/scripts/build-unity-package.ps1`
already verify version consistency between the built assembly and
`package.json`, so the semver check runs alongside that as one more release
gate. `org.gamecult.caching.unity` has no build script at all today — it is
pure C# source tagged directly with no compile step — so
`scripts/verify-caching-unity-release.ps1` exists solely to run this check
before that package is tagged; it is not a build script and does not become
one.
None of this is enforced by a git hook or branch protection rule: it runs
when someone remembers to run the Unity release scripts, the same way the
existing assembly-version check does. A tag pushed by hand without running
the release script bypasses both checks equally; closing that gap is a CI
trigger on tag push for the Unity packages, not something this change adds.
Rust crates (consumed as pinned git revisions, not tagged releases in the
usual sense) and the CultNet protocol surface itself (no automated wire
compatibility test currently gates a release) are policy by convention only —
described above, not mechanically checked. Extending the checker to a Rust
crate's `Cargo.toml`/`CHANGELOG.md` or to an automated CultNet interop
compatibility gate is future work, not part of this change.
`@gamecult/cultcache-ts`, `cultcache-py`, `cultnet-py`, `cultmesh-py`, and
`org.gamecult.cultmath` currently have **no `CHANGELOG.md` file at all**. This
is a real, pre-existing gap, not something introduced by this policy: their
next tagged release will fail the new check for exactly that reason ("no
changelog entry for version X") until each package's `CHANGELOG.md` exists.
That failure is intended — a release with no changelog is not a smaller
problem than a mislabelled one — but it does mean the very next release of
any of these five packages needs a `CHANGELOG.md` created first. Backfilling
changelog content for their past releases is not part of this change: it
would mean inventing release-note prose for history this pass has no
first-hand knowledge of, which is worse than leaving the gap named.

File diff suppressed because it is too large Load diff

View file

@ -1,160 +0,0 @@
# TypeScript QUIC Realtime Plane Target
Date: 2026-09-16
Status: target accepted by the operator (2026-09-16), ready for Eureka. No cut
map exists yet. When the pipeline starts, Imagination maps the cut into
`docs/typescript-quic-realtime-cut.md` against `main` at `a0813c6` or later.
The TypeScript runtime gains the CultMesh QUIC realtime state plane that the C#
runtime already has: a provider that serves frames and a consumer that receives
them, byte-compatible with `GameCult.Mesh.Quic` and the native MsQuic connector
the Unity package ships. This document states the end state and the invariants
that constrain getting there.
Operator ruling, 2026-09-16, on providing QUIC for a Node service: "Option 2 all
the way, this is what CultLib is supposed to be, we're not half assing it." A
separate .NET provider process and a WebSocket shortcut were both rejected.
## Why now
The first consumer is StreamPixels. Its service (Node 24, TypeScript, on
Yggdrasil) must publish realtime state to a Unity overlay running on a
streamer's machine. The overlay is a receive-only client using the Unity
package's `CultMeshNativeQuicRealtimeTransportConnector`. Today no TypeScript
path exists:
- Node v24.15.0 has no `node:quic`, with or without `--experimental-quic`.
- `packages/cultmesh-ts` and `packages/cultnet-ts` contain no QUIC code.
- `GameCult.Mesh.Quic` (.NET 10, `System.Net.Quic`) is the only QUIC provider.
- The C# TCP control and content connectors are plaintext and restricted to
loopback (`src/GameCult.Mesh/docs/transport-planes.md`, "Security and release
gate"), so they cannot carry the overlay's remote session either.
## This touches the foundation
- **Wire parity is an invariant.** The C# codec is the reference:
`src/GameCult.Mesh/CultMeshRealtimeWireProtocol.cs`. The TypeScript runtime
must produce and accept identical bytes:
- ALPN `cultmesh-state-v1`; connection close code `0x43554c54`, stream abort
code `0x53544154`; reliable stream type `1`, latest-only stream type `2`.
- Frame: magic `0x31545343`, then a 37-byte little-endian fixed header
(delivery byte, producer epoch i64, sequence i64, channel/schema/body id
lengths u16, payload length i32, header size i32 = 37, wire version u16 =
1), then UTF-8 channel id, schema id, body id, then payload.
- Limits: payload at most 64 MiB; each identity at most 65,535 UTF-8 bytes;
decode rejects wrong magic, unknown version, out-of-range delivery, and any
length that does not sum exactly.
- **Delivery semantics are an invariant**
(`src/GameCult.Mesh.Quic/README.md`, `CultMeshRealtimeTransports.cs`):
- `ReliableOrdered`: one persistent ordered stream; broadcast is
completion-bearing.
- `LatestOnly`: independent streams; at most one pending frame per
`(channel, body)`, replaced by a newer `(producer epoch, sequence)`
generation; a keyed coalescing outbox per physical peer, so a slow or
departed consumer never backpressures the producer.
- `Unreliable`: fails closed until datagrams are exposed, matching the managed
C# adapter.
- **Trust is an invariant.** No remote session comes online without the chain
in `transport-planes.md`: an Odin-signed `CultMeshRouteCertificate` (Verse,
authority runtime, protocol set, endpoint, generation, validity, provider
P-256 key) checked against consumer-pinned Odin roots, TLS 1.3, and the
endpoint certificate bound to that route, including the
`cultmesh-state+quic://host:port?cert-sha256=<uppercase SHA-256>` pin.
Signatures are ECDSA P-256 over SHA-256 with P1363 `r || s` encoding and the
length-prefixed canonical transcript. Unsigned traffic exists only under an
explicit `LocalDevelopment` policy on loopback.
- **One implementation of TypeScript route verification.** `cultmesh-browser`
already verifies Odin route certificates and provider nonce possession with
WebCrypto (`packages/cultmesh-browser/src/index.ts`, `verifyAuthorityRoute`,
`canonicalRoute`, `canonicalSession`, `verifyP256`). The Node QUIC path must
reuse that owner, not grow a second verifier.
- **The realtime plane carries state frames only.** Schemas, commands,
receipts, manifests, and immutable content do not travel through it
(`ICultMeshRealtimeTransport` doc comment). RUDP is never installed
implicitly in its place.
- **Existing consumers survive.** The StreamPixels service already imports
`cultcache-ts`, `cultnet-ts`, and `cultmesh-ts` (`verse-state.ts`,
`idunn-rudp-health.ts`) for Odin advertisement and Idunn health over RUDP.
Imagination audits every other TypeScript consumer across `F:\Projects`
before sizing the cut.
## Target state
- `cultmesh-ts` exposes a QUIC realtime provider and a QUIC realtime consumer
with the same ergonomic shape as the C# `CultMeshQuicRealtimeServer`
(listen, broadcast with an explicit delivery mode) and
`ICultMeshRealtimeTransportConnector` (connect by advertised candidate,
receive frames), under the branded `CultMesh` entrypoint.
- QUIC comes from native MsQuic bindings owned by CultLib, not from a Node
polyfill or a third-party QUIC stack with different semantics. How the binding
is built and shipped is the means (Imagination probes it); what it must
deliver is TLS 1.3, ALPN, independent unidirectional or bidirectional streams,
certificate validation with a caller hook, and clean close and abort codes.
- Frame encoding and decoding exist once in TypeScript and are tested against
bytes produced by the C# codec.
- Application code never picks endpoints, ranks routes, or writes reconnect
loops; `CultMeshSessionManager`-equivalent route selection owns that, as in C#.
- The build produces binaries for the hosts that run them: Windows x64 for
local development, and Linux x64 for Yggdrasil, built on Linux or the deploy
path's builder, not cross-compiled on Starfire.
## Not in this migration
One foundation per pipeline. These are separate, named pipelines or consumer
work:
- **TypeScript content plane** (a provider in `cultmesh-ts`, a verifying HTTPS
reader in `cultmesh-browser`) for exported character artifacts. HTTPS content
transport and single-host trust on Yggdrasil were already ruled.
- **Authenticated remote control plane** (TLS on the TCP schema and content
connectors). See Q1.
- **Browser QUIC** (WebTransport). Browsers keep the verified WebSocket path.
- **Python, Rust, and Kotlin QUIC.** The parity scope is role-scoped; this
migration claims TypeScript and C# only.
- **StreamPixels integration** (service publication, overlay subscription, and
the catalog and scene-intent schemas) is consumer work in StreamPixels after a
released `cultmesh-ts` exists.
## Verification the cut must reach
- Golden frame bytes from the C# codec decode in TypeScript, and TypeScript
encodings decode in C#, for every delivery mode and at the length limits.
Malformed inputs are rejected identically.
- Separate processes, both directions: a TypeScript provider to the C# native
connector (the one Unity uses) and to the managed connector, and a C# managed
provider to the TypeScript consumer. Extend the existing parity harnesses
(`CULTMESH_NATIVE_EXTERNAL_ENDPOINT`, `packages/cultnet-ts/test/interop`) rather
than adding a parallel one.
- `LatestOnly` coalescing: a stalled consumer does not delay the producer, and
the consumer converges on the terminal generation.
- Trust negatives: an expired or unsigned route, the wrong Odin root, a
mismatched `cert-sha256` pin, and a replayed nonce are each refused, with a
mutation per rule.
- The Linux x64 binary runs on the Yggdrasil deploy path, verified on that host.
## Rulings (operator, 2026-09-16)
The operator accepted every recommendation below ("You got it"). Each question
is kept with its options as the record of what was decided against.
- **Q1. Which remote plane carries the overlay's non-frame data?** The realtime
plane forbids schemas, commands, and content, and the TCP planes are
loopback-only. A: publish her character, the subathon window, and scene
intents as QUIC state frames with their CultCache schema id and payload, and
ship the costume catalog as a versioned immutable artifact on the HTTPS
content plane; the overlay sends no commands, so it needs no remote control
plane. B: add TLS to the TCP schema and content connectors as its own
pipeline first. **Recommended: A.** It needs no new plane for M1, matches a
receive-only client, and leaves TLS TCP to the first consumer that sends
commands.
- **Q2. Where does shared TypeScript route verification live?** It is inside
`cultmesh-browser` today, and `cultmesh-ts` is Node-only. A: move it to a
browser-safe shared module that both import (for example beside
`cultnet-ts/contracts`). B: have `cultmesh-ts` depend on `cultmesh-browser`.
**Recommended: A**, so neither transport package owns the other's trust.
- **Q3. Which platforms must the native binding support at release?**
**Recommended: Windows x64 and Linux x64 only**, the two hosts that run it
today.
- **Q4. Are QUIC datagrams (`Unreliable`) in scope?** **Recommended: no.** Fail
closed like the managed C# adapter; no consumer needs them.

View file

@ -1,118 +1,20 @@
cmake_minimum_required(VERSION 3.24)
project(gamecult_mesh_quic_native LANGUAGES CXX)
# Two platforms, one source. Windows takes MsQuic from the pinned OpenSSL NuGet
# layout (MSQUIC_ROOT); Linux takes it from the extracted libmsquic deb, whose
# headers and library land in unrelated places, so they are named separately.
if(NOT WIN32 AND NOT (CMAKE_SYSTEM_NAME STREQUAL "Linux"))
message(FATAL_ERROR "The CultMesh native QUIC bridge supports Windows x64 and Linux x64")
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)
# include/cultmesh_quic_native.h is the host-facing contract, not an internal
# detail: the bridge includes it, and so does any consumer that can.
target_include_directories(gamecult_mesh_quic_native PUBLIC include)
if(WIN32)
if(NOT DEFINED MSQUIC_ROOT)
message(FATAL_ERROR "MSQUIC_ROOT must point at the extracted Microsoft.Native.Quic.MsQuic.OpenSSL package build/native directory")
endif()
# Static CRT. A Node or Unity host without the Visual C++ redistributable
# installed cannot load a DLL that imports vcruntime140.dll, and the failure
# arrives as "the specified module could not be found" with nothing naming the
# missing runtime. The bridge carries its own instead.
set_property(TARGET gamecult_mesh_quic_native PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded")
target_compile_definitions(gamecult_mesh_quic_native PRIVATE 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")
# crypt32 and bcrypt belong to the v1 shim alone: CryptHashCertificate2
# compares the Unity pin. Nothing in the v2 path needs them. ntdll carries
# RtlIpv4/6StringToAddressEx, which msquic_winuser.h's inline QuicAddrFromString
# calls to parse the listener host; the Linux header parses it in-tree instead.
target_link_libraries(gamecult_mesh_quic_native PRIVATE msquic crypt32 bcrypt ntdll)
# The DLL is committed beside its source, so a rebuild must reproduce it: /Brepro drops the link timestamps.
target_compile_options(gamecult_mesh_quic_native PRIVATE /Brepro)
target_link_options(gamecult_mesh_quic_native PRIVATE /Brepro)
else()
if(NOT DEFINED MSQUIC_INCLUDE_DIR OR NOT DEFINED MSQUIC_LIB_DIR)
message(FATAL_ERROR "MSQUIC_INCLUDE_DIR and MSQUIC_LIB_DIR must point at the msquic v2.5.9 headers and the extracted libmsquic library directory")
endif()
target_include_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_INCLUDE_DIR}")
target_link_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_LIB_DIR}")
target_link_libraries(gamecult_mesh_quic_native PRIVATE msquic pthread)
# Only the CULTMESH_API exports leave the .so; everything else stays internal.
target_compile_options(gamecult_mesh_quic_native PRIVATE -fvisibility=hidden -fvisibility-inlines-hidden)
# libmsquic.so.2 ships beside the bridge rather than being installed
# system-wide, so the loader is pointed at the bridge's own directory.
set_target_properties(gamecult_mesh_quic_native PROPERTIES
BUILD_WITH_INSTALL_RPATH TRUE
INSTALL_RPATH "$ORIGIN")
endif()
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"
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
# The runtime-lifetime scenarios. Off by default: the shipped artifact is the
# library alone, and a delivery build has no business producing an executable.
# CULTMESH_QUIC_DEBUG_ASSERTS goes with them, never into a shipped binary — it
# makes the bridge state the quiesce invariant instead of leaving it to whatever
# a sanitizer happens to catch.
option(CULTMESH_QUIC_BUILD_TESTS "Build the runtime-lifetime scenario runner" OFF)
option(CULTMESH_QUIC_DEBUG_ASSERTS "Abort the bridge on a violated internal invariant" OFF)
if(CULTMESH_QUIC_DEBUG_ASSERTS)
target_compile_definitions(gamecult_mesh_quic_native PRIVATE CULTMESH_QUIC_DEBUG_ASSERTS)
endif()
if(CULTMESH_QUIC_BUILD_TESTS)
add_executable(cultmesh_quic_native_tests tests/cultmesh_quic_native_tests.cpp)
target_compile_features(cultmesh_quic_native_tests PRIVATE cxx_std_20)
# The header only. The scenarios drive the bridge as a host does, so they get
# no more of it than a host has.
target_link_libraries(cultmesh_quic_native_tests PRIVATE gamecult_mesh_quic_native)
# The scenarios see the development seam (section 7 of the header) exactly when
# the library has one, so `holdclose` compiles out of the same builds the
# bridge's own assertions compile out of.
if(CULTMESH_QUIC_DEBUG_ASSERTS)
target_compile_definitions(cultmesh_quic_native_tests PRIVATE CULTMESH_QUIC_DEBUG_ASSERTS)
endif()
set_target_properties(cultmesh_quic_native_tests PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
if(WIN32)
# gamecult_mesh_quic_native.dll imports msquic.dll, and the scenario
# runner loads it from its own directory (or PATH) at process start, not
# from MSQUIC_ROOT. build-quic-native.ps1's packaged output gets a copy
# from its own recipe; a test build has no packaging step, so without this
# the loader falls through to whatever msquic.dll happens to already be on
# PATH — silently running the scenarios against an unpinned, wrong-version
# MsQuic instead of failing to start. CMake already knows MSQUIC_ROOT, so
# it places the pinned DLL itself instead of leaving the reader to guess
# why a scenario passed against the wrong binary.
add_custom_command(TARGET cultmesh_quic_native_tests POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy
"${MSQUIC_ROOT}/bin/x64/msquic.dll"
"$<TARGET_FILE_DIR:cultmesh_quic_native_tests>/msquic.dll")
else()
target_link_libraries(cultmesh_quic_native_tests PRIVATE pthread)
# The linker resolves the bridge's own NEEDED libmsquic.so.2 while linking
# this executable, and the runtime copy only lands beside the binaries after
# the build; point it at the extracted package instead.
target_link_options(cultmesh_quic_native_tests PRIVATE "-Wl,-rpath-link,${MSQUIC_LIB_DIR}")
set_target_properties(cultmesh_quic_native_tests PROPERTIES
BUILD_WITH_INSTALL_RPATH TRUE
INSTALL_RPATH "$ORIGIN")
# Both binaries carry INSTALL_RPATH "$ORIGIN" and expect libmsquic.so.2
# beside them at load time. build-quic-native.sh's packaged output gets
# that copy from its own recipe; a test build has no packaging step, so
# the runner would otherwise die at once with "cannot open shared object
# file". CMake already knows where MSQUIC_LIB_DIR is, so it places the
# file itself instead of leaving the reader to run an extra cp by hand.
add_custom_command(TARGET cultmesh_quic_native_tests POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy
"${MSQUIC_LIB_DIR}/libmsquic.so.2"
"$<TARGET_FILE_DIR:cultmesh_quic_native_tests>/libmsquic.so.2")
endif()
endif()
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")

View file

@ -1,168 +1,10 @@
# GameCult.Mesh.Quic.Native
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.
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 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. It is
stated once, in [`include/cultmesh_quic_native.h`](include/cultmesh_quic_native.h):
the 64-byte event struct with its field order and asserted offsets, the thirteen
exports with their signatures, the id and return-code conventions, the object
lifetime rules and the threading rules. That file is the contract. This README
does not restate it, and where the two ever disagree the header is right.
A host that can include it should. A host that cannot — koffi, Unity's
P/Invoke — declares the same thing in its own FFI and is checked against the
header by a test rather than by eye.
Two things worth reading before writing a host, because they are easy to get
wrong from the outside:
- **Return codes are negative on a bad call, on every platform.** `-1` is a bad
call, `-2` a rejected argument, `<= -1000` a MsQuic refusal. `QUIC_STATUS`
itself is not portable — a negative HRESULT on Windows, a positive errno on
POSIX — so it never reaches a host; `cultmesh_quic_last_status` hands over the
raw value for diagnostics and nothing else.
- **`cultmesh_quic_runtime_close` quiesces.** It marks the runtime closing, wakes
every blocked `cultmesh_quic_next_event`, and waits for every in-flight call to
return before it frees anything. No host call may begin after it starts.
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 `Microsoft.Native.Quic.MsQuic.OpenSSL` 2.5.9, pinned twice: the
NuGet zip by digest and size, and the `msquic.dll` inside it by digest and size,
because the archive is what the download is checked against and the DLL is what
actually ships beside the bridge. The OpenSSL flavour rather than Schannel
because Schannel's MsQuic refuses `QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12`,
which is the only credential type a provider has on both platforms.
The build uses MSVC with `/Brepro` so that a build reproduces itself: the link
timestamps are dropped, and building the same source twice gives the same bytes.
It links the CRT statically (`/MT`), because a Node or Unity host without the
Visual C++ redistributable cannot load a DLL that imports `vcruntime140.dll`,
and the failure it reports names no missing runtime; the bridge carries its own.
The committed Unity plugin
(`unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/gamecult_mesh_quic_native.dll`)
is this build: 302,080 bytes, `/MT`, OpenSSL MsQuic, no CRT import. It replaced
an older 40,448-byte build — before `/MT` and before the move off Schannel,
still importing `VCRUNTIME140.dll` and `MSVCP140.dll` — at the CultLib 1.0.60 /
Studio 1.4.0 release.
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`.
The development loop for it is one committed image,
[`scripts/quic-native-linux-dev.Dockerfile`](../../scripts/quic-native-linux-dev.Dockerfile),
pinned to the same `debian:13` digest the release workflow builds in. It carries
the toolchain, MsQuic's runtime dependencies, and `setarch`, because a
sanitizer build of the runtime-lifetime scenarios needs `personality(ADDR_NO_RANDOMIZE)`
and a container that cannot ask for that cannot run them:
docker build -t cultlib-quic-native-dev -f scripts/quic-native-linux-dev.Dockerfile scripts
docker run --rm --security-opt seccomp=unconfined -v "${PWD}:/src" -w /src cultlib-quic-native-dev bash -lc "
scripts/build-quic-native.sh
cmake -S native/GameCult.Mesh.Quic.Native -B artifacts/quic-native-tests/linux-x64 -G Ninja \
-DCMAKE_BUILD_TYPE=Release -DCULTMESH_QUIC_BUILD_TESTS=ON -DCULTMESH_QUIC_DEBUG_ASSERTS=ON \
-DMSQUIC_INCLUDE_DIR=\$(pwd)/artifacts/dependencies/msquic-linux-2.5.9/include \
-DMSQUIC_LIB_DIR=\$(pwd)/artifacts/dependencies/msquic-linux-2.5.9/package/usr/lib/x86_64-linux-gnu
cmake --build artifacts/quic-native-tests/linux-x64
for scenario in closerace holdclose holdtimeout polltimeout pollbusy pollhammer zerotimeout payloadfit latecall waitseam; do
setarch -R artifacts/quic-native-tests/linux-x64/bin/cultmesh_quic_native_tests \"\${scenario}\" || exit 1
done
"
Both lines are run from the repository root, and the mount is that root wherever
it is: `${PWD}` in PowerShell, `$(pwd)` in a POSIX shell. `MSQUIC_INCLUDE_DIR`
and `MSQUIC_LIB_DIR` need `$(pwd)` too, even though the `cmake` invocation
itself already runs from `/src`: CMake resolves a relative path passed as a
cache variable against the CMakeLists.txt that consumes it
(`native/GameCult.Mesh.Quic.Native`), not against the directory `cmake` was
invoked from, so a bare `artifacts/...` here looks for the dependency one level
too deep and fails the compile before it reaches the loader. The test target's
own build now copies `libmsquic.so.2` from `MSQUIC_LIB_DIR` beside the built
binaries, so nothing else here needs the packaged output directory `scripts/build-quic-native.sh`
produces. `CULTMESH_QUIC_BUILD_TESTS`
is off in the shipped configuration (see the CMakeLists.txt option comment); it
turns on the scenario runner `tests/cultmesh_quic_native_tests.cpp` compiles into,
and `CULTMESH_QUIC_DEBUG_ASSERTS` turns on both the bridge's internal invariant
assertions and the four scenarios (`holdclose`, `latecall`, `holdtimeout`,
`waitseam`) that need the development seam in section 7 of the header. Each
scenario name is a positional argument to the built binary; run them individually
to bisect a failure, or all ten as shown to cover the runtime-lifetime rules.
A Windows clone made before `.gitattributes` kept shell scripts at LF still has
`scripts/build-quic-native.sh` with carriage returns, and pulling does not fix
it: the script's content did not change, so git neither rewrites it nor reports
it modified. The container then dies at once on the interpreter `bash\r`. From
the repository root, in either shell, this checks the script out again under the
current attributes:
git rm --cached -q scripts/build-quic-native.sh; git checkout HEAD -- scripts/build-quic-native.sh
`--security-opt seccomp=unconfined` is load-bearing, not caution. A
ThreadSanitizer configuration needs the process's address space where it expects
it, so a TSan run must be re-executed under `setarch -R`, which asks the kernel
for `personality(ADDR_NO_RANDOMIZE)`. Docker's default seccomp profile denies
that call. Without it ThreadSanitizer dies before `main` with "unexpected memory
mapping" — a configuration that never starts, not a scenario that passed.
A win32-x64 build of the scenario runner needs the checkout somewhere short —
near a drive root rather than under a deep temporary directory. MSVC builds the
bridge and its scenario runner through MSBuild, whose file tracker gives out on
long paths, and the build then fails outright rather than naming the path as the
cause.
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.
The build consumes Microsoft's pinned
`Microsoft.Native.Quic.MsQuic.Schannel` package. It does not contain
Aetheria-specific code.

File diff suppressed because it is too large Load diff

View file

@ -1,371 +0,0 @@
/* GameCult.Mesh.Quic.Native — C ABI v2, the runtime-neutral CultMesh QUIC bridge.
*
* This header is the contract. It is the single place the event layout, the
* exports, the id and return-code conventions, the object lifetime rules and the
* threading rules are stated; `cultmesh_quic_native.cpp` includes it, the README
* points at it, and a host that cannot include it (koffi, P/Invoke) declares the
* same thing in its own FFI and is checked against this file by a test.
*
* It is C. It includes nothing of MsQuic, nothing of Node, nothing of Unity, and
* nothing platform-specific: a host binds these twelve queue exports plus
* `cultmesh_quic_last_status` and needs no other build input.
*
* The v1 Unity exports (`cultmesh_quic_open`, `_state`, `_poll`, `_error`,
* `_close`) are deliberately not here. They are a Windows-only shim for a
* shipped managed connector, not a contract offered to new hosts.
*/
#ifndef CULTMESH_QUIC_NATIVE_H
#define CULTMESH_QUIC_NATIVE_H
#include <stddef.h>
#include <stdint.h>
#if defined(__cplusplus)
#define CULTMESH_QUIC_STATIC_ASSERT(condition, message) static_assert(condition, message)
#elif defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L
#define CULTMESH_QUIC_STATIC_ASSERT(condition, message) _Static_assert(condition, message)
#else
#define CULTMESH_QUIC_STATIC_ASSERT(condition, message)
#endif
/* `CULTMESH_QUIC_BUILD` is defined by the bridge's own translation unit and by
* nothing else. A consumer gets plain declarations and links by name, which is
* what every host here does: all three load the library at runtime. */
#if defined(CULTMESH_QUIC_BUILD)
# if defined(_WIN32)
# define CULTMESH_QUIC_API __declspec(dllexport)
# else
# define CULTMESH_QUIC_API __attribute__((visibility("default")))
# endif
#else
# define CULTMESH_QUIC_API
#endif
#if defined(__cplusplus)
extern "C" {
#endif
/* ---------------------------------------------------------------------------
* 1. The event
*
* Exactly 64 bytes, no implicit padding, field order below is the ABI. A host
* that reorders a field reads a different struct; the offsets are asserted here
* so a compiler that pads differently fails the build rather than the wire.
* --------------------------------------------------------------------------- */
struct cultmesh_quic_event {
uint32_t type; /* offset 0 — one of the cultmesh_quic_event_type values */
uint32_t stream_kind; /* offset 4 — 1 reliable, 2 latest-only; 0 when not a stream event */
uint64_t listener_id; /* offset 8 — 0 for an outbound connection */
uint64_t connection_id; /* offset 16 */
uint64_t stream_id; /* offset 24 */
uint64_t code; /* offset 32 — meaning depends on `type`; see below */
int32_t status; /* offset 40 — the raw platform QUIC status, diagnostics only */
int32_t payload_length; /* offset 44 — bytes written into the caller's payload buffer */
uint8_t reserved[16]; /* offset 48 — zero today; do not read meaning into it */
};
CULTMESH_QUIC_STATIC_ASSERT(sizeof(struct cultmesh_quic_event) == 64,
"the event struct is a 64-byte ABI contract");
CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, type) == 0,
"event field offsets are an ABI contract");
CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, stream_kind) == 4,
"event field offsets are an ABI contract");
CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, listener_id) == 8,
"event field offsets are an ABI contract");
CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, connection_id) == 16,
"event field offsets are an ABI contract");
CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, stream_id) == 24,
"event field offsets are an ABI contract");
CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, code) == 32,
"event field offsets are an ABI contract");
CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, status) == 40,
"event field offsets are an ABI contract");
CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, payload_length) == 44,
"event field offsets are an ABI contract");
CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, reserved) == 48,
"event field offsets are an ABI contract");
enum cultmesh_quic_event_type {
/* A listener accepted an inbound connection. `listener_id`, `connection_id`. */
CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION = 1,
/* The handshake completed. `connection_id`. */
CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED = 2,
/* Payload: the peer's DER certificate. The handshake is blocked until the
* host answers with `cultmesh_quic_connection_certificate_complete`. */
CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED = 3,
/* The connection is gone, whoever ended it, and its id no longer resolves.
* Payload: a UTF-8 reason. `code` is the QUIC application error code — the
* peer's when the peer ended it, and the code the host passed to
* `cultmesh_quic_connection_shutdown` when the host did. Emitted exactly
* once per connection id, including for the initiator of a local shutdown:
* every connection id's end is observable from the host that owns it. */
CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN = 4,
/* An inbound stream's first byte named its kind. `stream_id`, `stream_kind`. */
CULTMESH_QUIC_EVENT_STREAM_STARTED = 5,
/* Payload: one whole encoded frame, the 4-byte length prefix stripped. */
CULTMESH_QUIC_EVENT_STREAM_FRAME = 6,
/* One per `cultmesh_quic_stream_send_frame` call that returned 0, and never
* for the bridge's own kind byte. `code` is a cultmesh_quic_send_result. */
CULTMESH_QUIC_EVENT_STREAM_SEND_COMPLETE = 7,
/* The stream is gone. `stream_id`, `stream_kind`. */
CULTMESH_QUIC_EVENT_STREAM_SHUTDOWN = 8,
/* The listener stopped. `listener_id`. */
CULTMESH_QUIC_EVENT_LISTENER_STOPPED = 9
};
/* The `code` of event 7. A canceled send is a send MsQuic released without
* putting on the wire — the stream was aborted or the connection ended under it.
* It is reported, not silently dropped. */
enum cultmesh_quic_send_result {
CULTMESH_QUIC_SEND_COMPLETED = 0,
CULTMESH_QUIC_SEND_CANCELED = 1
};
/* The first byte of every CultMesh QUIC stream. */
enum cultmesh_quic_stream_kind {
CULTMESH_QUIC_STREAM_RELIABLE = 1,
CULTMESH_QUIC_STREAM_LATEST_ONLY = 2
};
/* ---------------------------------------------------------------------------
* 2. Ids
*
* Listeners, connections and streams are named by `uint64_t` ids drawn from one
* per-runtime counter that starts at 1 and never repeats. 0 is never a valid id
* and is what an out parameter holds when a call fails. An id is the only thing
* a host ever holds: there is no host-visible pointer to a connection or stream,
* so a host cannot outlive an object it is naming. An id whose object is gone is
* a bad call (-1), not undefined behaviour.
* --------------------------------------------------------------------------- */
/* ---------------------------------------------------------------------------
* 3. Return codes — negative on a bad call, on every platform
*
* Every int32_t-returning export returns:
*
* 0 the call succeeded
* 1, 2 `cultmesh_quic_next_event` only; see its comment
* -1 a bad call: a null handle, a null out parameter,
* or an id whose object is gone
* -2 an argument the bridge rejects on its own terms:
* an unknown stream kind, an oversized frame, a
* listener host that is not an IP literal
* <= -1000 MsQuic refused. The bridge code is
* -(1000 + (raw_status & 0xffff)); the raw
* platform status is available from
* `cultmesh_quic_last_status` and a sentence about
* it from `cultmesh_quic_last_error`.
*
* This mapping exists because `QUIC_STATUS` itself is not portable: it is a
* negative HRESULT on Windows and a positive errno on POSIX, so a host that
* checked for a negative value saw every Linux failure as success.
*
* What the mapping makes portable is the sign, and only the sign. A failure is
* negative everywhere, and that is what a host may branch on. The magnitude of a
* <= -1000 code is diagnostic and platform-specific: the same MsQuic failure
* carries different numbers on different platforms (a TLS error is -12032 on
* win32-x64 and -1126 on linux-x64; an ALPN already in use is -1009 against
* -1091), and the 16-bit mask can give two distinct statuses the same code
* (`QUIC_STATUS_USER_CANCELED` and `QUIC_STATUS_FILE_NOT_FOUND` are both -1002
* on Windows). Switching on a particular code is therefore not portable and not
* a reliable identification of the failure. Report it, log it, and read
* `cultmesh_quic_last_status` and `cultmesh_quic_last_error` beside it.
* --------------------------------------------------------------------------- */
#define CULTMESH_QUIC_RESULT_BAD_CALL (-1)
#define CULTMESH_QUIC_RESULT_BAD_ARGUMENT (-2)
#define CULTMESH_QUIC_RESULT_MSQUIC_BASE (-1000)
/* ---------------------------------------------------------------------------
* 4. Object lifetime — resolved from an id, kept alive across the call
*
* The bridge's objects are shared-owned. The runtime's maps hold one reference
* each; an export that names an id takes its own reference under the lock and
* holds it for the whole MsQuic call, so an object cannot be freed under a host
* thread that is using it. MsQuic's worker, on shutdown-complete, removes the
* map entry and drops the map's reference only — it never frees an object a host
* call is inside.
*
* The MsQuic handle follows the object: it is closed exactly once, by whichever
* holder drops the last reference (or by `cultmesh_quic_runtime_close`), under a
* one-shot exchange guard. A handle is therefore valid for as long as any holder
* has a reference to its object, which is the property the host thread needs. A
* stream holds a reference to its connection, so stream handles are always
* closed before the connection handle they belong to.
*
* `cultmesh_quic_runtime_close` quiesces before it tears anything down. It marks
* the runtime closing, wakes every blocked `cultmesh_quic_next_event`, and waits
* for every in-flight export to return before it closes a handle or frees a
* byte. No host call may begin after `cultmesh_quic_runtime_close` starts. That
* ordering is the host's, and it is a prohibition rather than a race the bridge
* arbitrates: once the close returns, the handle names freed memory, and a call
* made with it has nothing left to refuse it. A call that races the start of the
* close, while it is still running, is refused with -1 and touches nothing; that
* narrows one mis-ordering and does not make the prohibition optional.
* --------------------------------------------------------------------------- */
/* ---------------------------------------------------------------------------
* 5. Threading
*
* There are no host callbacks. Every crossing is the host calling in.
*
* - MsQuic's worker threads run inside this library only. They push events onto
* the runtime's queue and wake the one blocking wait. No host code is ever
* called from a thread the host does not own.
* - `cultmesh_quic_next_event` is called from host threads, one or many. It is
* the only blocking export. Concurrent callers each get a distinct event.
* - Every other export may be called from any host thread at any time, except
* during or after `cultmesh_quic_runtime_close`.
* - `cultmesh_quic_runtime_close` is called once, from one thread, and
* quiesces as described above.
* --------------------------------------------------------------------------- */
/* ---------------------------------------------------------------------------
* 6. The exports
* --------------------------------------------------------------------------- */
/* Opens a runtime: one MsQuic registration, one event queue. `app_name` may be
* null. `*out_runtime` is the opaque handle every other export takes. */
CULTMESH_QUIC_API int32_t cultmesh_quic_runtime_open(
const char* app_name, void** out_runtime);
/* Quiesces, tears down and frees the runtime. Safe to call with null. After it
* returns, the handle is dead; see section 4 for the ordering rule. */
CULTMESH_QUIC_API void cultmesh_quic_runtime_close(void* runtime);
/* Opens a provider listener on `host`:`port` with a PKCS12 credential. `host`
* may be null or empty for any address, otherwise it must be an IP literal.
* `port` 0 binds an ephemeral port, which `*out_bound_port` reports. A failure
* leaves no listener behind and no id allocated to the host. */
CULTMESH_QUIC_API int32_t cultmesh_quic_listener_open(
void* runtime, const char* host, uint16_t port,
const uint8_t* pkcs12, int32_t pkcs12_length, const char* password,
uint64_t* out_listener_id, uint16_t* out_bound_port);
/* Stops and closes a listener. Its accepted connections are not affected.
* Emits event 9. Unknown ids are ignored. */
CULTMESH_QUIC_API void cultmesh_quic_listener_close(
void* runtime, uint64_t listener_id);
/* Starts an outbound connection. Certificate validation is deferred: the host
* gets event 3 and must answer. */
CULTMESH_QUIC_API int32_t cultmesh_quic_connection_open(
void* runtime, const char* host, uint16_t port, uint64_t* out_connection_id);
/* Answers event 3. `accept` non-zero continues the handshake; zero fails it with
* a bad-certificate alert. */
CULTMESH_QUIC_API int32_t cultmesh_quic_connection_certificate_complete(
void* runtime, uint64_t connection_id, int32_t accept);
/* Ends a connection with a QUIC application error code. The initiator gets its
* own event 4 carrying `code`, as the peer does. Unknown ids are ignored. */
CULTMESH_QUIC_API void cultmesh_quic_connection_shutdown(
void* runtime, uint64_t connection_id, uint64_t code);
/* Opens an outbound unidirectional stream and puts `kind` on the wire as its
* first byte. That byte is the bridge's own and never produces an event 7. */
CULTMESH_QUIC_API int32_t cultmesh_quic_stream_open(
void* runtime, uint64_t connection_id, uint8_t kind, uint64_t* out_stream_id);
/* Queues one whole encoded frame, length-prefixed by the bridge. A call that
* returns 0 produces exactly one event 7 later, completed or canceled. */
CULTMESH_QUIC_API int32_t cultmesh_quic_stream_send_frame(
void* runtime, uint64_t stream_id,
const uint8_t* encoded_frame, int32_t length, int32_t fin);
/* Aborts a stream with a QUIC application error code. Unknown ids are ignored. */
CULTMESH_QUIC_API void cultmesh_quic_stream_shutdown(
void* runtime, uint64_t stream_id, uint64_t code);
/* The one blocking crossing.
*
* 0 nothing within `timeout_ms`. A poller already blocked here when
* `cultmesh_quic_runtime_close` starts is woken and returns 0. A poll that
* begins after the close starts is forbidden by section 4, and is not a
* case this return code covers.
* 1 `*out_event` filled and up to `payload_capacity` payload bytes copied;
* `*out_required` is the payload size
* 2 `payload_capacity` is too small. Nothing is consumed and nothing is
* written to `*out_event`; `*out_required` is the size to allocate. Ask
* again with a big enough buffer and the same event is delivered.
* -1 a bad call, including a null `payload` with a non-zero
* `payload_capacity`, which is a lie about the buffer and is refused
* rather than treated as a discard.
*
* `payload` may be null when `payload_capacity` is 0: that is the two-phase
* poll's first ask. */
CULTMESH_QUIC_API int32_t cultmesh_quic_next_event(
void* runtime, int32_t timeout_ms, struct cultmesh_quic_event* out_event,
uint8_t* payload, int32_t payload_capacity, int32_t* out_required);
/* The last error sentence, NUL-terminated, truncated to `capacity`. Returns the
* number of bytes written, or -1 on a bad call. */
CULTMESH_QUIC_API int32_t cultmesh_quic_last_error(
void* runtime, char* destination, int32_t capacity);
/* The raw platform `QUIC_STATUS` behind the last <= -1000 return, as its own
* bits: a negative HRESULT on Windows, a positive errno on POSIX. Diagnostics
* only; what is portable about the return code is its sign, not its value. 0
* when nothing has failed. */
CULTMESH_QUIC_API int32_t cultmesh_quic_last_status(void* runtime);
/* ---------------------------------------------------------------------------
* 7. The development seam
*
* Present only in a library configured with `CULTMESH_QUIC_DEBUG_ASSERTS`, which
* a shipped build never is. It exists for the runtime-lifetime scenarios: the
* quiesce is a promise about host calls that are inside the library, and the
* wait scenarios need the same kind of promise about the exact duration the
* bridge hands its own condition wait. A scenario that can only sleep and hope
* cannot tell a bridge that counts and waits correctly from one that does not.
*
* It is process-wide, not per-runtime, because the numbers outlive the runtime
* they describe: `cultmesh_quic_runtime_close` frees the runtime, and the count
* it waited on is read afterwards. The scenarios open one runtime at a time.
* --------------------------------------------------------------------------- */
#if defined(CULTMESH_QUIC_DEBUG_ASSERTS)
/* Non-zero arms the hold and resets both counters: every call that reaches the
* blocking wait in `cultmesh_quic_next_event` parks inside the library instead
* of returning. Zero releases every held call and disarms it. A held call
* returns 0 and touches the runtime no further. */
CULTMESH_QUIC_API void cultmesh_quic_debug_hold_calls(int32_t hold);
/* The high-water mark of host calls counted inside the library since the hold
* was armed. Zero means no call was ever counted. */
CULTMESH_QUIC_API int32_t cultmesh_quic_debug_peak_calls(void);
/* How many host calls were counted inside the library when the last
* `cultmesh_quic_runtime_close` began its wait, or -1 if no close has begun one
* since the hold was armed. This is the number the wait exists for. */
CULTMESH_QUIC_API int32_t cultmesh_quic_debug_calls_at_close(void);
/* The whole duration, in milliseconds, most recently handed to the condition
* wait inside `cultmesh_quic_next_event`, recorded at the point the wait is
* entered rather than observed from outside. A poll whose queue is not empty,
* or whose `timeout_ms` is not greater than zero, never enters that wait and
* leaves this unchanged. -1 until the first such wait, or after
* `cultmesh_quic_debug_reset_last_wait_ms` — tracked by a separate internal
* flag, not by -1 being stored here, so a wait that ever recorded exactly -1
* itself could not be confused with nothing having been recorded.
*
* This exists because a wall-clock probe cannot tell the host's timeout from
* any function of it that is the identity at the probe's own value — a floor,
* a round, a clamp, a later-poll fault, there is always another one that
* matches. This reads what the bridge actually handed its wait instead of
* inferring it from how long a call took. */
CULTMESH_QUIC_API int32_t cultmesh_quic_debug_last_wait_ms(void);
/* Resets the value `cultmesh_quic_debug_last_wait_ms` reports to -1, without
* touching the hold or the two call counters above. */
CULTMESH_QUIC_API void cultmesh_quic_debug_reset_last_wait_ms(void);
#endif /* CULTMESH_QUIC_DEBUG_ASSERTS */
#if defined(__cplusplus)
}
#endif
#endif /* CULTMESH_QUIC_NATIVE_H */

355
package-lock.json generated
View file

@ -489,326 +489,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@koromix/koffi-android-arm64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-android-arm64/-/koffi-android-arm64-3.3.0.tgz",
"integrity": "sha512-lHXE2mq+uG6CVeX1iepn2ATcAWMZPagr+J0ICYge7wTMDYc5pPRQMq0Rr/16775i6QlUqQqpk8c5Gu66+u3gGA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-android-x64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-android-x64/-/koffi-android-x64-3.3.0.tgz",
"integrity": "sha512-7EeVK/KS9XNbOsUfy0B3uuSjPQZenjJ35y9QSi6R6eK4Fv0oGvREQaBzIJRJwonxp7sJfGObnwnQTnKTVK2DWA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-darwin-arm64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.3.0.tgz",
"integrity": "sha512-2R9bihYmYFyRP98wRG2m5WQVKMONf7DoPJI8RkGCOUB2afOXJEVXFepiSKxBZGPml8i1USwAxMUwkzbJ5uMG1A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-darwin-x64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.3.0.tgz",
"integrity": "sha512-OecYCQfzhUx+XyAtatIHlk0jIz87wKNjtYvwlwgMwgaGQwO/TxFeReNo+s3167Rh1a9c8rUIOjq1jfzAPBxbKA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-freebsd-arm64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.3.0.tgz",
"integrity": "sha512-6ytHzV1DuyxTP5LtFmQ3imBk71j/F/Nb0Hcwhjcn35esnknm5rQbgRl6tNRldrkGnM4GIAJpsagooUBh1xAzYg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-freebsd-ia32": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.3.0.tgz",
"integrity": "sha512-hINo5z1Iz2u3Rx3oHfzcmi5A03gYAoYf4nCJJYaY0bb5IuGdIC7noQTyyI9YxfyTJgoqw3wKFWWYtvsAmEo9Hw==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-freebsd-x64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.3.0.tgz",
"integrity": "sha512-7XQYAZh828nW6+y3IdyUNdo2VbVHL5ZC8Qb+yqHrlDjTjVXSyGYZJHAyJZ5c7eSTTKBhtIMTX238rIsdexxrog==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-arm": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm/-/koffi-linux-arm-3.3.0.tgz",
"integrity": "sha512-NbahRKKKFkHoFN/ZpLY+w4F6eYSqV4KMN40PmjZ1xQjG/XjYb6PIYgE41c7/acAPxOYqujrcaI3B4BAgWYm5/A==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-arm64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.3.0.tgz",
"integrity": "sha512-Eo/+wGeawGICzJkUlkzi/KerAJ6U1L/Hf7cnJKH7aGgCOY5RRnjUqXs/eL+UDpRcFHzGeUn+Fq+8q2VxYxvAfw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-ia32": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.3.0.tgz",
"integrity": "sha512-u9tKuj42RFWJU8ZgZqcBhkFCkIZE/SE6oR1ljpukttJUhOVuaf/lmsWxyOxiVu3U8T1gtD658FlLgrbMVIn6aQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-loong64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.3.0.tgz",
"integrity": "sha512-2hKlNZ6OXkTUp6fcueZcP0sQ6yhpdfANg9/mWxDKzT1o8qIj4qJD7UcZLCcRIC8JXiP8JQwZBtlPuyXIqofDQg==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-ppc64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ppc64/-/koffi-linux-ppc64-3.3.0.tgz",
"integrity": "sha512-bIVAoHgzyRN7xs529BK7rF3BbZUTJgIr0t/wvBvAgaIpreKsdZ/QWxhedKRbTN7ljlDHkPZFmu5XcCjb/vqUag==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-riscv64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.3.0.tgz",
"integrity": "sha512-2niYAe+TBXbvf7yog/aiHiDUrKQn+RkMSH5x3ZYo65OQyII1CY3Jk2kP3Pe735gq0LzIYJsDuGMW+02ighSadQ==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-x64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.3.0.tgz",
"integrity": "sha512-apU8ZMOrOh1eqMP/Pp0sDZ/WY6A4r6JWph26h63VM9nZ+5KCTYuPgojfoLPNvrfI/bn1PY3+bpXKRhZHI3gxbQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-openbsd-arm64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-arm64/-/koffi-openbsd-arm64-3.3.0.tgz",
"integrity": "sha512-iTyHMCSSs+X579sWJv7XVFMp9gOfROI8IUEwp09YxJpGvmiSoipI0J7kytFuFq6dyuwO0Ymy4JdlmKnCM0qkxg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-openbsd-ia32": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.3.0.tgz",
"integrity": "sha512-xCvMV+4VNHjTxgVPEsBzU0k8dlA7aoFO5QjRF9lVU/NzGhUedlYvFgaF6IWsGOoWEaNt9hgP4KK/wxMbm9v8EQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-openbsd-x64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.3.0.tgz",
"integrity": "sha512-xItIwx1elM8k1pTudEfuV/1U2gQ06+rFfxCVXrZkyWYYGTzmA5To00f8garJFR+E9gbjQJbn8QDFvWMI+i2sMA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-win32-arm64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.3.0.tgz",
"integrity": "sha512-fD41LgKBx5i1YlJDuds4tRHAaG5vV55tvagzJwKFDR/5BDeaFvH9QFMUMDoNl2LkmVyeug0dIElHr/oxejh3IA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-win32-ia32": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.3.0.tgz",
"integrity": "sha512-BfDbDVEuQwPZOvWXawc3JT/7i9cN7QHYotgo014jjXb9pNv46Ykhb0RRglkcHg+SdytW/7pxYn18YwcQP6LLvQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-win32-x64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.3.0.tgz",
"integrity": "sha512-D+ORp73rTmvGVfq01+4uBmgVZqrPqU9N9xCZCfexaCDlVk0WQsaqid1dGsi6eScJuHt4Cp8s1i5/4CsecxFTKQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@msgpack/msgpack": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz",
@ -1035,38 +715,6 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/koffi": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/koffi/-/koffi-3.3.0.tgz",
"integrity": "sha512-UeyQtptuPeCP0BqKD+Q8ftTQhIUBHO7bXyK9DVIaW9hJSg0BrICm4K5N8Qtn7zKk20sb5vpnES9KBqVksM2kpQ==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"url": "https://liberapay.com/Koromix"
},
"optionalDependencies": {
"@koromix/koffi-android-arm64": "3.3.0",
"@koromix/koffi-android-x64": "3.3.0",
"@koromix/koffi-darwin-arm64": "3.3.0",
"@koromix/koffi-darwin-x64": "3.3.0",
"@koromix/koffi-freebsd-arm64": "3.3.0",
"@koromix/koffi-freebsd-ia32": "3.3.0",
"@koromix/koffi-freebsd-x64": "3.3.0",
"@koromix/koffi-linux-arm": "3.3.0",
"@koromix/koffi-linux-arm64": "3.3.0",
"@koromix/koffi-linux-ia32": "3.3.0",
"@koromix/koffi-linux-loong64": "3.3.0",
"@koromix/koffi-linux-ppc64": "3.3.0",
"@koromix/koffi-linux-riscv64": "3.3.0",
"@koromix/koffi-linux-x64": "3.3.0",
"@koromix/koffi-openbsd-arm64": "3.3.0",
"@koromix/koffi-openbsd-ia32": "3.3.0",
"@koromix/koffi-openbsd-x64": "3.3.0",
"@koromix/koffi-win32-arm64": "3.3.0",
"@koromix/koffi-win32-ia32": "3.3.0",
"@koromix/koffi-win32-x64": "3.3.0"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@ -1219,8 +867,7 @@
"dependencies": {
"@gamecult/cultcache-ts": "^0.14.0",
"@msgpack/msgpack": "^3.1.2",
"cultnet-ts": "^0.2.0",
"koffi": "3.3.0"
"cultnet-ts": "^0.2.0"
},
"devDependencies": {
"@types/node": "^24.0.0",

View file

@ -26,13 +26,13 @@ shader semantics.
acceleration and semi-implicit Euler integration
- `math` intrinsics: `radians`, `degrees`, `abs`, `floor`, `ceil`, `frac`,
`min`, `max`, `clamp`, `saturate`, `lerp`, `step`, `smoothstep`, `dot`,
`cross`, `length`, `distance`, `normalize`, `reflect`, `csum`, `erf`,
`erfinv` (scalar only), exponential
`cross`, `length`, `distance`, `normalize`, `reflect`, `csum`, exponential
`decay`/`damp`, intercept/segment helpers, Catmull-Rom, Bezier curves, and
deterministic value-noise primitives
- `shaders/CultMath.hlsl`, a canonical HLSL mirror include for shader-side
parity. HLSL already owns `float2`, `float3`, and `float4`; the include
exposes `cultmath_*` functions for shared semantics HLSL does not provide.
exposes `cultmath_*` functions for shared semantics, including CultMath's
safe `normalize` contract.
- `Voronoi.SampleTones`, a C# batch surface that calls the Rust
`cultmath-core` native kernel when `cultmath_core` is available and falls back
to the managed parity path otherwise.
@ -59,7 +59,7 @@ float grain = math.value_noise_bicubic(math.float2(12.0f, 3.5f));
```hlsl
#include "CultMath.hlsl"
float3 normal = normalize(float3(0.25, 1.0, -0.1));
float3 normal = cultmath_normalize(float3(0.25, 1.0, -0.1));
float rim = cultmath_saturate(1.0 - dot(normal, float3(0.0, 0.0, -1.0)));
float glow = cultmath_smoothstep(0.2, 1.0, rim);
float grain = cultmath_value_noise_bicubic(float2(12.0, 3.5));

View file

@ -52,26 +52,7 @@ That makes these HLSL rules, not options:
operand returns the other (so `saturate(NaN)` is 0), `clamp(x, a, b)` is
`min(max(x, a), b)`, and `saturate(x)` is `min(1, max(0, x))`, which maps -0
to +0; `any`/`all` accept numeric vectors (a component is true when it is
not 0); `min`, `max`, and `abs` on int vectors return int vectors; int
`clamp` is `IMin(IMax(x, a), b)`, so inverted bounds return `b`.
- Same-size vector conversions (`intN(floatN)`, `floatN(intN)`, `boolN(floatN)`,
`boolN(intN)`, `floatN(boolN)`, `intN(boolN)`) exist as constructors and
`math` functions and follow dxc: float to int is `fptosi`, truncating toward
zero; numeric to bool is `!= 0` (`fcmp une`, so NaN is true); bool to numeric
is 0 or 1. DXIL leaves float-to-int undefined for NaN and out-of-range values,
and so does C#; CultMath does not pin them.
- `normalize(x)` is `x * Rsqrt(Dot(x, x))`, which is how dxc lowers it, and
DXIL.rst defines `Rsqrt` as `1 / sqrt(src)`. A zero vector therefore
normalizes to NaN (`0 * inf`), and a NaN component makes every component NaN.
(The special-value table under `Rsqrt` in DXIL.rst is a copy of the `Round`
table and contradicts that definition, so CultMath follows the definition.)
`length` and `distance` are `Sqrt` of the summed squares, so a zero vector
gives 0. Callers that can meet zero-length vectors guard them before calling
`normalize`.
- dxc demotes double `frac`, `exp`, `lerp`, and `floor` to float. CultMath's
double overloads keep double precision instead: `frac(double)` is
`x - floor(x)` in double. That is a deliberate divergence for CPU simulation
time, which shaders do not carry.
not 0); `min`, `max`, and `abs` on int vectors return int vectors.
dxc marks float arithmetic and compares `fast` (no NaNs) unless a value is
`precise`, so drivers may optimize NaN handling away and NaN results on real
@ -83,7 +64,9 @@ GPU. Because `min`, `max`, and `saturate` return the non-NaN operand, some
functions turn NaN or infinity into numbers: `smoothstep(a, a, a)` is 0;
infinite inputs to `smoothstep` and the intercept helpers give numbers; the
bezier, `smoothstep01`, and `smootherstep` functions return finite values for
NaN input. Callers that must detect bad data should check `isnan` before calling these
NaN input; and `normalize((NaN, 2.41))` is `(NaN, 2.41e20)`, because the NaN
length is clamped to the epsilon and the other component is divided by it.
Callers that must detect bad data should check `isnan` before calling these
functions.
Deferred until a consumer needs them: `float4x4`, `transpose`, `determinant`,
@ -120,20 +103,9 @@ compiled under `using static CultMath.math;`, so its intrinsics are C# `math`
itself: the test proves the composition in the text, not the intrinsic rules,
which only `HlslSemanticsTests` pins.
Integer hashing uses the PCG hashes from Jarzynski and Olano, "Hash Functions
for GPU Rendering" (JCGT 9(3), 2020): `pcg(uint)` is O'Neill's RXS-M-XS 32/32
permutation over one LCG step, and `pcg3d`/`pcg4d` are the paper's (3 → 3) and
(4 → 4) hashes. They use only uint multiply, add, xor, and shifts. dxc wraps
uint multiply and add modulo 2^32 exactly as C# unchecked arithmetic does, so
they are integer-exact in HLSL and on the CPU, GPU included, unlike the
`sin`-based `hash`. CultMath has no uint vectors, so `pcg3d(int3)` and
`pcg4d(int4)` carry uint bit patterns in int components. The `float2`/`float3`/`float4`
overloads hash the IEEE-754 bits (`asuint`), so -0 and 0 hash differently.
`pcg3d(float2)` holds z at 0, which is the paper's route for (2 → N) inputs.
Seeds and other scalar uses take one output component.
Where HLSL is silent, CultMath keeps its own decisions and does not defer to
Unity.Mathematics: `hash` returns float, and `Random` is CultMath's own xorshift32. Engine-shaped
Unity.Mathematics: `normalize` is safe (divides by `max(length, 1e-20)`),
`hash` returns float, and `Random` is CultMath's own xorshift32. Engine-shaped
helpers that HLSL lacks (`float2x2.Rotate`, `float3x3.Euler`, `quaternion`,
`snoise`) enter only when a consumer needs them, with their semantics stated in
code.

View file

@ -27,25 +27,6 @@ if (Test-Path -LiteralPath $outputRoot) {
Remove-Item -LiteralPath $outputRoot -Recurse -Force
}
# docs/semver-policy.md (CultLib root, two levels up from this script):
# refuse to build a release whose CHANGELOG.md entry claims a breaking change
# under too small a bump, or whose version skips or reverses the previous
# published tag. Runs before the build below so a bad version number fails
# fast. cultmath is pre-1.0 (0.y.z): a breaking change must be at least a
# minor bump. $repoRoot above is this package's own root (packages\cultmath),
# not CultLib's; the shared checker script lives one level above that.
$cultLibRoot = Split-Path -Parent (Split-Path -Parent $repoRoot)
$cultmathVersion = (Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json).version
& node (Join-Path $cultLibRoot "scripts\check-changelog-semver.mjs") `
--package "org.gamecult.cultmath" `
--changelog (Join-Path $templateRoot "CHANGELOG.md") `
--version $cultmathVersion `
--tag-prefix "cultmath-unity" `
--cwd $cultLibRoot
if ($LASTEXITCODE -ne 0) {
throw "CultMath Unity package failed the semver policy check (see docs/semver-policy.md)."
}
# The tracked DLL and pdb are committed beside their source, so neither may name a commit: Source Link
# writes the commit SHA into the pdb, and the DLL carries that pdb's content id.
dotnet build $projectPath -c $Configuration -f netstandard2.1 -o $buildRoot --no-incremental `

View file

@ -11,6 +11,7 @@
static const float CULTMATH_PI = 3.14159265358979323846;
static const float CULTMATH_TAU = 6.28318530717958647692;
static const float CULTMATH_HALF_PI = 1.57079632679489661923;
static const float CULTMATH_NORMALIZE_EPSILON = 1.0e-20;
float cultmath_radians(float degrees_value) { return degrees_value * (CULTMATH_PI / 180.0); }
float cultmath_degrees(float radians_value) { return radians_value * (180.0 / CULTMATH_PI); }
@ -170,6 +171,10 @@ float cultmath_distance(float2 left, float2 right) { return length(left - right)
float cultmath_distance(float3 left, float3 right) { return length(left - right); }
float cultmath_distance(float4 left, float4 right) { return length(left - right); }
float2 cultmath_normalize(float2 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); }
float3 cultmath_normalize(float3 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); }
float4 cultmath_normalize(float4 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); }
float2 cultmath_reflect(float2 incident, float2 normal_value)
{
return incident - 2.0 * dot(normal_value, incident) * normal_value;
@ -281,38 +286,6 @@ float cultmath_hash(float value) { return cultmath_frac(sin(value) * 43758.5453)
float cultmath_hash(float2 value) { return cultmath_hash(dot(value, float2(127.1, 311.7))); }
float cultmath_hash(float3 value) { return cultmath_hash(dot(value, float3(127.1, 311.7, 74.7))); }
// PCG hashes (Jarzynski and Olano, JCGT 2020), written over scalar uints so CultMath's int3/int4 carry the bits.
uint cultmath_pcg(uint value)
{
uint state = value * 747796405u + 2891336453u;
uint word = ((state >> (int)((state >> 28) + 4)) ^ state) * 277803737u;
return (word >> 22) ^ word;
}
int3 cultmath_pcg3d(int3 value)
{
uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u, z = (uint)value.z * 1664525u + 1013904223u;
x += y * z; y += z * x; z += x * y;
x ^= x >> 16; y ^= y >> 16; z ^= z >> 16;
x += y * z; y += z * x; z += x * y;
return int3((int)x, (int)y, (int)z);
}
int3 cultmath_pcg3d(float2 value) { return cultmath_pcg3d(float3(value, 0.0)); }
int3 cultmath_pcg3d(float3 value) { return cultmath_pcg3d(int3((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z))); }
int4 cultmath_pcg4d(int4 value)
{
uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u;
uint z = (uint)value.z * 1664525u + 1013904223u, w = (uint)value.w * 1664525u + 1013904223u;
x += y * w; y += z * x; z += x * y; w += y * z;
x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; w ^= w >> 16;
x += y * w; y += z * x; z += x * y; w += y * z;
return int4((int)x, (int)y, (int)z, (int)w);
}
int4 cultmath_pcg4d(float4 value) { return cultmath_pcg4d(int4((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z), (int)asuint(value.w))); }
float cultmath_value_noise(float2 position)
{
float2 cell = floor(position);

View file

@ -8,7 +8,7 @@
<RootNamespace>CultMath</RootNamespace>
<AssemblyName>CultMath</AssemblyName>
<PackageId>GameCult.Math</PackageId>
<Version>0.2.3</Version>
<Version>0.2.0</Version>
<Description>Clean-room C# math shaped for HLSL-style renderer code.</Description>
<Authors>GameCult</Authors>
<PackageReadmeFile>README.md</PackageReadmeFile>

View file

@ -7,8 +7,6 @@ namespace CultMath;
public partial struct float2
{
public float2(float2 xy) : this(xy.x, xy.y) { }
public float2(int2 value) : this(value.x, value.y) { }
public float2(bool2 value) : this(value.x ? 1.0f : 0.0f, value.y ? 1.0f : 0.0f) { }
public float r { readonly get => x; set => x = value; }
public float g { readonly get => y; set => y = value; }
public readonly float2 xx => new(x, x);
@ -74,8 +72,6 @@ public partial struct float3
public float3(float x, float2 yz) : this(x, yz.x, yz.y) { }
public float3(float2 xy, float z) : this(xy.x, xy.y, z) { }
public float3(float3 xyz) : this(xyz.x, xyz.y, xyz.z) { }
public float3(int3 value) : this(value.x, value.y, value.z) { }
public float3(bool3 value) : this(value.x ? 1.0f : 0.0f, value.y ? 1.0f : 0.0f, value.z ? 1.0f : 0.0f) { }
public float r { readonly get => x; set => x = value; }
public float g { readonly get => y; set => y = value; }
public float b { readonly get => z; set => z = value; }
@ -324,8 +320,6 @@ public partial struct float4
public float4(float2 xy, float2 zw) : this(xy.x, xy.y, zw.x, zw.y) { }
public float4(float3 xyz, float w) : this(xyz.x, xyz.y, xyz.z, w) { }
public float4(float4 xyzw) : this(xyzw.x, xyzw.y, xyzw.z, xyzw.w) { }
public float4(int4 value) : this(value.x, value.y, value.z, value.w) { }
public float4(bool4 value) : this(value.x ? 1.0f : 0.0f, value.y ? 1.0f : 0.0f, value.z ? 1.0f : 0.0f, value.w ? 1.0f : 0.0f) { }
public float r { readonly get => x; set => x = value; }
public float g { readonly get => y; set => y = value; }
public float b { readonly get => z; set => z = value; }
@ -1007,8 +1001,6 @@ public partial struct float4
public partial struct int2
{
public int2(int2 xy) : this(xy.x, xy.y) { }
public int2(float2 value) : this((int)value.x, (int)value.y) { }
public int2(bool2 value) : this(value.x ? 1 : 0, value.y ? 1 : 0) { }
public int r { readonly get => x; set => x = value; }
public int g { readonly get => y; set => y = value; }
public readonly int2 xx => new(x, x);
@ -1074,8 +1066,6 @@ public partial struct int3
public int3(int x, int2 yz) : this(x, yz.x, yz.y) { }
public int3(int2 xy, int z) : this(xy.x, xy.y, z) { }
public int3(int3 xyz) : this(xyz.x, xyz.y, xyz.z) { }
public int3(float3 value) : this((int)value.x, (int)value.y, (int)value.z) { }
public int3(bool3 value) : this(value.x ? 1 : 0, value.y ? 1 : 0, value.z ? 1 : 0) { }
public int r { readonly get => x; set => x = value; }
public int g { readonly get => y; set => y = value; }
public int b { readonly get => z; set => z = value; }
@ -1324,8 +1314,6 @@ public partial struct int4
public int4(int2 xy, int2 zw) : this(xy.x, xy.y, zw.x, zw.y) { }
public int4(int3 xyz, int w) : this(xyz.x, xyz.y, xyz.z, w) { }
public int4(int4 xyzw) : this(xyzw.x, xyzw.y, xyzw.z, xyzw.w) { }
public int4(float4 value) : this((int)value.x, (int)value.y, (int)value.z, (int)value.w) { }
public int4(bool4 value) : this(value.x ? 1 : 0, value.y ? 1 : 0, value.z ? 1 : 0, value.w ? 1 : 0) { }
public int r { readonly get => x; set => x = value; }
public int g { readonly get => y; set => y = value; }
public int b { readonly get => z; set => z = value; }
@ -2007,8 +1995,6 @@ public partial struct int4
public partial struct bool2
{
public bool2(bool2 xy) : this(xy.x, xy.y) { }
public bool2(float2 value) : this(value.x != 0.0f, value.y != 0.0f) { }
public bool2(int2 value) : this(value.x != 0, value.y != 0) { }
public bool r { readonly get => x; set => x = value; }
public bool g { readonly get => y; set => y = value; }
public readonly bool2 xx => new(x, x);
@ -2074,8 +2060,6 @@ public partial struct bool3
public bool3(bool x, bool2 yz) : this(x, yz.x, yz.y) { }
public bool3(bool2 xy, bool z) : this(xy.x, xy.y, z) { }
public bool3(bool3 xyz) : this(xyz.x, xyz.y, xyz.z) { }
public bool3(float3 value) : this(value.x != 0.0f, value.y != 0.0f, value.z != 0.0f) { }
public bool3(int3 value) : this(value.x != 0, value.y != 0, value.z != 0) { }
public bool r { readonly get => x; set => x = value; }
public bool g { readonly get => y; set => y = value; }
public bool b { readonly get => z; set => z = value; }
@ -2324,8 +2308,6 @@ public partial struct bool4
public bool4(bool2 xy, bool2 zw) : this(xy.x, xy.y, zw.x, zw.y) { }
public bool4(bool3 xyz, bool w) : this(xyz.x, xyz.y, xyz.z, w) { }
public bool4(bool4 xyzw) : this(xyzw.x, xyzw.y, xyzw.z, xyzw.w) { }
public bool4(float4 value) : this(value.x != 0.0f, value.y != 0.0f, value.z != 0.0f, value.w != 0.0f) { }
public bool4(int4 value) : this(value.x != 0, value.y != 0, value.z != 0, value.w != 0) { }
public bool r { readonly get => x; set => x = value; }
public bool g { readonly get => y; set => y = value; }
public bool b { readonly get => z; set => z = value; }
@ -3120,15 +3102,9 @@ public partial struct double3
public static partial class math
{
public static float2 float2(float2 xy) => new(xy);
public static float2 float2(int2 value) => new(value);
public static float2 float2(bool2 value) => new(value);
public static float2 float2(int value) => new(value, value);
public static float3 float3(float x, float2 yz) => new(x, yz);
public static float3 float3(float2 xy, float z) => new(xy, z);
public static float3 float3(float3 xyz) => new(xyz);
public static float3 float3(int3 value) => new(value);
public static float3 float3(bool3 value) => new(value);
public static float3 float3(int value) => new(value, value, value);
public static float4 float4(float x, float y, float2 zw) => new(x, y, zw);
public static float4 float4(float x, float2 yz, float w) => new(x, yz, w);
public static float4 float4(float x, float3 yzw) => new(x, yzw);
@ -3136,17 +3112,10 @@ public static partial class math
public static float4 float4(float2 xy, float2 zw) => new(xy, zw);
public static float4 float4(float3 xyz, float w) => new(xyz, w);
public static float4 float4(float4 xyzw) => new(xyzw);
public static float4 float4(int4 value) => new(value);
public static float4 float4(bool4 value) => new(value);
public static float4 float4(int value) => new(value, value, value, value);
public static int2 int2(int2 xy) => new(xy);
public static int2 int2(float2 value) => new(value);
public static int2 int2(bool2 value) => new(value);
public static int3 int3(int x, int2 yz) => new(x, yz);
public static int3 int3(int2 xy, int z) => new(xy, z);
public static int3 int3(int3 xyz) => new(xyz);
public static int3 int3(float3 value) => new(value);
public static int3 int3(bool3 value) => new(value);
public static int4 int4(int x, int y, int2 zw) => new(x, y, zw);
public static int4 int4(int x, int2 yz, int w) => new(x, yz, w);
public static int4 int4(int x, int3 yzw) => new(x, yzw);
@ -3154,16 +3123,10 @@ public static partial class math
public static int4 int4(int2 xy, int2 zw) => new(xy, zw);
public static int4 int4(int3 xyz, int w) => new(xyz, w);
public static int4 int4(int4 xyzw) => new(xyzw);
public static int4 int4(float4 value) => new(value);
public static int4 int4(bool4 value) => new(value);
public static bool2 bool2(bool2 xy) => new(xy);
public static bool2 bool2(float2 value) => new(value);
public static bool2 bool2(int2 value) => new(value);
public static bool3 bool3(bool x, bool2 yz) => new(x, yz);
public static bool3 bool3(bool2 xy, bool z) => new(xy, z);
public static bool3 bool3(bool3 xyz) => new(xyz);
public static bool3 bool3(float3 value) => new(value);
public static bool3 bool3(int3 value) => new(value);
public static bool4 bool4(bool x, bool y, bool2 zw) => new(x, y, zw);
public static bool4 bool4(bool x, bool2 yz, bool w) => new(x, yz, w);
public static bool4 bool4(bool x, bool3 yzw) => new(x, yzw);
@ -3171,6 +3134,4 @@ public static partial class math
public static bool4 bool4(bool2 xy, bool2 zw) => new(xy, zw);
public static bool4 bool4(bool3 xyz, bool w) => new(xyz, w);
public static bool4 bool4(bool4 xyzw) => new(xyzw);
public static bool4 bool4(float4 value) => new(value);
public static bool4 bool4(int4 value) => new(value);
}

View file

@ -105,9 +105,6 @@ public static partial class math
public static float2 frac(float2 value) => value - floor(value);
public static float3 frac(float3 value) => value - floor(value);
public static float4 frac(float4 value) => value - floor(value);
// dxc has no double Frc: it demotes to float, as it does for exp and lerp. CultMath's double
// overloads keep double precision instead, so frac(double) is x - floor(x) in double.
public static double frac(double value) => value - Math.Floor(value);
// dxc lowers float min/max/clamp to DXIL FMin/FMax (saturate to Saturate, defined as FMin(1, FMax(0, x))).
// DXIL.rst: FMin(a, b) is a < b ? a : b, FMax(a, b) is a >= b ? a : b, and a NaN operand returns the other.
@ -133,11 +130,6 @@ public static partial class math
public static float2 clamp(float2 value, float2 minimum, float2 maximum) => min(max(value, minimum), maximum);
public static float3 clamp(float3 value, float3 minimum, float3 maximum) => min(max(value, minimum), maximum);
public static float4 clamp(float4 value, float4 minimum, float4 maximum) => min(max(value, minimum), maximum);
// dxc lowers int clamp to IMin(IMax(x, a), b): inverted bounds return b rather than throwing like Math.Clamp.
public static int clamp(int value, int minimum, int maximum) => min(max(value, minimum), maximum);
public static int2 clamp(int2 value, int2 minimum, int2 maximum) => min(max(value, minimum), maximum);
public static int3 clamp(int3 value, int3 minimum, int3 maximum) => min(max(value, minimum), maximum);
public static int4 clamp(int4 value, int4 minimum, int4 maximum) => min(max(value, minimum), maximum);
// DXIL Saturate is FMin(1, FMax(0, x)); that operand order, unlike clamp(x, 0, 1), maps -0 to +0.
public static float saturate(float value) => min(1.0f, max(0.0f, value));
@ -213,10 +205,9 @@ public static partial class math
public static float distance(float3 left, float3 right) => length(left - right);
public static float distance(float4 left, float4 right) => length(left - right);
// dxc lowers normalize(x) to x * Rsqrt(Dot(x, x)), and DXIL Rsqrt is 1 / sqrt(src): a zero vector is 0 * inf = NaN.
public static float2 normalize(float2 value) => value * (1.0f / MathF.Sqrt(dot(value, value)));
public static float3 normalize(float3 value) => value * (1.0f / MathF.Sqrt(dot(value, value)));
public static float4 normalize(float4 value) => value * (1.0f / MathF.Sqrt(dot(value, value)));
public static float2 normalize(float2 value) => value / max(length(value), 1.0e-20f);
public static float3 normalize(float3 value) => value / max(length(value), 1.0e-20f);
public static float4 normalize(float4 value) => value / max(length(value), 1.0e-20f);
public static quaternion normalize(quaternion value)
{
var length = MathF.Sqrt(value.x * value.x + value.y * value.y + value.z * value.z + value.w * value.w);
@ -382,43 +373,6 @@ public static partial class math
public static float hash(float2 value) => hash(dot(value, new float2(127.1f, 311.7f)));
public static float hash(float3 value) => hash(dot(value, new float3(127.1f, 311.7f, 74.7f)));
public static uint asuint(float value) => (uint)BitConverter.SingleToInt32Bits(value);
// PCG hashes from Jarzynski and Olano, "Hash Functions for GPU Rendering" (JCGT 9(3), 2020). Integer-only, so
// dxc and C# agree bit for bit. pcg3d/pcg4d carry uint bit patterns in int3/int4; float overloads hash IEEE bits.
public static uint pcg(uint value)
{
var state = value * 747796405u + 2891336453u;
var word = ((state >> (int)((state >> 28) + 4)) ^ state) * 277803737u;
return (word >> 22) ^ word;
}
public static int3 pcg3d(int3 value)
{
uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u, z = (uint)value.z * 1664525u + 1013904223u;
x += y * z; y += z * x; z += x * y;
x ^= x >> 16; y ^= y >> 16; z ^= z >> 16;
x += y * z; y += z * x; z += x * y;
return new int3((int)x, (int)y, (int)z);
}
// The paper hashes 2 -> N inputs through pcg3d with the unused component held constant.
public static int3 pcg3d(float2 value) => pcg3d(new float3(value, 0.0f));
public static int3 pcg3d(float3 value) => pcg3d(new int3((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z)));
public static int4 pcg4d(int4 value)
{
uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u;
uint z = (uint)value.z * 1664525u + 1013904223u, w = (uint)value.w * 1664525u + 1013904223u;
x += y * w; y += z * x; z += x * y; w += y * z;
x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; w ^= w >> 16;
x += y * w; y += z * x; z += x * y; w += y * z;
return new int4((int)x, (int)y, (int)z, (int)w);
}
public static int4 pcg4d(float4 value) =>
pcg4d(new int4((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z), (int)asuint(value.w)));
public static float log(float value) => MathF.Log(value);
public static float2 log(float2 value) => new(log(value.x), log(value.y));
public static float3 log(float3 value) => new(log(value.x), log(value.y), log(value.z));
@ -521,74 +475,6 @@ public static partial class math
a._m20 * b._m01 + a._m21 * b._m11 + a._m22 * b._m21,
a._m20 * b._m02 + a._m21 * b._m12 + a._m22 * b._m22);
// Abramowitz & Stegun 7.1.26, a single-precision rational/exponential fit with a stated
// max absolute error of 1.5e-7. erf is odd, so only x >= 0 is fit and the sign is restored.
// Measured against an independent reference: worst absolute error 6.621e-7, inside the
// 2e-6 bar this package's tests pin.
public static float erf(float value)
{
const float p = 0.3275911f;
const float a1 = 0.254829592f;
const float a2 = -0.284496736f;
const float a3 = 1.421413741f;
const float a4 = -1.453152027f;
const float a5 = 1.061405429f;
var s = sign(value);
var x = abs(value);
var t = 1.0f / (1.0f + p * x);
var poly = ((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t;
var result = 1.0f - poly * exp(-x * x);
return s * result;
}
// Giles, "Approximating the erfinv function" (GPU Computing Gems, 2010): a single-precision
// minimax polynomial in two ranges of w = -ln((1-x)(1+x)), central and tail.
// Domain is [-1, 1]. Outside it, including +-infinity, the result is NaN (there is no real
// inverse). At the edges the mathematical limit is infinite: erfinv(1) = +infinity and
// erfinv(-1) = -infinity. The unguarded polynomial hits log(0) = -infinity at those edges and
// the tail branch's leading coefficient is negative, so it delivers the wrong sign; guard the
// edges explicitly instead of trusting the fit through the singularity.
// Measured against an independent reference: worst absolute error 5.066e-7, and the
// erf(erfinv(y)) round trip 6.109e-7 worst case, both inside the 2e-6 bar this package's
// tests pin.
public static float erfinv(float value)
{
if (value >= 1.0f) return value > 1.0f ? float.NaN : float.PositiveInfinity;
if (value <= -1.0f) return value < -1.0f ? float.NaN : float.NegativeInfinity;
var w = -log((1.0f - value) * (1.0f + value));
float p;
if (w < 5.0f)
{
w -= 2.5f;
p = 2.81022636e-08f;
p = 3.43273939e-07f + p * w;
p = -3.5233877e-06f + p * w;
p = -4.39150654e-06f + p * w;
p = 0.00021858087f + p * w;
p = -0.00125372503f + p * w;
p = -0.00417768164f + p * w;
p = 0.246640727f + p * w;
p = 1.50140941f + p * w;
}
else
{
w = sqrt(w) - 3.0f;
p = -0.000200214257f;
p = 0.000100950558f + p * w;
p = 0.00134934322f + p * w;
p = -0.00367342844f + p * w;
p = 0.00573950773f + p * w;
p = -0.0076224613f + p * w;
p = 0.00943887047f + p * w;
p = 1.00167406f + p * w;
p = 2.83297682f + p * w;
}
return p * value;
}
// Ashima Arts / Ian McEwan 3D simplex noise (MIT), mirrored by cultmath_snoise(float3)
// in shaders/CultMath.hlsl with the same float32 evaluation order.
public static float snoise(float3 value)

View file

@ -31,6 +31,7 @@ public sealed class HlslMirrorTests
"cultmath_smootherstep",
"cultmath_lengthsq",
"cultmath_distance",
"cultmath_normalize",
"cultmath_reflect",
"cultmath_rotate",
"cultmath_csum",
@ -40,9 +41,6 @@ public sealed class HlslMirrorTests
"cultmath_quadratic_bezier",
"cultmath_cubic_bezier",
"cultmath_hash",
"cultmath_pcg(uint",
"cultmath_pcg3d(int3",
"cultmath_pcg4d(int4",
"cultmath_value_noise",
"cultmath_snoise(float2",
"cultmath_snoise(float3",
@ -56,8 +54,8 @@ public sealed class HlslMirrorTests
Assert.Contains(symbol, include);
}
// HLSL's normalize intrinsic is the contract; a mirror copy would only restate it.
Assert.DoesNotContain("cultmath_normalize", include);
Assert.Contains("CULTMATH_NORMALIZE_EPSILON", include);
Assert.Contains("max(length(value), CULTMATH_NORMALIZE_EPSILON)", include);
}
private static string GetCultMathRoot()

View file

@ -187,33 +187,6 @@ public sealed class HlslSemanticsTests
Assert.Equal(DoubleBits(-0.0), DoubleBits(math.max(-0.0, 0.0)));
Assert.Equal(DoubleBits(0.0), DoubleBits(math.min(-0.0, 0.0)));
// dxc: normalize(x) is x * Rsqrt(Dot(x, x)); length and distance are Sqrt of the summed squares.
static bool AllNaN(float4 value) =>
float.IsNaN(value.x) && float.IsNaN(value.y) && float.IsNaN(value.z) && float.IsNaN(value.w);
var n2 = math.normalize(float2.zero);
Assert.True(float.IsNaN(n2.x) && float.IsNaN(n2.y));
var n3 = math.normalize(float3.zero);
Assert.True(float.IsNaN(n3.x) && float.IsNaN(n3.y) && float.IsNaN(n3.z));
Assert.True(AllNaN(math.normalize(float4.zero)));
var nanX = math.normalize(new float2(float.NaN, 2.41f));
Assert.True(float.IsNaN(nanX.x) && float.IsNaN(nanX.y));
Assert.True(AllNaN(math.normalize(new float4(1.0f, float.NaN, 2.41f, 1.0f))));
var skew = new float3(0.25f, 1.0f, -0.1f);
var inverseLength = 1.0f / MathF.Sqrt(skew.x * skew.x + skew.y * skew.y + skew.z * skew.z);
var normalized = math.normalize(skew);
Assert.Equal(Bits(skew.x * inverseLength), Bits(normalized.x));
Assert.Equal(Bits(skew.y * inverseLength), Bits(normalized.y));
Assert.Equal(Bits(skew.z * inverseLength), Bits(normalized.z));
Assert.Equal(Bits(0.0f), Bits(math.length(float2.zero)));
Assert.Equal(Bits(0.0f), Bits(math.length(float3.zero)));
Assert.Equal(Bits(0.0f), Bits(math.length(float4.zero)));
Assert.Equal(Bits(0.0f), Bits(math.distance(new float3(1.0f, 2.0f, 3.0f), new float3(1.0f, 2.0f, 3.0f))));
// asuint(float) reinterprets the IEEE-754 bits.
Assert.Equal(0x80000000u, math.asuint(-0.0f));
Assert.Equal(0x3FC00000u, math.asuint(1.5f));
Assert.Equal(0x7FC00000u, math.asuint(BitConverter.Int32BitsToSingle(0x7FC00000)));
Assert.Equal(0, math.sign(float.NaN));
Assert.Equal(-1, math.sign(-0.5f));
Assert.Equal(new int3(-1, 0, 1), math.sign(new float3(-2.0f, 0.0f, 4.0f)));
@ -338,43 +311,6 @@ public sealed class HlslSemanticsTests
Assert.Equal(new int4(5, 5, 5, 5), math.int4(5));
}
[Fact]
public void VectorConversionsFollowDxc()
{
// fptosi truncates toward zero; NaN and out-of-range inputs are undefined in DXIL and not pinned.
Assert.Equal(new int2(-1, 2), math.int2(new float2(-1.7f, 2.9f)));
Assert.Equal(new int3(0, 0, 3), new int3(new float3(-0.5f, 0.5f, 3.99f)));
Assert.Equal(new float4(1.0f, -2.0f, 3.0f, 0.0f), math.float4(new int4(1, -2, 3, 0)));
// Numeric to bool is `!= 0` (fcmp une for float, so NaN is true); bool to numeric is 0 or 1.
Assert.Equal(new bool2(true, false), math.bool2(new float2(float.NaN, -0.0f)));
Assert.Equal(new bool3(false, true, true), math.bool3(new int3(0, 5, -1)));
Assert.Equal(new float2(1.0f, 0.0f), math.float2(new bool2(true, false)));
Assert.Equal(new int4(0, 1, 1, 0), math.int4(new bool4(false, true, true, false)));
// intN(intN) is exact; it must not promote through float and lose 2^24 + 1.
Assert.Equal(new int2(16777217, -16777217), math.int2(new int2(16777217, -16777217)));
float2 splat = math.float2(0);
Assert.Equal(float2.zero, splat);
}
[Fact]
public void FracAndIntegerClampFollowHlsl()
{
// frac(double) keeps double precision; a float frac of 1e9 + 0.25 would return 0.
Assert.Equal(0.25, math.frac(1.0e9 + 0.25));
Assert.Equal(0.75, math.frac(-0.25));
Assert.Equal(0.5f, math.frac(2.5f));
// IMin(IMax(x, a), b): the result stays int, and inverted bounds return b.
int clamped = math.clamp(7, -1, 3);
Assert.Equal(3, clamped);
Assert.Equal(-1, math.clamp(-5, -1, 3));
Assert.Equal(0, math.clamp(1, 3, 0));
Assert.Equal(new int2(0, 3), math.clamp(new int2(-4, 9), 0, 3));
Assert.Equal(new int3(1, 2, 3), math.clamp(new int3(1, 2, 3), new int3(0, 0, 0), new int3(5, 5, 5)));
Assert.Equal(new int4(2, 2, 4, 4), math.clamp(new int4(1, 2, 5, 4), new int4(2), new int4(4)));
Assert.Equal(1.0f, math.clamp(2.0f, -1, 1));
}
[Fact]
public void MatrixMulFollowsHlslRowMajorConvention()
{

View file

@ -78,33 +78,26 @@ public sealed class HlslSourceCompatibilityTests
foreach (var input in cases)
{
var slot = 0;
var args = types.Select(t => Scalars.Contains(t) ? Scalar(t, input(slot++))
: Activator.CreateInstance(t, Components(t).Select(f => Scalar(f.FieldType, input(slot++))).ToArray())!).ToArray();
var expected = Values(counterpart.Invoke(null, args)!);
var actual = Values(mirror.Invoke(shader, args)!);
if (expected.Length != actual.Length || expected.Zip(actual).Any(p => p.First is float a && p.Second is float b
? !(float.IsNaN(a) && float.IsNaN(b)) && BitConverter.SingleToInt32Bits(a) != BitConverter.SingleToInt32Bits(b)
: !p.First.Equals(p.Second)))
var args = types.Select(t => t == typeof(float) ? (object)input(slot++)
: Activator.CreateInstance(t, Enumerable.Range(0, Components(t)).Select(_ => (object)input(slot++)).ToArray())!).ToArray();
var expected = Floats(counterpart.Invoke(null, args)!);
var actual = Floats(mirror.Invoke(shader, args)!);
if (expected.Length != actual.Length || expected.Zip(actual).Any(p => !(float.IsNaN(p.First) && float.IsNaN(p.Second))
&& BitConverter.SingleToInt32Bits(p.First) != BitConverter.SingleToInt32Bits(p.Second)))
{
mismatches.Add($"{mirror.Name}({string.Join(", ", args.Select(a => string.Join(" ", Values(a))))}): C# {string.Join(" ", expected)}, mirror {string.Join(" ", actual)}");
mismatches.Add($"{mirror.Name}({string.Join(", ", args.Select(a => string.Join(" ", Floats(a))))}): C# {string.Join(" ", expected)}, mirror {string.Join(" ", actual)}");
}
}
}
Assert.True(mismatches.Count == 0, $"{mismatches.Count} mismatches:{Environment.NewLine}{string.Join(Environment.NewLine, mismatches.Take(12))}");
Assert.Equal(28, compared.Count);
Assert.Equal(26, compared.Count);
}
// Integer arguments take the bit pattern of the float input, so specials become 0, 0x80000000, NaN bits, ...
private static readonly Type[] Scalars = { typeof(float), typeof(int), typeof(uint) };
private static int Components(Type t) => t.GetFields().Count(f => f.FieldType == typeof(float) && !f.IsStatic);
private static object Scalar(Type t, float f) => t == typeof(float) ? f
: t == typeof(int) ? BitConverter.SingleToInt32Bits(f) : (object)(uint)BitConverter.SingleToInt32Bits(f);
private static FieldInfo[] Components(Type t) => t.GetFields().Where(f => !f.IsStatic && Scalars.Contains(f.FieldType)).ToArray();
private static object[] Values(object value) => Scalars.Contains(value.GetType()) ? new[] { value }
: Components(value.GetType()).Select(field => field.GetValue(value)!).ToArray();
private static float[] Floats(object value) => value is float f ? new[] { f }
: value.GetType().GetFields().Where(field => field.FieldType == typeof(float) && !field.IsStatic).Select(field => (float)field.GetValue(value)!).ToArray();
/// <summary>The documented HLSL-to-C# transformations, and nothing else.</summary>
internal static string TransformHlslToCSharp(string hlsl)

View file

@ -12,41 +12,6 @@ public sealed class MathTests
Assert.Equal(0.75f, math.frac(-1.25f));
}
// Expected values come from a Python transcription of the paper's pcg3d/pcg4d (JCGT 9(3) 2020, p. 32) and
// O'Neill's pcg_output_rxs_m_xs_32_32 with PCG_DEFAULT_MULTIPLIER_32/INCREMENT_32, independent of this code.
[Fact]
public void PcgHashesMatchReferenceTranscription()
{
static int3 U3(uint x, uint y, uint z) => new((int)x, (int)y, (int)z);
static int4 U4(uint x, uint y, uint z, uint w) => new((int)x, (int)y, (int)z, (int)w);
Assert.Equal(0x07BB2FE2u, math.pcg(0u));
Assert.Equal(0xA8BEEA3Cu, math.pcg(1u));
Assert.Equal(0x995312E1u, math.pcg(0x12345678u));
Assert.Equal(0xE62A4902u, math.pcg(0xFFFFFFFFu));
Assert.Equal(U3(0x9BAFD7C6u, 0xA8E88A6Bu, 0x3F15482Cu), math.pcg3d(new int3(0, 0, 0)));
Assert.Equal(U3(0xFA9F79A6u, 0x48F2F44Cu, 0x596F5AB1u), math.pcg3d(new int3(1, 2, 3)));
Assert.Equal(U3(0x67DCF282u, 0x49E87E17u, 0x10018917u), math.pcg3d(U3(0xFFFFFFFFu, 0x80000000u, 0x7FFFFFFFu)));
Assert.Equal(U4(0x0F02F829u, 0x2D568769u, 0x32B0C43Bu, 0xD32548EAu), math.pcg4d(new int4(0, 0, 0, 0)));
Assert.Equal(U4(0x3622CD16u, 0xF11471D8u, 0xE1109B3Fu, 0x02B94C2Fu), math.pcg4d(new int4(1, 2, 3, 4)));
Assert.Equal(U4(0x72180037u, 0x81D493E6u, 0xD9BA7226u, 0x14C9ABA7u), math.pcg4d(U4(0xFFFFFFFFu, 0x80000000u, 0x7FFFFFFFu, 0xDEADBEEFu)));
Assert.Equal(U3(0xA7B40FEEu, 0x9B70DFC0u, 0x3106C2C5u), math.pcg3d(new float3(1.5f, -2.25f, 1e7f)));
Assert.Equal(U3(0x5A09E5C6u, 0x53EC0DABu, 0x4BE4A1ECu), math.pcg3d(new float2(3.0f, 4.0f)));
Assert.Equal(U4(0x1849E380u, 0xF199DEE0u, 0x6C6B4934u, 0x34AF16DFu),
math.pcg4d(new float4(-0.0f, BitConverter.Int32BitsToSingle(0x7FC00000), 0.5f, 1e-7f)));
}
[Fact]
public void Pcg3dSeparatesAPositionGrid()
{
// A single 32-bit component is subject to birthday collisions (x has one here); the full output is distinct.
var hashes = new HashSet<int3>();
for (var i = 0; i < 100; i++)
for (var j = 0; j < 100; j++)
hashes.Add(math.pcg3d(new float2(i * 0.5f - 25.0f, j * 0.5f - 25.0f)));
Assert.Equal(10000, hashes.Count);
}
[Fact]
public void VectorOperationsAreComponentWise()
{
@ -367,160 +332,4 @@ public sealed class MathTests
Assert.Equal(0.0f, px[1], precision: 5);
Assert.Equal(0.0f, py[1], precision: 5);
}
// Tabulated to 16 significant figures (Abramowitz & Stegun table 7.1 lineage / standard erf tables).
[Theory]
[InlineData(0.0f, 0.0)]
[InlineData(0.5f, 0.5204998778130465)]
[InlineData(-0.5f, -0.5204998778130465)]
[InlineData(1.0f, 0.8427007929497149)]
[InlineData(-1.0f, -0.8427007929497149)]
[InlineData(2.0f, 0.9953222650189527)]
[InlineData(-2.0f, -0.9953222650189527)]
[InlineData(3.0f, 0.9999779095030014)]
[InlineData(-3.0f, -0.9999779095030014)]
public void ErfMatchesReferenceValues(float x, double expected)
{
Assert.True(
Math.Abs(math.erf(x) - expected) <= 2e-6,
$"erf({x}) = {math.erf(x)}, expected {expected}");
}
[Fact]
public void ErfIsOdd()
{
for (var i = -30; i <= 30; i++)
{
var x = i / 10.0f;
Assert.Equal(-math.erf(x), math.erf(-x));
}
}
[Fact]
public void ErfApproachesItsLimitsOfPlusMinusOne()
{
Assert.Equal(1.0f, math.erf(6.0f));
Assert.Equal(1.0f, math.erf(20.0f));
Assert.Equal(-1.0f, math.erf(-6.0f));
Assert.Equal(-1.0f, math.erf(-20.0f));
}
[Fact]
public void ErfinvInvertsErf()
{
for (var i = -999; i <= 999; i++)
{
var y = i / 1000.0f;
var x = math.erfinv(y);
var roundTrip = math.erf(x);
Assert.True(
MathF.Abs(roundTrip - y) < 1e-5f,
$"erf(erfinv({y})) = {roundTrip}");
}
}
// Reference values from bisecting the platform's double-precision erf (Python's math.erf,
// glibc-backed), not this package's own erf, to 200 iterations over [-6, 6] -- effectively
// exact at float precision. This is what ErfinvInvertsErf cannot be: a check against this
// package's own erf only proves the two functions agree with each other, not that either is
// numerically correct. Soul measured the round-trip test alone to be blind to a coefficient
// change that degraded erfinv's worst error 300-400x (5.0e-7 to 2.0e-4).
[Theory]
// Expected values are computed for the actual float32-rounded input (e.g. the literal
// 0.999f is really 0.9990000128746033 as a double), not the decimal literal, because
// erfinv's derivative diverges near +-1: at y = 0.999f that rounding alone moves the
// true answer by ~2.6e-6, larger than the test's own error bar, if ignored.
[InlineData(0.0f, 0.0)]
[InlineData(0.1f, 0.08885599182530649)]
[InlineData(-0.1f, -0.08885599182530649)]
[InlineData(0.3f, 0.2724627261055238)]
[InlineData(-0.3f, -0.2724627261055238)]
[InlineData(0.5f, 0.4769362762044699)]
[InlineData(-0.5f, -0.4769362762044699)]
[InlineData(0.7f, 0.7328690598827587)]
[InlineData(-0.7f, -0.732869059882759)]
[InlineData(0.9f, 1.1630870719457724)]
[InlineData(-0.9f, -1.1630870719457729)]
[InlineData(0.95f, 1.3859037522360764)]
[InlineData(-0.95f, -1.3859037522360773)]
[InlineData(0.99f, 1.8213866009002766)]
[InlineData(-0.99f, -1.8213866009002793)]
[InlineData(0.999f, 2.3267563267961435)]
[InlineData(-0.999f, -2.3267563267961657)]
[InlineData(0.9999f, 2.751035437903192)]
[InlineData(-0.9999f, -2.751035437903383)]
public void ErfinvMatchesReferenceValues(float y, double expected)
{
Assert.True(
Math.Abs(math.erfinv(y) - expected) <= 2e-6,
$"erfinv({y}) = {math.erfinv(y)}, expected {expected}");
}
[Fact]
public void ErfinvAtDomainEdgesAndBeyond()
{
// Domain is [-1, 1]. The edges are the correctly-signed infinite limits; anything
// outside, including +-infinity itself, has no real inverse and is NaN.
Assert.Equal(float.PositiveInfinity, math.erfinv(1.0f));
Assert.Equal(float.NegativeInfinity, math.erfinv(-1.0f));
Assert.True(float.IsNaN(math.erfinv(1.0001f)));
Assert.True(float.IsNaN(math.erfinv(-1.0001f)));
Assert.True(float.IsNaN(math.erfinv(2.0f)));
Assert.True(float.IsNaN(math.erfinv(-2.0f)));
Assert.True(float.IsNaN(math.erfinv(float.PositiveInfinity)));
Assert.True(float.IsNaN(math.erfinv(float.NegativeInfinity)));
Assert.True(float.IsNaN(math.erfinv(float.NaN)));
}
// The deleted ErfinvIsMonotonicallyIncreasing asserted a property erfinv does not have:
// Soul measured 9662 backward steps scanning every representable float32 in [0, 0.999999],
// first at y = 0.00022214651, worst drop 5.960464e-8 (one ULP at that scale) -- invisible
// only because that fixture stepped by 0.001. erf has the same shape: 100 backward steps
// over [0, 4.5], worst drop 1.192093e-7. Pin what is actually true: monotone to within a
// tolerance comfortably above that measured noise floor.
[Fact]
public void ErfinvIsMonotonicWithinFloatingPointTolerance()
{
const float tolerance = 1e-6f;
var previous = float.NegativeInfinity;
for (var i = -999; i <= 999; i++)
{
var y = i / 1000.0f;
var x = math.erfinv(y);
Assert.True(
x >= previous - tolerance,
$"erfinv({y}) = {x} dropped more than {tolerance} below previous value {previous}");
previous = x;
}
}
// One property the consumer needs, not the whole of correctness: fire control turns
// adjacent erf values into a cell's probability mass, so a backward step in erf would
// show up as a negative mass. An erf that returned a constant would pass this and is
// caught by ErfMatchesReferenceValues instead.
// Soul checked these geometries clean: 16/64/256/1024/4096 cells over
// +-4 sigma, plus a tight 1 sigma spread and a very tight 0.25 sigma spread.
[Theory]
[InlineData(16, 4f)]
[InlineData(64, 4f)]
[InlineData(256, 4f)]
[InlineData(1024, 4f)]
[InlineData(4096, 4f)]
[InlineData(256, 1f)]
[InlineData(64, 0.25f)]
public void ErfCellMassesStayNonNegativeAcrossRealisticGeometries(int cells, float sigmaRange)
{
var lo = -sigmaRange;
var hi = sigmaRange;
var step = (hi - lo) / cells;
var previous = math.erf(lo / MathF.Sqrt(2f));
for (var i = 1; i <= cells; i++)
{
var edge = lo + step * i;
var current = math.erf(edge / MathF.Sqrt(2f));
var mass = current - previous;
Assert.True(mass >= 0f, $"cell {i} of {cells} over +-{sigmaRange} sigma has negative mass {mass}");
previous = current;
}
}
}

View file

@ -21,19 +21,6 @@ $families = @(
@{ Scalar = "bool"; Sizes = @(2, 3, 4); Functions = $true },
@{ Scalar = "double"; Sizes = @(2, 3); Functions = $false }
)
# Same-size vector conversions, as dxc lowers them: float to int is fptosi (toward zero; NaN
# and out-of-range are undefined in DXIL), int to float is sitofp, numeric to bool is `!= 0`
# (fcmp une, so NaN is true), bool to numeric is 0 or 1. Keyed "target<-source"; {0} is a
# source component. Identity forms (intN(intN), ...) come from the composition loop, which
# also keeps an int vector argument off the lossy intN(floatN) overload.
$conversions = [ordered]@{
"float<-int" = "{0}"
"float<-bool" = "{0} ? 1.0f : 0.0f"
"int<-float" = "(int){0}"
"int<-bool" = "{0} ? 1 : 0"
"bool<-float" = "{0} != 0.0f"
"bool<-int" = "{0} != 0"
}
$componentFields = @("x", "y", "z", "w")
$namings = @(@("x", "y", "z", "w"), @("r", "g", "b", "a"))
@ -99,20 +86,6 @@ foreach ($family in $families) {
}
}
foreach ($source in @("float", "int", "bool")) {
$expression = $conversions["$scalar<-$source"]
if (-not $expression) { continue }
$converted = for ($i = 0; $i -lt $size; $i++) { $expression -f "value.$($componentFields[$i])" }
Line " public $type($source$size value) : this($($converted -join ', ')) { }"
if ($family.Functions) {
$functionLines.Add(" public static $type $type($source$size value) => new(value);")
}
}
if ($scalar -eq "float" -and $family.Functions) {
# floatN(int) splat: without it floatN(0) is ambiguous between floatN(float) and floatN(intN).
$functionLines.Add(" public static $type $type(int value) => new($((@('value') * $size) -join ', '));")
}
# Single-component color aliases; x/y/z/w are the fields themselves.
$letters = $namings[1]
for ($i = 0; $i -lt $size; $i++) {

View file

@ -11,6 +11,7 @@
static const float CULTMATH_PI = 3.14159265358979323846;
static const float CULTMATH_TAU = 6.28318530717958647692;
static const float CULTMATH_HALF_PI = 1.57079632679489661923;
static const float CULTMATH_NORMALIZE_EPSILON = 1.0e-20;
float cultmath_radians(float degrees_value) { return degrees_value * (CULTMATH_PI / 180.0); }
float cultmath_degrees(float radians_value) { return radians_value * (180.0 / CULTMATH_PI); }
@ -170,6 +171,10 @@ float cultmath_distance(float2 left, float2 right) { return length(left - right)
float cultmath_distance(float3 left, float3 right) { return length(left - right); }
float cultmath_distance(float4 left, float4 right) { return length(left - right); }
float2 cultmath_normalize(float2 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); }
float3 cultmath_normalize(float3 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); }
float4 cultmath_normalize(float4 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); }
float2 cultmath_reflect(float2 incident, float2 normal_value)
{
return incident - 2.0 * dot(normal_value, incident) * normal_value;
@ -281,38 +286,6 @@ float cultmath_hash(float value) { return cultmath_frac(sin(value) * 43758.5453)
float cultmath_hash(float2 value) { return cultmath_hash(dot(value, float2(127.1, 311.7))); }
float cultmath_hash(float3 value) { return cultmath_hash(dot(value, float3(127.1, 311.7, 74.7))); }
// PCG hashes (Jarzynski and Olano, JCGT 2020), written over scalar uints so CultMath's int3/int4 carry the bits.
uint cultmath_pcg(uint value)
{
uint state = value * 747796405u + 2891336453u;
uint word = ((state >> (int)((state >> 28) + 4)) ^ state) * 277803737u;
return (word >> 22) ^ word;
}
int3 cultmath_pcg3d(int3 value)
{
uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u, z = (uint)value.z * 1664525u + 1013904223u;
x += y * z; y += z * x; z += x * y;
x ^= x >> 16; y ^= y >> 16; z ^= z >> 16;
x += y * z; y += z * x; z += x * y;
return int3((int)x, (int)y, (int)z);
}
int3 cultmath_pcg3d(float2 value) { return cultmath_pcg3d(float3(value, 0.0)); }
int3 cultmath_pcg3d(float3 value) { return cultmath_pcg3d(int3((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z))); }
int4 cultmath_pcg4d(int4 value)
{
uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u;
uint z = (uint)value.z * 1664525u + 1013904223u, w = (uint)value.w * 1664525u + 1013904223u;
x += y * w; y += z * x; z += x * y; w += y * z;
x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; w ^= w >> 16;
x += y * w; y += z * x; z += x * y; w += y * z;
return int4((int)x, (int)y, (int)z, (int)w);
}
int4 cultmath_pcg4d(float4 value) { return cultmath_pcg4d(int4((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z), (int)asuint(value.w))); }
float cultmath_value_noise(float2 position)
{
float2 cell = floor(position);

View file

@ -1,7 +1,7 @@
{
"name": "org.gamecult.cultmath",
"displayName": "CultMath",
"version": "0.2.3",
"version": "0.2.0",
"unity": "2021.3",
"description": "Precompiled CultMath numeric primitives and matching HLSL semantics for GameCult Unity runtimes.",
"license": "MPL-2.0",

View file

@ -15,28 +15,42 @@ import {
type CultMeshSessionAcceptedMessage,
type CultMeshSessionOpenMessage,
} from "cultnet-ts/contracts";
import {
base64ToBytes,
bytesToBase64,
isLoopbackEndpoint,
isUnsignedCertificate,
routeCertificateView,
verifyAuthorityRoute,
verifyProviderSessionProof,
type CultMeshAuthorityIdentity,
type CultMeshAuthorityRouteCertificate,
type CultMeshAuthorityRouteView,
type CultMeshAuthorityTrustMode,
type CultMeshAuthorityTrustPolicy,
type CultMeshP256PublicKey,
} from "cultnet-ts/authority";
export type CultMeshBrowserIdentity = CultMeshAuthorityIdentity;
export type CultMeshBrowserRoute = CultMeshAuthorityRouteView;
export type CultMeshBrowserP256PublicKey = CultMeshP256PublicKey;
export type CultMeshBrowserRouteCertificate = CultMeshAuthorityRouteCertificate;
export type CultMeshBrowserAuthorityTrustMode = CultMeshAuthorityTrustMode;
export type CultMeshBrowserAuthorityTrustPolicy = CultMeshAuthorityTrustPolicy;
export interface CultMeshBrowserIdentity {
verseId: string;
authorityRuntimeId: string;
}
export interface CultMeshBrowserRoute extends CultMeshBrowserIdentity {
endpoint: string;
protocolId?: string;
protocolIds?: readonly string[];
priority?: number;
generation: string;
certificate?: CultMeshBrowserRouteCertificate;
}
export interface CultMeshBrowserP256PublicKey {
keyId: string;
x: string;
y: string;
}
export interface CultMeshBrowserRouteCertificate {
providerKey: CultMeshBrowserP256PublicKey;
odinKeyId: string;
issuedAtUnixMilliseconds: number;
expiresAtUnixMilliseconds: number;
signature: string;
}
export type CultMeshBrowserAuthorityTrustMode = "authenticated-remote" | "local-development";
export interface CultMeshBrowserAuthorityTrustPolicy {
mode: CultMeshBrowserAuthorityTrustMode;
odinRoots?: readonly CultMeshBrowserP256PublicKey[];
now?: () => number;
}
export interface CultMeshBrowserRendezvous {
resolve(
@ -780,6 +794,19 @@ export function decodeCultNetOperationPayload<T>(response: CultNetOperationRespo
return decode(base64ToBytes(response.payload)) as T;
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
}
return btoa(binary);
}
function base64ToBytes(value: string): Uint8Array {
const binary = atob(value);
return Uint8Array.from(binary, character => character.charCodeAt(0));
}
function requireWebSocketEndpoint(value: string, field: string): URL {
let endpoint: URL;
try {
@ -871,7 +898,17 @@ async function selectOdinRoute(
protocolIds: route.protocolIds,
priority: route.priority,
generation: route.generation,
...(route.certificate ? { certificate: routeCertificateView(route.certificate) } : {}),
...(route.certificate ? { certificate: {
providerKey: {
keyId: route.certificate.providerKeyId,
x: route.certificate.providerPublicKeyX,
y: route.certificate.providerPublicKeyY,
},
odinKeyId: route.certificate.odinKeyId,
issuedAtUnixMilliseconds: route.certificate.issuedAtUnixMilliseconds,
expiresAtUnixMilliseconds: route.certificate.expiresAtUnixMilliseconds,
signature: route.certificate.signature,
} } : {}),
};
try {
await verifyAuthorityRoute(candidate, trust);
@ -904,26 +941,136 @@ async function validateSessionAcceptance(
`expected '${request.verseId}/${request.authorityRuntimeId}/${protocolId}/${route.generation}'.`,
);
}
// `CultMeshVerifiedAuthorityRoute.IsLocalDevelopment` in C#: an unsigned
// loopback route under local-development needs no provider session proof.
if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint) && isUnsignedCertificate(route.certificate)) {
return undefined;
}
if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint) && !route.certificate) return undefined;
const providerKey = route.certificate?.providerKey;
if (!providerKey || message.providerKeyId !== providerKey.keyId || !message.providerSignature) {
return new Error("CultMesh authority did not prove possession of the Odin-certified provider key.");
}
if (!await verifyProviderSessionProof(request, route.endpoint, providerKey, message.providerSignature)) {
if (!await verifyP256(providerKey, canonicalSession(request, route.endpoint), message.providerSignature)) {
return new Error("CultMesh provider session proof is invalid.");
}
return undefined;
}
async function verifyAuthorityRoute(
route: CultMeshBrowserRoute,
trust: CultMeshBrowserAuthorityTrustPolicy,
): Promise<void> {
const certificate = route.certificate;
if (!certificate) {
if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint)) return;
throw new Error("Remote CultMesh routes require an Odin-signed authority certificate.");
}
const endpoint = new URL(route.endpoint);
if (endpoint.protocol !== "wss:" && !(trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint))) {
throw new Error("Authenticated remote CultMesh browser routes require wss:// channel protection.");
}
const now = trust.now?.() ?? Date.now();
if (now < certificate.issuedAtUnixMilliseconds || now >= certificate.expiresAtUnixMilliseconds) {
throw new Error("The Odin route certificate is not currently valid.");
}
const root = trust.odinRoots?.find(candidate => candidate.keyId === certificate.odinKeyId);
if (!root) throw new Error(`Odin key '${certificate.odinKeyId}' is not trusted by this consumer.`);
if (!await verifyP256(root, canonicalRoute(route), certificate.signature)) {
throw new Error("The Odin route certificate signature is invalid.");
}
}
function canonicalRoute(route: CultMeshBrowserRoute): Uint8Array {
const certificate = route.certificate!;
return canonicalFields(
"gamecult.cultmesh.route-certificate.v1",
route.verseId,
route.authorityRuntimeId,
route.endpoint,
[...(route.protocolIds ?? [route.protocolId ?? "cultmesh.documents.v1"])].sort().join("\u001f"),
String(route.priority ?? 0),
route.generation,
certificate.providerKey.keyId,
certificate.providerKey.x,
certificate.providerKey.y,
certificate.odinKeyId,
String(certificate.issuedAtUnixMilliseconds),
String(certificate.expiresAtUnixMilliseconds),
);
}
function canonicalSession(request: CultMeshSessionOpenMessage, endpoint: string): Uint8Array {
return canonicalFields(
"gamecult.cultmesh.session-proof.v1",
request.clientNonce,
request.messageId,
request.sourceRuntimeId,
request.verseId,
request.authorityRuntimeId,
request.protocolId,
endpoint,
request.routeGeneration,
);
}
function canonicalFields(...values: string[]): Uint8Array {
const encoder = new TextEncoder();
const encoded = values.map(value => encoder.encode(value));
const total = encoded.reduce((sum, value) => sum + 4 + value.byteLength, 0);
const result = new Uint8Array(total);
const view = new DataView(result.buffer);
let offset = 0;
for (const value of encoded) {
view.setUint32(offset, value.byteLength, false);
offset += 4;
result.set(value, offset);
offset += value.byteLength;
}
return result;
}
async function verifyP256(
key: CultMeshBrowserP256PublicKey,
payload: Uint8Array,
signatureBase64: string,
): Promise<boolean> {
try {
const x = base64ToBytes(key.x);
const y = base64ToBytes(key.y);
const signature = base64ToBytes(signatureBase64);
if (x.byteLength !== 32 || y.byteLength !== 32 || signature.byteLength !== 64) return false;
const raw = new Uint8Array(65);
raw[0] = 4;
raw.set(x, 1);
raw.set(y, 33);
const publicKey = await crypto.subtle.importKey(
"raw",
raw.slice().buffer as ArrayBuffer,
{ name: "ECDSA", namedCurve: "P-256" },
false,
["verify"],
);
return await crypto.subtle.verify(
{ name: "ECDSA", hash: "SHA-256" },
publicKey,
signature.slice().buffer as ArrayBuffer,
payload.slice().buffer as ArrayBuffer,
);
} catch {
return false;
}
}
function randomNonce(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32));
return bytesToBase64(bytes);
}
function isLoopbackEndpoint(value: string): boolean {
try {
const host = new URL(value).hostname.replace(/^\[|\]$/g, "").toLowerCase();
return host === "localhost" || host === "127.0.0.1" || host === "::1";
} catch {
return false;
}
}
function requireText(value: string, field: string): void {
if (!value || value.trim().length === 0) throw new Error(`CultMesh browser ${field} is required.`);
}

View file

@ -236,54 +236,6 @@ test("browser client rejects a credential-free authority impersonator", async ()
);
});
test("browser client opens an unsigned-certificate loopback session under local-development without a provider proof, as in C#", async () => {
const connect = (route: CultMeshBrowserRoute, trust: { mode: "local-development" | "authenticated-remote"; odinRoots: CultMeshBrowserP256PublicKey[] }) =>
CultMeshBrowserClient.connect({
...route,
runtimeId: "browser-unsigned-certificate-test",
rendezvous: { resolve: async () => route },
trust,
socketFactory: () => handshakeOnlySocket(route),
});
const blank = (route: CultMeshBrowserRoute, signature: string) =>
({ ...route, certificate: { ...route.certificate!, signature } });
const loopback = await createSignedRoute("ws://127.0.0.1:4501/mesh");
const localTrust = { mode: "local-development" as const, odinRoots: [loopback.odinPublic] };
const remote = await createSignedRoute("wss://provider.example/mesh");
// Empty is the easy case. The whitespace signatures are the ones that catch a
// local re-reading of "unsigned" here: an inline `signature === ""` passes
// every other test in this file and fails these, and U+0085 also separates
// `String.prototype.trim` from the C# `char.IsWhiteSpace` the shared
// `isUnsignedCertificate` uses. Both sides of the session check have to read
// "unsigned" the same way, or the route verifier accepts a session the
// acceptance check then demands a provider proof for.
for (const signature of ["", " ", "\u0085", "\u00a0\t\n"]) {
const label = JSON.stringify(signature);
const client = await connect(blank(loopback.route, signature), localTrust);
assert.equal(client.state, "connected", label);
await client.dispose();
await assert.rejects(
connect(blank(remote.route, signature), { mode: "local-development", odinRoots: [remote.odinPublic] }),
/Odin-signed authority certificate/,
label,
);
await assert.rejects(
connect(blank(remote.route, signature), { mode: "authenticated-remote", odinRoots: [remote.odinPublic] }),
/Odin-signed authority certificate/,
label,
);
}
// U+FEFF is whitespace to JavaScript and not to C#, so it is a signature, not
// an unsigned route, and the loopback route is refused for its bytes.
await assert.rejects(connect(blank(loopback.route, "\ufeff"), localTrust), /not base64|not IEEE P1363/);
// A signed loopback certificate is an authenticated route even under
// local-development; the provider must still prove its key.
await assert.rejects(connect(loopback.route, localTrust), /did not prove possession/);
});
test("browser client rejects a mutated or expired signed route before opening a provider socket", async () => {
const fixture = await createSignedRoute("wss://provider.example/mesh");
let opened = false;

View file

@ -1359,7 +1359,7 @@ fun defineCultNetSchemaDescriptor(
schemaJson = schemaJson,
)
private const val cultNetSchemaBase = "https://github.com/GameCult/CultLib/contracts/cultnet"
private const val cultNetSchemaBase = "https://github.com/GameCult/cultnet-ts/contracts"
private data class CultNetBuiltInSchemaSpec(
val schemaVersion: String,
@ -1377,58 +1377,58 @@ private val cultNetBuiltInSchemaSpecs = listOf(
title = "CultNet Hello Message",
schemaId = "$cultNetSchemaBase/cultnet.hello.schema.json",
requiredFields = listOf("schemaVersion", "runtimeId", "runtimeKind"),
contentHash = "f660ef803f293e2882650b3981c6eed835bd8e595c9b880d6c6271e0dcbd79e4",
contentHash = "1d2114bf0c92a2dd5e07dd735778ceeb939402c6424550d6d281d9db423f56b3",
),
CultNetBuiltInSchemaSpec(
schemaVersion = "cultnet.document_delete.v0",
title = "CultNet Document Delete Message",
schemaId = "$cultNetSchemaBase/cultnet.document-delete.schema.json",
requiredFields = listOf("schemaVersion", "messageId", "schemaId", "recordKey"),
contentHash = "ed7dc202e4f1ebe0d88c5d60c81c53005b44a2557698d3d3c5d2126c01b40f08",
contentHash = "037f7cf1b30756904e0bfada6bcff486269378997afbe3c4b6fc5c573fc8a4df",
),
CultNetBuiltInSchemaSpec(
schemaVersion = "cultnet.document_put_raw.v0",
title = "CultNet Raw Document Put Message",
schemaId = "$cultNetSchemaBase/cultnet.document-put-raw.schema.json",
contentHash = "34b61b14b49cfc887728673f08aa0f33df7ccd401e79c77d3bc324c72e547177",
contentHash = "b61e7e6cce8508653fbeccc4d956ef211c853a174d67c7e1903ce294e15985a2",
),
CultNetBuiltInSchemaSpec(
schemaVersion = "cultnet.snapshot_request.v0",
title = "CultNet Snapshot Request Message",
schemaId = "$cultNetSchemaBase/cultnet.snapshot-request.schema.json",
contentHash = "05267d9b5eb450d4aa8979179f74ba3aec2f3a82fead7ba4a16ab5462c7750fa",
contentHash = "9f6546f51a1aab85978554e6908d7a59e96b3329feff7b35022be210495dd721",
),
CultNetBuiltInSchemaSpec(
schemaVersion = "cultnet.snapshot_response_raw.v0",
title = "CultNet Raw Snapshot Response Message",
schemaId = "$cultNetSchemaBase/cultnet.snapshot-response-raw.schema.json",
contentHash = "fdefbdf5d6b0844dd0eabe2c7430d054295d78e41289f96b227dc39f4aab78b9",
contentHash = "0dbca3338a3ae5d4011aed2c973c75b9b47b6b9dfc0874459ac56da01fef4582",
),
CultNetBuiltInSchemaSpec(
schemaVersion = "cultnet.schema_catalog_request.v0",
title = "CultNet Schema Catalog Request Message",
schemaId = "$cultNetSchemaBase/cultnet.schema-catalog-request.schema.json",
wireContracts = listOf("cultnet.schema.v0", "gamecult.networking.v0"),
contentHash = "ed6d63e29de2d492bb831f030b26ff49ba6883cd2be791ec4936c385f765c76a",
contentHash = "bdd49b44428daf24b514c521e53027ec944aecfebb39ca0d10967661c303e096",
),
CultNetBuiltInSchemaSpec(
schemaVersion = "cultnet.schema_catalog_response.v0",
title = "CultNet Schema Catalog Response Message",
schemaId = "$cultNetSchemaBase/cultnet.schema-catalog-response.schema.json",
wireContracts = listOf("cultnet.schema.v0", "gamecult.networking.v0"),
contentHash = "10b4835d33181fe4b93a33e39b63886c5514899e93682de786e298f7e4a213e9",
contentHash = "b158de53d1ca614eacf2e05767122fb4b9e06504c037ad76c649cffdf71dd648",
),
CultNetBuiltInSchemaSpec(
schemaVersion = "cultnet.shard_catalog_request.v0",
title = "CultNet Shard Catalog Request Message",
schemaId = "$cultNetSchemaBase/cultnet.shard-catalog-request.schema.json",
contentHash = "b4edecd8a24f68ebccf0aca9b429d234618ff7e8858a10123d0b328aeae7eb3f",
schemaId = "cultnet.shard_catalog_request.v0",
contentHash = "0abd131a1db12f6731010333b86e104d550160bd58f539b18413a2b61198fa38",
),
CultNetBuiltInSchemaSpec(
schemaVersion = "cultnet.shard_catalog_response.v0",
title = "CultNet Shard Catalog Response Message",
schemaId = "$cultNetSchemaBase/cultnet.shard-catalog-response.schema.json",
contentHash = "d86a94c57d7820483912b8b5cef6214ac951d34cb2641d2d21dcb1f1bc0263c0",
schemaId = "cultnet.shard_catalog_response.v0",
contentHash = "93a0e90ac81bc6ab21b68925ad25e381edfac8773232333679d3275141cff37b",
),
CultNetBuiltInSchemaSpec(
schemaVersion = "cultnet.shard_log_request.v0",
@ -1468,7 +1468,7 @@ private val cultNetBuiltInSchemaSpecs = listOf(
schemaId = "$cultNetSchemaBase/cultnet.transport-profile.schema.json",
kind = "shared_contract",
requiredFields = listOf("schemaVersion", "runtimeId", "transports"),
contentHash = "63dfd484e1fd332c167caadf52cb5b4fe12d5ffea4415206c3ef111c196507aa",
contentHash = "84473264f543ca3443267999e43d72b7993b61b7b2ac53c642c9cbe8b6de0217",
),
)
@ -3717,16 +3717,16 @@ private fun cultNetReconnectControllerSchedulesAttemptsAndReset() {
private const val interopDocumentType = "cultnet.interop-note"
private const val interopSchemaVersion = "cultnet.interop_note.v0"
private const val interopMutationIntentType = "cultnet.interop-note-mutation-intent"
private const val interopMutationIntentSchemaId = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-note-mutation-intent.schema.json"
private const val interopMutationIntentSchemaId = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-note-mutation-intent.schema.json"
private const val interopMutationIntentSchemaVersion = "cultnet.interop_note_mutation_intent.v0"
private const val interopMutationReceiptType = "cultnet.interop-note-mutation-receipt"
private const val interopMutationReceiptSchemaId = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-note-mutation-receipt.schema.json"
private const val interopMutationReceiptSchemaId = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-note-mutation-receipt.schema.json"
private const val interopMutationReceiptSchemaVersion = "cultnet.interop_note_mutation_receipt.v0"
private const val interopFireCommandType = "cultnet.interop-fire-weapon-command"
private const val interopFireCommandSchemaId = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-fire-weapon-command.schema.json"
private const val interopFireCommandSchemaId = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-fire-weapon-command.schema.json"
private const val interopFireCommandSchemaVersion = "cultnet.interop_fire_weapon_command.v0"
private const val interopFireReceiptType = "cultnet.interop-fire-weapon-receipt"
private const val interopFireReceiptSchemaId = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-fire-weapon-receipt.schema.json"
private const val interopFireReceiptSchemaId = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-fire-weapon-receipt.schema.json"
private const val interopFireReceiptSchemaVersion = "cultnet.interop_fire_weapon_receipt.v0"
private const val rudpInteropConnectionId = 0x43554c54L
private const val rudpInteropMaxFragmentBytes = 1024
@ -4627,11 +4627,11 @@ private fun cultNetBuiltInSchemaCatalogAdvertisesWireContracts() {
check(schemaCatalogRequest["wireContracts"] == listOf("cultnet.schema.v0", "gamecult.networking.v0"))
check(!schemaCatalogRequest.containsKey("schemaJson"))
val shardCatalogRequest = schemas.single { it["schemaVersion"] == "cultnet.shard_catalog_request.v0" }
check(shardCatalogRequest["schemaId"] == "$cultNetSchemaBase/cultnet.shard-catalog-request.schema.json")
check(shardCatalogRequest["schemaId"] == "cultnet.shard_catalog_request.v0")
check(shardCatalogRequest["wireContracts"] == listOf("cultnet.schema.v0"))
check(shardCatalogRequest["contentHash"] == "b4edecd8a24f68ebccf0aca9b429d234618ff7e8858a10123d0b328aeae7eb3f")
check(shardCatalogRequest["contentHash"] == "0abd131a1db12f6731010333b86e104d550160bd58f539b18413a2b61198fa38")
val shardCatalogResponse = schemas.single { it["schemaVersion"] == "cultnet.shard_catalog_response.v0" }
check(shardCatalogResponse["schemaId"] == "$cultNetSchemaBase/cultnet.shard-catalog-response.schema.json")
check(shardCatalogResponse["schemaId"] == "cultnet.shard_catalog_response.v0")
check(shardCatalogResponse["wireContracts"] == listOf("cultnet.schema.v0"))
val inlineCatalog = cultNetBuiltInSchemaCatalog(includeSchemaJson = true)
@ -4644,7 +4644,7 @@ private fun cultNetBuiltInSchemaCatalogAdvertisesWireContracts() {
)
val transportProfile = mapList(transportResponse.body["schemas"]).single()
check(transportProfile["kind"] == "shared_contract")
check(transportProfile["contentHash"] == "63dfd484e1fd332c167caadf52cb5b4fe12d5ffea4415206c3ef111c196507aa")
check(transportProfile["contentHash"] == "84473264f543ca3443267999e43d72b7993b61b7b2ac53c642c9cbe8b6de0217")
check(!transportProfile.containsKey("schemaJson"))
val filteredTransportCatalog = CultMesh.createBuiltInSchemaCatalog(

View file

@ -1759,11 +1759,11 @@ class CultMeshTests(unittest.TestCase):
)
self.assertEqual(
builtins.list()[0].schema_id,
"https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.transport-profile.schema.json",
"https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json",
)
self.assertIsNone(builtins.list()[0].schema_json)
peer_exchange_schema_id = (
"https://github.com/GameCult/CultLib/contracts/cultnet/cultmesh.peer-exchange-request.schema.json"
"https://github.com/GameCult/cultnet-ts/contracts/cultmesh.peer-exchange-request.schema.json"
)
filtered_builtin = CultMesh.create_builtin_schema_catalog(
include_schema_json=True,
@ -4033,7 +4033,7 @@ class CultMeshTests(unittest.TestCase):
self.assertIn("cultmesh.peer_exchange_response.v0", wire_descriptors)
self.assertIn("cultnet.error.v0", {descriptor.schema_version for descriptor in synced_wire_descriptors})
self.assertIn("cultnet.document_put_raw.v0", {descriptor.schema_version for descriptor in synced_wire_descriptors})
self.assertIsNotNone(synced_schema_catalog.get("https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.document-put-raw.schema.json"))
self.assertIsNotNone(synced_schema_catalog.get("https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put-raw.schema.json"))
self.assertEqual(snapshot_response["documents"][0]["recordKey"], "note:1")
self.assertEqual(typed_snapshot_response.documents[0].record_key, "note:1")
self.assertEqual(typed_snapshot_response.documents[0].schema_id, "mesh.server_note.v1")

View file

@ -44,8 +44,7 @@
"dependencies": {
"@msgpack/msgpack": "^3.1.2",
"@gamecult/cultcache-ts": "^0.14.0",
"cultnet-ts": "^0.2.0",
"koffi": "3.3.0"
"cultnet-ts": "^0.2.0"
},
"devDependencies": {
"@types/node": "^24.0.0",

View file

@ -34,18 +34,6 @@ import {
type CultNetWireContract,
} from "cultnet-ts";
import {
CultMeshQuicRealtimeConnector,
CultMeshQuicRealtimeProvider,
CultMeshQuicRealtimeSessionManager,
parseQuicRealtimeEndpoint,
type CultMeshQuicRealtimeConnectorOptions,
type CultMeshQuicRealtimeProviderOptions,
type CultMeshQuicRealtimeSessionManagerOptions,
} from "./realtime-quic";
export * from "./realtime-wire";
export * from "./realtime-quic";
export * from "./provider-session";
export * from "./provider-session-wire";
export * from "./provider-rudp-transport";
@ -6280,28 +6268,6 @@ export class CultMesh {
}
return CultMesh.createRudpPeerForPeer(runtimeId, connectionId, peer, options);
}
public static parseQuicRealtimeEndpoint(endpoint: string): ReturnType<typeof parseQuicRealtimeEndpoint> {
return parseQuicRealtimeEndpoint(endpoint);
}
public static createQuicRealtimeConnector(
options: CultMeshQuicRealtimeConnectorOptions = {},
): CultMeshQuicRealtimeConnector {
return new CultMeshQuicRealtimeConnector(options);
}
public static createQuicRealtimeSessionManager(
options: CultMeshQuicRealtimeSessionManagerOptions,
): CultMeshQuicRealtimeSessionManager {
return new CultMeshQuicRealtimeSessionManager(options);
}
public static createQuicRealtimeProvider(
options: CultMeshQuicRealtimeProviderOptions,
): Promise<CultMeshQuicRealtimeProvider> {
return CultMeshQuicRealtimeProvider.listen(options);
}
}
function cultMeshSchemaFromDefinition(

View file

@ -1,535 +0,0 @@
// The koffi binding for `native/GameCult.Mesh.Quic.Native`'s C ABI v2
// (`include/cultmesh_quic_native.h`). This file is the only place `koffi` is
// imported, and it is imported lazily, inside `loadBindings`, so a consumer
// that never touches QUIC (AetheriaEve Electron, Odin, Stonks, weksa) loads no
// native module at all.
//
// No koffi callbacks. `cultmesh_quic_next_event` is the single blocking
// export; `CultMeshQuicNativeRuntime` runs it on koffi's async thread pool in
// one loop per process-wide runtime and dispatches decoded events to
// registered listener/connection handlers by id. Every other export is a
// plain synchronous call.
//
// Binary resolution: `CULTMESH_QUIC_NATIVE_DIR` when set (the development
// loop and CI point it at `artifacts/quic-native/<platform>` straight from a
// build), otherwise `path.join(__dirname, "..", "native", "<platform>-<arch>")`
// (the committed tree a later cut lands; `__dirname` is `dist/` at runtime, so
// this path holds in the repo, an `npm pack` tarball and a pnpm `file:` copy
// alike). No `node_modules` lookup, no platform package, no registry.
//
// Two koffi quirks this file works around, both probed against the pinned
// 3.3.0 build: `_Out_` does not auto-allocate storage, so every out
// parameter is `koffi.alloc(type, 1)` written by the call and read back with
// `koffi.decode(buffer, type)`; and a decoded 64-bit value (`void *`,
// `uint64_t`) comes back as a plain `number` when it fits a safe integer,
// not a `bigint`. `toBigInt64` below is the one place that coerces either
// into the `bigint` every exported type in this module promises.
import { existsSync } from "node:fs";
import { join } from "node:path";
/** One decoded native event, payload already sliced to `payload_length`. */
export interface CultMeshQuicNativeEvent {
readonly type: number;
readonly streamKind: number;
readonly listenerId: bigint;
readonly connectionId: bigint;
readonly streamId: bigint;
readonly code: bigint;
readonly status: number;
readonly payload: Uint8Array;
}
export const CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION = 1;
export const CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED = 2;
export const CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED = 3;
export const CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN = 4;
export const CULTMESH_QUIC_EVENT_STREAM_STARTED = 5;
export const CULTMESH_QUIC_EVENT_STREAM_FRAME = 6;
export const CULTMESH_QUIC_EVENT_STREAM_SEND_COMPLETE = 7;
export const CULTMESH_QUIC_EVENT_STREAM_SHUTDOWN = 8;
export const CULTMESH_QUIC_EVENT_LISTENER_STOPPED = 9;
export const CULTMESH_QUIC_SEND_COMPLETED = 0;
export const CULTMESH_QUIC_SEND_CANCELED = 1;
export const CULTMESH_QUIC_STREAM_RELIABLE = 1;
export const CULTMESH_QUIC_STREAM_LATEST_ONLY = 2;
export const CULTMESH_QUIC_RESULT_BAD_CALL = -1;
export const CULTMESH_QUIC_RESULT_BAD_ARGUMENT = -2;
type NativeEventListener = (event: CultMeshQuicNativeEvent) => void;
/** Invoked once when a connection is faulted: a throwing listener, a malformed
* frame, or any other error raised while dispatching an event for it. */
type NativeFaultHandler = (error: Error) => void;
/** koffi decodes a 64-bit value as `number` when it fits a safe integer and as
* `bigint` otherwise (probed on the pinned 3.3.0 build); this is the single
* place that coerces either into the `bigint` every exported type promises. */
function toBigInt64(value: number | bigint): bigint {
return typeof value === "bigint" ? value : BigInt(value);
}
/** The opaque native runtime handle: a `void *`, decoded by koffi as a `bigint`. */
export type CultMeshQuicNativeHandle = bigint;
interface NativeEventStruct {
type: number;
stream_kind: number;
listener_id: bigint;
connection_id: bigint;
stream_id: bigint;
code: bigint;
status: number;
payload_length: number;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type KoffiLib = any;
/**
* The loaded native library: koffi itself, the declared struct, and the
* twelve raw v2 exports. Untyped (koffi is `any`); the runtime class methods
* below are the type surface for these calls, so wrapping them a second time
* in a typed interface would only restate what the methods already say.
*/
interface LoadedNativeLibrary {
koffi: KoffiLib;
event: KoffiLib;
runtimeOpenFn: KoffiLib;
runtimeCloseFn: KoffiLib;
listenerOpenFn: KoffiLib;
listenerCloseFn: KoffiLib;
connectionOpenFn: KoffiLib;
connectionCertificateCompleteFn: KoffiLib;
connectionShutdownFn: KoffiLib;
streamOpenFn: KoffiLib;
streamSendFrameFn: KoffiLib;
streamShutdownFn: KoffiLib;
nextEventAsync: (
runtime: CultMeshQuicNativeHandle,
timeoutMs: number,
outEvent: unknown,
payload: Uint8Array | null,
payloadCapacity: number,
outRequired: unknown,
) => Promise<number>;
lastErrorFn: KoffiLib;
}
let libraryCache: LoadedNativeLibrary | undefined;
function platformArchDir(): string {
return `${process.platform}-${process.arch}`;
}
function platformFileNames(): { bridge: string; dependency: string } {
if (process.platform === "win32") {
return { bridge: "gamecult_mesh_quic_native.dll", dependency: "msquic.dll" };
}
if (process.platform === "linux") {
return { bridge: "libgamecult_mesh_quic_native.so", dependency: "libmsquic.so.2" };
}
throw new Error(`CultMesh QUIC native binding does not support platform '${process.platform}'.`);
}
function resolveNativeDir(): string {
const configured = process.env.CULTMESH_QUIC_NATIVE_DIR;
if (configured && configured.length > 0) return configured;
return join(__dirname, "..", "native", platformArchDir());
}
/** Lazily loads koffi, resolves the binary directory, and declares the ABI. */
function loadBindings(): LoadedNativeLibrary {
if (libraryCache) return libraryCache;
// Untyped on purpose: koffi's own type declarations are ESM-flavored and
// fight a CommonJS `typeof import(...)` reference under Node16 resolution.
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-explicit-any
const koffi: any = require("koffi");
const { bridge, dependency } = platformFileNames();
const dir = resolveNativeDir();
const bridgePath = join(dir, bridge);
const dependencyPath = join(dir, dependency);
if (!existsSync(bridgePath) || !existsSync(dependencyPath)) {
const envDir = process.env.CULTMESH_QUIC_NATIVE_DIR;
throw new Error(
"CultMesh QUIC native binaries were not found. Tried " +
`'${dir}' (${envDir ? "CULTMESH_QUIC_NATIVE_DIR" : "the package's committed native/ tree"}). ` +
"Set CULTMESH_QUIC_NATIVE_DIR to a directory built by scripts/build-quic-native.ps1 or " +
"scripts/build-quic-native.sh, or ship the committed native/ tree.",
);
}
// The dependent library must be resolvable before the bridge loads.
koffi.load(dependencyPath);
const bridgeLib = koffi.load(bridgePath);
const event = koffi.struct("cultmesh_quic_event", {
type: "uint32_t",
stream_kind: "uint32_t",
listener_id: "uint64_t",
connection_id: "uint64_t",
stream_id: "uint64_t",
code: "uint64_t",
status: "int32_t",
payload_length: "int32_t",
reserved: koffi.array("uint8_t", 16),
});
const runtimeOpenFn = bridgeLib.func(
"int32_t cultmesh_quic_runtime_open(str app_name, void **out_runtime)",
);
const runtimeCloseFn = bridgeLib.func("void cultmesh_quic_runtime_close(void *runtime)");
const listenerOpenFn = bridgeLib.func(
"int32_t cultmesh_quic_listener_open(void *runtime, str host, uint16_t port, " +
"uint8_t *pkcs12, int32_t pkcs12_length, str password, " +
"uint64_t *out_listener_id, uint16_t *out_bound_port)",
);
const listenerCloseFn = bridgeLib.func(
"void cultmesh_quic_listener_close(void *runtime, uint64_t listener_id)",
);
const connectionOpenFn = bridgeLib.func(
"int32_t cultmesh_quic_connection_open(void *runtime, str host, uint16_t port, " +
"uint64_t *out_connection_id)",
);
const connectionCertificateCompleteFn = bridgeLib.func(
"int32_t cultmesh_quic_connection_certificate_complete(void *runtime, uint64_t connection_id, int32_t accept)",
);
const connectionShutdownFn = bridgeLib.func(
"void cultmesh_quic_connection_shutdown(void *runtime, uint64_t connection_id, uint64_t code)",
);
const streamOpenFn = bridgeLib.func(
"int32_t cultmesh_quic_stream_open(void *runtime, uint64_t connection_id, uint8_t kind, " +
"uint64_t *out_stream_id)",
);
const streamSendFrameFn = bridgeLib.func(
"int32_t cultmesh_quic_stream_send_frame(void *runtime, uint64_t stream_id, " +
"uint8_t *encoded_frame, int32_t length, int32_t fin)",
);
const streamShutdownFn = bridgeLib.func(
"void cultmesh_quic_stream_shutdown(void *runtime, uint64_t stream_id, uint64_t code)",
);
const nextEventFn = bridgeLib.func(
"int32_t cultmesh_quic_next_event(void *runtime, int32_t timeout_ms, " +
`${event.name} *out_event, uint8_t *payload, int32_t payload_capacity, ` +
"int32_t *out_required)",
);
const lastErrorFn = bridgeLib.func(
"int32_t cultmesh_quic_last_error(void *runtime, uint8_t *destination, int32_t capacity)",
);
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { promisify } = require("node:util") as typeof import("node:util");
const nextEventAsync = promisify(nextEventFn.async) as LoadedNativeLibrary["nextEventAsync"];
libraryCache = {
koffi,
event,
runtimeOpenFn,
runtimeCloseFn,
listenerOpenFn,
listenerCloseFn,
connectionOpenFn,
connectionCertificateCompleteFn,
connectionShutdownFn,
streamOpenFn,
streamSendFrameFn,
streamShutdownFn,
nextEventAsync,
lastErrorFn,
};
return libraryCache;
}
function readLastError(lib: LoadedNativeLibrary, runtime: CultMeshQuicNativeHandle): string {
const buffer = Buffer.alloc(1024);
const written = lib.lastErrorFn(runtime, buffer, buffer.length);
return written > 0 ? buffer.subarray(0, written).toString("utf8") : "CultMesh native QUIC call failed.";
}
/**
* One MsQuic registration and one event queue, shared process-wide.
* `open`/`release` are reference-counted: the underlying runtime opens on the
* first `open` and closes on the last matching `release`. `open`/`release`
* calls do not need to be paired 1:1 by the same caller as long as the counts
* balance overall.
*/
export class CultMeshQuicNativeRuntime {
private static shared: CultMeshQuicNativeRuntime | undefined;
private static refCountValue = 0;
/** Outstanding `open()` references not yet matched by `release()`. Test-visible: the UAF and eviction regression tests assert it returns to its baseline. */
static get refCount(): number {
return CultMeshQuicNativeRuntime.refCountValue;
}
private readonly handle: CultMeshQuicNativeHandle;
private readonly lib: LoadedNativeLibrary;
private readonly listenerListeners = new Map<bigint, NativeEventListener>();
private readonly connectionListeners = new Map<
bigint,
{ listener: NativeEventListener; onFault?: NativeFaultHandler }
>();
private closed = false;
private pumpLoop: Promise<void>;
private constructor(handle: CultMeshQuicNativeHandle, lib: LoadedNativeLibrary) {
this.handle = handle;
this.lib = lib;
this.pumpLoop = this.pump();
}
static async open(): Promise<CultMeshQuicNativeRuntime> {
if (!CultMeshQuicNativeRuntime.shared) {
const lib = loadBindings();
const out = lib.koffi.alloc("void *", 1);
const status = lib.runtimeOpenFn(null, out);
if (status !== 0) {
throw new Error(`cultmesh_quic_runtime_open failed with status ${status}.`);
}
const handle = toBigInt64(lib.koffi.decode(out, "void *"));
CultMeshQuicNativeRuntime.shared = new CultMeshQuicNativeRuntime(handle, lib);
}
CultMeshQuicNativeRuntime.refCountValue += 1;
return CultMeshQuicNativeRuntime.shared;
}
/**
* Drops one reference; closes the shared runtime once none remain. Refuses
* a release when no reference is outstanding. The bridge header (section 4)
* forbids any host call beginning once `cultmesh_quic_runtime_close` has
* started, so this marks the runtime closed and waits for the pump to exit
* (it wakes within its 250 ms `nextEvent` timeout) before making that call:
* a queued, not-yet-started `nextEvent` call must never begin after close
* and run against freed memory.
*/
async release(): Promise<void> {
if (CultMeshQuicNativeRuntime.refCountValue === 0) {
throw new Error("CultMeshQuicNativeRuntime.release called with no outstanding reference.");
}
CultMeshQuicNativeRuntime.refCountValue -= 1;
if (CultMeshQuicNativeRuntime.refCountValue > 0) return;
this.closed = true;
CultMeshQuicNativeRuntime.shared = undefined;
await this.pumpLoop;
this.lib.runtimeCloseFn(this.handle);
}
onListenerEvent(listenerId: bigint, listener: NativeEventListener): void {
this.listenerListeners.set(listenerId, listener);
}
offListenerEvent(listenerId: bigint): void {
this.listenerListeners.delete(listenerId);
}
/**
* Registers the event listener for one connection. `onFault`, if given, is
* invoked exactly once if `listener` (or anything it calls synchronously,
* such as a caller-supplied certificate validator) throws while dispatching
* an event for this connection: the pump isolates the fault to this
* connection, shuts it down at the native level, removes the listener, and
* keeps running for every other connection.
*/
onConnectionEvent(connectionId: bigint, listener: NativeEventListener, onFault?: NativeFaultHandler): void {
this.connectionListeners.set(connectionId, { listener, onFault });
}
offConnectionEvent(connectionId: bigint): void {
this.connectionListeners.delete(connectionId);
}
listenerOpen(
host: string | null,
port: number,
pkcs12: Uint8Array,
password: string | null,
): { listenerId: bigint; boundPort: number } {
const outListenerId = this.lib.koffi.alloc("uint64_t", 1);
const outBoundPort = this.lib.koffi.alloc("uint16_t", 1);
const status = this.lib.listenerOpenFn(
this.handle,
host,
port,
pkcs12,
pkcs12.length,
password,
outListenerId,
outBoundPort,
);
if (status !== 0) {
throw new Error(`cultmesh_quic_listener_open failed: ${readLastError(this.lib, this.handle)}`);
}
return {
listenerId: toBigInt64(this.lib.koffi.decode(outListenerId, "uint64_t")),
boundPort: this.lib.koffi.decode(outBoundPort, "uint16_t") as number,
};
}
listenerClose(listenerId: bigint): void {
this.lib.listenerCloseFn(this.handle, listenerId);
this.offListenerEvent(listenerId);
}
connectionOpen(host: string, port: number): bigint {
const out = this.lib.koffi.alloc("uint64_t", 1);
const status = this.lib.connectionOpenFn(this.handle, host, port, out);
if (status !== 0) {
throw new Error(`cultmesh_quic_connection_open failed: ${readLastError(this.lib, this.handle)}`);
}
return toBigInt64(this.lib.koffi.decode(out, "uint64_t"));
}
connectionCertificateComplete(connectionId: bigint, accept: boolean): void {
const status = this.lib.connectionCertificateCompleteFn(this.handle, connectionId, accept ? 1 : 0);
if (status !== 0) {
throw new Error(
`cultmesh_quic_connection_certificate_complete failed: ${readLastError(this.lib, this.handle)}`,
);
}
}
connectionShutdown(connectionId: bigint, code: bigint): void {
this.lib.connectionShutdownFn(this.handle, connectionId, code);
}
streamOpen(connectionId: bigint, kind: number): bigint {
const out = this.lib.koffi.alloc("uint64_t", 1);
const status = this.lib.streamOpenFn(this.handle, connectionId, kind, out);
if (status !== 0) {
throw new Error(`cultmesh_quic_stream_open failed: ${readLastError(this.lib, this.handle)}`);
}
return toBigInt64(this.lib.koffi.decode(out, "uint64_t"));
}
streamSendFrame(streamId: bigint, encodedFrame: Uint8Array, fin: boolean): void {
const status = this.lib.streamSendFrameFn(this.handle, streamId, encodedFrame, encodedFrame.length, fin ? 1 : 0);
if (status !== 0) {
throw new Error(`cultmesh_quic_stream_send_frame failed: ${readLastError(this.lib, this.handle)}`);
}
}
streamShutdown(streamId: bigint, code: bigint): void {
this.lib.streamShutdownFn(this.handle, streamId, code);
}
/**
* Isolates the faulting connection so one bad event never brings down the
* pump: a malformed frame, a stream-kind/delivery mismatch, or a throwing
* caller-supplied listener (certificate validator, waiter) faults only its
* own connection. The listener is removed, the connection is shut down at
* the native level, and `onFault` (if registered) is told, so pending and
* future work on that connection can reject with the error. Every other
* connection, and the pump itself, keep running.
*/
private dispatch(event: NativeEventStruct, payload: Uint8Array): void {
const decoded: CultMeshQuicNativeEvent = {
type: event.type,
streamKind: event.stream_kind,
listenerId: event.listener_id,
connectionId: event.connection_id,
streamId: event.stream_id,
code: event.code,
status: event.status,
payload,
};
if (decoded.type === CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION || decoded.type === CULTMESH_QUIC_EVENT_LISTENER_STOPPED) {
try {
this.listenerListeners.get(decoded.listenerId)?.(decoded);
} catch (error) {
// No fault contract for listener-level events today; do not let a
// throwing handler crash the pump.
this.reportUnhandledDispatchError(error);
}
return;
}
const entry = this.connectionListeners.get(decoded.connectionId);
if (!entry) return;
try {
entry.listener(decoded);
} catch (error) {
this.faultConnection(decoded.connectionId, error instanceof Error ? error : new Error(String(error)), entry.onFault);
}
}
private faultConnection(connectionId: bigint, error: Error, onFault: NativeFaultHandler | undefined): void {
this.connectionListeners.delete(connectionId);
try {
this.lib.connectionShutdownFn(this.handle, connectionId, 0n);
} catch {
// Best-effort: the connection may already be gone.
}
if (onFault) {
try {
onFault(error);
} catch (handlerError) {
// The fault handler itself is caller-supplied code; a throw here
// must not escape into the pump any more than the original fault did.
this.reportUnhandledDispatchError(handlerError);
}
} else {
this.reportUnhandledDispatchError(error);
}
}
private reportUnhandledDispatchError(error: unknown): void {
// eslint-disable-next-line no-console
console.error("CultMesh QUIC native event dispatch failed with no fault handler registered:", error);
}
private async nextEvent(
timeoutMs: number,
payload: Uint8Array | null,
payloadCapacity: number,
): Promise<{ status: number; event: NativeEventStruct | null; required: number }> {
const outEvent = this.lib.koffi.alloc(this.lib.event, 1);
const outRequired = this.lib.koffi.alloc("int32_t", 1);
const status = await this.lib.nextEventAsync(this.handle, timeoutMs, outEvent, payload, payloadCapacity, outRequired);
const decoded = status === 1 ? (this.lib.koffi.decode(outEvent, this.lib.event) as NativeEventStruct) : null;
return {
status,
event: decoded && {
...decoded,
listener_id: toBigInt64(decoded.listener_id),
connection_id: toBigInt64(decoded.connection_id),
stream_id: toBigInt64(decoded.stream_id),
code: toBigInt64(decoded.code),
},
required: this.lib.koffi.decode(outRequired, "int32_t") as number,
};
}
private async pump(): Promise<void> {
while (!this.closed) {
let first: Awaited<ReturnType<CultMeshQuicNativeRuntime["nextEvent"]>>;
try {
first = await this.nextEvent(250, null, 0);
} catch {
if (this.closed) return;
continue;
}
if (this.closed) return;
if (first.status === 0 || first.status === CULTMESH_QUIC_RESULT_BAD_CALL) continue;
if (first.status === 1 && first.event) {
this.dispatch(first.event, new Uint8Array(0));
continue;
}
if (first.status === 2 && first.required > 0) {
const buffer = Buffer.alloc(first.required);
let second: Awaited<ReturnType<CultMeshQuicNativeRuntime["nextEvent"]>>;
try {
second = await this.nextEvent(0, buffer, buffer.length);
} catch {
if (this.closed) return;
continue;
}
if (second.status === 1 && second.event) {
this.dispatch(second.event, buffer.subarray(0, second.event.payload_length));
}
continue;
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,191 +0,0 @@
// The one TypeScript encoding of a CultMesh realtime frame.
//
// Rule for rule, this is `CultMeshRealtimeWireProtocol` in the C# reference
// (`src/GameCult.Mesh/CultMeshRealtimeWireProtocol.cs`), over the frame shape
// `CultMeshRealtimeTransports.cs` defines. QUIC stream framing stays
// transport-owned; this file owns only the typed frame bytes and never touches
// a socket.
//
// Byte parity is proven both ways, cross-process, by two committed vector files
// under `contracts/cultmesh/`: `realtime-frame-vectors.json`, which the C# test
// `CultMeshRealtimeWireProtocolTests` writes under CULTMESH_WRITE_VECTORS=1 and
// this package's test decodes and re-encodes, and
// `realtime-frame-vectors.ts-written.json`, which
// `scripts/write-cultmesh-realtime-frame-vectors.mjs` writes with this codec and
// the same C# test decodes. A fixture authored on one side alone would only
// agree with itself.
import { isNullOrWhiteSpaceCSharp } from "cultnet-ts";
/** `CultMeshRealtimeWireProtocol.ApplicationProtocolName`: the QUIC ALPN. */
export const CULTMESH_REALTIME_ALPN = "cultmesh-state-v1";
/** `ConnectionCloseCode`: ASCII "CULT". A QUIC error code is 62-bit, so bigint. */
export const CULTMESH_REALTIME_CONNECTION_CLOSE_CODE = 0x43554c54n;
/** `StreamAbortCode`: ASCII "STAT". */
export const CULTMESH_REALTIME_STREAM_ABORT_CODE = 0x53544154n;
/** `ReliableStream`: the stream kind carrying `reliable-ordered`. */
export const CULTMESH_REALTIME_RELIABLE_STREAM = 1;
/** `LatestOnlyStream`: the stream kind carrying `latest-only`. */
export const CULTMESH_REALTIME_LATEST_ONLY_STREAM = 2;
/** `MaximumFrameBytes`: the payload ceiling, 64 MiB. */
export const CULTMESH_REALTIME_MAX_PAYLOAD_BYTES = 64 * 1024 * 1024;
/** `MaximumEncodedFrameBytes`: the payload ceiling plus the header and three maximal identities. */
export const CULTMESH_REALTIME_MAX_ENCODED_FRAME_BYTES =
CULTMESH_REALTIME_MAX_PAYLOAD_BYTES + 37 + 3 * 0xffff;
/**
* `CultMeshRealtimeDelivery`. The wire byte is the C# enum's ordinal, so the
* order of these three is the wire contract, not a naming choice.
*/
export type CultMeshRealtimeDelivery = "reliable-ordered" | "latest-only" | "unreliable";
const DELIVERY_BYTES: readonly CultMeshRealtimeDelivery[] = ["reliable-ordered", "latest-only", "unreliable"];
/** `CultMeshRealtimeFrame`. `producerEpoch` and `sequence` are `long` in C#, which exceeds `Number.MAX_SAFE_INTEGER`. */
export interface CultMeshRealtimeFrame {
channelId: string;
schemaId: string;
bodyId: string;
producerEpoch: bigint;
sequence: bigint;
delivery: CultMeshRealtimeDelivery;
payload: Uint8Array;
}
const MAGIC = 0x31545343;
const FIXED_HEADER_BYTES = 37;
const WIRE_VERSION = 1;
const MAX_IDENTITY_BYTES = 0xffff;
const INT64_MIN = -(2n ** 63n);
const INT64_MAX = 2n ** 63n - 1n;
/**
* `CultMeshRealtimeFrame.Validate`. The C# reading of "non-empty" is
* `string.IsNullOrWhiteSpace`, whose whitespace set is not
* `String.prototype.trim`'s, so this borrows the one TypeScript implementation
* of that set from `cultnet-ts` rather than keeping a second reading here.
*
* The range check has no C# counterpart because `long` gives it for free; a
* `bigint` does not, and a `DataView` would otherwise throw a bare `RangeError`
* from inside the encoder.
*/
function validateFrame(frame: CultMeshRealtimeFrame): void {
if (isNullOrWhiteSpaceCSharp(frame.channelId)) throw new Error("Realtime channel identity is required.");
if (isNullOrWhiteSpaceCSharp(frame.schemaId)) throw new Error("Realtime schema identity is required.");
if (isNullOrWhiteSpaceCSharp(frame.bodyId)) throw new Error("Realtime body identity is required.");
if (frame.producerEpoch < 0n || frame.sequence < 0n) {
throw new Error("Realtime epoch and sequence must be non-negative.");
}
if (frame.producerEpoch > INT64_MAX || frame.sequence > INT64_MAX || frame.producerEpoch < INT64_MIN || frame.sequence < INT64_MIN) {
throw new Error("Realtime epoch and sequence must fit a 64-bit signed integer.");
}
}
/** `CultMeshRealtimeWireProtocol.EncodeFrame`. */
export function encodeRealtimeFrame(frame: CultMeshRealtimeFrame): Uint8Array {
validateFrame(frame);
const deliveryByte = DELIVERY_BYTES.indexOf(frame.delivery);
// C# reads an enum, which holds any byte; a TypeScript union does not, so the
// unspellable value is refused here with the message decode gives it.
if (deliveryByte < 0) throw new Error("Realtime frame delivery mode is invalid.");
const encoder = new TextEncoder();
const channel = encoder.encode(frame.channelId);
const schema = encoder.encode(frame.schemaId);
const body = encoder.encode(frame.bodyId);
if (channel.byteLength > MAX_IDENTITY_BYTES || schema.byteLength > MAX_IDENTITY_BYTES || body.byteLength > MAX_IDENTITY_BYTES) {
throw new Error("Realtime frame identity exceeds the QUIC wire limit.");
}
if (frame.payload.byteLength > CULTMESH_REALTIME_MAX_PAYLOAD_BYTES) {
throw new Error("Realtime frame payload exceeds the QUIC wire limit.");
}
const result = new Uint8Array(
FIXED_HEADER_BYTES + channel.byteLength + schema.byteLength + body.byteLength + frame.payload.byteLength,
);
const view = new DataView(result.buffer);
view.setUint32(0, MAGIC, true);
result[4] = deliveryByte;
view.setBigInt64(5, frame.producerEpoch, true);
view.setBigInt64(13, frame.sequence, true);
view.setUint16(21, channel.byteLength, true);
view.setUint16(23, schema.byteLength, true);
view.setUint16(25, body.byteLength, true);
view.setInt32(27, frame.payload.byteLength, true);
view.setInt32(31, FIXED_HEADER_BYTES, true);
view.setUint16(35, WIRE_VERSION, true);
let offset = FIXED_HEADER_BYTES;
result.set(channel, offset); offset += channel.byteLength;
result.set(schema, offset); offset += schema.byteLength;
result.set(body, offset); offset += body.byteLength;
result.set(frame.payload, offset);
return result;
}
/**
* `CultMeshRealtimeWireProtocol.DecodeFrame`, refusal for refusal and in the
* same order. Like the reference, it does not call `validateFrame`: a frame
* whose identities are blank decodes here exactly as it does in C#, and only
* encoding refuses it.
*
* One corner has no C# counterpart. The reference sums the lengths under
* `checked`, so a payload length near `int.MaxValue` throws `OverflowException`
* there rather than its length refusal; JavaScript numbers do not overflow, so
* the sum simply fails to match and the length refusal covers that case too.
* No encoder on either side can produce such a frame.
*/
export function decodeRealtimeFrame(bytes: Uint8Array): CultMeshRealtimeFrame {
if (bytes.byteLength < FIXED_HEADER_BYTES) throw new Error("Realtime frame header is truncated.");
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (view.getUint32(0, true) !== MAGIC) throw new Error("Realtime frame magic is invalid.");
if (view.getUint16(35, true) !== WIRE_VERSION || view.getInt32(31, true) !== FIXED_HEADER_BYTES) {
throw new Error("Realtime frame wire version is unsupported.");
}
const delivery = DELIVERY_BYTES[bytes[4]!];
if (delivery === undefined) throw new Error("Realtime frame delivery mode is invalid.");
const channelLength = view.getUint16(21, true);
const schemaLength = view.getUint16(23, true);
const bodyLength = view.getUint16(25, true);
const payloadLength = view.getInt32(27, true);
const expected = FIXED_HEADER_BYTES + channelLength + schemaLength + bodyLength + payloadLength;
if (payloadLength < 0 || expected !== bytes.byteLength) throw new Error("Realtime frame length is invalid.");
// `Encoding.UTF8.GetString` substitutes U+FFFD for invalid bytes rather than
// throwing, which is what a non-fatal `TextDecoder` does. `ignoreBOM` is not
// cosmetic: by default `TextDecoder` *deletes* a leading U+FEFF, and
// `GetString` keeps it, so an identity starting with a byte-order mark would
// decode to a different string on the two sides. U+FEFF is not whitespace to
// .NET either, so such an identity passes the transport contract and reaches
// the wire.
const decoder = new TextDecoder("utf-8", { ignoreBOM: true });
let offset = FIXED_HEADER_BYTES;
const channelId = decoder.decode(bytes.subarray(offset, offset + channelLength)); offset += channelLength;
const schemaId = decoder.decode(bytes.subarray(offset, offset + schemaLength)); offset += schemaLength;
const bodyId = decoder.decode(bytes.subarray(offset, offset + bodyLength)); offset += bodyLength;
return {
channelId,
schemaId,
bodyId,
producerEpoch: view.getBigInt64(5, true),
sequence: view.getBigInt64(13, true),
delivery,
// Not `bytes.slice(...)`. Node's `Buffer` overrides `slice` with an alias of
// `subarray`, so on a `Buffer` — which is what every socket read hands a Node
// caller — that spelling returns a *view* of the frame, and a decoded payload
// that changes when the frame buffer is reused.
//
// Nor `Uint8Array.prototype.slice.call(bytes, ...)`, which does bypass the
// override and copy, but takes its result constructor from `@@species`: on a
// `Buffer` that is `FastBuffer`, so the payload comes back a `Buffer` from a
// `Buffer` frame and a `Uint8Array` from a `Uint8Array` one. The declared
// type holds either way, but `deepStrictEqual` does not, so the same frame
// would compare equal or not depending on how it reached the decoder.
//
// The constructor copies and is the one type on both inputs.
payload: new Uint8Array(bytes.subarray(offset, offset + payloadLength)),
};
}

View file

@ -2500,20 +2500,16 @@ test("CultMesh TS branded facade exposes schema and shard catalogs", () => {
const builtIns = CultMesh.createBuiltInSchemaCatalog();
assert.equal(
builtIns.get(
"https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.shard-catalog-request.schema.json",
)?.schemaVersion,
builtIns.get("cultnet.shard_catalog_request.v0")?.schemaVersion,
"cultnet.shard_catalog_request.v0",
);
assert.equal(
builtIns.get(
"https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.shard-catalog-response.schema.json",
)?.kind,
builtIns.get("cultnet.shard_catalog_response.v0")?.kind,
"wire_message",
);
assert.equal(
builtIns.get(
"https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.transport-profile.schema.json",
"https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json",
)?.schemaVersion,
"cultnet.transport_profile.v0",
);

View file

@ -1,27 +0,0 @@
# QUIC test fixtures
`quic-test.p12` and `quic-test-cert.der` are a self-signed P-256 credential
used only to open a loopback MsQuic listener in tests
(`realtime-quic-native.test.ts`, `realtime-quic-consumer.test.ts`). Not a
production credential; no private key outside this pair signs anything real.
`quic-test-cert.der`'s subject is `CN=cultmesh-quic-native-tests`, and its
SHA-256 is what the tests pin as the expected certificate pin
(`cert-sha256`).
Generated with (mirrors the recipe the cut map records at P13):
```sh
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
-keyout quic-test-key.pem -out quic-test-cert.pem \
-subj "/CN=cultmesh-quic-native-tests" -days 3650
openssl x509 -in quic-test-cert.pem -outform der -out quic-test-cert.der
openssl pkcs12 -export -inkey quic-test-key.pem -in quic-test-cert.pem \
-out quic-test.p12 -passout pass:
```
Regenerate the same way if the pair ever needs replacing; nothing here
depends on today's exact bytes past what the tests read back out (the DER
cert and its SHA-256, and a PKCS12 the native bridge can load).

Binary file not shown.

View file

@ -1,229 +0,0 @@
// The TypeScript half of the QUIC realtime interop lane. Two modes:
//
// `dial` (Cut 4): a one-shot process that dials an advertised
// `cultmesh-state+quic://` endpoint with the native connector, receives
// `--expect` frames from a peer (normally the C# managed provider started by
// `cultnet-interop.test.ts`'s "CultMesh QUIC realtime" test), and prints them
// as one JSON line.
//
// `serve` (Cut 5): the StreamPixels role. Opens a `CultMeshQuicRealtimeProvider`
// listener, prints `{status: "ready", endpoint}` (the same shape the C#
// `quic-realtime-serve` peer prints), waits for a first peer before
// broadcasting (mirroring `QuicRealtimeServeAsync`'s
// `WaitForFirstConnectionAsync`, since a broadcast to nobody would otherwise
// silently no-op), then broadcasts `--frames` frames at `--delivery` with
// `--interval-ms` between them, watching stdin for EOF as its own shutdown
// signal like the C# peer's `WatchStdinCloseAsync`.
//
// Neither mode imports `cultmesh-ts` itself (the harness drives processes,
// and the dependency direction is `cultmesh-ts` -> `cultnet-ts`, never the
// reverse); this script reaches the provider/connector through its own
// package's `src/`.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import process from "node:process";
import {
CultMeshQuicRealtimeConnector,
CultMeshQuicRealtimeProvider,
type CultMeshRealtimeTarget,
} from "../../src/realtime-quic";
import { encodeRealtimeFrame, type CultMeshRealtimeDelivery, type CultMeshRealtimeFrame } from "../../src/realtime-wire";
// `__dirname` at runtime is `dist-test/test/interop`; fixtures are binary and
// are never compiled/copied there, so they are read from their source
// location, three levels up. The same self-signed test credential the unit
// tests use (`test/fixtures/README.md`): not a production credential.
const FIXTURE_P12 = join(__dirname, "..", "..", "..", "test", "fixtures", "quic-test.p12");
function parseArgs(argv: readonly string[]): Map<string, string> {
const args = new Map<string, string>();
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (!token.startsWith("--")) continue;
const name = token.slice(2);
const value = argv[index + 1];
if (!value || value.startsWith("--")) throw new Error(`Missing value for --${name}`);
args.set(name, value);
index += 1;
}
return args;
}
function requireArg(args: Map<string, string>, name: string): string {
const value = args.get(name);
if (!value) throw new Error(`Missing required argument --${name}`);
return value;
}
function optionalIntArg(args: Map<string, string>, name: string, fallback: number): number {
const raw = args.get(name);
if (!raw) return fallback;
const parsed = Number.parseInt(raw, 10);
if (!Number.isInteger(parsed)) throw new Error(`Argument --${name} must be an integer.`);
return parsed;
}
function writeJsonLine(value: unknown): void {
process.stdout.write(`${JSON.stringify(value)}\n`);
}
function writeLog(event: string, payload: Record<string, unknown>): void {
process.stderr.write(`${JSON.stringify({ event, ...payload })}\n`);
}
function watchStdinCloseAsync(): Promise<void> {
return new Promise((resolve) => {
process.stdin.resume();
process.stdin.once("end", resolve);
});
}
async function serveAsync(args: Map<string, string>): Promise<void> {
const frames = optionalIntArg(args, "frames", 5);
const deliveryArg = args.get("delivery") ?? "latest-only";
if (deliveryArg !== "latest-only" && deliveryArg !== "reliable-ordered") {
throw new Error(`Unsupported --delivery '${deliveryArg}'.`);
}
const delivery = deliveryArg as CultMeshRealtimeDelivery;
const intervalMs = optionalIntArg(args, "interval-ms", 50);
const port = optionalIntArg(args, "port", 0);
const provider = await CultMeshQuicRealtimeProvider.listen({
host: "127.0.0.1",
port,
serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" },
});
writeJsonLine({ status: "ready", endpoint: provider.advertisedEndpoint });
try {
// A broadcast to zero peers is not an error, but it sends nothing:
// matching the C# `quic-realtime-serve` peer, wait for a first
// connection, bounded, so a peer that never shows up still exits.
const connectDeadline = Date.now() + 30_000;
while (provider.connectionCount === 0) {
if (Date.now() >= connectDeadline) throw new Error("quic-realtime-serve: no client connected within 30000ms.");
await new Promise((resolve) => setTimeout(resolve, 20));
}
const stdinClosed = watchStdinCloseAsync();
let stopped = false;
void stdinClosed.then(() => {
stopped = true;
});
for (let sequence = 1; sequence <= frames && !stopped; sequence += 1) {
const frame: CultMeshRealtimeFrame = {
channelId: "interop.quic-realtime",
schemaId: "gamecult.interop.quic_realtime_frame.v0",
bodyId: "interop:quic-realtime:frame",
producerEpoch: 1n,
sequence: BigInt(sequence),
delivery,
payload: Buffer.from(`frame-${sequence}`, "utf8"),
};
await provider.broadcast(frame);
// Printed so the harness can compare this exact frame's TypeScript
// encoding against the same frame's C#-side re-encoding (golden bytes
// across processes), without the harness importing the codec itself.
writeLog("quic-realtime-serve", { sent: sequence, hex: Buffer.from(encodeRealtimeFrame(frame)).toString("hex") });
if (intervalMs > 0) await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
} finally {
provider.dispose();
// Mirrors the C# peer returning from `QuicRealtimeServeAsync`: once
// broadcasting is done (or stdin closed early), let the process exit on
// its own instead of keeping the event loop alive on a stdin that may
// never close. The harness still ends/kills this process explicitly in
// its own cleanup; this only lets a plain manual run exit cleanly too.
process.stdin.pause();
}
}
async function main(): Promise<void> {
const [mode, ...rest] = process.argv.slice(2);
if (mode === "serve") {
await serveAsync(parseArgs(rest));
return;
}
if (mode !== "dial") throw new Error("Expected mode: dial | serve");
const args = parseArgs(rest);
const endpoint = requireArg(args, "endpoint");
const expect = optionalIntArg(args, "expect", 1);
// Alternative stop condition to --expect: keep receiving (through whatever
// the consumer's own coalescing inbox delivers) until a frame with this
// sequence arrives, then stop. Lets a caller prove convergence under
// latest-only coalescing, where the frame *count* a stalled reader sees is
// not the interesting number, but the final value it converges on is.
const expectSequenceRaw = args.get("expect-sequence");
const expectSequence = expectSequenceRaw !== undefined ? BigInt(expectSequenceRaw) : undefined;
const timeoutMs = optionalIntArg(args, "timeout-ms", 15_000);
// Simulates "a test hook on the runtime that stops calling next_event"
// (spec section 10, LatestOnly-under-a-stalled-consumer lane) at the level
// this subprocess can reach: after receiving --pause-after frames, stop
// calling receiveFrame for --pause-ms. The shared native pump keeps
// dispatching and coalescing into this transport's own inbox regardless
// (Cut 4), so not reading is externally equivalent to not polling.
const pauseAfter = optionalIntArg(args, "pause-after", -1);
const pauseMs = optionalIntArg(args, "pause-ms", 0);
const target: CultMeshRealtimeTarget = { verseId: "interop", authorityRuntimeId: "interop.csharp-provider" };
const connector = new CultMeshQuicRealtimeConnector();
const transport = await connector.connect(
{ endpoint, authorityRuntimeId: target.authorityRuntimeId, priority: 0, generation: "interop" },
target,
);
writeLog("connected", { endpoint, transportId: transport.transportId });
const frames: Array<{
channelId: string;
schemaId: string;
bodyId: string;
producerEpoch: string;
sequence: string;
delivery: string;
payloadHex: string;
}> = [];
try {
const deadline = Date.now() + timeoutMs;
let lastSequence: bigint | undefined;
while (
(expectSequence === undefined && frames.length < expect) ||
(expectSequence !== undefined && lastSequence !== expectSequence)
) {
if (pauseAfter >= 0 && frames.length === pauseAfter) {
writeLog("pausing", { afterFrames: frames.length, pauseMs });
await new Promise((resolve) => setTimeout(resolve, pauseMs));
writeLog("resumed", { afterFrames: frames.length });
}
const remaining = deadline - Date.now();
if (remaining <= 0) throw new Error(`Timed out after receiving ${frames.length} frames (last sequence ${lastSequence}).`);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), remaining);
try {
const frame = await transport.receiveFrame(controller.signal);
lastSequence = frame.sequence;
frames.push({
channelId: frame.channelId,
schemaId: frame.schemaId,
bodyId: frame.bodyId,
producerEpoch: frame.producerEpoch.toString(),
sequence: frame.sequence.toString(),
delivery: frame.delivery,
payloadHex: Buffer.from(frame.payload).toString("hex"),
});
} finally {
clearTimeout(timer);
}
}
} finally {
transport.dispose();
}
writeJsonLine({ endpoint, transportId: transport.transportId, frames });
}
main().catch((error) => {
writeLog("fatal", { error: error instanceof Error ? (error.stack ?? error.message) : String(error) });
process.exitCode = 1;
});

File diff suppressed because it is too large Load diff

View file

@ -1,207 +0,0 @@
// The Cut 3 FFI smoke, now run against `CultMeshQuicNativeRuntime` instead of
// raw koffi calls: open a runtime, open a listener with a real credential,
// connect a real loopback connection, exchange one frame's worth of raw
// bytes, and close everything down. This proves the binding's declared ABI
// matches the shipped bridge; frame semantics (delivery, generation) are
// `realtime-quic-consumer.test.ts`'s job.
//
// Skips instead of failing when no native bridge is available: set
// CULTMESH_QUIC_NATIVE_DIR to a directory `scripts/build-quic-native.ps1` or
// `scripts/build-quic-native.sh` built, or run from the committed
// `packages/cultmesh-ts/native/<platform>` tree once a later cut lands it.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
import {
CultMeshQuicNativeRuntime,
CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION,
CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED,
CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED,
CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN,
CULTMESH_QUIC_EVENT_STREAM_STARTED,
CULTMESH_QUIC_EVENT_STREAM_FRAME,
CULTMESH_QUIC_EVENT_STREAM_SEND_COMPLETE,
CULTMESH_QUIC_STREAM_RELIABLE,
} from "../src/realtime-quic-native";
import { nativeBridgeAvailable } from "./support/native-bridge";
// __dirname at runtime is dist-test/test; fixtures are binary and are never
// compiled/copied there, so they are read from their source location, two
// levels up.
const FIXTURE_P12 = join(__dirname, "..", "..", "test", "fixtures", "quic-test.p12");
async function waitFor<T>(collect: (resolve: (value: T) => void) => void, timeoutMs = 5_000): Promise<T> {
return await new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("timed out waiting for a native QUIC event")), timeoutMs);
collect((value) => {
clearTimeout(timer);
resolve(value);
});
});
}
test("CultMeshQuicNativeRuntime opens and closes with no listener or connection", async (t) => {
if (!nativeBridgeAvailable()) {
t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge.");
return;
}
const runtime = await CultMeshQuicNativeRuntime.open();
await runtime.release();
});
test("CultMeshQuicNativeRuntime opens and closes a listener with a real PKCS12 credential", async (t) => {
if (!nativeBridgeAvailable()) {
t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge.");
return;
}
const runtime = await CultMeshQuicNativeRuntime.open();
try {
const pkcs12 = readFileSync(FIXTURE_P12);
const { listenerId, boundPort } = runtime.listenerOpen("127.0.0.1", 0, pkcs12, "");
assert.ok(boundPort > 0);
runtime.listenerClose(listenerId);
} finally {
await runtime.release();
}
});
test("a full loopback connection delivers one raw frame end to end", async (t) => {
if (!nativeBridgeAvailable()) {
t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge.");
return;
}
const runtime = await CultMeshQuicNativeRuntime.open();
try {
const pkcs12 = readFileSync(FIXTURE_P12);
const { listenerId, boundPort } = runtime.listenerOpen("127.0.0.1", 0, pkcs12, "");
const accepted = waitFor<bigint>((resolve) => {
runtime.onListenerEvent(listenerId, (event) => {
if (event.type === CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION) resolve(event.connectionId);
});
});
const clientConnectionId = runtime.connectionOpen("127.0.0.1", boundPort);
const clientConnected = waitFor<void>((resolve) => {
runtime.onConnectionEvent(clientConnectionId, (event) => {
if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED) {
runtime.connectionCertificateComplete(clientConnectionId, true);
return;
}
if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED) resolve();
});
});
const serverConnectionId = await accepted;
const serverFrame = waitFor<Uint8Array>((resolve) => {
runtime.onConnectionEvent(serverConnectionId, (event) => {
if (event.type === CULTMESH_QUIC_EVENT_STREAM_STARTED) {
assert.equal(event.streamKind, CULTMESH_QUIC_STREAM_RELIABLE);
return;
}
if (event.type === CULTMESH_QUIC_EVENT_STREAM_FRAME) resolve(event.payload);
});
});
await clientConnected;
const streamId = runtime.streamOpen(clientConnectionId, CULTMESH_QUIC_STREAM_RELIABLE);
const sendComplete = waitFor<void>((resolve) => {
runtime.onConnectionEvent(clientConnectionId, (event) => {
if (event.type === CULTMESH_QUIC_EVENT_STREAM_SEND_COMPLETE) resolve();
});
});
runtime.streamSendFrame(streamId, new Uint8Array([1, 2, 3, 4]), true);
await sendComplete;
const payload = await serverFrame;
assert.deepEqual(Array.from(payload), [1, 2, 3, 4]);
runtime.connectionShutdown(clientConnectionId, 0n);
runtime.connectionShutdown(serverConnectionId, 0n);
runtime.listenerClose(listenerId);
} finally {
await runtime.release();
}
});
test("F7: a 64-bit event code round-trips exactly through the struct decode", async (t) => {
if (!nativeBridgeAvailable()) {
t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge.");
return;
}
const runtime = await CultMeshQuicNativeRuntime.open();
try {
const pkcs12 = readFileSync(FIXTURE_P12);
const { listenerId, boundPort } = runtime.listenerOpen("127.0.0.1", 0, pkcs12, "");
// `code` (a `uint64_t`, the same field `CULTMESH_QUIC_SEND_CANCELED` is
// compared against on the stream-send path) is exercised here through
// CONNECTION_SHUTDOWN instead, whose code is an application-chosen value
// this test fully controls: two values above 2^53 (where koffi's decode
// stops being a plain safe-integer `number`) prove the whole
// encode-native-decode-coerce path is exact, deterministically, with no
// dependency on MsQuic's own send-completion timing.
for (const code of [(1n << 53n) + 1n, (1n << 62n) - 1n]) {
const accepted = waitFor<bigint>((resolve) => {
runtime.onListenerEvent(listenerId, (event) => {
if (event.type === CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION) resolve(event.connectionId);
});
});
const clientConnectionId = runtime.connectionOpen("127.0.0.1", boundPort);
const clientConnected = waitFor<void>((resolve) => {
runtime.onConnectionEvent(clientConnectionId, (event) => {
if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED) {
runtime.connectionCertificateComplete(clientConnectionId, true);
return;
}
if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED) resolve();
});
});
const serverConnectionId = await accepted;
await clientConnected;
const seenOnClient = waitFor<bigint>((resolve) => {
runtime.onConnectionEvent(clientConnectionId, (event) => {
if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN) resolve(event.code);
});
});
runtime.connectionShutdown(serverConnectionId, code);
const seen = await seenOnClient;
assert.equal(typeof seen, "bigint", "a decoded event code must always be bigint, never number");
assert.equal(seen, code, `shutdown code ${code} must round-trip exactly through the struct decode`);
}
runtime.listenerClose(listenerId);
} finally {
await runtime.release();
}
});
test("fix 2: release() refuses a call with no outstanding reference", async (t) => {
if (!nativeBridgeAvailable()) {
t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge.");
return;
}
const runtime = await CultMeshQuicNativeRuntime.open();
await runtime.release();
await assert.rejects(runtime.release(), /no outstanding reference/i);
});
test("negative grep: no koffi callbacks, .async only on nextEvent", () => {
// __dirname at runtime is dist-test/test; the source lives two levels up,
// in src/, since only .js output is mirrored under dist-test.
const source = readFileSync(join(__dirname, "..", "..", "src", "realtime-quic-native.ts"), "utf8");
// `.async` is referenced (not necessarily called with a literal "(" at the
// reference site: it is handed to `promisify`, which calls it) only for
// `nextEvent`, the ABI's single blocking export.
const asyncUses = [...source.matchAll(/\.async\b/g)];
assert.ok(asyncUses.length > 0, "expected at least one .async use for nextEvent");
for (const match of asyncUses) {
const before = source.slice(Math.max(0, match.index! - 40), match.index!);
assert.match(before, /nextEvent/i, "the only .async reference must be nextEvent");
}
assert.doesNotMatch(source, /koffi\.register/, "no koffi callbacks");
});

View file

@ -1,466 +0,0 @@
// `CultMeshQuicRealtimeProvider`, Cut 5's StreamPixels role, exercised
// end-to-end against the real native bridge and the real consumer connector
// from Cut 4 (no mocks): reliable ordering in both directions
// (`CultMeshQuicRealtimeTransportTests.cs:71-110`), latest-only coalescing
// per `(channel, body)` key, a non-blocking broadcast, the uppercase pin, and
// eviction on peer shutdown.
//
// Skips instead of failing when no native bridge is available; see
// `realtime-quic-native.test.ts` for why.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
import {
CultMeshQuicNativeRuntime,
} from "../src/realtime-quic-native";
import {
CultMeshQuicRealtimeConnector,
CultMeshQuicRealtimeProvider,
type CultMeshRealtimeCandidate,
type CultMeshRealtimeTarget,
type CultMeshRealtimeTransport,
} from "../src/realtime-quic";
import type { CultMeshRealtimeFrame } from "../src/realtime-wire";
import { nativeBridgeAvailable } from "./support/native-bridge";
// __dirname at runtime is dist-test/test; fixtures are binary and are never
// compiled/copied there, so they are read from their source location, two
// levels up.
const FIXTURE_P12 = join(__dirname, "..", "..", "test", "fixtures", "quic-test.p12");
/**
* `assert.rejects`, but safe against a mutant or a real regression that
* makes the promise resolve instead: a transport that unexpectedly connects
* is disposed before the assertion fails, so a broken certificate check
* cannot hang this file the way M1 did before realtime-quic-consumer.test.ts
* gained this same helper.
*/
async function assertRejectsAndDisposes(
promise: Promise<{ dispose(): void }>,
match?: RegExp,
): Promise<void> {
let resolved: { dispose(): void } | undefined;
try {
resolved = await promise;
} catch (error) {
if (match) assert.match(error instanceof Error ? error.message : String(error), match);
return;
}
resolved.dispose();
assert.fail("expected the promise to reject, but it resolved (the resolved value has been disposed)");
}
function testFrame(overrides: Partial<CultMeshRealtimeFrame> = {}): CultMeshRealtimeFrame {
return {
channelId: "aetheria.entities",
schemaId: "eve.entity_soa.v1",
bodyId: "body:aetheria:entities",
producerEpoch: 7n,
sequence: 1n,
delivery: "latest-only",
payload: new Uint8Array([3, 4, 5]),
...overrides,
};
}
async function startProvider(): Promise<CultMeshQuicRealtimeProvider> {
return await CultMeshQuicRealtimeProvider.listen({
host: "127.0.0.1",
port: 0,
serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" },
});
}
async function dialProvider(provider: CultMeshQuicRealtimeProvider): Promise<CultMeshRealtimeTransport> {
const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" };
const candidate: CultMeshRealtimeCandidate = {
endpoint: provider.advertisedEndpoint,
authorityRuntimeId: target.authorityRuntimeId,
priority: 0,
generation: "gen-1",
};
const connector = new CultMeshQuicRealtimeConnector();
return await connector.connect(candidate, target);
}
async function waitUntil(predicate: () => boolean, timeoutMs = 5_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() >= deadline) throw new Error("timed out waiting for a provider condition");
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
/**
* Waits for `CultMeshQuicNativeRuntime.refCount` to stop moving: `dispose()`
* releases its runtime reference fire-and-forget (`void this.runtime.release()`),
* so a just-finished test's cleanup can still be landing when the next test
* starts. A test that snapshots refCount as a baseline without waiting for
* this first can catch that in-flight decrement mid-test and see refCount
* undershoot its own "before" value instead of returning to it.
*/
async function waitForRefCountToStabilize(quietMs = 200, timeoutMs = 5_000): Promise<number> {
const deadline = Date.now() + timeoutMs;
let last = CultMeshQuicNativeRuntime.refCount;
let stableSince = Date.now();
for (;;) {
await new Promise((resolve) => setTimeout(resolve, 20));
const current = CultMeshQuicNativeRuntime.refCount;
if (current !== last) {
last = current;
stableSince = Date.now();
} else if (Date.now() - stableSince >= quietMs) {
return current;
}
if (Date.now() >= deadline) return current;
}
}
test("the advertised endpoint carries an uppercase cert-sha256 pin the Unity connector requires", async (t) => {
if (!nativeBridgeAvailable()) return void t.skip("no native bridge available");
const provider = await startProvider();
try {
assert.match(
provider.advertisedEndpoint,
/^cultmesh-state\+quic:\/\/127\.0\.0\.1:\d+\?cert-sha256=[0-9A-F]{64}$/,
);
// Not merely uppercase-tolerant: it must not contain a lowercase hex digit.
const pin = new URL(provider.advertisedEndpoint).searchParams.get("cert-sha256")!;
assert.equal(pin, pin.toUpperCase());
assert.notEqual(pin, pin.toLowerCase());
} finally {
provider.dispose();
}
});
test("reliable-ordered delivers frames in order, provider to consumer and consumer to provider", async (t) => {
if (!nativeBridgeAvailable()) return void t.skip("no native bridge available");
const provider = await startProvider();
try {
const consumer = await dialProvider(provider);
try {
await waitUntil(() => provider.connectionCount === 1);
await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence: 1n }));
await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence: 2n }));
const firstAtConsumer = await consumer.receiveFrame();
const secondAtConsumer = await consumer.receiveFrame();
assert.deepEqual([firstAtConsumer.sequence, secondAtConsumer.sequence], [1n, 2n]);
await consumer.sendFrame(testFrame({ delivery: "reliable-ordered", sequence: 3n }));
await consumer.sendFrame(testFrame({ delivery: "reliable-ordered", sequence: 4n }));
const firstAtProvider = await provider.receive();
const secondAtProvider = await provider.receive();
assert.deepEqual([firstAtProvider.sequence, secondAtProvider.sequence], [3n, 4n]);
} finally {
consumer.dispose();
}
} finally {
provider.dispose();
}
});
test("latest-only broadcast coalesces back-to-back sends for the same key to the newest frame, and never blocks on I/O", async (t) => {
if (!nativeBridgeAvailable()) return void t.skip("no native bridge available");
const provider = await startProvider();
try {
const consumer = await dialProvider(provider);
try {
await waitUntil(() => provider.connectionCount === 1);
// Three broadcasts for the same (channelId, bodyId) key, issued back to
// back with no await between them. `broadcast` for latest-only never
// awaits any I/O (mutating it to await the peer's send would make this
// loop, and the assertion below, run after real network round trips
// instead of within one JS turn): the synchronous middle publish is
// therefore always coalesced away, deterministically, not by luck of
// network timing. Mutating the coalescing key (e.g. dropping bodyId)
// would let sequence 200 leak through as a separate delivered frame
// instead of overwriting 100.
const before = Date.now();
const first = provider.broadcast(testFrame({ sequence: 1n }));
const second = provider.broadcast(testFrame({ sequence: 100n }));
const third = provider.broadcast(testFrame({ sequence: 200n }));
const afterCallsReturned = Date.now();
// All three promises exist before any of them could possibly have
// waited on the peer's round trip: the loop above ran in well under a
// network round trip's worth of time.
assert.ok(afterCallsReturned - before < 50, "broadcast must not block on any peer's I/O");
await Promise.all([first, second, third]);
// The consumer's own receive-side inbox (Cut 4) also coalesces
// latest-only frames it has not yet read, so whether 1 is delivered as
// its own frame or is itself coalesced away by 200 arriving first
// depends on read timing; that is Cut 4's contract, not this test's.
// What this test owns is the provider's outbox: 100 must never appear,
// and the last frame delivered must be 200.
const received: bigint[] = [];
let last: bigint | undefined;
while (last !== 200n) {
const frame = await consumer.receiveFrame();
received.push(frame.sequence);
last = frame.sequence;
}
assert.ok(!received.includes(100n), `100 must be coalesced away, got ${received}`);
assert.ok(received.length <= 2, `expected at most 2 delivered frames, got ${received}`);
assert.equal(last, 200n);
} finally {
consumer.dispose();
}
} finally {
provider.dispose();
}
});
test("a stalled peer's outbox holds at most one pending frame per key across many broadcasts", async (t) => {
if (!nativeBridgeAvailable()) return void t.skip("no native bridge available");
const provider = await startProvider();
try {
const consumer = await dialProvider(provider);
try {
await waitUntil(() => provider.connectionCount === 1);
// 200 broadcasts, none awaited individually, none read by the consumer
// until they are all issued: mirrors the interop lane's stalled-reader
// scenario at unit-test speed. `broadcast` completing this loop at all
// (bounded time) proves it never blocks on the unread peer.
const started = Date.now();
const sends: Promise<void>[] = [];
for (let sequence = 1; sequence <= 200; sequence += 1) {
sends.push(provider.broadcast(testFrame({ sequence: BigInt(sequence) })));
}
await Promise.all(sends);
assert.ok(Date.now() - started < 2_000, "200 broadcasts to one key must complete quickly, unread");
const receivedSequences: bigint[] = [];
let last: bigint | undefined;
while (last !== 200n) {
const frame = await consumer.receiveFrame();
receivedSequences.push(frame.sequence);
last = frame.sequence;
}
assert.equal(last, 200n);
// Coalescing means far fewer than 200 frames were actually put on the
// wire; the exact count depends on scheduling, but it must be small.
assert.ok(receivedSequences.length < 200, `expected coalescing, got ${receivedSequences.length} frames`);
} finally {
consumer.dispose();
}
} finally {
provider.dispose();
}
});
test("unreliable broadcast fails closed", async (t) => {
if (!nativeBridgeAvailable()) return void t.skip("no native bridge available");
const provider = await startProvider();
try {
const consumer = await dialProvider(provider);
try {
await waitUntil(() => provider.connectionCount === 1);
await assert.rejects(provider.broadcast(testFrame({ delivery: "unreliable" })), /datagrams/i);
} finally {
consumer.dispose();
}
} finally {
provider.dispose();
}
});
test("a peer is evicted from the provider on connection shutdown", async (t) => {
if (!nativeBridgeAvailable()) return void t.skip("no native bridge available");
const provider = await startProvider();
try {
const consumer = await dialProvider(provider);
await waitUntil(() => provider.connectionCount === 1);
consumer.dispose();
await waitUntil(() => provider.connectionCount === 0);
} finally {
provider.dispose();
}
});
test("a reliable-ordered send failure evicts the peer without broadcast() throwing to the caller", async (t) => {
if (!nativeBridgeAvailable()) return void t.skip("no native bridge available");
const provider = await startProvider();
try {
const consumer = await dialProvider(provider);
await waitUntil(() => provider.connectionCount === 1);
consumer.dispose();
// Keep broadcasting reliable-ordered frames into the dying/dead peer's
// outbound stream for a short window: somewhere in this window the
// send to that peer fails (either the native stream call itself, or
// the peer's own CONNECTION_SHUTDOWN cleanup rejecting it), and
// CultMeshQuicRealtimeProvider.broadcast()'s per-peer try/catch must
// dispose it rather than let the rejection propagate or leave a dead
// peer counted forever.
const deadline = Date.now() + 2_000;
let sequence = 0n;
while (Date.now() < deadline && provider.connectionCount > 0) {
sequence += 1n;
// Must never throw: a removed subtraction of the catch's dispose()
// call would not itself make broadcast() throw, but a regression
// that let the rejection propagate instead of being caught at all
// would, and this loop would then fail loudly instead of hanging.
await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence }));
}
assert.equal(provider.connectionCount, 0, "the dead peer must be evicted, not counted forever");
} finally {
provider.dispose();
}
});
test("connectionCount reflects accepted peers, and dispose releases the runtime for a fresh listen", async (t) => {
if (!nativeBridgeAvailable()) return void t.skip("no native bridge available");
const provider = await startProvider();
assert.equal(provider.connectionCount, 0);
const consumer = await dialProvider(provider);
try {
await waitUntil(() => provider.connectionCount === 1);
} finally {
consumer.dispose();
provider.dispose();
}
// A leaked runtime reference from any earlier path (accept, handshake
// timeout, dispose) would leave the shared runtime unable to open a fresh
// listener cleanly; opening and tearing down a second provider proves
// release ran on every path exercised above.
const again = await startProvider();
again.dispose();
});
test("the runtime reference is released when extracting the certificate fails, before any listener opens", async (t) => {
if (!nativeBridgeAvailable()) return void t.skip("no native bridge available");
const before = CultMeshQuicNativeRuntime.refCount;
await assert.rejects(
CultMeshQuicRealtimeProvider.listen({
host: "127.0.0.1",
port: 0,
// Not a PKCS12 credential at all: extractLeafCertificateDer() throws
// before CultMeshQuicNativeRuntime.open() is ever called, so there is
// no reference to leak on this path; this pins that (trivial but
// real) invariant rather than exercising listen()'s own
// `catch { await runtime.release(); throw error; }`, which the next
// test covers with a listener-level failure instead.
serverCertificate: { pkcs12: new Uint8Array([1, 2, 3, 4]), password: "" },
}),
);
assert.equal(CultMeshQuicNativeRuntime.refCount, before, "must not leak a reference that was never opened");
});
test("the runtime reference is released when the listener itself fails to open", async (t) => {
if (!nativeBridgeAvailable()) return void t.skip("no native bridge available");
const holder = await startProvider();
try {
const boundPort = new URL(holder.advertisedEndpoint).port;
const before = CultMeshQuicNativeRuntime.refCount;
// A real, valid PKCS12 (so extractLeafCertificateDer succeeds and
// CultMeshQuicNativeRuntime.open() actually runs), but the port
// `holder` already has bound: listenerOpen() itself must fail, driving
// listen()'s `catch { await runtime.release(); throw error; }`.
await assert.rejects(
CultMeshQuicRealtimeProvider.listen({
host: "127.0.0.1",
port: Number(boundPort),
serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" },
}),
);
assert.equal(
CultMeshQuicNativeRuntime.refCount,
before,
"a listenerOpen() failure must release the reference listen() had already opened",
);
} finally {
holder.dispose();
}
});
test(
"the runtime reference is released on dispose with a pending send in flight",
{ timeout: 10_000 },
async (t) => {
if (!nativeBridgeAvailable()) return void t.skip("no native bridge available");
const before = CultMeshQuicNativeRuntime.refCount;
const provider = await startProvider();
const consumer = await dialProvider(provider);
await waitUntil(() => provider.connectionCount === 1);
// Start a reliable-ordered broadcast and dispose before it can possibly
// have settled; dispose() must tear the peer down (and release its
// runtime reference) regardless of the outstanding send. `broadcast()`
// must itself never hang: an explicit `{ timeout }` above fails this
// test loudly, rather than the whole file, if it ever does.
const broadcasting = provider.broadcast(testFrame({ delivery: "reliable-ordered" })).catch(() => {});
provider.dispose();
await broadcasting;
consumer.dispose();
await waitUntil(() => CultMeshQuicNativeRuntime.refCount === before);
},
);
test(
"dispose() evicts a connection still mid-handshake, not just fully attached peers",
{ timeout: 10_000 },
async (t) => {
if (!nativeBridgeAvailable()) return void t.skip("no native bridge available");
// A long handshake timeout: without dispose() sweeping still-pending
// accepts (fixed alongside this test), a connection whose handshake
// never reaches CONNECTED — here, the client rejects the provider's
// certificate — would sit on its own handshake timer, and once
// attachPeer's own CultMeshQuicNativeRuntime.open() resolves, hold a
// runtime reference, for up to this long after dispose() returns. Found
// this way: mutating the certificate-pin comparison to be
// case-sensitive (M2) made every provider test that dials with an
// uppercase-pinned endpoint reject during the handshake, and the whole
// file then hung for over two minutes past its last visible test.
// Earlier tests' dispose() releases fire-and-forget; wait for the
// shared refCount to actually settle before treating it as a baseline
// (see waitForRefCountToStabilize's own doc comment).
const before = await waitForRefCountToStabilize();
const provider = await CultMeshQuicRealtimeProvider.listen({
host: "127.0.0.1",
port: 0,
serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" },
handshakeTimeoutMs: 30_000,
});
try {
const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" };
const candidate: CultMeshRealtimeCandidate = {
// A pin that can never match this provider's real certificate: the
// connector rejects it during CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED,
// before the connection ever reaches CONNECTED.
endpoint: provider.advertisedEndpoint.replace(/cert-sha256=[0-9A-F]+/, `cert-sha256=${"0".repeat(64)}`),
authorityRuntimeId: target.authorityRuntimeId,
priority: 0,
generation: "gen-1",
};
const connector = new CultMeshQuicRealtimeConnector();
// If the certificate check ever regresses into accepting everything
// (M1), this resolves into a fully attached peer instead of rejecting;
// assertRejectsAndDisposes() disposes the client-side transport either
// way, but only the outer `finally` below disposes the provider itself
// (and the peer it would then hold), which is what actually closes
// the file-hanging gap M1 found here.
await assertRejectsAndDisposes(connector.connect(candidate, target), /certificate|rejected/i);
// The provider's own accept flow is asynchronous (attachPeer awaits a
// runtime reference before it can even see the rejection); give it a
// moment to actually start before disposing, so this test exercises a
// real in-flight pending accept rather than one that never began.
await new Promise((resolve) => setTimeout(resolve, 100));
const disposedAt = Date.now();
provider.dispose();
await waitUntil(() => CultMeshQuicNativeRuntime.refCount === before, 5_000);
assert.ok(
Date.now() - disposedAt < 5_000,
"dispose() must evict a mid-handshake connection promptly, not wait out its 30s handshake timeout",
);
} finally {
provider.dispose();
}
},
);

View file

@ -1,43 +0,0 @@
// Regression test for fix 2: CultMeshQuicNativeRuntime.release() must await
// the pump loop's exit before calling cultmesh_quic_runtime_close, never
// after. Reverting that order (close before awaiting the pump) reliably
// crashes this test under a saturated libuv thread pool and MALLOC_PERTURB_,
// at every pool size tried by hand while verifying this batch; restored
// immediately afterward, never committed.
//
// UV_THREADPOOL_SIZE and MALLOC_PERTURB_ must be set before Node starts, so
// this spawns a child `node` process running test/support/release-uaf-probe.js
// with that environment, once per pool size, and asserts it exits 0. Kept to
// one scenario (see the probe's own header) so the whole test stays bounded
// to a few seconds.
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { join } from "node:path";
import test from "node:test";
import { nativeBridgeAvailable } from "./support/native-bridge";
const PROBE_SCRIPT = join(__dirname, "..", "..", "test", "support", "release-uaf-probe.js");
test("fix 2 regression: release()'s ordering survives a saturated thread pool at pool sizes 1, 2 and 4", async (t) => {
if (!nativeBridgeAvailable()) return void t.skip("no native bridge available");
for (const poolSize of [1, 2, 4]) {
const result = spawnSync(process.execPath, [PROBE_SCRIPT], {
env: {
...process.env,
UV_THREADPOOL_SIZE: String(poolSize),
MALLOC_PERTURB_: "165",
},
encoding: "utf8",
timeout: 60_000,
});
assert.equal(
result.status,
0,
`pool size ${poolSize}: probe exited status=${result.status} signal=${result.signal}\n` +
`stdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
);
}
});

View file

@ -1,358 +0,0 @@
// `CultMeshQuicRealtimeSessionManager`'s own rules, isolated from the native
// bridge with fake connectors/transports: eviction on transport death (fix
// 4), single-flight (fix 4), the disposed guard (fix 4), first-success racing
// with abort of the losers (fix 5), the `protocolIds` filter (M7), the
// `authorityRuntimeId` filter (M8), the session key including
// `authorityRuntimeId` (M9), and the `maxRacedCandidates` cap (M6).
//
// Routes use `trust.mode = "local-development"` over loopback endpoints, so
// `verifyAuthorityRoute` short-circuits without a signed certificate
// (`cultmesh-authority.ts:99`): these tests are about the session manager's
// own binding/racing/caching rules, not route trust, which
// `realtime-quic-consumer.test.ts`'s trust negatives already pin.
import assert from "node:assert/strict";
import test from "node:test";
import type { CultMeshAuthorityTrustPolicy } from "cultnet-ts";
import type { CultMeshAuthorityRouteMessage, CultMeshVerseDescriptorMessage } from "cultnet-ts/contracts";
import {
CultMeshQuicRealtimeSessionManager,
CultMeshStaticRealtimeLookupSource,
CULTMESH_REALTIME_STATE_PROTOCOL_ID,
type CultMeshRealtimeCandidate,
type CultMeshRealtimeTarget,
type CultMeshRealtimeTransport,
type CultMeshRealtimeTransportConnector,
} from "../src/realtime-quic";
const trust: CultMeshAuthorityTrustPolicy = { mode: "local-development", odinRoots: [] };
function route(overrides: Partial<CultMeshAuthorityRouteMessage> = {}): CultMeshAuthorityRouteMessage {
return {
authorityRuntimeId: "service:aetheria.daemon",
endpoint: "cultmesh-state+quic://127.0.0.1:9443",
protocolIds: [CULTMESH_REALTIME_STATE_PROTOCOL_ID],
priority: 0,
generation: "gen-1",
...overrides,
};
}
function verseWithRoutes(verseId: string, routes: readonly CultMeshAuthorityRouteMessage[]): CultMeshVerseDescriptorMessage {
return {
verseId,
displayName: verseId,
authorityModel: "operator-cluster",
compatibility: { transportVersion: "cultmesh.v1", rulesHash: "test", compatibleVerseIds: [], requiredPluginIds: [], optionalPluginIds: [] },
discoveryEndpoints: routes.map((r) => r.endpoint),
authorityRuntimeIds: [...new Set(routes.map((r) => r.authorityRuntimeId))],
authorityRoutes: [...routes],
};
}
interface FakeTransportHandle {
readonly transport: CultMeshRealtimeTransport;
disposed(): boolean;
}
/** A transport that never resolves `receiveFrame` and tracks its own disposal. */
function fakeTransport(endpoint: string, verified = true): FakeTransportHandle {
let disposed = false;
const disposalHandlers: Array<() => void> = [];
const transport: CultMeshRealtimeTransport = {
transportId: "fake",
endpoint,
async sendFrame() {},
async receiveFrame() {
return await new Promise<never>(() => {});
},
dispose() {
if (disposed) return;
disposed = true;
for (const handler of disposalHandlers) handler();
},
isVerifiedFor: () => verified,
onDisposed(handler) {
if (disposed) {
handler();
return;
}
disposalHandlers.push(handler);
},
};
return { transport, disposed: () => disposed };
}
/** A connector that resolves immediately (unless `delayMs` is given) with one fake transport per call. */
function spyConnector(
connectorId: string,
priority: number,
options: { delayMs?: number; fails?: boolean; ignoreAbort?: boolean } = {},
): {
connector: CultMeshRealtimeTransportConnector;
calls: CultMeshRealtimeCandidate[];
handles: FakeTransportHandle[];
/** Timestamps at which this connector's abort signal actually fired. */
aborts: number[];
} {
const calls: CultMeshRealtimeCandidate[] = [];
const handles: FakeTransportHandle[] = [];
const aborts: number[] = [];
const connector: CultMeshRealtimeTransportConnector = {
connectorId,
priority,
canConnect: () => true,
connect: async (candidate, _target, signal) => {
calls.push(candidate);
if (options.delayMs) {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, options.delayMs);
if (!options.ignoreAbort) {
signal?.addEventListener("abort", () => {
clearTimeout(timer);
aborts.push(Date.now());
reject(new Error(`${connectorId} connect aborted`));
});
}
});
}
if (options.fails) throw new Error(`${connectorId} refused to connect`);
const handle = fakeTransport(candidate.endpoint);
handles.push(handle);
return handle.transport;
},
};
return { connector, calls, handles, aborts };
}
const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" };
test("fix 4: connect() caches a session and evicts it once the transport dies, so the next connect redials", async () => {
const { connector, calls } = spyConnector("fake", 0);
const manager = new CultMeshQuicRealtimeSessionManager({
lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoutes(target.verseId, [route()])]),
trust,
connectors: [connector],
});
const first = await manager.connect(target);
assert.equal(await manager.connect(target), first, "a live session is returned from cache, not redialed");
assert.equal(calls.length, 1);
first.dispose();
const second = await manager.connect(target);
assert.notEqual(second, first, "a dead session is evicted, so the next connect redials");
assert.equal(calls.length, 2);
});
test("fix 4: concurrent connect() calls for the same target share one dial (single-flight)", async () => {
const { connector, calls } = spyConnector("fake", 0, { delayMs: 50 });
const manager = new CultMeshQuicRealtimeSessionManager({
lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoutes(target.verseId, [route()])]),
trust,
connectors: [connector],
});
const [a, b, c] = await Promise.all([manager.connect(target), manager.connect(target), manager.connect(target)]);
assert.equal(a, b);
assert.equal(b, c);
assert.equal(calls.length, 1, "three concurrent callers must share one dial");
});
test("fix 4/low: dispose() aborts a dial in flight instead of letting it land and discarding the result", async () => {
// A long delay: if `dispose()` did not cancel the dial, this test would
// have to wait it out (or race a timing-dependent assertion) to observe
// that the eventually-created transport gets disposed instead of cached.
const { connector, handles } = spyConnector("fake", 0, { delayMs: 5_000 });
const manager = new CultMeshQuicRealtimeSessionManager({
lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoutes(target.verseId, [route()])]),
trust,
connectors: [connector],
});
const pending = manager.connect(target);
const start = Date.now();
manager.dispose();
await assert.rejects(pending);
assert.ok(
Date.now() - start < 1_000,
"dispose() must cancel the in-flight dial rather than waiting out the connector's own timeout",
);
assert.equal(handles.length, 0, "an aborted dial must never construct a transport only to immediately discard it");
await assert.rejects(manager.connect(target), /disposed/i, "a disposed manager refuses further connects");
});
test("fix 4/low: disconnect() during a dial does not let the dial cache a session once it lands", async () => {
const { connector, handles } = spyConnector("fake", 0, { delayMs: 30 });
const manager = new CultMeshQuicRealtimeSessionManager({
lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoutes(target.verseId, [route()])]),
trust,
connectors: [connector],
});
const pending = manager.connect(target);
manager.disconnect(target);
await assert.rejects(pending);
// Whether or not the aborted dial still managed to construct a transport
// (a race against the abort), it must end up disposed, never cached.
await new Promise((resolve) => setTimeout(resolve, 60));
for (const handle of handles) assert.equal(handle.disposed(), true, "a cancelled dial's transport must be disposed");
// The manager itself is still usable: a fresh connect redials and succeeds.
const { connector: connector2 } = spyConnector("fake", 0);
const manager2 = new CultMeshQuicRealtimeSessionManager({
lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoutes(target.verseId, [route()])]),
trust,
connectors: [connector2],
});
const fresh = await manager2.connect(target);
assert.ok(fresh);
});
test("fix 5: the race returns the first verified success and aborts the losers", async () => {
const winner = spyConnector("fast", 0, { delayMs: 10 });
const loser = spyConnector("slow", 0, { delayMs: 5_000 });
const manager = new CultMeshQuicRealtimeSessionManager({
lookupSource: new CultMeshStaticRealtimeLookupSource([
verseWithRoutes(target.verseId, [
route({ endpoint: "cultmesh-state+quic://127.0.0.1:9001" }),
route({ endpoint: "cultmesh-state+quic://127.0.0.1:9002" }),
]),
]),
trust,
connectors: [winner.connector, loser.connector],
maxRacedCandidates: 2,
});
const startedAt = Date.now();
const transport = await manager.connect(target);
const elapsedMs = Date.now() - startedAt;
assert.ok(elapsedMs < 1_000, `connect() must return on the first success, not wait out the slow route (took ${elapsedMs}ms)`);
assert.equal(transport.endpoint, "cultmesh-state+quic://127.0.0.1:9001");
});
test("F5abort: a losing candidate is told to abort promptly, not left to run out its own timeout", async () => {
const winner = spyConnector("fast", 0, { delayMs: 10 });
const loser = spyConnector("slow", 0, { delayMs: 5_000 });
const manager = new CultMeshQuicRealtimeSessionManager({
lookupSource: new CultMeshStaticRealtimeLookupSource([
verseWithRoutes(target.verseId, [
route({ endpoint: "cultmesh-state+quic://127.0.0.1:9001" }),
route({ endpoint: "cultmesh-state+quic://127.0.0.1:9002" }),
]),
]),
trust,
connectors: [winner.connector, loser.connector],
maxRacedCandidates: 2,
});
const startedAt = Date.now();
await manager.connect(target);
await new Promise((resolve) => setTimeout(resolve, 300));
assert.equal(loser.aborts.length, 1, "the losing candidate's connect() must observe an abort signal");
assert.ok(
loser.aborts[0]! - startedAt < 1_000,
"the abort must arrive promptly after the winner is decided, not after the loser's own 5s timeout",
);
});
test("F5loser: a losing candidate that connects anyway (past the point abort helps) is disposed, not left dangling", async () => {
const winner = spyConnector("fast", 0, { delayMs: 10 });
// Ignores the abort signal entirely, simulating a candidate already past
// the point where cancellation helps: it still produces a real transport.
const loser = spyConnector("slow", 0, { delayMs: 80, ignoreAbort: true });
const manager = new CultMeshQuicRealtimeSessionManager({
lookupSource: new CultMeshStaticRealtimeLookupSource([
verseWithRoutes(target.verseId, [
route({ endpoint: "cultmesh-state+quic://127.0.0.1:9001" }),
route({ endpoint: "cultmesh-state+quic://127.0.0.1:9002" }),
]),
]),
trust,
connectors: [winner.connector, loser.connector],
maxRacedCandidates: 2,
});
const transport = await manager.connect(target);
assert.equal(transport.endpoint, "cultmesh-state+quic://127.0.0.1:9001");
await new Promise((resolve) => setTimeout(resolve, 200));
assert.equal(loser.handles.length, 1, "the loser still connects despite racing to lose");
assert.equal(
loser.handles[0]!.disposed(),
true,
"a losing candidate that connects after the race is decided must be disposed, never left connected",
);
});
test("M6: maxRacedCandidates caps how many candidates in a tier are attempted", async () => {
const spy = spyConnector("fake", 0);
const manager = new CultMeshQuicRealtimeSessionManager({
lookupSource: new CultMeshStaticRealtimeLookupSource([
verseWithRoutes(target.verseId, [
route({ endpoint: "cultmesh-state+quic://127.0.0.1:9001", priority: 0 }),
route({ endpoint: "cultmesh-state+quic://127.0.0.1:9002", priority: 1 }),
route({ endpoint: "cultmesh-state+quic://127.0.0.1:9003", priority: 2 }),
]),
]),
trust,
connectors: [spy.connector],
maxRacedCandidates: 1,
});
await manager.connect(target);
assert.equal(spy.calls.length, 1, "only the top maxRacedCandidates entries in the tier are attempted");
assert.equal(spy.calls[0]!.endpoint, "cultmesh-state+quic://127.0.0.1:9001", "the lowest route priority races first");
});
test("M7: a route whose protocolIds omit the realtime-state protocol is not a candidate", async () => {
const spy = spyConnector("fake", 0);
const manager = new CultMeshQuicRealtimeSessionManager({
lookupSource: new CultMeshStaticRealtimeLookupSource([
verseWithRoutes(target.verseId, [route({ protocolIds: ["cultmesh.other_protocol.v1"] })]),
]),
trust,
connectors: [spy.connector],
});
await assert.rejects(manager.connect(target), /No realtime state route was advertised/);
assert.equal(spy.calls.length, 0);
});
test("M8: a route advertised for a different authorityRuntimeId is not a candidate", async () => {
const spy = spyConnector("fake", 0);
const manager = new CultMeshQuicRealtimeSessionManager({
lookupSource: new CultMeshStaticRealtimeLookupSource([
verseWithRoutes(target.verseId, [route({ authorityRuntimeId: "service:other.daemon" })]),
]),
trust,
connectors: [spy.connector],
});
await assert.rejects(manager.connect(target), /No realtime state route was advertised/);
assert.equal(spy.calls.length, 0);
});
test("M9: the session key includes authorityRuntimeId, so two authorities in the same Verse get separate sessions", async () => {
const spy = spyConnector("fake", 0);
const targetA: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:a.daemon" };
const targetB: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:b.daemon" };
const manager = new CultMeshQuicRealtimeSessionManager({
lookupSource: new CultMeshStaticRealtimeLookupSource([
verseWithRoutes("aetheria", [
route({ authorityRuntimeId: "service:a.daemon", endpoint: "cultmesh-state+quic://127.0.0.1:9001" }),
route({ authorityRuntimeId: "service:b.daemon", endpoint: "cultmesh-state+quic://127.0.0.1:9002" }),
]),
]),
trust,
connectors: [spy.connector],
});
const sessionA = await manager.connect(targetA);
const sessionB = await manager.connect(targetB);
assert.notEqual(sessionA, sessionB);
assert.equal(sessionA.endpoint, "cultmesh-state+quic://127.0.0.1:9001");
assert.equal(sessionB.endpoint, "cultmesh-state+quic://127.0.0.1:9002");
assert.equal(spy.calls.length, 2);
});

View file

@ -1,397 +0,0 @@
// The realtime frame codec, pinned rule by rule against the C# reference.
//
// The bytes themselves are pinned by two committed files under
// `contracts/cultmesh/`. `realtime-frame-vectors.json` is written by
// `CultMeshRealtimeWireProtocolTests.RealtimeFrameVectorsAreSharedWithTypeScript`
// under CULTMESH_WRITE_VECTORS=1: this side decodes it and re-encodes to the
// same bytes. `realtime-frame-vectors.ts-written.json` is written by
// `scripts/write-cultmesh-realtime-frame-vectors.mjs` with this codec and
// decoded by the C# reference, so parity is proven in both directions and in
// two processes. A fixture authored on one side alone would only agree with
// itself, which is what the authority vectors already taught.
import test from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
CULTMESH_REALTIME_ALPN,
CULTMESH_REALTIME_CONNECTION_CLOSE_CODE,
CULTMESH_REALTIME_LATEST_ONLY_STREAM,
CULTMESH_REALTIME_MAX_ENCODED_FRAME_BYTES,
CULTMESH_REALTIME_MAX_PAYLOAD_BYTES,
CULTMESH_REALTIME_RELIABLE_STREAM,
CULTMESH_REALTIME_STREAM_ABORT_CODE,
decodeRealtimeFrame,
encodeRealtimeFrame,
type CultMeshRealtimeDelivery,
type CultMeshRealtimeFrame,
} from "../src/realtime-wire";
const CONTRACTS = join(__dirname, "..", "..", "..", "..", "contracts", "cultmesh");
const CSHARP_VECTORS = join(CONTRACTS, "realtime-frame-vectors.json");
const TS_VECTORS = join(CONTRACTS, "realtime-frame-vectors.ts-written.json");
interface IdentityRule { repeat: string; byteLength: number }
interface RampRule { ramp: number }
interface VectorFields {
channelId: string | IdentityRule;
schemaId: string | IdentityRule;
bodyId: string | IdentityRule;
producerEpoch: number;
sequence: number;
delivery: CultMeshRealtimeDelivery;
payload: string | RampRule;
}
interface Vectors {
frames: {
label: string;
fields: VectorFields;
encoding: string | null;
encodedLength: number;
sha256OfEncoding: string;
}[];
malformed: { label: string; encoding: string; message: string }[];
}
/** The repeat unit written whole until the UTF-8 byte length is exactly `byteLength`. */
function repeatToByteLength(unit: string, byteLength: number): string {
const unitBytes = Buffer.byteLength(unit, "utf8");
assert.equal(byteLength % unitBytes, 0, `'${unit}' is ${unitBytes} UTF-8 bytes and does not divide ${byteLength}`);
return unit.repeat(byteLength / unitBytes);
}
/** The deterministic payload both runtimes build the same way. */
function ramp(length: number): Uint8Array {
const bytes = new Uint8Array(length);
for (let index = 0; index < length; index += 1) bytes[index] = index % 251;
return bytes;
}
function identity(value: string | IdentityRule): string {
return typeof value === "string" ? value : repeatToByteLength(value.repeat, value.byteLength);
}
function payload(value: string | RampRule): Uint8Array {
return typeof value === "string" ? new Uint8Array(Buffer.from(value, "base64")) : ramp(value.ramp);
}
function frameOf(fields: VectorFields): CultMeshRealtimeFrame {
return {
channelId: identity(fields.channelId),
schemaId: identity(fields.schemaId),
bodyId: identity(fields.bodyId),
// The C# `long` fields are written as JSON integers and can exceed the safe
// integer range, so they are read from the raw text, never through
// `JSON.parse`'s number.
producerEpoch: 0n,
sequence: 0n,
delivery: fields.delivery,
payload: payload(fields.payload),
};
}
// `JSON.parse` would round `long.MaxValue` to 9223372036854775808, which is not
// a valid i64 and not what either side encoded. The two i64 fields are lifted
// out of the raw text as bigints before the document is parsed.
function readVectors(path: string): { doc: Vectors; int64s: Map<string, { producerEpoch: bigint; sequence: bigint }> } {
const text = readFileSync(path, "utf8");
const int64s = new Map<string, { producerEpoch: bigint; sequence: bigint }>();
const pattern = /"label": "(?<label>(?:[^"\\]|\\.)*)"[\s\S]*?"producerEpoch": (?<epoch>-?\d+),\s*"sequence": (?<sequence>-?\d+),/g;
for (const match of text.matchAll(pattern)) {
const groups = match.groups!;
int64s.set(JSON.parse(`"${groups["label"]!}"`) as string, {
producerEpoch: BigInt(groups["epoch"]!),
sequence: BigInt(groups["sequence"]!),
});
}
return { doc: JSON.parse(text) as Vectors, int64s };
}
const sha256 = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
for (const [label, path] of [["C# reference", CSHARP_VECTORS], ["TypeScript-written", TS_VECTORS]] as const) {
test(`every ${label} frame vector decodes to its recorded fields and re-encodes to its recorded bytes`, () => {
const { doc, int64s } = readVectors(path);
assert.ok(doc.frames.length >= 8, `${label} carries ${doc.frames.length} frames`);
const seen = new Set<CultMeshRealtimeDelivery>();
for (const vector of doc.frames) {
const int64 = int64s.get(vector.label);
assert.ok(int64, `${vector.label}: no i64 pair was lifted from the text`);
const frame = { ...frameOf(vector.fields), ...int64 };
seen.add(frame.delivery);
// This side encodes the recorded fields to the recorded bytes.
const encoded = encodeRealtimeFrame(frame);
assert.equal(encoded.byteLength, vector.encodedLength, vector.label);
assert.equal(sha256(encoded), vector.sha256OfEncoding, vector.label);
// The two oversized cases carry no literal encoding; the digest above is
// the whole of their parity.
const recorded = vector.encoding === null ? encoded : new Uint8Array(Buffer.from(vector.encoding, "base64"));
assert.deepEqual(recorded, encoded, vector.label);
// And decodes those bytes back to the recorded fields.
const decoded = decodeRealtimeFrame(recorded);
assert.equal(decoded.channelId, frame.channelId, vector.label);
assert.equal(decoded.schemaId, frame.schemaId, vector.label);
assert.equal(decoded.bodyId, frame.bodyId, vector.label);
assert.equal(decoded.producerEpoch, frame.producerEpoch, vector.label);
assert.equal(decoded.sequence, frame.sequence, vector.label);
assert.equal(decoded.delivery, frame.delivery, vector.label);
assert.deepEqual(decoded.payload, frame.payload, vector.label);
}
// One vector per delivery mode, or a mode is unpinned.
assert.deepEqual([...seen].sort(), ["latest-only", "reliable-ordered", "unreliable"]);
});
test(`every ${label} malformed vector is refused with the C# reference's exact message`, () => {
const { doc } = readVectors(path);
assert.ok(doc.malformed.length >= 8, `${label} carries ${doc.malformed.length} malformed frames`);
for (const vector of doc.malformed) {
const bytes = new Uint8Array(Buffer.from(vector.encoding, "base64"));
assert.throws(
() => decodeRealtimeFrame(bytes),
(error: Error) => error.message === vector.message,
`${vector.label}: expected ${JSON.stringify(vector.message)}`,
);
}
// Every refusal the C# decoder can give is exercised by the file.
assert.deepEqual([...new Set(doc.malformed.map(vector => vector.message))].sort(), [
"Realtime frame delivery mode is invalid.",
"Realtime frame header is truncated.",
"Realtime frame length is invalid.",
"Realtime frame magic is invalid.",
"Realtime frame wire version is unsupported.",
]);
});
}
const sample = (overrides: Partial<CultMeshRealtimeFrame> = {}): CultMeshRealtimeFrame => ({
channelId: "aetheria.zone-1",
schemaId: "cultmesh.realtime.v1",
bodyId: "body-7",
producerEpoch: 1n,
sequence: 2n,
delivery: "reliable-ordered",
payload: Uint8Array.from([1, 2, 3]),
...overrides,
});
test("the wire constants are the C# reference's, including the ALPN and the two QUIC error codes", () => {
assert.equal(CULTMESH_REALTIME_ALPN, "cultmesh-state-v1");
assert.equal(CULTMESH_REALTIME_CONNECTION_CLOSE_CODE, 0x43554c54n);
assert.equal(CULTMESH_REALTIME_STREAM_ABORT_CODE, 0x53544154n);
assert.equal(CULTMESH_REALTIME_RELIABLE_STREAM, 1);
assert.equal(CULTMESH_REALTIME_LATEST_ONLY_STREAM, 2);
assert.equal(CULTMESH_REALTIME_MAX_PAYLOAD_BYTES, 64 * 1024 * 1024);
assert.equal(CULTMESH_REALTIME_MAX_ENCODED_FRAME_BYTES, 64 * 1024 * 1024 + 37 + 3 * 65535);
// The ALPN and the close codes are ASCII the C# side spells as numbers.
assert.equal(Buffer.from("CULT", "ascii").readUInt32BE(0), Number(CULTMESH_REALTIME_CONNECTION_CLOSE_CODE));
assert.equal(Buffer.from("STAT", "ascii").readUInt32BE(0), Number(CULTMESH_REALTIME_STREAM_ABORT_CODE));
});
// `CultMeshRealtimeWireProtocol.cs:37-54`, offset by offset. A vector proves the
// whole layout at once; this says which byte carries which field, so a swap of
// two same-width fields names itself.
test("the fixed header is 37 little-endian bytes in the reference's order", () => {
const encoded = encodeRealtimeFrame(sample({
channelId: "ch", schemaId: "sch", bodyId: "bdy",
producerEpoch: 0x0102030405060708n, sequence: 0x1112131415161718n,
delivery: "unreliable", payload: Uint8Array.from([0xaa]),
}));
const view = new DataView(encoded.buffer, encoded.byteOffset, encoded.byteLength);
assert.equal(view.getUint32(0, true), 0x31545343, "magic, little-endian");
assert.deepEqual([...encoded.subarray(0, 4)], [0x43, 0x53, 0x54, 0x31], "magic bytes as written");
assert.equal(encoded[4], 2, "delivery");
assert.equal(view.getBigInt64(5, true), 0x0102030405060708n, "producer epoch");
assert.equal(view.getBigInt64(13, true), 0x1112131415161718n, "sequence");
assert.equal(view.getUint16(21, true), 2, "channel length");
assert.equal(view.getUint16(23, true), 3, "schema length");
assert.equal(view.getUint16(25, true), 3, "body length");
assert.equal(view.getInt32(27, true), 1, "payload length");
assert.equal(view.getInt32(31, true), 37, "header size");
assert.equal(view.getUint16(35, true), 1, "wire version");
assert.equal(Buffer.from(encoded.subarray(37, 37 + 2 + 3 + 3)).toString("utf8"), "chschbdy", "identities in order");
assert.deepEqual([...encoded.subarray(45)], [0xaa], "payload last");
assert.equal(encoded.byteLength, 37 + 2 + 3 + 3 + 1);
});
test("the delivery byte is the C# enum ordinal: 0, 1, 2 in that order", () => {
const bytes: Record<CultMeshRealtimeDelivery, number> = { "reliable-ordered": 0, "latest-only": 1, "unreliable": 2 };
for (const [delivery, byte] of Object.entries(bytes) as [CultMeshRealtimeDelivery, number][]) {
const encoded = encodeRealtimeFrame(sample({ delivery }));
assert.equal(encoded[4], byte, delivery);
assert.equal(decodeRealtimeFrame(encoded).delivery, delivery, delivery);
}
assert.throws(
() => encodeRealtimeFrame(sample({ delivery: "at-most-once" as CultMeshRealtimeDelivery })),
/delivery mode is invalid/,
);
});
test("identities are UTF-8 and their length prefixes count bytes, not characters", () => {
const frame = sample({ channelId: "z\u00f6ne-\u221e", schemaId: "s", bodyId: "b" });
const encoded = encodeRealtimeFrame(frame);
const view = new DataView(encoded.buffer, encoded.byteOffset, encoded.byteLength);
assert.equal(frame.channelId.length, 6);
assert.equal(view.getUint16(21, true), Buffer.byteLength(frame.channelId, "utf8"));
assert.equal(view.getUint16(21, true), 9);
assert.equal(decodeRealtimeFrame(encoded).channelId, frame.channelId);
});
test("an identity of exactly 65535 UTF-8 bytes encodes and one byte more is refused", () => {
const wide = repeatToByteLength("\u00e4\u20ac", 65535);
assert.equal(Buffer.byteLength(wide, "utf8"), 65535);
assert.notEqual(wide.length, 65535);
const encoded = encodeRealtimeFrame(sample({ channelId: wide }));
assert.equal(new DataView(encoded.buffer, encoded.byteOffset).getUint16(21, true), 65535);
assert.equal(decodeRealtimeFrame(encoded).channelId, wide);
for (const field of ["channelId", "schemaId", "bodyId"] as const) {
assert.throws(
() => encodeRealtimeFrame(sample({ [field]: `${wide}a` })),
/identity exceeds the QUIC wire limit/,
field,
);
}
});
test("a payload at the 64 MiB ceiling encodes and one byte more is refused", () => {
const encoded = encodeRealtimeFrame(sample({ payload: ramp(CULTMESH_REALTIME_MAX_PAYLOAD_BYTES) }));
assert.equal(encoded.byteLength, 37 + 15 + 20 + 6 + CULTMESH_REALTIME_MAX_PAYLOAD_BYTES);
assert.equal(decodeRealtimeFrame(encoded).payload.byteLength, CULTMESH_REALTIME_MAX_PAYLOAD_BYTES);
assert.throws(
() => encodeRealtimeFrame(sample({ payload: new Uint8Array(CULTMESH_REALTIME_MAX_PAYLOAD_BYTES + 1) })),
/payload exceeds the QUIC wire limit/,
);
});
// `CultMeshRealtimeTransports.cs:29-39`, message for message. The blank strings
// are C#'s `IsNullOrWhiteSpace` set, not `String.prototype.trim`'s: U+0085 is
// whitespace to .NET and not to JavaScript, and U+FEFF the other way round.
test("encode refuses what the transport contract forbids, in the reference's words", () => {
for (const blank of ["", " ", "\t", "\u0085", "\u3000"]) {
assert.throws(() => encodeRealtimeFrame(sample({ channelId: blank })), /^Error: Realtime channel identity is required\.$/);
assert.throws(() => encodeRealtimeFrame(sample({ schemaId: blank })), /^Error: Realtime schema identity is required\.$/);
assert.throws(() => encodeRealtimeFrame(sample({ bodyId: blank })), /^Error: Realtime body identity is required\.$/);
}
// U+FEFF is not whitespace to .NET, so it is a legal one-character identity.
assert.equal(decodeRealtimeFrame(encodeRealtimeFrame(sample({ channelId: "\ufeff" }))).channelId, "\ufeff");
assert.throws(() => encodeRealtimeFrame(sample({ producerEpoch: -1n })), /epoch and sequence must be non-negative/);
assert.throws(() => encodeRealtimeFrame(sample({ sequence: -1n })), /epoch and sequence must be non-negative/);
// C# gets this bound from `long`; a bigint does not.
assert.throws(() => encodeRealtimeFrame(sample({ sequence: 2n ** 63n })), /must fit a 64-bit signed integer/);
assert.equal(encodeRealtimeFrame(sample({ sequence: 2n ** 63n - 1n })).byteLength, 37 + 15 + 20 + 6 + 3);
});
// `CultMeshRealtimeWireProtocol.cs:60-76`, refusal for refusal and in the same
// order: a frame that breaks two rules names the earlier one.
test("decode refuses every malformed frame the reference refuses, in the reference's order", () => {
const good = encodeRealtimeFrame(sample());
const spoil = (mutate: (bytes: Uint8Array) => void) => { const copy = Uint8Array.from(good); mutate(copy); return copy; };
const cases: readonly (readonly [string, Uint8Array, string])[] = [
["a header one byte short", good.subarray(0, 36), "Realtime frame header is truncated."],
["an empty buffer", new Uint8Array(0), "Realtime frame header is truncated."],
["a wrong magic", spoil(bytes => { bytes[3] = 0x32; }), "Realtime frame magic is invalid."],
["a big-endian magic", spoil(bytes => bytes.set([0x31, 0x54, 0x53, 0x43], 0)), "Realtime frame magic is invalid."],
["wire version 0", spoil(bytes => { bytes[35] = 0; }), "Realtime frame wire version is unsupported."],
["wire version 2", spoil(bytes => { bytes[35] = 2; }), "Realtime frame wire version is unsupported."],
["header size 36", spoil(bytes => { bytes[31] = 36; }), "Realtime frame wire version is unsupported."],
["header size 38", spoil(bytes => { bytes[31] = 38; }), "Realtime frame wire version is unsupported."],
["delivery 3", spoil(bytes => { bytes[4] = 3; }), "Realtime frame delivery mode is invalid."],
["delivery 255", spoil(bytes => { bytes[4] = 255; }), "Realtime frame delivery mode is invalid."],
["a negative payload length", spoil(bytes => { bytes[30] = 0x80; }), "Realtime frame length is invalid."],
["one byte too many", Uint8Array.from([...good, 0]), "Realtime frame length is invalid."],
["one byte too few", good.subarray(0, good.byteLength - 1), "Realtime frame length is invalid."],
["a channel length one too long", spoil(bytes => { bytes[21] += 1; }), "Realtime frame length is invalid."],
];
for (const [label, bytes, message] of cases) {
assert.throws(() => decodeRealtimeFrame(bytes), (error: Error) => error.message === message, label);
}
// Order: a frame whose magic and delivery are both wrong is refused for the
// magic, and one whose version and delivery are both wrong for the version.
assert.throws(
() => decodeRealtimeFrame(spoil(bytes => { bytes[3] = 0x32; bytes[4] = 9; })),
(error: Error) => error.message === "Realtime frame magic is invalid.",
);
assert.throws(
() => decodeRealtimeFrame(spoil(bytes => { bytes[35] = 3; bytes[4] = 9; })),
(error: Error) => error.message === "Realtime frame wire version is unsupported.",
);
// Like the reference, decode does not re-run the transport contract: a frame
// with a blank identity decodes rather than being refused.
const blank = spoil(bytes => bytes.set(new Uint8Array(15).fill(0x20), 37));
assert.equal(decodeRealtimeFrame(blank).channelId, " ".repeat(15));
});
// The `payloadLength < 0` clause in the length refusal is not redundant with the
// sum check beside it, which is what a surviving mutant that dropped it claimed.
// A negative payload length cancels against the identity lengths, so a frame can
// be crafted whose sum lands exactly on its own byte length. Without the clause
// each of these decodes to blank identities and an empty payload where the C#
// reference (`CultMeshRealtimeWireProtocol.cs:74-76`, same clause) refuses.
test("decode refuses a negative payload length that the length sum cancels out", () => {
const good = encodeRealtimeFrame(sample());
// A valid magic, wire version, header size and delivery byte, then the three
// identity lengths and the payload length written by hand.
const crafted = (channelLength: number, payloadLength: number, totalBytes: number) => {
const bytes = new Uint8Array(totalBytes);
bytes.set(good.subarray(0, 37), 0);
const view = new DataView(bytes.buffer);
view.setUint16(21, channelLength, true);
view.setUint16(23, 0, true);
view.setUint16(25, 0, true);
view.setInt32(27, payloadLength, true);
// The sum the decoder computes must land exactly on the frame's own length,
// or the check beside the clause would refuse it and prove nothing.
assert.equal(37 + channelLength + 0 + 0 + payloadLength, totalBytes, "the crafted sum must match");
return bytes;
};
const cases: readonly (readonly [string, Uint8Array])[] = [
["a maximal channel length cancelled by -65535, summing to 37", crafted(0xffff, -0xffff, 37)],
["a channel length of 1 cancelled by -1, summing to 37", crafted(1, -1, 37)],
["a channel length of 2 cancelled by -1, summing to 38", crafted(2, -1, 38)],
];
for (const [label, bytes] of cases) {
assert.throws(
() => decodeRealtimeFrame(bytes),
(error: Error) => error.message === "Realtime frame length is invalid.",
label,
);
}
});
test("decode reads exactly the viewed bytes of an offset view, not the buffer behind it", () => {
const good = encodeRealtimeFrame(sample({ payload: Uint8Array.from([9, 8, 7]) }));
const pool = new Uint8Array(good.byteLength + 64).fill(0xa5);
pool.set(good, 17);
const view = pool.subarray(17, 17 + good.byteLength);
assert.deepEqual(decodeRealtimeFrame(view).payload, Uint8Array.from([9, 8, 7]));
assert.equal(decodeRealtimeFrame(view).channelId, "aetheria.zone-1");
// And the decoded payload does not alias the frame it came from.
const decoded = decodeRealtimeFrame(good);
good[good.byteLength - 1] = 0;
assert.deepEqual(decoded.payload, Uint8Array.from([9, 8, 7]));
});
// The `Uint8Array` case above passes with a plain `bytes.slice(...)`; this one
// does not. Node's `Buffer` overrides `slice` with an alias of `subarray`, and a
// `Buffer` is what a socket read hands a Node caller, so the aliasing bug this
// pins is the one that would actually reach a consumer.
test("the decoded payload copies out of a Node Buffer too, which overrides slice", () => {
const frame = Buffer.from(encodeRealtimeFrame(sample({ payload: Uint8Array.from([9, 8, 7]) })));
const decoded = decodeRealtimeFrame(frame);
frame[frame.byteLength - 1] = 0;
assert.deepEqual(decoded.payload, Uint8Array.from([9, 8, 7]), "payload unchanged by a later write to the frame");
assert.equal(Buffer.isBuffer(decoded.payload), false, "the copy is a plain Uint8Array, not a Buffer");
assert.notEqual(decoded.payload.buffer, frame.buffer, "the copy has its own backing store");
// The three identities need no such check: `TextDecoder` returns strings, which
// hold no reference to the bytes they were decoded from.
assert.equal(decoded.channelId, "aetheria.zone-1");
});
test("the codec touches no socket: no node: import in the source", () => {
const source = readFileSync(join(__dirname, "..", "..", "src", "realtime-wire.ts"), "utf8");
assert.doesNotMatch(source, /from "node:|require\("node:/);
});

View file

@ -1,20 +0,0 @@
// Shared by every QUIC test file that skips instead of failing when no native
// bridge is built: true only when both the bridge and its `msquic` dependency
// are present under `CULTMESH_QUIC_NATIVE_DIR`.
import { readFileSync } from "node:fs";
import { join } from "node:path";
export function nativeBridgeAvailable(): boolean {
const dir = process.env.CULTMESH_QUIC_NATIVE_DIR;
if (!dir) return false;
try {
const bridge = process.platform === "win32" ? "gamecult_mesh_quic_native.dll" : "libgamecult_mesh_quic_native.so";
const dependency = process.platform === "win32" ? "msquic.dll" : "libmsquic.so.2";
readFileSync(join(dir, bridge));
readFileSync(join(dir, dependency));
return true;
} catch {
return false;
}
}

View file

@ -1,79 +0,0 @@
"use strict";
// Fix 2 regression probe: CultMeshQuicNativeRuntime.release() must await the
// pump loop's exit BEFORE calling cultmesh_quic_runtime_close, never after.
// The bridge header forbids any host call beginning once
// cultmesh_quic_runtime_close has started; the old order let a queued,
// not-yet-started nextEvent call begin after close and run against freed
// memory.
//
// Plain JS, not TypeScript: this file is spawned directly as a child `node`
// process (see ../release-uaf.test.ts) so that UV_THREADPOOL_SIZE and
// MALLOC_PERTURB_ are in effect before Node's own thread pool and allocator
// initialize, which setting them from within an already-running process
// cannot achieve. It requires the compiled dist/ output directly, exactly
// like the package's own compiled tests do.
//
// Kept to one scenario (bare open/release cycles, including a fresh open
// while the previous runtime is still closing) so the probe stays bounded to
// a few seconds; it is the scenario that exercises release()'s ordering
// directly, without a listener or a peer.
const path = require("node:path");
const { pbkdf2 } = require("node:crypto");
const root = path.join(__dirname, "..", "..");
const native = require(path.join(root, "dist", "realtime-quic-native.js"));
const RT = native.CultMeshQuicNativeRuntime;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
let saturating = false;
function saturate(n) {
saturating = true;
const one = () => {
if (!saturating) return;
pbkdf2("x", "y", 20000, 32, "sha256", () => one());
};
for (let i = 0; i < n; i += 1) one();
}
function stopSaturate() {
saturating = false;
}
const CYCLES = Number(process.env.UAF_PROBE_CYCLES || 20);
(async () => {
const pool = Number(process.env.UV_THREADPOOL_SIZE || 4);
saturate(pool * 4);
try {
for (let i = 0; i < CYCLES; i += 1) {
const rt = await RT.open();
if (i % 3 === 0) await sleep(Math.random() * 5);
if (i % 2 === 0) {
// Release the old runtime and open a fresh one without waiting for
// the release to finish: the fresh open can land while the old
// runtime is still closing, which is exactly the overlap fix 2's
// ordering protects.
const releasing = rt.release();
const rt2 = await RT.open();
await sleep(Math.random() * 3);
await releasing;
await rt2.release();
} else {
await rt.release();
}
}
} finally {
stopSaturate();
}
if (RT.refCount !== 0) {
console.error(`FAIL: refCount=${RT.refCount} after ${CYCLES} open/release cycles, want 0`);
process.exitCode = 1;
return;
}
console.log(`OK: ${CYCLES} open/release cycles at pool=${pool}, refCount=0`);
})().catch((error) => {
console.error("FAIL: probe threw", error);
process.exitCode = 1;
});

View file

@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"module": "CommonJS",
"moduleResolution": "Node",
"strict": true,
"declaration": true,
"declarationMap": true,

View file

@ -55,19 +55,19 @@ from .transport import (
INTEROP_DOCUMENT_TYPE = "cultnet.interop-note"
INTEROP_SCHEMA_VERSION = "cultnet.interop_note.v0"
INTEROP_MUTATION_INTENT_DOCUMENT_TYPE = "cultnet.interop-note-mutation-intent"
INTEROP_MUTATION_INTENT_SCHEMA_ID = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-note-mutation-intent.schema.json"
INTEROP_MUTATION_INTENT_SCHEMA_ID = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-note-mutation-intent.schema.json"
INTEROP_MUTATION_INTENT_SCHEMA_VERSION = "cultnet.interop_note_mutation_intent.v0"
INTEROP_MUTATION_RECEIPT_DOCUMENT_TYPE = "cultnet.interop-note-mutation-receipt"
INTEROP_MUTATION_RECEIPT_SCHEMA_ID = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-note-mutation-receipt.schema.json"
INTEROP_MUTATION_RECEIPT_SCHEMA_ID = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-note-mutation-receipt.schema.json"
INTEROP_MUTATION_RECEIPT_SCHEMA_VERSION = "cultnet.interop_note_mutation_receipt.v0"
INTEROP_FIRE_COMMAND_DOCUMENT_TYPE = "cultnet.interop-fire-weapon-command"
INTEROP_FIRE_COMMAND_SCHEMA_ID = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-fire-weapon-command.schema.json"
INTEROP_FIRE_COMMAND_SCHEMA_ID = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-fire-weapon-command.schema.json"
INTEROP_FIRE_COMMAND_SCHEMA_VERSION = "cultnet.interop_fire_weapon_command.v0"
INTEROP_FIRE_RECEIPT_DOCUMENT_TYPE = "cultnet.interop-fire-weapon-receipt"
INTEROP_FIRE_RECEIPT_SCHEMA_ID = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-fire-weapon-receipt.schema.json"
INTEROP_FIRE_RECEIPT_SCHEMA_ID = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-fire-weapon-receipt.schema.json"
INTEROP_FIRE_RECEIPT_SCHEMA_VERSION = "cultnet.interop_fire_weapon_receipt.v0"
WITNESS_ARTIFACT_BUNDLE_DOCUMENT_TYPE = "cultnet.witness_artifact_bundle"
WITNESS_ARTIFACT_BUNDLE_SCHEMA_ID = "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.witness-artifact-bundle.schema.json"
WITNESS_ARTIFACT_BUNDLE_SCHEMA_ID = "https://github.com/GameCult/cultnet-ts/contracts/cultnet.witness-artifact-bundle.schema.json"
WITNESS_ARTIFACT_BUNDLE_SCHEMA_VERSION = "cultnet.witness_artifact_bundle.v0"
SIMULATION_FACT_SCHEMA_ID = simulation_fact_document.catalog_entry().schema_id
DISCOVERY_PROBE_SCHEMA_VERSION = "cultnet.discovery_probe.v0"

View file

@ -6,7 +6,7 @@ from dataclasses import dataclass, field
from typing import Any
INTEROP_WIRE_CONTRACT = "cultnet.schema.v0"
CULTNET_SCHEMA_BASE = "https://github.com/GameCult/CultLib/contracts/cultnet"
CULTNET_SCHEMA_BASE = "https://github.com/GameCult/cultnet-ts/contracts"
VERSE_CATALOG_REQUEST = "cultmesh.verse_catalog_request.v0"
PEER_EXCHANGE_REQUEST = "cultmesh.peer_exchange_request.v0"
TRANSPORT_PROFILE_SCHEMA_VERSION = "cultnet.transport_profile.v0"

View file

@ -1677,7 +1677,7 @@ class CultNetTests(unittest.TestCase):
self.assertIn("operations", mutation_contract["properties"])
self.assertEqual(
hello_schema["properties"]["transportProfiles"]["items"]["$ref"],
"https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.transport-profile.schema.json",
"https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json",
)
consensus_schema = json.loads(by_version["cultnet.simulation_consensus_candidate.v0"]["schemaJson"])
@ -1686,7 +1686,7 @@ class CultNetTests(unittest.TestCase):
def test_cultnet_schema_catalog_applies_filters_and_responses(self) -> None:
catalog = wire_message_schema_catalog(include_schema_json=True)
descriptor = catalog.get("https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.document-put-raw.schema.json")
descriptor = catalog.get("https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put-raw.schema.json")
self.assertIsNotNone(descriptor)
assert descriptor is not None
self.assertEqual(descriptor.kind, "wire_message")
@ -1902,7 +1902,7 @@ class CultNetTests(unittest.TestCase):
runtime_kind="python",
display_name="Python Interop Test",
agent_id="python-interop-test-agent",
schema_path=str(Path("contracts/cultnet/interop/cultnet.interop-note.schema.json")),
schema_path=str(Path("packages/cultnet-ts/integration/contracts/cultnet.interop-note.schema.json")),
)
binding = state.bindings["note"]
logged_value = {

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.document-delete.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-delete.schema.json",
"title": "CultNet Document Delete Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.document-mutation-contract.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-mutation-contract.schema.json",
"title": "CultNet Document Mutation Contract",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.document-put-raw.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put-raw.schema.json",
"title": "CultNet Raw Document Put Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.document-put.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put.schema.json",
"title": "CultNet Document Put Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.document-record.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-record.schema.json",
"title": "CultNet Document Record",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.error.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.error.schema.json",
"title": "CultNet Error Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.hello.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.hello.schema.json",
"title": "CultNet Hello Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.login-success.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.login-success.schema.json",
"title": "CultNet Login Success Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.login.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.login.schema.json",
"title": "CultNet Login Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.raw-document-record.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.raw-document-record.schema.json",
"title": "CultNet Raw Document Record",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.register.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.register.schema.json",
"title": "CultNet Register Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.sample-change-name.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.sample-change-name.schema.json",
"title": "CultNet Sample Change Name Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.sample-chat.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.sample-chat.schema.json",
"title": "CultNet Sample Chat Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.schema-catalog-request.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.schema-catalog-request.schema.json",
"title": "CultNet Schema Catalog Request Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.schema-catalog-response.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.schema-catalog-response.schema.json",
"title": "CultNet Schema Catalog Response Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.schema-descriptor.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.schema-descriptor.schema.json",
"title": "CultNet Schema Descriptor",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.shard-catalog-request.schema.json",
"$id": "cultnet.shard_catalog_request.v0",
"type": "object",
"required": ["schemaVersion", "messageId"],
"additionalProperties": false,

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.shard-catalog-response.schema.json",
"$id": "cultnet.shard_catalog_response.v0",
"type": "object",
"required": ["schemaVersion", "messageId", "shards"],
"additionalProperties": false,

View file

@ -0,0 +1,23 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.snapshot-request.schema.json",
"title": "CultNet Snapshot Request Message",
"type": "object",
"required": [
"schemaVersion",
"messageId"
],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.snapshot_request.v0" },
"messageId": { "type": "string", "minLength": 1 },
"schemaIds": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"recordKeys": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
}
}

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.snapshot-response-raw.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.snapshot-response-raw.schema.json",
"title": "CultNet Raw Snapshot Response Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.snapshot-response.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.snapshot-response.schema.json",
"title": "CultNet Snapshot Response Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.transport-profile.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json",
"title": "CultNet Transport Profile",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.verify.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.verify.schema.json",
"title": "CultNet Verify Message",
"type": "object",
"required": [

View file

@ -55,16 +55,16 @@ use std::time::Instant;
const INTEROP_DOCUMENT_TYPE: &str = "cultnet.interop-note";
const INTEROP_SCHEMA_VERSION: &str = "cultnet.interop_note.v0";
const MUTATION_INTENT_TYPE: &str = "cultnet.interop-note-mutation-intent";
const MUTATION_INTENT_SCHEMA_ID: &str = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-note-mutation-intent.schema.json";
const MUTATION_INTENT_SCHEMA_ID: &str = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-note-mutation-intent.schema.json";
const MUTATION_INTENT_SCHEMA_VERSION: &str = "cultnet.interop_note_mutation_intent.v0";
const MUTATION_RECEIPT_TYPE: &str = "cultnet.interop-note-mutation-receipt";
const MUTATION_RECEIPT_SCHEMA_ID: &str = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-note-mutation-receipt.schema.json";
const MUTATION_RECEIPT_SCHEMA_ID: &str = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-note-mutation-receipt.schema.json";
const MUTATION_RECEIPT_SCHEMA_VERSION: &str = "cultnet.interop_note_mutation_receipt.v0";
const FIRE_COMMAND_TYPE: &str = "cultnet.interop-fire-weapon-command";
const FIRE_COMMAND_SCHEMA_ID: &str = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-fire-weapon-command.schema.json";
const FIRE_COMMAND_SCHEMA_ID: &str = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-fire-weapon-command.schema.json";
const FIRE_COMMAND_SCHEMA_VERSION: &str = "cultnet.interop_fire_weapon_command.v0";
const FIRE_RECEIPT_TYPE: &str = "cultnet.interop-fire-weapon-receipt";
const FIRE_RECEIPT_SCHEMA_ID: &str = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-fire-weapon-receipt.schema.json";
const FIRE_RECEIPT_SCHEMA_ID: &str = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-fire-weapon-receipt.schema.json";
const FIRE_RECEIPT_SCHEMA_VERSION: &str = "cultnet.interop_fire_weapon_receipt.v0";
const DISCOVERY_ANNOUNCE_SCHEMA_VERSION: &str = "cultnet.discovery_announce.v0";
const RUDP_INTEROP_CONNECTION_ID: u32 = 0x43554C54;

View file

@ -194,28 +194,28 @@ pub fn builtin_schema_registry() -> Result<CultNetSchemaRegistry> {
for registration in [
schema_registration(
include_str!("../../../contracts/cultnet/cultnet.hello.schema.json"),
include_str!("../contracts/cultnet.hello.schema.json"),
CultNetSchemaKind::WireMessage,
vec![CultNetWireContract::CultNetSchemaV0],
Some("cultnet.hello.v0"),
None,
)?,
schema_registration(
include_str!("../../../contracts/cultnet/cultnet.document-mutation-contract.schema.json"),
include_str!("../contracts/cultnet.document-mutation-contract.schema.json"),
CultNetSchemaKind::SharedContract,
vec![CultNetWireContract::CultNetSchemaV0],
None,
None,
)?,
schema_registration(
include_str!("../../../contracts/cultnet/cultnet.transport-profile.schema.json"),
include_str!("../contracts/cultnet.transport-profile.schema.json"),
CultNetSchemaKind::SharedContract,
vec![CultNetWireContract::CultNetSchemaV0],
Some("cultnet.transport_profile.v0"),
None,
)?,
schema_registration(
include_str!("../../../contracts/cultnet/cultnet.login.schema.json"),
include_str!("../contracts/cultnet.login.schema.json"),
CultNetSchemaKind::WireMessage,
vec![
CultNetWireContract::CultNetSchemaV0,
@ -225,7 +225,7 @@ pub fn builtin_schema_registry() -> Result<CultNetSchemaRegistry> {
None,
)?,
schema_registration(
include_str!("../../../contracts/cultnet/cultnet.register.schema.json"),
include_str!("../contracts/cultnet.register.schema.json"),
CultNetSchemaKind::WireMessage,
vec![
CultNetWireContract::CultNetSchemaV0,
@ -235,7 +235,7 @@ pub fn builtin_schema_registry() -> Result<CultNetSchemaRegistry> {
None,
)?,
schema_registration(
include_str!("../../../contracts/cultnet/cultnet.verify.schema.json"),
include_str!("../contracts/cultnet.verify.schema.json"),
CultNetSchemaKind::WireMessage,
vec![
CultNetWireContract::CultNetSchemaV0,
@ -245,7 +245,7 @@ pub fn builtin_schema_registry() -> Result<CultNetSchemaRegistry> {
None,
)?,
schema_registration(
include_str!("../../../contracts/cultnet/cultnet.login-success.schema.json"),
include_str!("../contracts/cultnet.login-success.schema.json"),
CultNetSchemaKind::WireMessage,
vec![
CultNetWireContract::CultNetSchemaV0,
@ -255,7 +255,7 @@ pub fn builtin_schema_registry() -> Result<CultNetSchemaRegistry> {
None,
)?,
schema_registration(
include_str!("../../../contracts/cultnet/cultnet.error.schema.json"),
include_str!("../contracts/cultnet.error.schema.json"),
CultNetSchemaKind::WireMessage,
vec![
CultNetWireContract::CultNetSchemaV0,
@ -265,7 +265,7 @@ pub fn builtin_schema_registry() -> Result<CultNetSchemaRegistry> {
None,
)?,
schema_registration(
include_str!("../../../contracts/cultnet/cultnet.sample-change-name.schema.json"),
include_str!("../contracts/cultnet.sample-change-name.schema.json"),
CultNetSchemaKind::WireMessage,
vec![
CultNetWireContract::CultNetSchemaV0,
@ -275,7 +275,7 @@ pub fn builtin_schema_registry() -> Result<CultNetSchemaRegistry> {
None,
)?,
schema_registration(
include_str!("../../../contracts/cultnet/cultnet.sample-chat.schema.json"),
include_str!("../contracts/cultnet.sample-chat.schema.json"),
CultNetSchemaKind::WireMessage,
vec![
CultNetWireContract::CultNetSchemaV0,
@ -285,56 +285,56 @@ pub fn builtin_schema_registry() -> Result<CultNetSchemaRegistry> {
None,
)?,
schema_registration(
include_str!("../../../contracts/cultnet/cultnet.document-put.schema.json"),
include_str!("../contracts/cultnet.document-put.schema.json"),
CultNetSchemaKind::WireMessage,
vec![CultNetWireContract::CultNetSchemaV0],
Some("cultnet.document_put.v0"),
None,
)?,
schema_registration(
include_str!("../../../contracts/cultnet/cultnet.document-delete.schema.json"),
include_str!("../contracts/cultnet.document-delete.schema.json"),
CultNetSchemaKind::WireMessage,
vec![CultNetWireContract::CultNetSchemaV0],
Some("cultnet.document_delete.v0"),
None,
)?,
schema_registration(
include_str!("../../../contracts/cultnet/cultnet.raw-document-record.schema.json"),
include_str!("../contracts/cultnet.raw-document-record.schema.json"),
CultNetSchemaKind::SharedContract,
vec![CultNetWireContract::CultNetSchemaV0],
None,
None,
)?,
schema_registration(
include_str!("../../../contracts/cultnet/cultnet.document-put-raw.schema.json"),
include_str!("../contracts/cultnet.document-put-raw.schema.json"),
CultNetSchemaKind::WireMessage,
vec![CultNetWireContract::CultNetSchemaV0],
Some("cultnet.document_put_raw.v0"),
None,
)?,
schema_registration(
include_str!("../../../contracts/cultnet/cultnet.snapshot-request.schema.json"),
include_str!("../contracts/cultnet.snapshot-request.schema.json"),
CultNetSchemaKind::WireMessage,
vec![CultNetWireContract::CultNetSchemaV0],
Some("cultnet.snapshot_request.v0"),
None,
)?,
schema_registration(
include_str!("../../../contracts/cultnet/cultnet.snapshot-response.schema.json"),
include_str!("../contracts/cultnet.snapshot-response.schema.json"),
CultNetSchemaKind::WireMessage,
vec![CultNetWireContract::CultNetSchemaV0],
Some("cultnet.snapshot_response.v0"),
None,
)?,
schema_registration(
include_str!("../../../contracts/cultnet/cultnet.snapshot-response-raw.schema.json"),
include_str!("../contracts/cultnet.snapshot-response-raw.schema.json"),
CultNetSchemaKind::WireMessage,
vec![CultNetWireContract::CultNetSchemaV0],
Some("cultnet.snapshot_response_raw.v0"),
None,
)?,
schema_registration(
include_str!("../../../contracts/cultnet/cultnet.schema-catalog-request.schema.json"),
include_str!("../contracts/cultnet.schema-catalog-request.schema.json"),
CultNetSchemaKind::WireMessage,
vec![
CultNetWireContract::CultNetSchemaV0,
@ -344,7 +344,7 @@ pub fn builtin_schema_registry() -> Result<CultNetSchemaRegistry> {
None,
)?,
schema_registration(
include_str!("../../../contracts/cultnet/cultnet.schema-catalog-response.schema.json"),
include_str!("../contracts/cultnet.schema-catalog-response.schema.json"),
CultNetSchemaKind::WireMessage,
vec![
CultNetWireContract::CultNetSchemaV0,
@ -354,21 +354,21 @@ pub fn builtin_schema_registry() -> Result<CultNetSchemaRegistry> {
None,
)?,
schema_registration(
include_str!("../../../contracts/cultnet/cultnet.shard-catalog-request.schema.json"),
include_str!("../contracts/cultnet.shard-catalog-request.schema.json"),
CultNetSchemaKind::WireMessage,
vec![CultNetWireContract::CultNetSchemaV0],
Some("cultnet.shard_catalog_request.v0"),
None,
)?,
schema_registration(
include_str!("../../../contracts/cultnet/cultnet.shard-catalog-response.schema.json"),
include_str!("../contracts/cultnet.shard-catalog-response.schema.json"),
CultNetSchemaKind::WireMessage,
vec![CultNetWireContract::CultNetSchemaV0],
Some("cultnet.shard_catalog_response.v0"),
None,
)?,
schema_registration(
include_str!("../../../contracts/cultnet/ghostlight.agent-state.schema.json"),
include_str!("../contracts/ghostlight.agent-state.schema.json"),
CultNetSchemaKind::DocumentPayload,
vec![CultNetWireContract::CultNetSchemaV0],
Some("ghostlight.agent_state.v0"),

View file

@ -1759,7 +1759,7 @@ fn cultmesh_facade_exposes_schema_registry_and_shard_catalog_owners() -> Result<
});
assert!(shared_contracts.iter().any(|schema| {
schema.schema_id
== "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.transport-profile.schema.json"
== "https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json"
&& schema.schema_version.as_deref() == Some("cultnet.transport_profile.v0")
}));
@ -2285,7 +2285,7 @@ fn builtin_schema_registry_advertises_canonical_ghostlight_schema_without_inline
assert_eq!(transport_profile.kind, CultNetSchemaKind::SharedContract);
assert_eq!(
transport_profile.schema_id,
"https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.transport-profile.schema.json"
"https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json"
);
assert_eq!(
transport_profile.wire_contracts,

View file

@ -2,6 +2,3 @@ node_modules/
dist/
dist-test/
*.tgz
# Mirrored from CultLib/contracts/cultnet by tools/generate-swarm-contracts.mjs
# (REC-032). Not tracked: CultLib/contracts/cultnet is the only source.
contracts/

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultmesh.session-accepted.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.session-accepted.schema.json",
"title": "CultMesh Session Accepted",
"type": "object",
"required": ["schemaVersion", "messageId", "accepted", "verseId", "authorityRuntimeId", "protocolId", "routeGeneration", "clientNonce", "providerKeyId", "providerSignature"],

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultmesh.session-open.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.session-open.schema.json",
"title": "CultMesh Session Open",
"type": "object",
"required": ["schemaVersion", "messageId", "sourceRuntimeId", "verseId", "authorityRuntimeId", "protocolId", "routeGeneration", "clientNonce"],

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultmesh.verse-catalog-request.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.verse-catalog-request.schema.json",
"title": "CultMesh Verse Catalog Request",
"type": "object",
"required": ["schemaVersion", "messageId"],

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultmesh.verse-catalog-response.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.verse-catalog-response.schema.json",
"title": "CultMesh Verse Catalog Response",
"type": "object",
"required": ["schemaVersion", "messageId", "verses"],

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.database-change-raw.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.database-change-raw.schema.json",
"title": "CultNet Raw Database Change Message",
"type": "object",
"required": ["schemaVersion", "messageId", "subscriptionId", "changeKind"],

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.database-subscribe.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.database-subscribe.schema.json",
"title": "CultNet Database Subscribe Message",
"type": "object",
"required": ["schemaVersion", "messageId", "subscriptionId", "includeSnapshot"],

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.database-unsubscribe.schema.json",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.database-unsubscribe.schema.json",
"title": "CultNet Database Unsubscribe Message",
"type": "object",
"required": ["schemaVersion", "messageId", "subscriptionId"],

View file

@ -0,0 +1,19 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-delete.schema.json",
"title": "CultNet Document Delete Message",
"type": "object",
"required": [
"schemaVersion",
"messageId",
"schemaId",
"recordKey"
],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.document_delete.v0" },
"messageId": { "type": "string", "minLength": 1 },
"schemaId": { "type": "string", "minLength": 1 },
"recordKey": { "type": "string", "minLength": 1 }
}
}

View file

@ -0,0 +1,40 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-mutation-contract.schema.json",
"title": "CultNet Document Mutation Contract",
"type": "object",
"required": [
"documentType",
"operations",
"authority"
],
"additionalProperties": false,
"properties": {
"documentType": { "type": "string", "minLength": 1 },
"payloadSchemaVersion": { "type": "string", "minLength": 1 },
"operations": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"enum": ["snapshot", "documentPut", "documentDelete", "intentSubmit", "receiptWatch"]
}
},
"authority": {
"type": "string",
"enum": ["readOnly", "localUser", "coordinator", "runtime", "denied"]
},
"intentDocumentTypes": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"receiptDocumentTypes": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"notes": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
}
}

View file

@ -0,0 +1,19 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put-raw.schema.json",
"title": "CultNet Raw Document Put Message",
"type": "object",
"required": [
"schemaVersion",
"messageId",
"document"
],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.document_put_raw.v0" },
"messageId": { "type": "string", "minLength": 1 },
"document": {
"$ref": "./cultnet.raw-document-record.schema.json"
}
}
}

View file

@ -0,0 +1,19 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put.schema.json",
"title": "CultNet Document Put Message",
"type": "object",
"required": [
"schemaVersion",
"messageId",
"document"
],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.document_put.v0" },
"messageId": { "type": "string", "minLength": 1 },
"document": {
"$ref": "./cultnet.document-record.schema.json"
}
}
}

Some files were not shown because too many files have changed in this diff Show more