Compare commits
254 changed files with 3267 additions and 28758 deletions
13
.gitattributes
vendored
13
.gitattributes
vendored
|
|
@ -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
|
||||
|
|
|
|||
43
.github/workflows/cultmesh-portability.yml
vendored
43
.github/workflows/cultmesh-portability.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
26
.github/workflows/cultnet-interop.yml
vendored
26
.github/workflows/cultnet-interop.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
40
.github/workflows/publish-packages.yml
vendored
40
.github/workflows/publish-packages.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=="
|
||||
}
|
||||
}
|
||||
|
|
@ -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=="
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.database-subscribe.v1.schema.json",
|
||||
"title": "CultNet Database Subscribe Message v1",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "messageId", "subscriptionId", "selection"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.database_subscribe.v1" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"subscriptionId": { "type": "string", "minLength": 1 },
|
||||
"selection": { "$ref": "cultnet.selection.schema.json" },
|
||||
"includeSnapshot": { "type": "boolean", "default": true },
|
||||
"consumerRuntimeId": { "type": "string", "minLength": 1 },
|
||||
"bodyIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"supportedBodyTransports": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.error.schema.json",
|
||||
"title": "CultNet Error Message",
|
||||
"description": "R-X (docs/cultnet-selection-cut.md, fix batch 4): CultNetErrorMessage is a MessagePack-CSharp map-mode object, so every declared key is written on the wire, nil included - routingHint, code and details are `required` here the same as cultnet.raw-document-header.schema.json's nullable fields, not optional just because a given refusal leaves them null.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"error",
|
||||
"routingHint",
|
||||
"code",
|
||||
"details"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.error.v0" },
|
||||
"error": { "type": "string", "minLength": 1 },
|
||||
"routingHint": {
|
||||
"description": "Optional shard routing detail for an authority failure. Null for every selection/cursor refusal code (R-N).",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"required": ["shard", "reason"],
|
||||
"properties": {
|
||||
"shard": { "type": ["object", "null"] },
|
||||
"reason": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"code": {
|
||||
"description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it.",
|
||||
"type": ["string", "null"],
|
||||
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target"]
|
||||
},
|
||||
"details": {
|
||||
"description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"required": ["field", "value", "asOf", "current"],
|
||||
"properties": {
|
||||
"field": { "description": "selection_invalid: the selection field that failed.", "type": ["string", "null"] },
|
||||
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming.", "type": ["string", "null"] },
|
||||
"asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 },
|
||||
"current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.raw-document-header.schema.json",
|
||||
"title": "CultNet Raw Document Header",
|
||||
"description": "R-S (docs/cultnet-selection-cut.md, fix batch 3): CultNetRawDocumentRecord minus payload/payloadEncoding - CultNetRawDocumentHeader has no such members at all, so a header must not $ref the record schema (which requires them). Matches MessagePack-CSharp's map-mode output: every declared key is written, nil included, so every property here is `required` even where its value may be null.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaId",
|
||||
"schemaName",
|
||||
"schemaVersion",
|
||||
"schemaContentHash",
|
||||
"recordKey",
|
||||
"storedAt",
|
||||
"sourceRuntimeId",
|
||||
"sourceAgentId",
|
||||
"sourceRole",
|
||||
"tags"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaId": { "type": "string", "minLength": 1 },
|
||||
"schemaName": { "type": ["string", "null"], "minLength": 1 },
|
||||
"schemaVersion": { "type": ["string", "null"], "minLength": 1 },
|
||||
"schemaContentHash": { "type": ["string", "null"], "minLength": 1 },
|
||||
"recordKey": { "type": "string", "minLength": 1 },
|
||||
"storedAt": { "type": "string", "minLength": 1 },
|
||||
"sourceRuntimeId": { "type": ["string", "null"], "minLength": 1 },
|
||||
"sourceAgentId": { "type": ["string", "null"], "minLength": 1 },
|
||||
"sourceRole": { "type": ["string", "null"], "minLength": 1 },
|
||||
"tags": {
|
||||
"type": ["array", "null"],
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.raw-document-record.schema.json",
|
||||
"title": "CultNet Raw Document Record",
|
||||
"description": "R-S (docs/cultnet-selection-cut.md, fix batch 3): matches MessagePack-CSharp's map-mode output for CultNetRawDocumentRecord - every declared key is written, nil included, so every property here is `required` (present on the wire) even where its value may be null.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaId",
|
||||
"schemaName",
|
||||
"schemaVersion",
|
||||
"schemaContentHash",
|
||||
"recordKey",
|
||||
"storedAt",
|
||||
"payloadEncoding",
|
||||
"payload",
|
||||
"sourceRuntimeId",
|
||||
"sourceAgentId",
|
||||
"sourceRole",
|
||||
"tags"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaId": { "type": "string", "minLength": 1 },
|
||||
"schemaName": { "type": ["string", "null"], "minLength": 1 },
|
||||
"schemaVersion": { "type": ["string", "null"], "minLength": 1 },
|
||||
"schemaContentHash": { "type": ["string", "null"], "minLength": 1 },
|
||||
"recordKey": { "type": "string", "minLength": 1 },
|
||||
"storedAt": { "type": "string", "minLength": 1 },
|
||||
"payloadEncoding": { "const": "messagepack" },
|
||||
"payload": {
|
||||
"type": "string",
|
||||
"contentEncoding": "base64",
|
||||
"x-cult-runtime-type": "bytes"
|
||||
},
|
||||
"sourceRuntimeId": { "type": ["string", "null"], "minLength": 1 },
|
||||
"sourceAgentId": { "type": ["string", "null"], "minLength": 1 },
|
||||
"sourceRole": { "type": ["string", "null"], "minLength": 1 },
|
||||
"tags": {
|
||||
"type": ["array", "null"],
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.selection.schema.json",
|
||||
"title": "CultNet Typed Selection",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemas": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 },
|
||||
"minItems": 1,
|
||||
"description": "Absent means every schema. Present and empty is refused at the door (selection_invalid), never treated as \"every schema\": omit the field instead."
|
||||
},
|
||||
"keys": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 },
|
||||
"minItems": 1,
|
||||
"description": "Absent means every key. Present and empty is refused at the door (selection_invalid), never treated as \"every key\": omit the field instead."
|
||||
},
|
||||
"fields": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/fieldPredicate" }
|
||||
},
|
||||
"cites": { "$ref": "#/$defs/citation" },
|
||||
"cited": { "$ref": "#/$defs/incoming" },
|
||||
"projection": {
|
||||
"type": "string",
|
||||
"enum": ["header", "document"],
|
||||
"default": "header"
|
||||
},
|
||||
"descending": { "type": "boolean", "default": false },
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 200,
|
||||
"description": "Clamped to 1..=200 by the answering server, not refused outside that range: an absent value or one outside the bound is treated as the nearer bound (absent = 200)."
|
||||
},
|
||||
"cursor": { "type": "string" }
|
||||
},
|
||||
"$defs": {
|
||||
"recordRef": {
|
||||
"type": "object",
|
||||
"required": ["schemaId", "recordKey"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaId": { "type": "string", "minLength": 1 },
|
||||
"recordKey": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
},
|
||||
"fieldPredicate": {
|
||||
"type": "object",
|
||||
"required": ["index", "op"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"index": { "type": "string", "minLength": 1 },
|
||||
"op": { "type": "string", "enum": ["any_of", "lt", "le", "ge", "gt"] },
|
||||
"values": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"minItems": 1
|
||||
},
|
||||
"number": {
|
||||
"type": "string",
|
||||
"description": "Q-J: a canonical decimal string, never a JSON number. Matches ^-?(0|[1-9][0-9]*)(\\.[0-9]*[1-9])?$ and is not \"-0\"; any other spelling is refused typed at the door (selection_invalid), never normalised.",
|
||||
"pattern": "^(?!-0$)-?(0|[1-9][0-9]*)(\\.[0-9]*[1-9])?$"
|
||||
}
|
||||
},
|
||||
"oneOf": [
|
||||
{
|
||||
"required": ["values"],
|
||||
"not": { "required": ["number"] },
|
||||
"properties": { "op": { "const": "any_of" } }
|
||||
},
|
||||
{
|
||||
"required": ["number"],
|
||||
"not": { "required": ["values"] },
|
||||
"properties": { "op": { "enum": ["lt", "le", "ge", "gt"] } }
|
||||
}
|
||||
]
|
||||
},
|
||||
"citation": {
|
||||
"type": "object",
|
||||
"required": ["target"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"target": { "$ref": "#/$defs/recordRef" },
|
||||
"role": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
},
|
||||
"incoming": {
|
||||
"type": "object",
|
||||
"required": ["role", "exists"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"role": { "type": "string", "minLength": 1 },
|
||||
"exists": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.snapshot-request.v1.schema.json",
|
||||
"title": "CultNet Snapshot Request Message v1",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "messageId", "selection"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.snapshot_request.v1" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"selection": { "$ref": "cultnet.selection.schema.json" },
|
||||
"shardId": { "type": "string", "minLength": 1 },
|
||||
"shardEpoch": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.snapshot-response-raw.v1.schema.json",
|
||||
"title": "CultNet Snapshot Response Raw Message v1",
|
||||
"description": "R-S (docs/cultnet-selection-cut.md, fix batch 3): matches MessagePack-CSharp's map-mode output for CultNetSnapshotResponseRawV1Message - every declared key is written, nil included, so every property is `required` (present on the wire) even where its value may be null. `headers`/`documents` are still exactly one populated by projection (C5), but the OTHER key is present too, as an explicit null, not absent - the prior `oneOf: [{required:[headers]},{required:[documents]}]` matched both branches on every real page (both keys always present) and so never validated at all.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion", "messageId", "matched", "asOf", "next",
|
||||
"headers", "documents", "edges", "shardId", "shardEpoch", "shardLogSequence"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"oneOf": [
|
||||
{
|
||||
"required": ["headers", "documents"],
|
||||
"properties": { "headers": { "type": "array" }, "documents": { "type": "null" } }
|
||||
},
|
||||
{
|
||||
"required": ["headers", "documents"],
|
||||
"properties": { "headers": { "type": "null" }, "documents": { "type": "array" } }
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.snapshot_response_raw.v1" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"matched": { "type": "integer", "minimum": 0 },
|
||||
"asOf": { "type": "integer", "minimum": 0 },
|
||||
"next": { "type": ["string", "null"] },
|
||||
"headers": {
|
||||
"type": ["array", "null"],
|
||||
"items": { "$ref": "cultnet.raw-document-header.schema.json" }
|
||||
},
|
||||
"documents": {
|
||||
"type": ["array", "null"],
|
||||
"items": { "$ref": "cultnet.raw-document-record.schema.json" }
|
||||
},
|
||||
"edges": {
|
||||
"type": ["array", "null"],
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["from", "role", "to", "payloadEncoding", "payload"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"from": { "$ref": "cultnet.selection.schema.json#/$defs/recordRef" },
|
||||
"role": { "type": "string", "minLength": 1 },
|
||||
"to": { "$ref": "cultnet.selection.schema.json#/$defs/recordRef" },
|
||||
"payloadEncoding": { "type": ["string", "null"] },
|
||||
"payload": {
|
||||
"type": ["string", "null"],
|
||||
"contentEncoding": "base64",
|
||||
"x-cult-runtime-type": "bytes"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"shardId": { "type": ["string", "null"], "minLength": 1 },
|
||||
"shardEpoch": { "type": ["integer", "null"] },
|
||||
"shardLogSequence": { "type": ["integer", "null"] }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,334 +0,0 @@
|
|||
{
|
||||
"vectors": [
|
||||
{
|
||||
"name": "any_of_plus_ge_conjunction",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHOShKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORpndlYXBvbqZudW1iZXLAhKVpbmRleKRtYXNzom9womdlpnZhbHVlc8CmbnVtYmVyoTWlY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq"
|
||||
],
|
||||
"matched": 1,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "descending_with_limit",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHPApWNpdGVzwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8OlbGltaXQDpmN1cnNvcsA=",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema",
|
||||
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/zz-tie-schema"
|
||||
],
|
||||
"matched": 18,
|
||||
"hasNext": true,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "gt_numeric_boundary",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRtYXNzom9womd0pnZhbHVlc8CmbnVtYmVyoTGlY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
|
||||
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/b-hi",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass"
|
||||
],
|
||||
"matched": 4,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "incoming_exists_true",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzkdlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaka2V5c8CmZmllbGRzwKVjaXRlc8ClY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8OqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key"
|
||||
],
|
||||
"matched": 2,
|
||||
"hasNext": false,
|
||||
"expectedEdges": [
|
||||
{
|
||||
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1",
|
||||
"role": "Design",
|
||||
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
|
||||
"payloadBase64": null
|
||||
},
|
||||
{
|
||||
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target",
|
||||
"role": "Design",
|
||||
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
|
||||
"payloadBase64": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "incoming_negation",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzkdlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaka2V5c8CmZmllbGRzwKVjaXRlc8ClY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8KqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/\uD83D\uDE02-row",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema"
|
||||
],
|
||||
"matched": 7,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "shared_index_value_three_rows",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORpnNoYXJlZKZudW1iZXLApWNpdGVzwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1",
|
||||
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/shared-2",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3"
|
||||
],
|
||||
"matched": 3,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "float_tie_exact_ge",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRtYXNzom9womdlpnZhbHVlc8CmbnVtYmVyqjIyMzM3NTkuMjWlY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass"
|
||||
],
|
||||
"matched": 1,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "hop_by_role_cites_exact_id",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkadjaXRlci0xpmZpZWxkc8ClY2l0ZXOCpnRhcmdldIKoc2NoZW1hSWTZR3NoYTI1NjpmZjM1ZjNkOWQ2ZGUzZjEwNjZiOTM2OWU3M2ZlNmM3NjQ4ZTg4MzY4ZDE4ZWZjMjI3NWM5OTQ3MzBjZDg0Y2ZmqXJlY29yZEtleaRhLWVxpHJvbGWmRGVzaWdupWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1"
|
||||
],
|
||||
"matched": 1,
|
||||
"hasNext": false,
|
||||
"expectedEdges": [
|
||||
{
|
||||
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1",
|
||||
"role": "Design",
|
||||
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
|
||||
"payloadBase64": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "cites_target_by_name_alias",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkadjaXRlci0xpmZpZWxkc8ClY2l0ZXOCpnRhcmdldIKoc2NoZW1hSWSpbGVhZl9hLnY5qXJlY29yZEtleaRhLWVxpHJvbGWmRGVzaWdupWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1"
|
||||
],
|
||||
"matched": 1,
|
||||
"hasNext": false,
|
||||
"expectedEdges": [
|
||||
{
|
||||
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1",
|
||||
"role": "Design",
|
||||
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
|
||||
"payloadBase64": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "schemas_by_name_alias",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzkalsZWFmX2Eudjmka2V5c8CmZmllbGRzwKVjaXRlc8ClY2l0ZWTAqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfCpWxpbWl0wKZjdXJzb3LA",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/\uD83D\uDE02-row",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema"
|
||||
],
|
||||
"matched": 9,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "astral_key_code_point_order",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORp3VuaWNvZGWmbnVtYmVywKVjaXRlc8ClY2l0ZWTAqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfCpWxpbWl0wKZjdXJzb3LA",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/\uD83D\uDE02-row"
|
||||
],
|
||||
"matched": 2,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "header_projection",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORp3VuaWNvZGWmbnVtYmVywKVjaXRlc8ClY2l0ZWTAqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfCpWxpbWl0wKZjdXJzb3LA",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/\uD83D\uDE02-row"
|
||||
],
|
||||
"matched": 2,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "document_projection",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORp3VuaWNvZGWmbnVtYmVywKVjaXRlc8ClY2l0ZWTAqnByb2plY3Rpb26oZG9jdW1lbnSqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/\uD83D\uDE02-row"
|
||||
],
|
||||
"matched": 2,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "row_tiebreak_schema_id_before_record_key_at_tied_ordinal",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORqnRpZS1zY2hlbWGmbnVtYmVywKVjaXRlc8ClY2l0ZWTAqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfCpWxpbWl0wKZjdXJzb3LA",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/zz-tie-schema",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema"
|
||||
],
|
||||
"matched": 2,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "shared_key_narrow_ref_resolves_leaf_within_target",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkbVuYXJyb3ctY2l0ZXItYWNjZXB0ZWSmZmllbGRzwKVjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5p2R1cC1rZXmkcm9sZapuYXJyb3dfcmVmpWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d/narrow-citer-accepted"
|
||||
],
|
||||
"matched": 1,
|
||||
"hasNext": false,
|
||||
"expectedEdges": [
|
||||
{
|
||||
"fromId": "sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d/narrow-citer-accepted",
|
||||
"role": "narrow_ref",
|
||||
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
|
||||
"payloadBase64": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "shared_key_design_resolves_the_first_in_target_candidate_in_row_order",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkbRjaXRlci1ib3RoLWluLXRhcmdldKZmaWVsZHPApWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqlyZWNvcmRLZXmnZHVwLWtleaRyb2xlpkRlc2lnbqVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [
|
||||
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target"
|
||||
],
|
||||
"matched": 1,
|
||||
"hasNext": false,
|
||||
"expectedEdges": [
|
||||
{
|
||||
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target",
|
||||
"role": "Design",
|
||||
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
|
||||
"payloadBase64": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "refuses_empty_schemas_list",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzkKRrZXlzwKZmaWVsZHPApWNpdGVzwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": "schemas",
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [],
|
||||
"matched": 0,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "refuses_blank_key_entry",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkqRhLWVxoyAgIKZmaWVsZHPApWNpdGVzwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": "keys",
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [],
|
||||
"matched": 0,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "refuses_unmatched_cites_target_schema",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHPApWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlkvHNoYTI1Njpub3QtYS1kZWNsYXJlZC1zY2hlbWGpcmVjb3JkS2V5pGEtZXGkcm9sZcClY2l0ZWTAqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfCpWxpbWl0wKZjdXJzb3LA",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": "cites.target.schemaId",
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [],
|
||||
"matched": 0,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "refuses_cites_target_by_hash_shaped_alias",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHPApWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UpzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZi52MalyZWNvcmRLZXmkYS1lcaRyb2xlwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": "cites.target.schemaId",
|
||||
"expectedEvaluationRefusal": null,
|
||||
"expectedIds": [],
|
||||
"matched": 0,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "shared_key_narrow_ref_refuses_when_only_candidate_is_outside_target",
|
||||
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkbRuYXJyb3ctY2l0ZXItcmVmdXNlZKZmaWVsZHPApWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqlyZWNvcmRLZXmrbmFycm93LW9ubHmkcm9sZapuYXJyb3dfcmVmpWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": null,
|
||||
"expectedEvaluationRefusal": "reference_outside_target",
|
||||
"expectedIds": [],
|
||||
"matched": 0,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
{
|
||||
"//": "CultNet typed selection, Cut 1 fix batch (docs/cultnet-selection-cut.md, Self's rulings for the Cut 1 fix batch, R-E and the shared-fixture ruling, 2026-09-22). The one row set both the C# reference and cultnet-rs build their parity vectors from. schemaId is each schema's real SHA-256 content-hash id, exactly as GameCult.Caching.CultDocumentRegistry computes it for the fixture's C# document types (leaf_a=ParityLeafA, leaf_b=ParityLeafB, citer=ParityCiter, citer_narrow=ParityCiterNarrow, tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs) - CultCache.cs's Sha256(semanticFingerprint), which carries no '.vN' suffix of its own; the version is folded into the hash, never appended as text. Both writers read this file instead of building their own fixture rows.",
|
||||
"//aliases": "Two DIFFERENT alias forms, now deliberate rather than a cross-runtime defect (S-12, 2026-09-22: the Rust alias port made this so - see docs/cultnet-selection-cut.md, 'Self's ruling, 2026-09-22: the C# reference's alias rule is the rule'). Both runtimes resolve a schema alias the same one way: strip a trailing .vN and compare the remainder against the descriptor's declared SchemaName (CultNetSchemaAliasMatching.Matches(candidate, CultDocumentDescriptor), src/GameCult.Networking/CultNetDatabase.cs:74-87; cultnet_rs::selection::schema_alias::matches ports it name-for-name and deletes the hash-stripping overload it used to carry). nameAlias is <SchemaName>.v9 and resolves through both runtimes under that one rule. hashAlias is <schemaId>.v1 and is refused as unmatched by both - it is not a wire form either runtime's production call sites ever produce or accept, and it is kept in the fixture specifically to pin that neither runtime resolves it.",
|
||||
"//references": "R-AF, 2026-09-23: a reference's `references[].targetKey` is the only thing a stored edge actually carries on the wire - GameCult.Caching.MessagePack.CultRecordRefFormatter<T> serializes a CultRecordRef<T> as a bare key string (probed: MessagePackSerializer.Serialize on a real Design field produced the single msgpack string 'a-eq', nothing else). This fixture used to also carry a `targetSchema` per reference; it was never anything but documentation - SelectionParityVectorTests.BuildDocument and cultnet-rs's build_row both read only targetKey to construct the edge - and it is removed rather than kept as a field the wire does not have. Resolving which row an edge names is by targetKey alone, filtered to the declared role's target leaves (D9); see citer_narrow below for the shared-key shape that rule is pinned against.",
|
||||
"schemas": {
|
||||
"leaf_a": {
|
||||
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
|
||||
"nameAlias": "leaf_a.v9",
|
||||
"hashAlias": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff.v1",
|
||||
"indexes": [
|
||||
{ "name": "kind", "numeric": false },
|
||||
{ "name": "mass", "numeric": true }
|
||||
]
|
||||
},
|
||||
"leaf_b": {
|
||||
"schemaId": "sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42",
|
||||
"nameAlias": "leaf_b.v9",
|
||||
"hashAlias": "sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42.v1",
|
||||
"indexes": [
|
||||
{ "name": "kind", "numeric": false },
|
||||
{ "name": "mass", "numeric": true }
|
||||
]
|
||||
},
|
||||
"citer": {
|
||||
"schemaId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc",
|
||||
"nameAlias": "citer.v9",
|
||||
"hashAlias": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc.v1",
|
||||
"roles": [
|
||||
{ "name": "Design", "targets": ["leaf_a", "leaf_b"] }
|
||||
]
|
||||
},
|
||||
"citer_narrow": {
|
||||
"//": "R-AF: a citer whose declared reference target is one leaf only (leaf_a), so a record key shared by a leaf_a row and a leaf_b row has exactly one candidate inside the declared target and exactly one outside it - the shape neither runtime's parity vectors covered before this cut (zero duplicate record keys across the original ten rows).",
|
||||
"schemaId": "sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d",
|
||||
"nameAlias": "citer_narrow.v9",
|
||||
"hashAlias": "sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d.v1",
|
||||
"roles": [
|
||||
{ "name": "narrow_ref", "targets": ["leaf_a"] }
|
||||
]
|
||||
}
|
||||
},
|
||||
"rows": [
|
||||
{ "schema": "leaf_a", "key": "a-lo", "ordinal": 1, "fields": { "kind": "weapon", "mass": "4" } },
|
||||
{ "schema": "leaf_a", "key": "a-eq", "ordinal": 2, "fields": { "kind": "weapon", "mass": "5" } },
|
||||
{ "schema": "leaf_b", "key": "b-hi", "ordinal": 3, "fields": { "kind": "armor", "mass": "6" } },
|
||||
{ "schema": "leaf_a", "key": "shared-1", "ordinal": 4, "fields": { "kind": "shared", "mass": "1" } },
|
||||
{ "schema": "leaf_b", "key": "shared-2", "ordinal": 5, "fields": { "kind": "shared", "mass": "1" } },
|
||||
{ "schema": "leaf_a", "key": "shared-3", "ordinal": 6, "fields": { "kind": "shared", "mass": "1" } },
|
||||
{
|
||||
"schema": "citer",
|
||||
"key": "citer-1",
|
||||
"ordinal": 7,
|
||||
"references": [ { "role": "Design", "targetKey": "a-eq" } ]
|
||||
},
|
||||
{
|
||||
"//": "R-D: 2233759.25 is exactly representable as an f32 and sits on a round-to-even tie between two shortest-form spellings; its exact canonical decimal is 2233759.25 either way. kind=tie so no weapon/armor/shared case can sweep it up.",
|
||||
"schema": "leaf_a", "key": "tie-mass", "ordinal": 8, "fields": { "kind": "tie", "mass": "2233759.25" }
|
||||
},
|
||||
{
|
||||
"//": "R-C: BMP private-use key (U+E000), same ordinal as the astral row below so the tiebreak - not the ordinal - decides order. kind=unicode, mass=0 so no unfiltered or mass>1 case reaches these.",
|
||||
"schema": "leaf_a", "key": "-row", "ordinal": 9, "fields": { "kind": "unicode", "mass": "0" }
|
||||
},
|
||||
{
|
||||
"//": "R-C: astral key (U+1F602, encoded as a surrogate pair whose high half 0xD83D is less than 0xE000 - a UTF-16 code-unit compare sorts this first; code-point order sorts it after the BMP row above).",
|
||||
"schema": "leaf_a", "key": "😂-row", "ordinal": 9, "fields": { "kind": "unicode", "mass": "0" }
|
||||
},
|
||||
{
|
||||
"//": "R-AF shape 1: the only row at this key is outside citer_narrow's declared target (leaf_a only) - resolving the edge below must refuse reference_outside_target in both runtimes, not silently accept an empty page. kind=narrow-fixture (not \"shared\") so this row never sweeps into shared_index_value_three_rows, which pins an exact count of 3.",
|
||||
"schema": "leaf_b", "key": "narrow-only", "ordinal": 10, "fields": { "kind": "narrow-fixture", "mass": "1" }
|
||||
},
|
||||
{
|
||||
"//": "R-AF shape 2: two rows share this key, one inside citer_narrow's declared target and one outside it - resolving the edge below must accept, deterministically picking the leaf_a row, in both runtimes.",
|
||||
"schema": "leaf_a", "key": "dup-key", "ordinal": 11, "fields": { "kind": "narrow-fixture", "mass": "1" }
|
||||
},
|
||||
{
|
||||
"schema": "leaf_b", "key": "dup-key", "ordinal": 12, "fields": { "kind": "narrow-fixture", "mass": "1" }
|
||||
},
|
||||
{
|
||||
"schema": "citer_narrow",
|
||||
"key": "narrow-citer-refused",
|
||||
"ordinal": 13,
|
||||
"references": [ { "role": "narrow_ref", "targetKey": "narrow-only" } ]
|
||||
},
|
||||
{
|
||||
"schema": "citer_narrow",
|
||||
"key": "narrow-citer-accepted",
|
||||
"ordinal": 14,
|
||||
"references": [ { "role": "narrow_ref", "targetKey": "dup-key" } ]
|
||||
},
|
||||
{
|
||||
"//": "R-AJ: a tied ordinal across two DIFFERENT schemas (leaf_a and leaf_b), not two rows of the same leaf like the astral/BMP pair above - the shape the row order's schemaId tiebreak needs and the original ten rows never had. Keys are anti-correlated with the real schema ids on purpose: leaf_b's real id sorts before leaf_a's (sha256:b1f2... < sha256:ff35...), so leaf_b gets the alphabetically-*later* key (\"zz-tie-schema\") and leaf_a gets the *earlier* one (\"aa-tie-schema\") - a schemaId-first order and a recordKey-first order disagree here on purpose, so only the correct composition produces leaf_b's row first.",
|
||||
"schema": "leaf_b", "key": "zz-tie-schema", "ordinal": 15, "fields": { "kind": "tie-schema", "mass": "1" }
|
||||
},
|
||||
{
|
||||
"schema": "leaf_a", "key": "aa-tie-schema", "ordinal": 15, "fields": { "kind": "tie-schema", "mass": "1" }
|
||||
},
|
||||
{
|
||||
"//": "R-AO, 2026-09-23: 'dup-key' already carries two rows (leaf_a ordinal 11, leaf_b ordinal 12) - both are in citer's Design role's declared target ([leaf_a, leaf_b], unlike citer_narrow's narrow_ref above, which only ever had one in-target candidate at a shared key). This citer is the first row in this file (hence in both runtimes' row order) to name 'dup-key' through Design, so resolving it must deterministically pick the leaf_a row - the first in-target candidate in the caller's order - never the leaf_b row, in both runtimes.",
|
||||
"schema": "citer",
|
||||
"key": "citer-both-in-target",
|
||||
"ordinal": 16,
|
||||
"references": [ { "role": "Design", "targetKey": "dup-key" } ]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,282 +0,0 @@
|
|||
{
|
||||
"vectors": [
|
||||
{
|
||||
"name": "any_of_plus_ge_conjunction",
|
||||
"selectionMessagePackBase64": "g6ZmaWVsZHOSg6VpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORpndlYXBvboOlaW5kZXikbWFzc6JvcKJnZaZudW1iZXKhNapwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq"
|
||||
],
|
||||
"matched": 1,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "descending_with_limit",
|
||||
"selectionMessagePackBase64": "g6pwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nw6VsaW1pdAM=",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema",
|
||||
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/zz-tie-schema"
|
||||
],
|
||||
"matched": 18,
|
||||
"hasNext": true,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "gt_numeric_boundary",
|
||||
"selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRtYXNzom9womd0pm51bWJlcqExqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfC",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
|
||||
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/b-hi",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass"
|
||||
],
|
||||
"matched": 4,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "incoming_exists_true",
|
||||
"selectionMessagePackBase64": "hKdzY2hlbWFzkdlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmalY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8OqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key"
|
||||
],
|
||||
"matched": 2,
|
||||
"hasNext": false,
|
||||
"expectedEdges": [
|
||||
{
|
||||
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1",
|
||||
"role": "Design",
|
||||
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
|
||||
"payloadBase64": null
|
||||
},
|
||||
{
|
||||
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target",
|
||||
"role": "Design",
|
||||
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
|
||||
"payloadBase64": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "incoming_negation",
|
||||
"selectionMessagePackBase64": "hKdzY2hlbWFzkdlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmalY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8KqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/😂-row",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema"
|
||||
],
|
||||
"matched": 7,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "shared_index_value_three_rows",
|
||||
"selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORpnNoYXJlZKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1",
|
||||
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/shared-2",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3"
|
||||
],
|
||||
"matched": 3,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "hop_by_role_cites_exact_id",
|
||||
"selectionMessagePackBase64": "hKRrZXlzkadjaXRlci0xpWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqlyZWNvcmRLZXmkYS1lcaRyb2xlpkRlc2lnbqpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1"
|
||||
],
|
||||
"matched": 1,
|
||||
"hasNext": false,
|
||||
"expectedEdges": [
|
||||
{
|
||||
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1",
|
||||
"role": "Design",
|
||||
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
|
||||
"payloadBase64": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "hop_by_role_cites_name_alias",
|
||||
"selectionMessagePackBase64": "hKRrZXlzkadjaXRlci0xpWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlkqWxlYWZfYS52OalyZWNvcmRLZXmkYS1lcaRyb2xlpkRlc2lnbqpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1"
|
||||
],
|
||||
"matched": 1,
|
||||
"hasNext": false,
|
||||
"expectedEdges": [
|
||||
{
|
||||
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1",
|
||||
"role": "Design",
|
||||
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
|
||||
"payloadBase64": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "schemas_by_hash_alias",
|
||||
"selectionMessagePackBase64": "g6dzY2hlbWFzkdlKc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmYudjGqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
|
||||
"asOf": 1,
|
||||
"expectedIds": [],
|
||||
"matched": 0,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "schemas_by_name_alias",
|
||||
"selectionMessagePackBase64": "g6dzY2hlbWFzkalsZWFmX2EudjmqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/😂-row",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema"
|
||||
],
|
||||
"matched": 9,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "float_tie_exact_ge",
|
||||
"selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRtYXNzom9womdlpm51bWJlcqoyMjMzNzU5LjI1qnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfC",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass"
|
||||
],
|
||||
"matched": 1,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "astral_key_code_point_order",
|
||||
"selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORp3VuaWNvZGWqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/😂-row"
|
||||
],
|
||||
"matched": 2,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "row_tiebreak_schema_id_before_record_key_at_tied_ordinal",
|
||||
"selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORqnRpZS1zY2hlbWGqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/zz-tie-schema",
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema"
|
||||
],
|
||||
"matched": 2,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "shared_key_narrow_ref_resolves_leaf_within_target",
|
||||
"selectionMessagePackBase64": "hKRrZXlzkbVuYXJyb3ctY2l0ZXItYWNjZXB0ZWSlY2l0ZXOCpnRhcmdldIKoc2NoZW1hSWTZR3NoYTI1NjpmZjM1ZjNkOWQ2ZGUzZjEwNjZiOTM2OWU3M2ZlNmM3NjQ4ZTg4MzY4ZDE4ZWZjMjI3NWM5OTQ3MzBjZDg0Y2ZmqXJlY29yZEtleadkdXAta2V5pHJvbGWqbmFycm93X3JlZqpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d/narrow-citer-accepted"
|
||||
],
|
||||
"matched": 1,
|
||||
"hasNext": false,
|
||||
"expectedEdges": [
|
||||
{
|
||||
"fromId": "sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d/narrow-citer-accepted",
|
||||
"role": "narrow_ref",
|
||||
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
|
||||
"payloadBase64": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "shared_key_design_resolves_the_first_in_target_candidate_in_row_order",
|
||||
"selectionMessagePackBase64": "hKRrZXlzkbRjaXRlci1ib3RoLWluLXRhcmdldKVjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5p2R1cC1rZXmkcm9sZaZEZXNpZ26qcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
|
||||
"asOf": 1,
|
||||
"expectedIds": [
|
||||
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target"
|
||||
],
|
||||
"matched": 1,
|
||||
"hasNext": false,
|
||||
"expectedEdges": [
|
||||
{
|
||||
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target",
|
||||
"role": "Design",
|
||||
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
|
||||
"payloadBase64": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "refuses_empty_schemas_list",
|
||||
"selectionMessagePackBase64": "g6dzY2hlbWFzkKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": "schemas",
|
||||
"expectedIds": [],
|
||||
"matched": 0,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "refuses_blank_key_entry",
|
||||
"selectionMessagePackBase64": "g6RrZXlzkqRhLWVxoyAgIKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": "keys",
|
||||
"expectedIds": [],
|
||||
"matched": 0,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "refuses_unmatched_cites_target_schema",
|
||||
"selectionMessagePackBase64": "g6VjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZLlzaGEyNTY6bm90LWEta25vd24tc2NoZW1hqXJlY29yZEtleaRhLWVxpHJvbGWmRGVzaWduqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfC",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": "cites.target.schemaId",
|
||||
"expectedIds": [],
|
||||
"matched": 0,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "refuses_cites_target_by_hash_shaped_alias",
|
||||
"selectionMessagePackBase64": "g6VjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlKc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmYudjGpcmVjb3JkS2V5pGEtZXGkcm9sZaZEZXNpZ26qcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
|
||||
"asOf": 1,
|
||||
"expectedRefusalField": "cites.target.schemaId",
|
||||
"expectedIds": [],
|
||||
"matched": 0,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
},
|
||||
{
|
||||
"name": "shared_key_narrow_ref_refuses_when_only_candidate_is_outside_target",
|
||||
"selectionMessagePackBase64": "hKRrZXlzkbRuYXJyb3ctY2l0ZXItcmVmdXNlZKVjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5q25hcnJvdy1vbmx5pHJvbGWqbmFycm93X3JlZqpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
|
||||
"asOf": 1,
|
||||
"expectedEvaluationRefusal": "reference_outside_target",
|
||||
"expectedIds": [],
|
||||
"matched": 0,
|
||||
"hasNext": false,
|
||||
"expectedEdges": []
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -62,17 +62,6 @@ to speak CultNet or participate in CultMesh.
|
|||
shared transport port and expand RUDP adoption beyond the core proof.
|
||||
- `packages/cultmesh-kotlin/README.md` records Kotlin's runtime role and
|
||||
ergonomic surface, including the explicit non-claim around C# SoA storage.
|
||||
- CultNet typed selection (`cultnet.snapshot_response_raw.v1`, C#/Rust only;
|
||||
docs/cultnet-selection-cut.md, R-AY): byte parity for the
|
||||
`snapshot_response_raw.v1` envelope and its nested record types, verified at
|
||||
populated and absent optional values, plus semantic parity over the named
|
||||
field subset. `packages/cultnet-rs/tests/selection_wire_parity.rs` and
|
||||
`tests/GameCult.Networking.Tests/SelectionWireParityTests.cs` carry the byte
|
||||
vectors; `packages/cultnet-rs/tests/selection.rs`'s
|
||||
`write_selection_vectors_for_the_reference` and
|
||||
`tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs` carry the
|
||||
semantic ones. A single rows-to-bytes path driving both halves in one call
|
||||
remains its own cut (R-AR) and is not part of this claim.
|
||||
|
||||
## Expansion Work Outside The Current Parity Claim
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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.
|
||||
|
|
@ -1,94 +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(NOT WIN32)
|
||||
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")
|
||||
endif()
|
||||
endif()
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
|
||||
|
|
|
|||
|
|
@ -1,147 +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, `setarch`, and Node, because the
|
||||
mutation harness that drives the runtime-lifetime scenarios is a Node script and
|
||||
a container that cannot run it cannot check the bridge it just built:
|
||||
|
||||
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 && node scripts/mutate-cultmesh.mjs native"
|
||||
|
||||
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. The second is one line
|
||||
because the shell it is offered to is not decided here; a continuation would have
|
||||
to pick one, and a backtick pasted into a POSIX shell is not a continuation.
|
||||
|
||||
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. The
|
||||
ThreadSanitizer configuration needs the process's address space where it expects
|
||||
it, so `scripts/mutate-cultmesh.mjs` re-executes those runs 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, which a
|
||||
mutation harness would otherwise read as every mutant being killed. The harness
|
||||
stops with that diagnosis rather than reporting kills it did not earn.
|
||||
|
||||
The win32-x64 half of the harness 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 for every mutant including the no-op control.
|
||||
The harness stops on a red control rather than reporting a table of kills, so
|
||||
the failure is loud, but it names MSBuild and not the path.
|
||||
|
||||
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
|
|
@ -1,350 +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 a
|
||||
* scenario that can only sleep and hope cannot tell a bridge that counts them
|
||||
* 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);
|
||||
|
||||
#endif /* CULTMESH_QUIC_DEBUG_ASSERTS */
|
||||
|
||||
#if defined(__cplusplus)
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* CULTMESH_QUIC_NATIVE_H */
|
||||
|
|
@ -1,706 +0,0 @@
|
|||
// Scenario runner for the bridge's runtime lifetime rules.
|
||||
//
|
||||
// This is the one part of the bridge a host cannot check by reading: whether
|
||||
// `cultmesh_quic_runtime_close` is safe against host threads that are inside the
|
||||
// library when it starts. Everything here drives that path and nothing else. It
|
||||
// opens a runtime, parks pollers in `cultmesh_quic_next_event`, closes the
|
||||
// runtime underneath them, and asserts what the header promises: every parked
|
||||
// poller returns, and none of them touches the runtime after it is gone.
|
||||
//
|
||||
// It consumes the bridge through include/cultmesh_quic_native.h — the same
|
||||
// contract a host has — and nothing else. No MsQuic header, no internals.
|
||||
//
|
||||
// The failures it exists to catch are not visible in the return values, because
|
||||
// a use-after-free is not an error code. Two things make them visible instead:
|
||||
// a sanitizer build (ThreadSanitizer for the notify/destroy race, AddressSanitizer
|
||||
// for the freed-runtime dereference), and `CULTMESH_QUIC_DEBUG_ASSERTS`, which
|
||||
// makes the bridge state the quiesce invariant itself rather than leaving it to
|
||||
// be caught by an unlucky thread schedule.
|
||||
//
|
||||
// cultmesh_quic_native_tests closerace [iterations] [pollers]
|
||||
// cultmesh_quic_native_tests holdclose [iterations] [pollers]
|
||||
// cultmesh_quic_native_tests polltimeout [iterations]
|
||||
// cultmesh_quic_native_tests latecall [iterations]
|
||||
// cultmesh_quic_native_tests holdtimeout [iterations]
|
||||
//
|
||||
// `closerace` hunts the race: many pollers, many iterations, and whatever the
|
||||
// schedule gives. `holdclose` takes the guessing out of it — the bridge's
|
||||
// development seam (section 7 of the header) holds the pollers inside the
|
||||
// library until this scenario releases them, and reports how many host calls the
|
||||
// bridge actually counted, so the scenario asserts the quiesce instead of
|
||||
// assuming its own fixture worked. It needs CULTMESH_QUIC_DEBUG_ASSERTS.
|
||||
//
|
||||
// In both of those the wait is ended by the close, so the host's timeout governs
|
||||
// nothing either of them can see, and a bridge waiting on a constant of its own
|
||||
// passes both. `polltimeout` is the one that lets the timeout govern the return
|
||||
// and measures it. `latecall` covers the other half of section 4: a call that
|
||||
// races the start of a close is refused rather than counted behind its wait.
|
||||
// `holdtimeout` covers the seam's own rule, which nothing else is in a position
|
||||
// to see: the hold parks a call the wait woke and not one whose own timeout
|
||||
// expired, so the quiesce the hold scenarios assert is the bridge's doing and
|
||||
// not the fixture's.
|
||||
//
|
||||
// Exit code 0 means every assertion held. Anything else, including a sanitizer
|
||||
// abort or the bridge's own assertion, is a failure. Under ThreadSanitizer run
|
||||
// it with `TSAN_OPTIONS=halt_on_error=1`, or a reported race leaves the exit
|
||||
// code to the last thing that set it.
|
||||
|
||||
#include <cultmesh_quic_native.h>
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
// Long enough that a poller is still parked when the close begins. The close is
|
||||
// what ends the wait; this timeout only bounds the scenario if the close never
|
||||
// arrives, which is itself the failure.
|
||||
constexpr int32_t kPollTimeoutMs = 5000;
|
||||
|
||||
// A poller asks for a payload buffer, so the close races a `next_event` that has
|
||||
// somewhere to copy into rather than one that can only ever return 0.
|
||||
constexpr int32_t kPayloadCapacity = 4096;
|
||||
|
||||
int Fail(const std::string& message) {
|
||||
std::fprintf(stderr, "FAILED: %s\n", message.c_str());
|
||||
return 1;
|
||||
}
|
||||
|
||||
// One iteration: `pollers` threads parked in `next_event`, then a close under
|
||||
// them. Returns an empty string when every promise held.
|
||||
//
|
||||
// Each poller makes exactly one blocking call and does not call again. Section 5
|
||||
// of the header puts the ordering on the host — no call may *begin* during a
|
||||
// close — so a scenario that kept re-entering would be testing a shape the
|
||||
// bridge declines to support, and its report would be the harness's own fault.
|
||||
// A call already inside the library is the case the quiesce exists for, and that
|
||||
// is the one run here.
|
||||
std::string CloseRaceOnce(int pollers) {
|
||||
void* runtime = nullptr;
|
||||
const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime);
|
||||
if (opened != 0 || runtime == nullptr)
|
||||
return "cultmesh_quic_runtime_open returned " + std::to_string(opened);
|
||||
|
||||
// Counts threads about to enter the blocking call. The close waits for all of
|
||||
// them, so the race is run with the pollers actually inside rather than with
|
||||
// threads that have not started yet.
|
||||
std::atomic<int> entering{0};
|
||||
std::atomic<int> returned{0};
|
||||
std::atomic<int> undefined_return{0};
|
||||
std::atomic<int> timed_out{0};
|
||||
// Set just before the close, and read by each poller as it returns. A poller
|
||||
// that is already out by then was never in the race, and a scenario full of
|
||||
// those passes without testing anything: this is what catches a bridge whose
|
||||
// blocking crossing has stopped blocking.
|
||||
std::atomic<bool> closing_started{false};
|
||||
std::atomic<int> left_before_the_close{0};
|
||||
|
||||
std::vector<std::thread> threads;
|
||||
threads.reserve(static_cast<size_t>(pollers));
|
||||
for (int index = 0; index < pollers; ++index) {
|
||||
threads.emplace_back([runtime, &entering, &returned, &undefined_return, &timed_out,
|
||||
&closing_started, &left_before_the_close] {
|
||||
std::vector<uint8_t> payload(static_cast<size_t>(kPayloadCapacity));
|
||||
cultmesh_quic_event event{};
|
||||
int32_t required = 0;
|
||||
|
||||
entering.fetch_add(1);
|
||||
const auto started = std::chrono::steady_clock::now();
|
||||
// Parked here when the close arrives. `closing` wakes the wait; the
|
||||
// call scope this thread holds is what the close must count out
|
||||
// before it frees anything.
|
||||
const int32_t result = cultmesh_quic_next_event(
|
||||
runtime, kPollTimeoutMs, &event, payload.data(), kPayloadCapacity, &required);
|
||||
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - started).count();
|
||||
|
||||
if (result != 0 && result != 1 && result != 2) undefined_return.fetch_add(1);
|
||||
// The close is what ends this wait. Sitting out the whole timeout
|
||||
// instead means the wake never reached it.
|
||||
if (elapsed >= kPollTimeoutMs / 2) timed_out.fetch_add(1);
|
||||
if (!closing_started.load()) left_before_the_close.fetch_add(1);
|
||||
returned.fetch_add(1);
|
||||
});
|
||||
}
|
||||
|
||||
while (entering.load() < pollers) std::this_thread::yield();
|
||||
// Settle so the pollers are inside the blocking wait rather than on their way
|
||||
// to it. A poller that has not entered yet is a call beginning during the
|
||||
// close, which is the host's to avoid, not the bridge's to survive.
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
|
||||
closing_started.store(true);
|
||||
cultmesh_quic_runtime_close(runtime);
|
||||
for (auto& thread : threads) thread.join();
|
||||
|
||||
if (left_before_the_close.load() != 0)
|
||||
return std::to_string(left_before_the_close.load()) + " of " + std::to_string(pollers) +
|
||||
" pollers returned before the close began, so they were never in the race";
|
||||
if (undefined_return.load() != 0)
|
||||
return std::to_string(undefined_return.load()) + " poller(s) saw a return the header does not define";
|
||||
if (timed_out.load() != 0)
|
||||
return std::to_string(timed_out.load()) + " of " + std::to_string(pollers) +
|
||||
" pollers sat out the timeout instead of being woken by the close";
|
||||
if (returned.load() != pollers)
|
||||
return std::to_string(returned.load()) + " of " + std::to_string(pollers) + " pollers returned";
|
||||
return {};
|
||||
}
|
||||
|
||||
// The two timeouts `polltimeout` asks for. Two, and not one, because a single
|
||||
// value cannot tell a bridge that honours the host's timeout from one that waits
|
||||
// on that same number of its own: a constant has to satisfy both bands, and no
|
||||
// constant is in both.
|
||||
//
|
||||
// A constant was never the hard case, though, and the numbers are chosen for the
|
||||
// waits that are computed from the argument instead. Any mapping that is the
|
||||
// identity at both of these passes for free, so the two are picked to leave the
|
||||
// ordinary ones nowhere to be identity:
|
||||
//
|
||||
// - 5000 is above any ceiling a bridge would plausibly clamp a host's timeout
|
||||
// to. A wait of `min(timeout, 1000)` — cap it so a shutdown gets noticed — is
|
||||
// the most ordinary spelling this line will ever be given, and it returns
|
||||
// from this probe four seconds early.
|
||||
// - 200 is low enough that an added constant shows as a proportion. A wait of
|
||||
// `timeout + 150` is 75% late here, and the late tolerance below is a
|
||||
// fraction of what was asked for rather than a flat number it could hide in.
|
||||
constexpr int32_t kShortPollMs = 200;
|
||||
constexpr int32_t kLongPollMs = 5000;
|
||||
|
||||
// How much sooner than the timeout a poll may return. A wait may be late; it may
|
||||
// not be early, because returning early is exactly what waiting on a shorter
|
||||
// duration looks like. This is scheduler granularity, not slack.
|
||||
constexpr int kEarlyToleranceMs = 20;
|
||||
|
||||
// How much later than the timeout a poll may return: an allowance for scheduler
|
||||
// granularity plus an eighth of what was asked for.
|
||||
//
|
||||
// Proportional, and not the flat 300 ms this used to be, because a flat
|
||||
// tolerance is a gap a mapping hides in. `timeout + k` for any k under the flat
|
||||
// value passes at every probe, at every value, forever — and the measured
|
||||
// overshoot on both targets is about 10 ms, so a flat 300 was 290 ms of room
|
||||
// for a bridge to add a little of its own and be believed.
|
||||
//
|
||||
// The honest limit: a scaling smaller than an eighth survives this, and this
|
||||
// does not claim otherwise. An eighth is under the smallest scaling anyone
|
||||
// writes on purpose, and 60 ms is six times the overshoot either target shows.
|
||||
constexpr int LateToleranceMs(int32_t timeout_ms) { return 60 + timeout_ms / 8; }
|
||||
|
||||
struct TimedPoll {
|
||||
int32_t result;
|
||||
long long elapsed_ms;
|
||||
};
|
||||
|
||||
TimedPoll PollFor(void* runtime, int32_t timeout_ms) {
|
||||
std::vector<uint8_t> payload(static_cast<size_t>(kPayloadCapacity));
|
||||
cultmesh_quic_event event{};
|
||||
int32_t required = 0;
|
||||
const auto started = std::chrono::steady_clock::now();
|
||||
const int32_t result = cultmesh_quic_next_event(
|
||||
runtime, timeout_ms, &event, payload.data(), kPayloadCapacity, &required);
|
||||
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - started).count();
|
||||
return {result, elapsed};
|
||||
}
|
||||
|
||||
// The host's timeout, and the only thing it is: how long a poll with nothing to
|
||||
// deliver stays inside the library. Nothing else here checks it — `closerace`
|
||||
// and `holdclose` both end their waits with a close, so in both of them the
|
||||
// bridge could wait on any duration at all and every assertion would still hold.
|
||||
// A runtime with no listener and no connection has nothing to deliver, so the
|
||||
// timeout is what ends this poll, and the elapsed time is what it waited on.
|
||||
std::string PollTimeoutOnce() {
|
||||
void* runtime = nullptr;
|
||||
const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime);
|
||||
if (opened != 0 || runtime == nullptr)
|
||||
return "cultmesh_quic_runtime_open returned " + std::to_string(opened);
|
||||
|
||||
std::string failure;
|
||||
for (const int32_t timeout_ms : {kShortPollMs, kLongPollMs}) {
|
||||
const TimedPoll poll = PollFor(runtime, timeout_ms);
|
||||
const std::string asked = "a poll asking for " + std::to_string(timeout_ms) + " ms ";
|
||||
if (poll.result != 0) {
|
||||
failure = asked + "returned " + std::to_string(poll.result) +
|
||||
", not the 0 an idle runtime owes it";
|
||||
break;
|
||||
}
|
||||
if (poll.elapsed_ms < timeout_ms - kEarlyToleranceMs) {
|
||||
failure = asked + "returned after " + std::to_string(poll.elapsed_ms) +
|
||||
" ms: the wait ended on some shorter duration than the one it was given";
|
||||
break;
|
||||
}
|
||||
if (poll.elapsed_ms > timeout_ms + LateToleranceMs(timeout_ms)) {
|
||||
failure = asked + "returned after " + std::to_string(poll.elapsed_ms) +
|
||||
" ms: the wait ended on some longer duration than the one it was given";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
cultmesh_quic_runtime_close(runtime);
|
||||
return failure;
|
||||
}
|
||||
|
||||
int PollTimeout(int iterations) {
|
||||
for (int iteration = 0; iteration < iterations; ++iteration) {
|
||||
const std::string failure = PollTimeoutOnce();
|
||||
if (!failure.empty())
|
||||
return Fail("polltimeout iteration " + std::to_string(iteration) + ": " + failure);
|
||||
}
|
||||
std::printf("polltimeout %dx: ok\n", iterations);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Nothing listens on the discard port, so a connection to it fails and the
|
||||
// bridge says why. That reason is the payload this scenario needs, and reaching
|
||||
// it takes no listener, no credential and no established connection.
|
||||
constexpr uint16_t kClosedPort = 9;
|
||||
|
||||
// How long the failure is given to arrive. The loopback refusal is immediate;
|
||||
// if the refusal is swallowed instead, MsQuic's own handshake timeout ends the
|
||||
// connection at 10 seconds and event 4 still arrives, so this only bounds a
|
||||
// bridge that reports nothing at all.
|
||||
constexpr int kEventDeadlineMs = 20000;
|
||||
|
||||
// The bridge writes its own reason sentences and every one of them starts here.
|
||||
// Checking the bytes, and not only the length, is what separates a payload
|
||||
// copied out of the queued event from one copied out of an event that was popped
|
||||
// first: the second reads freed memory, which does not spell this.
|
||||
constexpr char kReasonPrefix[] = "CultMesh QUIC connection";
|
||||
|
||||
// The two-phase poll, which is the shape every host uses to size its buffer:
|
||||
// ask with nothing, be told what it needs, ask again. What makes it work is that
|
||||
// the refusal consumes nothing — the event is still queued, payload and all,
|
||||
// after a return of 2 — and that the copy happens before the event is popped.
|
||||
// Both are section 6 of the header, and nothing committed reached either.
|
||||
std::string PayloadFitOnce() {
|
||||
void* runtime = nullptr;
|
||||
const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime);
|
||||
if (opened != 0 || runtime == nullptr)
|
||||
return "cultmesh_quic_runtime_open returned " + std::to_string(opened);
|
||||
|
||||
std::string failure;
|
||||
uint64_t connection_id = 0;
|
||||
const int32_t connecting =
|
||||
cultmesh_quic_connection_open(runtime, "127.0.0.1", kClosedPort, &connection_id);
|
||||
if (connecting != 0) {
|
||||
cultmesh_quic_runtime_close(runtime);
|
||||
return "cultmesh_quic_connection_open returned " + std::to_string(connecting);
|
||||
}
|
||||
|
||||
// An out parameter the bridge must not write on a refusal, filled with
|
||||
// something the bridge never writes so that a write is visible.
|
||||
cultmesh_quic_event poisoned{};
|
||||
std::memset(&poisoned, 0xab, sizeof(poisoned));
|
||||
cultmesh_quic_event event = poisoned;
|
||||
int32_t required = 0;
|
||||
int32_t result = 0;
|
||||
|
||||
// Ask with no room at all until the event carrying the failure reason
|
||||
// arrives. An event with no payload fits in nothing, so it is delivered and
|
||||
// polled past rather than mistaken for this one.
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(kEventDeadlineMs);
|
||||
for (;;) {
|
||||
event = poisoned;
|
||||
required = 0;
|
||||
result = cultmesh_quic_next_event(runtime, 250, &event, nullptr, 0, &required);
|
||||
if (result == 2 && required > 0) break;
|
||||
if (result < 0) {
|
||||
failure = "cultmesh_quic_next_event returned " + std::to_string(result);
|
||||
break;
|
||||
}
|
||||
if (std::chrono::steady_clock::now() >= deadline) {
|
||||
failure = "no event carrying a payload arrived within " +
|
||||
std::to_string(kEventDeadlineMs) + " ms of a connection to a closed port";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (failure.empty() && std::memcmp(&event, &poisoned, sizeof(event)) != 0)
|
||||
failure = "the refusal wrote to *out_event, which the header says it leaves alone";
|
||||
|
||||
// Again, with the same empty buffer: a refusal that consumed the event
|
||||
// leaves nothing to find the second time.
|
||||
if (failure.empty()) {
|
||||
cultmesh_quic_event again = poisoned;
|
||||
int32_t required_again = 0;
|
||||
const int32_t second = cultmesh_quic_next_event(runtime, 0, &again, nullptr, 0, &required_again);
|
||||
if (second != 2 || required_again != required)
|
||||
failure = "the second ask returned " + std::to_string(second) + " needing " +
|
||||
std::to_string(required_again) + ", not the 2 needing " + std::to_string(required) +
|
||||
" the first one did: the refusal consumed the event";
|
||||
}
|
||||
|
||||
// And with room, which is where the copy happens. The tail beyond the
|
||||
// payload is written too, so a copy that ran past what it was told to write
|
||||
// is visible.
|
||||
if (failure.empty()) {
|
||||
constexpr uint8_t kTail = 0x5a;
|
||||
std::vector<uint8_t> payload(static_cast<size_t>(required) + 16, kTail);
|
||||
cultmesh_quic_event delivered = poisoned;
|
||||
int32_t required_delivered = 0;
|
||||
const int32_t third = cultmesh_quic_next_event(
|
||||
runtime, 0, &delivered, payload.data(), static_cast<int32_t>(payload.size()),
|
||||
&required_delivered);
|
||||
const std::string reason(reinterpret_cast<const char*>(payload.data()),
|
||||
static_cast<size_t>(required));
|
||||
if (third != 1 || required_delivered != required)
|
||||
failure = "the ask with room returned " + std::to_string(third) + " needing " +
|
||||
std::to_string(required_delivered) + ", not the 1 needing " +
|
||||
std::to_string(required) + " the refusal promised";
|
||||
else if (delivered.type != CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN ||
|
||||
delivered.connection_id != connection_id ||
|
||||
delivered.payload_length != required)
|
||||
failure = "the delivered event was type " + std::to_string(delivered.type) +
|
||||
" on connection " + std::to_string(delivered.connection_id) + " carrying " +
|
||||
std::to_string(delivered.payload_length) + " byte(s), not the failed connection's own";
|
||||
else if (reason.rfind(kReasonPrefix, 0) != 0)
|
||||
failure = "the payload delivered was '" + reason + "', not a reason this bridge writes";
|
||||
else if (payload[static_cast<size_t>(required)] != kTail)
|
||||
failure = "the copy wrote past the " + std::to_string(required) + " bytes it reported";
|
||||
}
|
||||
|
||||
// Consumed now, and only now.
|
||||
if (failure.empty()) {
|
||||
cultmesh_quic_event emptied = poisoned;
|
||||
int32_t required_emptied = 0;
|
||||
const int32_t fourth =
|
||||
cultmesh_quic_next_event(runtime, 0, &emptied, nullptr, 0, &required_emptied);
|
||||
if (fourth != 0)
|
||||
failure = "the event was still queued after being delivered: a further ask returned " +
|
||||
std::to_string(fourth);
|
||||
}
|
||||
|
||||
cultmesh_quic_runtime_close(runtime);
|
||||
return failure;
|
||||
}
|
||||
|
||||
int PayloadFit(int iterations) {
|
||||
for (int iteration = 0; iteration < iterations; ++iteration) {
|
||||
const std::string failure = PayloadFitOnce();
|
||||
if (!failure.empty())
|
||||
return Fail("payloadfit iteration " + std::to_string(iteration) + ": " + failure);
|
||||
}
|
||||
std::printf("payloadfit %dx: ok\n", iterations);
|
||||
return 0;
|
||||
}
|
||||
|
||||
#if defined(CULTMESH_QUIC_DEBUG_ASSERTS)
|
||||
|
||||
// How long a poll that does not block is given to prove it by returning, after
|
||||
// the calls have been counted and before the close begins. A bridge whose
|
||||
// blocking wait no longer blocks has emptied itself of host calls by the time
|
||||
// the close starts, and `calls_at_close` then says 0.
|
||||
constexpr int kSettleMs = 100;
|
||||
|
||||
// How long the close is required to stay inside `cultmesh_quic_runtime_close`
|
||||
// while the pollers are held. The held calls are what it is waiting for, and
|
||||
// nothing releases them until this elapses, so a close that returns first gave
|
||||
// up on a call that was still inside. This bounds what the scenario can see: a
|
||||
// close that waits for longer than this and then gives up is not distinguished
|
||||
// from one that waits properly, which is the honest limit of the check.
|
||||
constexpr int kHeldWindowMs = 500;
|
||||
|
||||
// How long the pollers are given to be counted inside the library before the
|
||||
// close begins. Reaching `pollers` is the normal path; the deadline only stops
|
||||
// a bridge that never counts them from hanging the scenario.
|
||||
constexpr int kCountedDeadlineMs = 2000;
|
||||
|
||||
// One iteration of the quiesce itself, with the guesswork taken out: the pollers
|
||||
// are held inside the library by the bridge's own development seam until this
|
||||
// scenario lets them go, and the bridge says how many calls it counted rather
|
||||
// than the scenario assuming the schedule put them there.
|
||||
//
|
||||
// Three promises, and a mutation that breaks any one of them is seen here:
|
||||
// - the calls are counted (`peak`), so a blocking crossing outside a call scope
|
||||
// is not merely uncounted-but-lucky, it reads as zero;
|
||||
// - they were still counted when the close began its wait (`calls_at_close`),
|
||||
// so a poll that stopped blocking cannot leave the scenario testing an empty
|
||||
// library;
|
||||
// - the close did not return while they were held, so a wait that gives up
|
||||
// after any interval shorter than the hold is not a wait.
|
||||
std::string HoldCloseOnce(int pollers) {
|
||||
cultmesh_quic_debug_hold_calls(1);
|
||||
void* runtime = nullptr;
|
||||
const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime);
|
||||
if (opened != 0 || runtime == nullptr) {
|
||||
cultmesh_quic_debug_hold_calls(0);
|
||||
return "cultmesh_quic_runtime_open returned " + std::to_string(opened);
|
||||
}
|
||||
|
||||
std::atomic<int> returned{0};
|
||||
std::atomic<int> undefined_return{0};
|
||||
std::vector<std::thread> threads;
|
||||
threads.reserve(static_cast<size_t>(pollers));
|
||||
for (int index = 0; index < pollers; ++index) {
|
||||
threads.emplace_back([runtime, &returned, &undefined_return] {
|
||||
std::vector<uint8_t> payload(static_cast<size_t>(kPayloadCapacity));
|
||||
cultmesh_quic_event event{};
|
||||
int32_t required = 0;
|
||||
const int32_t result = cultmesh_quic_next_event(
|
||||
runtime, kPollTimeoutMs, &event, payload.data(), kPayloadCapacity, &required);
|
||||
if (result != 0 && result != 1 && result != 2) undefined_return.fetch_add(1);
|
||||
returned.fetch_add(1);
|
||||
});
|
||||
}
|
||||
|
||||
const auto counted_deadline =
|
||||
std::chrono::steady_clock::now() + std::chrono::milliseconds(kCountedDeadlineMs);
|
||||
while (cultmesh_quic_debug_peak_calls() < pollers &&
|
||||
std::chrono::steady_clock::now() < counted_deadline)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(kSettleMs));
|
||||
|
||||
std::atomic<bool> closed{false};
|
||||
std::thread closer([runtime, &closed] {
|
||||
cultmesh_quic_runtime_close(runtime);
|
||||
closed.store(true);
|
||||
});
|
||||
const auto held_until =
|
||||
std::chrono::steady_clock::now() + std::chrono::milliseconds(kHeldWindowMs);
|
||||
while (!closed.load() && std::chrono::steady_clock::now() < held_until)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
|
||||
const bool closed_while_held = closed.load();
|
||||
const int32_t peak = cultmesh_quic_debug_peak_calls();
|
||||
const int32_t at_close = cultmesh_quic_debug_calls_at_close();
|
||||
|
||||
cultmesh_quic_debug_hold_calls(0);
|
||||
closer.join();
|
||||
for (auto& thread : threads) thread.join();
|
||||
|
||||
if (peak != pollers)
|
||||
return "the library's peak count of host calls inside it was " + std::to_string(peak) + ", not " +
|
||||
std::to_string(pollers) + ": either the blocking crossing is not inside a call scope, or it " +
|
||||
"did not block long enough for the pollers to be inside it together";
|
||||
if (at_close != pollers)
|
||||
return "the close began its wait with " + std::to_string(at_close) +
|
||||
" host call(s) counted inside, not " + std::to_string(pollers) +
|
||||
"; the pollers were not inside the library when the close started";
|
||||
if (closed_while_held)
|
||||
return "the close returned while " + std::to_string(pollers) +
|
||||
" host call(s) were still held inside the library";
|
||||
if (undefined_return.load() != 0)
|
||||
return std::to_string(undefined_return.load()) + " poller(s) saw a return the header does not define";
|
||||
if (returned.load() != pollers)
|
||||
return std::to_string(returned.load()) + " of " + std::to_string(pollers) + " pollers returned";
|
||||
return {};
|
||||
}
|
||||
|
||||
// One iteration of the other half of section 4: a call made while a close is
|
||||
// running is refused, and is not counted behind the close's wait.
|
||||
//
|
||||
// The prohibition is the host's — no call may begin after the close starts — and
|
||||
// the bridge does not make it optional. What it does do is refuse the call that
|
||||
// races the start, and that refusal is load-bearing rather than courteous: a
|
||||
// call admitted after the closer has begun increments the in-flight count behind
|
||||
// a wait that has already read it, and the wait can then be left waiting on a
|
||||
// count that reaches zero only when the late caller happens to leave.
|
||||
//
|
||||
// The seam is what makes the window wide enough to aim at: a held poller keeps
|
||||
// the close inside its wait for as long as this scenario wants it there.
|
||||
std::string LateCallOnce() {
|
||||
cultmesh_quic_debug_hold_calls(1);
|
||||
void* runtime = nullptr;
|
||||
const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime);
|
||||
if (opened != 0 || runtime == nullptr) {
|
||||
cultmesh_quic_debug_hold_calls(0);
|
||||
return "cultmesh_quic_runtime_open returned " + std::to_string(opened);
|
||||
}
|
||||
|
||||
std::thread poller([runtime] {
|
||||
std::vector<uint8_t> payload(static_cast<size_t>(kPayloadCapacity));
|
||||
cultmesh_quic_event event{};
|
||||
int32_t required = 0;
|
||||
cultmesh_quic_next_event(
|
||||
runtime, kPollTimeoutMs, &event, payload.data(), kPayloadCapacity, &required);
|
||||
});
|
||||
|
||||
const auto counted_deadline =
|
||||
std::chrono::steady_clock::now() + std::chrono::milliseconds(kCountedDeadlineMs);
|
||||
while (cultmesh_quic_debug_peak_calls() < 1 &&
|
||||
std::chrono::steady_clock::now() < counted_deadline)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(kSettleMs));
|
||||
|
||||
std::thread closer([runtime] { cultmesh_quic_runtime_close(runtime); });
|
||||
// The close has begun its wait once it has read the count, and the held
|
||||
// poller is what keeps it there.
|
||||
const auto started_deadline =
|
||||
std::chrono::steady_clock::now() + std::chrono::milliseconds(kCountedDeadlineMs);
|
||||
while (cultmesh_quic_debug_calls_at_close() < 0 &&
|
||||
std::chrono::steady_clock::now() < started_deadline)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
const int32_t at_close = cultmesh_quic_debug_calls_at_close();
|
||||
|
||||
// Two calls, from a thread of their own so that a bridge which admits them
|
||||
// cannot park this one: one that does not block, and then the blocking
|
||||
// crossing. That order, because a bridge that admits the blocking one parks
|
||||
// it inside the library until the hold is released, and a call made after
|
||||
// that would be racing the teardown rather than the wait.
|
||||
std::atomic<int32_t> late_poll{0};
|
||||
std::atomic<int32_t> late_error{0};
|
||||
std::thread late([runtime, &late_poll, &late_error] {
|
||||
std::array<char, 256> message{};
|
||||
late_error.store(cultmesh_quic_last_error(runtime, message.data(),
|
||||
static_cast<int32_t>(message.size())));
|
||||
std::vector<uint8_t> payload(static_cast<size_t>(kPayloadCapacity));
|
||||
cultmesh_quic_event event{};
|
||||
int32_t required = 0;
|
||||
late_poll.store(cultmesh_quic_next_event(
|
||||
runtime, kPollTimeoutMs, &event, payload.data(), kPayloadCapacity, &required));
|
||||
});
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(kSettleMs));
|
||||
cultmesh_quic_debug_hold_calls(0);
|
||||
late.join();
|
||||
closer.join();
|
||||
poller.join();
|
||||
|
||||
if (at_close < 1)
|
||||
return "the close did not begin its wait with the poller counted inside, so nothing held it "
|
||||
"open for a call to race";
|
||||
if (late_poll.load() != -1)
|
||||
return "cultmesh_quic_next_event returned " + std::to_string(late_poll.load()) +
|
||||
" to a call made during the close, not the -1 that refuses it";
|
||||
if (late_error.load() != -1)
|
||||
return "cultmesh_quic_last_error returned " + std::to_string(late_error.load()) +
|
||||
" to a call made during the close, not the -1 that refuses it";
|
||||
return {};
|
||||
}
|
||||
|
||||
// How long `holdtimeout`'s poll asks for, and how long the hold stays armed
|
||||
// under it. The gap between them is the whole measurement: a poll the hold
|
||||
// parks leaves when the hold is released, so its elapsed time lands at the
|
||||
// second number instead of the first.
|
||||
constexpr int32_t kHeldTimeoutPollMs = 150;
|
||||
constexpr int kHoldArmedMs = 800;
|
||||
|
||||
// The seam's own rule, and the only scenario that can see it: the hold parks a
|
||||
// call the wait woke, and not one whose own timeout expired.
|
||||
//
|
||||
// Everything else that arms the hold parks a woken call, so the guard governs
|
||||
// nothing either of them observes — the timeout scenario never arms the hold,
|
||||
// and the hold scenarios never let a timeout expire, so nothing was ever in
|
||||
// both states at once and the guard could be deleted with every scenario still
|
||||
// green. This puts one call in both states: the hold is armed, and the runtime
|
||||
// is idle, so the wait ends on the timeout and the call is on its way out.
|
||||
//
|
||||
// It matters because a scenario that parked such a call would then report the
|
||||
// bridge keeping a host call inside the library when what kept it was the
|
||||
// fixture — the quiesce numbers `holdclose` asserts would be the seam's, not
|
||||
// the bridge's.
|
||||
std::string HoldTimeoutOnce() {
|
||||
cultmesh_quic_debug_hold_calls(1);
|
||||
void* runtime = nullptr;
|
||||
const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime);
|
||||
if (opened != 0 || runtime == nullptr) {
|
||||
cultmesh_quic_debug_hold_calls(0);
|
||||
return "cultmesh_quic_runtime_open returned " + std::to_string(opened);
|
||||
}
|
||||
|
||||
// On a thread of its own, because a parked call does not come back until the
|
||||
// release below and this thread is what releases it.
|
||||
TimedPoll poll{};
|
||||
std::thread poller([runtime, &poll] { poll = PollFor(runtime, kHeldTimeoutPollMs); });
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(kHoldArmedMs));
|
||||
cultmesh_quic_debug_hold_calls(0);
|
||||
poller.join();
|
||||
cultmesh_quic_runtime_close(runtime);
|
||||
|
||||
if (poll.result != 0)
|
||||
return "a poll ended by its own timeout returned " + std::to_string(poll.result) +
|
||||
", not the 0 an idle runtime owes it";
|
||||
if (poll.elapsed_ms < kHeldTimeoutPollMs - kEarlyToleranceMs)
|
||||
return "a poll asking for " + std::to_string(kHeldTimeoutPollMs) + " ms returned after " +
|
||||
std::to_string(poll.elapsed_ms) + " ms, so the fixture never let its timeout run out";
|
||||
if (poll.elapsed_ms > kHeldTimeoutPollMs + LateToleranceMs(kHeldTimeoutPollMs))
|
||||
return "a poll asking for " + std::to_string(kHeldTimeoutPollMs) + " ms returned after " +
|
||||
std::to_string(poll.elapsed_ms) + " ms: the hold parked a call its own timeout had "
|
||||
"already ended, and it left when the hold did";
|
||||
return {};
|
||||
}
|
||||
|
||||
int HoldTimeout(int iterations) {
|
||||
for (int iteration = 0; iteration < iterations; ++iteration) {
|
||||
const std::string failure = HoldTimeoutOnce();
|
||||
if (!failure.empty())
|
||||
return Fail("holdtimeout iteration " + std::to_string(iteration) + ": " + failure);
|
||||
}
|
||||
std::printf("holdtimeout %dx: ok\n", iterations);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int LateCall(int iterations) {
|
||||
for (int iteration = 0; iteration < iterations; ++iteration) {
|
||||
const std::string failure = LateCallOnce();
|
||||
if (!failure.empty())
|
||||
return Fail("latecall iteration " + std::to_string(iteration) + ": " + failure);
|
||||
}
|
||||
std::printf("latecall %dx: ok\n", iterations);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int HoldClose(int iterations, int pollers) {
|
||||
for (int iteration = 0; iteration < iterations; ++iteration) {
|
||||
const std::string failure = HoldCloseOnce(pollers);
|
||||
if (!failure.empty())
|
||||
return Fail("holdclose iteration " + std::to_string(iteration) + ": " + failure);
|
||||
}
|
||||
std::printf("holdclose %dx%d: ok\n", iterations, pollers);
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endif // CULTMESH_QUIC_DEBUG_ASSERTS
|
||||
|
||||
int CloseRace(int iterations, int pollers) {
|
||||
for (int iteration = 0; iteration < iterations; ++iteration) {
|
||||
const std::string failure = CloseRaceOnce(pollers);
|
||||
if (!failure.empty())
|
||||
return Fail("closerace iteration " + std::to_string(iteration) + ": " + failure);
|
||||
}
|
||||
std::printf("closerace %dx%d: ok\n", iterations, pollers);
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc < 2) {
|
||||
std::fprintf(stderr,
|
||||
"usage: %s closerace|holdclose|holdtimeout|polltimeout|payloadfit|latecall "
|
||||
"[iterations] [pollers]\n",
|
||||
argv[0]);
|
||||
return 2;
|
||||
}
|
||||
const std::string scenario = argv[1];
|
||||
const int iterations = argc > 2 ? std::atoi(argv[2]) : 20;
|
||||
const int pollers = argc > 3 ? std::atoi(argv[3]) : 256;
|
||||
if (iterations <= 0 || pollers <= 0) {
|
||||
std::fprintf(stderr, "%s needs a positive iteration and poller count\n", scenario.c_str());
|
||||
return 2;
|
||||
}
|
||||
if (scenario == "closerace") return CloseRace(iterations, pollers);
|
||||
if (scenario == "polltimeout") return PollTimeout(iterations);
|
||||
if (scenario == "payloadfit") return PayloadFit(iterations);
|
||||
if (scenario == "holdclose" || scenario == "latecall" || scenario == "holdtimeout") {
|
||||
#if defined(CULTMESH_QUIC_DEBUG_ASSERTS)
|
||||
if (scenario == "latecall") return LateCall(iterations);
|
||||
if (scenario == "holdtimeout") return HoldTimeout(iterations);
|
||||
return HoldClose(iterations, pollers);
|
||||
#else
|
||||
std::fprintf(stderr,
|
||||
"%s needs the development seam: configure with "
|
||||
"-DCULTMESH_QUIC_DEBUG_ASSERTS=ON\n", scenario.c_str());
|
||||
return 2;
|
||||
#endif
|
||||
}
|
||||
std::fprintf(stderr, "unknown scenario '%s'\n", scenario.c_str());
|
||||
return 2;
|
||||
}
|
||||
|
|
@ -31,7 +31,8 @@ shader semantics.
|
|||
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.
|
||||
|
|
@ -58,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));
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 `
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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++) {
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -15,27 +15,42 @@ import {
|
|||
type CultMeshSessionAcceptedMessage,
|
||||
type CultMeshSessionOpenMessage,
|
||||
} from "cultnet-ts/contracts";
|
||||
import {
|
||||
base64ToBytes,
|
||||
bytesToBase64,
|
||||
isLoopbackEndpoint,
|
||||
isUnsignedCertificate,
|
||||
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(
|
||||
|
|
@ -779,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 {
|
||||
|
|
@ -913,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.`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -4317,14 +4317,6 @@ class CultMeshTests(unittest.TestCase):
|
|||
{"schemaVersion": "cultnet.document_delete.v0", "messageId": "bad-delete", "schemaId": "mesh.error_note.v1"},
|
||||
expected_schema_version="cultnet.error.v0",
|
||||
)
|
||||
# R-2, owed by Cut 1 (docs/cultnet-selection-cut.md, section 9): this v0-only Mesh server
|
||||
# refuses a cultnet.selection.v1 carrier message through the same delegating path
|
||||
# (_handle_connection_message -> handle_message) as any other unsupported schema, not a
|
||||
# silent empty response.
|
||||
unsupported_v1 = raw_client.request(
|
||||
{"schemaVersion": "cultnet.snapshot_request.v1", "messageId": "bad-v1-selection"},
|
||||
expected_schema_version="cultnet.error.v0",
|
||||
)
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
|
|
@ -4336,9 +4328,6 @@ class CultMeshTests(unittest.TestCase):
|
|||
self.assertEqual(malformed_put["code"], "malformed_document_put")
|
||||
self.assertEqual(malformed_delete["messageId"], "bad-delete")
|
||||
self.assertEqual(malformed_delete["code"], "malformed_document_delete")
|
||||
self.assertEqual(unsupported_v1["messageId"], "bad-v1-selection")
|
||||
self.assertEqual(unsupported_v1["code"], "unsupported_schema_version")
|
||||
self.assertEqual(unsupported_v1["details"]["schemaVersion"], "cultnet.snapshot_request.v1")
|
||||
|
||||
def test_cultmesh_authority_lease_requires_live_matching_lease(self) -> None:
|
||||
peer = CultMeshPeerCard(
|
||||
|
|
|
|||
|
|
@ -820,7 +820,7 @@ pub fn request_raw_snapshot_from_rudp_catalog(
|
|||
} if received == &message_id => {
|
||||
snapshot_response = Some(message);
|
||||
}
|
||||
cultnet_rs::CultNetMessage::Error { error, .. } => {
|
||||
cultnet_rs::CultNetMessage::Error { error } => {
|
||||
let _ = client.disconnect(b"snapshot-error".to_vec());
|
||||
anyhow::bail!("CultMesh RUDP snapshot failed: {error}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ import {
|
|||
type CultNetWireContract,
|
||||
} from "cultnet-ts";
|
||||
|
||||
export * from "./realtime-wire";
|
||||
export * from "./provider-session";
|
||||
export * from "./provider-session-wire";
|
||||
export * from "./provider-rudp-transport";
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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:/);
|
||||
});
|
||||
|
|
@ -3,7 +3,6 @@ from __future__ import annotations
|
|||
import argparse
|
||||
from collections import defaultdict
|
||||
import json
|
||||
import re
|
||||
import selectors
|
||||
import signal
|
||||
import socket
|
||||
|
|
@ -56,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"
|
||||
|
|
@ -385,38 +384,9 @@ def handle_server_message(state: PeerState, message: dict[str, Any], subscriptio
|
|||
return [state.verse_catalog.create_response(message)]
|
||||
if schema_version == PEER_EXCHANGE_REQUEST:
|
||||
return [state.peer_catalog.create_response(message)]
|
||||
if is_unsupported_cultnet_version(schema_version):
|
||||
# R-2, owed by Cut 1 (docs/cultnet-selection-cut.md, section 9): this peer speaks
|
||||
# cultnet.selection.v1's carrier messages (snapshot_request.v1, database_subscribe.v1, and
|
||||
# any later vN) not at all - it is v0-only. A non-v0 message is refused loudly, not answered
|
||||
# with a silent empty list. The prior check only matched ".v1" exactly, so v2+ fell through
|
||||
# to the silent empty return below instead of being refused (R-L).
|
||||
return [unsupported_schema_version_error(message)]
|
||||
return []
|
||||
|
||||
|
||||
_CULTNET_SCHEMA_VERSION_SUFFIX = re.compile(r"^cultnet\..*\.v(\d+)$")
|
||||
|
||||
|
||||
def is_unsupported_cultnet_version(schema_version: Any) -> bool:
|
||||
"""True for any cultnet.*.vN with N > 0 - every version this v0-only peer does not speak (R-2/R-L)."""
|
||||
if not isinstance(schema_version, str):
|
||||
return False
|
||||
match = _CULTNET_SCHEMA_VERSION_SUFFIX.match(schema_version)
|
||||
return match is not None and int(match.group(1)) > 0
|
||||
|
||||
|
||||
def unsupported_schema_version_error(message: dict[str, Any]) -> dict[str, Any]:
|
||||
schema_version = message.get("schemaVersion")
|
||||
return {
|
||||
"schemaVersion": "cultnet.error.v0",
|
||||
"messageId": str(message.get("messageId") or ""),
|
||||
"error": f"Unsupported CultNet message schema: {schema_version!r}.",
|
||||
"code": "unsupported_schema_version",
|
||||
"details": {"schemaVersion": schema_version},
|
||||
}
|
||||
|
||||
|
||||
def handle_database_subscribe(state: PeerState, message: dict[str, Any], subscriptions: dict[str, DatabaseSubscription]) -> list[dict[str, Any]]:
|
||||
subscription_id = str(message.get("subscriptionId") or message.get("messageId") or "")
|
||||
if not subscription_id:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -22,12 +22,7 @@ from cultnet_py.compare_csharp import (
|
|||
_median_result,
|
||||
_parity_status,
|
||||
)
|
||||
from cultnet_py.interop_peer import (
|
||||
append_shard_log_put,
|
||||
build_state,
|
||||
handle_server_message,
|
||||
raw_snapshot_response,
|
||||
)
|
||||
from cultnet_py.interop_peer import append_shard_log_put, build_state, raw_snapshot_response
|
||||
from cultnet_py import (
|
||||
compute_simulation_claim_hash,
|
||||
CultNetDatabaseChange,
|
||||
|
|
@ -1682,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"])
|
||||
|
|
@ -1691,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")
|
||||
|
|
@ -1907,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 = {
|
||||
|
|
@ -1953,31 +1948,6 @@ class CultNetTests(unittest.TestCase):
|
|||
self.assertEqual(response["shardLogSequence"], 1)
|
||||
self.assertEqual([record["recordKey"] for record in response["documents"]], ["note:logged"])
|
||||
|
||||
def test_python_interop_peer_refuses_v1_selection_messages(self) -> None:
|
||||
# R-2, owed by Cut 1 (docs/cultnet-selection-cut.md, section 9): a v0-only peer refuses any
|
||||
# non-v0 message loudly - cultnet.error.v0 { code: "unsupported_schema_version" } - rather than
|
||||
# silently answering with an empty list. handle_server_message never touches `state` before
|
||||
# this refusal fires, so no PeerState fixture is needed here. R-L: the original check matched
|
||||
# only ".v1" exactly, so v2+ silently fell through to an empty list instead of being refused -
|
||||
# this covers v2 and v10 (double-digit) too, not only the exact v1 spelling.
|
||||
for schema_version in (
|
||||
"cultnet.snapshot_request.v1",
|
||||
"cultnet.database_subscribe.v1",
|
||||
"cultnet.snapshot_request.v2",
|
||||
"cultnet.database_subscribe.v2",
|
||||
"cultnet.snapshot_request.v10",
|
||||
):
|
||||
with self.subTest(schema_version=schema_version):
|
||||
responses = handle_server_message(
|
||||
None, # type: ignore[arg-type]
|
||||
{"schemaVersion": schema_version, "messageId": "v1-refused"},
|
||||
{},
|
||||
)
|
||||
self.assertEqual(len(responses), 1)
|
||||
self.assertEqual(responses[0]["schemaVersion"], "cultnet.error.v0")
|
||||
self.assertEqual(responses[0]["code"], "unsupported_schema_version")
|
||||
self.assertEqual(responses[0]["messageId"], "v1-refused")
|
||||
|
||||
def test_cultnet_simulation_consensus_dedupes_witnesses_and_requires_quorum(self) -> None:
|
||||
from cultnet_py import CultNetSimulationConsensus
|
||||
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
@ -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": [
|
||||
|
|
@ -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": [
|
||||
|
|
@ -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": [
|
||||
|
|
@ -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": [
|
||||
15
packages/cultnet-rs/contracts/cultnet.error.schema.json
Normal file
15
packages/cultnet-rs/contracts/cultnet.error.schema.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.error.schema.json",
|
||||
"title": "CultNet Error Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"error"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.error.v0" },
|
||||
"error": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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": [
|
||||
|
|
@ -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": [
|
||||
|
|
@ -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": [
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.raw-document-record.schema.json",
|
||||
"title": "CultNet Raw Document Record",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaId",
|
||||
"recordKey",
|
||||
"storedAt",
|
||||
"payloadEncoding",
|
||||
"payload"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaId": { "type": "string", "minLength": 1 },
|
||||
"recordKey": { "type": "string", "minLength": 1 },
|
||||
"storedAt": { "type": "string", "minLength": 1 },
|
||||
"payloadEncoding": { "const": "messagepack" },
|
||||
"payload": {
|
||||
"type": "string",
|
||||
"contentEncoding": "base64",
|
||||
"x-cult-runtime-type": "bytes"
|
||||
},
|
||||
"sourceRuntimeId": { "type": "string", "minLength": 1 },
|
||||
"sourceAgentId": { "type": "string", "minLength": 1 },
|
||||
"sourceRole": { "type": "string", "minLength": 1 },
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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": [
|
||||
|
|
@ -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": [
|
||||
|
|
@ -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": [
|
||||
|
|
@ -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": [
|
||||
|
|
@ -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": [
|
||||
|
|
@ -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": [
|
||||
|
|
@ -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,
|
||||
|
|
@ -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,
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.snapshot-request.schema.json",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.snapshot-request.schema.json",
|
||||
"title": "CultNet Snapshot Request Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
@ -18,13 +18,6 @@
|
|||
"recordKeys": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"shardId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"shardEpoch": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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": [
|
||||
|
|
@ -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": [
|
||||
|
|
@ -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": [
|
||||
|
|
@ -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": [
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.error.schema.json",
|
||||
"title": "CultNet Error Message",
|
||||
"description": "R-X (docs/cultnet-selection-cut.md, fix batch 4): CultNetErrorMessage is a MessagePack-CSharp map-mode object, so every declared key is written on the wire, nil included - routingHint, code and details are `required` here the same as cultnet.raw-document-header.schema.json's nullable fields, not optional just because a given refusal leaves them null.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"error",
|
||||
"routingHint",
|
||||
"code",
|
||||
"details"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.error.v0" },
|
||||
"error": { "type": "string", "minLength": 1 },
|
||||
"routingHint": {
|
||||
"description": "Optional shard routing detail for an authority failure. Null for every selection/cursor refusal code (R-N).",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"required": ["shard", "reason"],
|
||||
"properties": {
|
||||
"shard": { "type": ["object", "null"] },
|
||||
"reason": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"code": {
|
||||
"description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it.",
|
||||
"type": ["string", "null"],
|
||||
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target"]
|
||||
},
|
||||
"details": {
|
||||
"description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"required": ["field", "value", "asOf", "current"],
|
||||
"properties": {
|
||||
"field": { "description": "selection_invalid: the selection field that failed.", "type": ["string", "null"] },
|
||||
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming.", "type": ["string", "null"] },
|
||||
"asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 },
|
||||
"current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.raw-document-record.schema.json",
|
||||
"title": "CultNet Raw Document Record",
|
||||
"description": "R-S (docs/cultnet-selection-cut.md, fix batch 3): matches MessagePack-CSharp's map-mode output for CultNetRawDocumentRecord - every declared key is written, nil included, so every property here is `required` (present on the wire) even where its value may be null.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaId",
|
||||
"schemaName",
|
||||
"schemaVersion",
|
||||
"schemaContentHash",
|
||||
"recordKey",
|
||||
"storedAt",
|
||||
"payloadEncoding",
|
||||
"payload",
|
||||
"sourceRuntimeId",
|
||||
"sourceAgentId",
|
||||
"sourceRole",
|
||||
"tags"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaId": { "type": "string", "minLength": 1 },
|
||||
"schemaName": { "type": ["string", "null"], "minLength": 1 },
|
||||
"schemaVersion": { "type": ["string", "null"], "minLength": 1 },
|
||||
"schemaContentHash": { "type": ["string", "null"], "minLength": 1 },
|
||||
"recordKey": { "type": "string", "minLength": 1 },
|
||||
"storedAt": { "type": "string", "minLength": 1 },
|
||||
"payloadEncoding": { "const": "messagepack" },
|
||||
"payload": {
|
||||
"type": "string",
|
||||
"contentEncoding": "base64",
|
||||
"x-cult-runtime-type": "bytes"
|
||||
},
|
||||
"sourceRuntimeId": { "type": ["string", "null"], "minLength": 1 },
|
||||
"sourceAgentId": { "type": ["string", "null"], "minLength": 1 },
|
||||
"sourceRole": { "type": ["string", "null"], "minLength": 1 },
|
||||
"tags": {
|
||||
"type": ["array", "null"],
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
{
|
||||
"//": "CultNet typed selection, Cut 1 fix batch (docs/cultnet-selection-cut.md, Self's rulings for the Cut 1 fix batch, R-E and the shared-fixture ruling, 2026-09-22). The one row set both the C# reference and cultnet-rs build their parity vectors from. schemaId is each schema's real SHA-256 content-hash id, exactly as GameCult.Caching.CultDocumentRegistry computes it for the fixture's C# document types (leaf_a=ParityLeafA, leaf_b=ParityLeafB, citer=ParityCiter, citer_narrow=ParityCiterNarrow, tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs) - CultCache.cs's Sha256(semanticFingerprint), which carries no '.vN' suffix of its own; the version is folded into the hash, never appended as text. Both writers read this file instead of building their own fixture rows.",
|
||||
"//aliases": "Two DIFFERENT alias forms, now deliberate rather than a cross-runtime defect (S-12, 2026-09-22: the Rust alias port made this so - see docs/cultnet-selection-cut.md, 'Self's ruling, 2026-09-22: the C# reference's alias rule is the rule'). Both runtimes resolve a schema alias the same one way: strip a trailing .vN and compare the remainder against the descriptor's declared SchemaName (CultNetSchemaAliasMatching.Matches(candidate, CultDocumentDescriptor), src/GameCult.Networking/CultNetDatabase.cs:74-87; cultnet_rs::selection::schema_alias::matches ports it name-for-name and deletes the hash-stripping overload it used to carry). nameAlias is <SchemaName>.v9 and resolves through both runtimes under that one rule. hashAlias is <schemaId>.v1 and is refused as unmatched by both - it is not a wire form either runtime's production call sites ever produce or accept, and it is kept in the fixture specifically to pin that neither runtime resolves it.",
|
||||
"//references": "R-AF, 2026-09-23: a reference's `references[].targetKey` is the only thing a stored edge actually carries on the wire - GameCult.Caching.MessagePack.CultRecordRefFormatter<T> serializes a CultRecordRef<T> as a bare key string (probed: MessagePackSerializer.Serialize on a real Design field produced the single msgpack string 'a-eq', nothing else). This fixture used to also carry a `targetSchema` per reference; it was never anything but documentation - SelectionParityVectorTests.BuildDocument and cultnet-rs's build_row both read only targetKey to construct the edge - and it is removed rather than kept as a field the wire does not have. Resolving which row an edge names is by targetKey alone, filtered to the declared role's target leaves (D9); see citer_narrow below for the shared-key shape that rule is pinned against.",
|
||||
"schemas": {
|
||||
"leaf_a": {
|
||||
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
|
||||
"nameAlias": "leaf_a.v9",
|
||||
"hashAlias": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff.v1",
|
||||
"indexes": [
|
||||
{ "name": "kind", "numeric": false },
|
||||
{ "name": "mass", "numeric": true }
|
||||
]
|
||||
},
|
||||
"leaf_b": {
|
||||
"schemaId": "sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42",
|
||||
"nameAlias": "leaf_b.v9",
|
||||
"hashAlias": "sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42.v1",
|
||||
"indexes": [
|
||||
{ "name": "kind", "numeric": false },
|
||||
{ "name": "mass", "numeric": true }
|
||||
]
|
||||
},
|
||||
"citer": {
|
||||
"schemaId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc",
|
||||
"nameAlias": "citer.v9",
|
||||
"hashAlias": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc.v1",
|
||||
"roles": [
|
||||
{ "name": "Design", "targets": ["leaf_a", "leaf_b"] }
|
||||
]
|
||||
},
|
||||
"citer_narrow": {
|
||||
"//": "R-AF: a citer whose declared reference target is one leaf only (leaf_a), so a record key shared by a leaf_a row and a leaf_b row has exactly one candidate inside the declared target and exactly one outside it - the shape neither runtime's parity vectors covered before this cut (zero duplicate record keys across the original ten rows).",
|
||||
"schemaId": "sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d",
|
||||
"nameAlias": "citer_narrow.v9",
|
||||
"hashAlias": "sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d.v1",
|
||||
"roles": [
|
||||
{ "name": "narrow_ref", "targets": ["leaf_a"] }
|
||||
]
|
||||
}
|
||||
},
|
||||
"rows": [
|
||||
{ "schema": "leaf_a", "key": "a-lo", "ordinal": 1, "fields": { "kind": "weapon", "mass": "4" } },
|
||||
{ "schema": "leaf_a", "key": "a-eq", "ordinal": 2, "fields": { "kind": "weapon", "mass": "5" } },
|
||||
{ "schema": "leaf_b", "key": "b-hi", "ordinal": 3, "fields": { "kind": "armor", "mass": "6" } },
|
||||
{ "schema": "leaf_a", "key": "shared-1", "ordinal": 4, "fields": { "kind": "shared", "mass": "1" } },
|
||||
{ "schema": "leaf_b", "key": "shared-2", "ordinal": 5, "fields": { "kind": "shared", "mass": "1" } },
|
||||
{ "schema": "leaf_a", "key": "shared-3", "ordinal": 6, "fields": { "kind": "shared", "mass": "1" } },
|
||||
{
|
||||
"schema": "citer",
|
||||
"key": "citer-1",
|
||||
"ordinal": 7,
|
||||
"references": [ { "role": "Design", "targetKey": "a-eq" } ]
|
||||
},
|
||||
{
|
||||
"//": "R-D: 2233759.25 is exactly representable as an f32 and sits on a round-to-even tie between two shortest-form spellings; its exact canonical decimal is 2233759.25 either way. kind=tie so no weapon/armor/shared case can sweep it up.",
|
||||
"schema": "leaf_a", "key": "tie-mass", "ordinal": 8, "fields": { "kind": "tie", "mass": "2233759.25" }
|
||||
},
|
||||
{
|
||||
"//": "R-C: BMP private-use key (U+E000), same ordinal as the astral row below so the tiebreak - not the ordinal - decides order. kind=unicode, mass=0 so no unfiltered or mass>1 case reaches these.",
|
||||
"schema": "leaf_a", "key": "-row", "ordinal": 9, "fields": { "kind": "unicode", "mass": "0" }
|
||||
},
|
||||
{
|
||||
"//": "R-C: astral key (U+1F602, encoded as a surrogate pair whose high half 0xD83D is less than 0xE000 - a UTF-16 code-unit compare sorts this first; code-point order sorts it after the BMP row above).",
|
||||
"schema": "leaf_a", "key": "😂-row", "ordinal": 9, "fields": { "kind": "unicode", "mass": "0" }
|
||||
},
|
||||
{
|
||||
"//": "R-AF shape 1: the only row at this key is outside citer_narrow's declared target (leaf_a only) - resolving the edge below must refuse reference_outside_target in both runtimes, not silently accept an empty page. kind=narrow-fixture (not \"shared\") so this row never sweeps into shared_index_value_three_rows, which pins an exact count of 3.",
|
||||
"schema": "leaf_b", "key": "narrow-only", "ordinal": 10, "fields": { "kind": "narrow-fixture", "mass": "1" }
|
||||
},
|
||||
{
|
||||
"//": "R-AF shape 2: two rows share this key, one inside citer_narrow's declared target and one outside it - resolving the edge below must accept, deterministically picking the leaf_a row, in both runtimes.",
|
||||
"schema": "leaf_a", "key": "dup-key", "ordinal": 11, "fields": { "kind": "narrow-fixture", "mass": "1" }
|
||||
},
|
||||
{
|
||||
"schema": "leaf_b", "key": "dup-key", "ordinal": 12, "fields": { "kind": "narrow-fixture", "mass": "1" }
|
||||
},
|
||||
{
|
||||
"schema": "citer_narrow",
|
||||
"key": "narrow-citer-refused",
|
||||
"ordinal": 13,
|
||||
"references": [ { "role": "narrow_ref", "targetKey": "narrow-only" } ]
|
||||
},
|
||||
{
|
||||
"schema": "citer_narrow",
|
||||
"key": "narrow-citer-accepted",
|
||||
"ordinal": 14,
|
||||
"references": [ { "role": "narrow_ref", "targetKey": "dup-key" } ]
|
||||
},
|
||||
{
|
||||
"//": "R-AJ: a tied ordinal across two DIFFERENT schemas (leaf_a and leaf_b), not two rows of the same leaf like the astral/BMP pair above - the shape the row order's schemaId tiebreak needs and the original ten rows never had. Keys are anti-correlated with the real schema ids on purpose: leaf_b's real id sorts before leaf_a's (sha256:b1f2... < sha256:ff35...), so leaf_b gets the alphabetically-*later* key (\"zz-tie-schema\") and leaf_a gets the *earlier* one (\"aa-tie-schema\") - a schemaId-first order and a recordKey-first order disagree here on purpose, so only the correct composition produces leaf_b's row first.",
|
||||
"schema": "leaf_b", "key": "zz-tie-schema", "ordinal": 15, "fields": { "kind": "tie-schema", "mass": "1" }
|
||||
},
|
||||
{
|
||||
"schema": "leaf_a", "key": "aa-tie-schema", "ordinal": 15, "fields": { "kind": "tie-schema", "mass": "1" }
|
||||
},
|
||||
{
|
||||
"//": "R-AO, 2026-09-23: 'dup-key' already carries two rows (leaf_a ordinal 11, leaf_b ordinal 12) - both are in citer's Design role's declared target ([leaf_a, leaf_b], unlike citer_narrow's narrow_ref above, which only ever had one in-target candidate at a shared key). This citer is the first row in this file (hence in both runtimes' row order) to name 'dup-key' through Design, so resolving it must deterministically pick the leaf_a row - the first in-target candidate in the caller's order - never the leaf_b row, in both runtimes.",
|
||||
"schema": "citer",
|
||||
"key": "citer-both-in-target",
|
||||
"ordinal": 16,
|
||||
"references": [ { "role": "Design", "targetKey": "dup-key" } ]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ use serde::Deserialize;
|
|||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{Edge, RawDocumentHeader, Selection, SelectionDocumentRecord};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum CultNetWireContract {
|
||||
#[serde(rename = "cultnet.schema.v0")]
|
||||
|
|
@ -250,59 +248,6 @@ impl CultNetShardDescriptor {
|
|||
}
|
||||
}
|
||||
|
||||
/// Machine-readable `cultnet.error.v0` refusal code (R-N). Mirrors the four strings
|
||||
/// `contracts/cultnet/cultnet.error.schema.json` enumerates for `code`; a peer that receives a fifth
|
||||
/// string it does not know refuses to decode rather than guess a meaning for it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CultNetErrorCode {
|
||||
SelectionInvalid,
|
||||
CursorStale,
|
||||
CursorInvalid,
|
||||
ReferenceOutsideTarget,
|
||||
}
|
||||
|
||||
impl CultNetErrorCode {
|
||||
fn as_wire_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::SelectionInvalid => "selection_invalid",
|
||||
Self::CursorStale => "cursor_stale",
|
||||
Self::CursorInvalid => "cursor_invalid",
|
||||
Self::ReferenceOutsideTarget => "reference_outside_target",
|
||||
}
|
||||
}
|
||||
|
||||
fn from_wire_str(value: &str) -> Result<Self> {
|
||||
match value {
|
||||
"selection_invalid" => Ok(Self::SelectionInvalid),
|
||||
"cursor_stale" => Ok(Self::CursorStale),
|
||||
"cursor_invalid" => Ok(Self::CursorInvalid),
|
||||
"reference_outside_target" => Ok(Self::ReferenceOutsideTarget),
|
||||
other => Err(anyhow!("ErrorMessage.Code {other:?} is not a recognized cultnet.error.v0 code")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Code-specific structured detail carried on `cultnet.error.v0`'s `code`/`details` (R-N). Mirrors
|
||||
/// `CultNetErrorDetails`: every field is populated only for the code that defines it and is nil
|
||||
/// (present, not omitted) on the wire otherwise, matching the C# reference's always-five-key map.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CultNetErrorDetails {
|
||||
/// The selection field that failed (`selection_invalid`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub field: Option<String>,
|
||||
/// The offending value, when there is one string worth naming (`selection_invalid`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub value: Option<String>,
|
||||
/// The cursor's own minted asOf (`cursor_stale`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub as_of: Option<u64>,
|
||||
/// The answering server's current asOf (`cursor_stale`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "schemaVersion")]
|
||||
pub enum CultNetMessage {
|
||||
|
|
@ -344,22 +289,8 @@ pub enum CultNetMessage {
|
|||
Verify { nonce: String, session: String },
|
||||
#[serde(rename = "cultnet.login_success.v0", rename_all = "camelCase")]
|
||||
LoginSuccess { nonce: String, session: String },
|
||||
// R-N (docs/cultnet-selection-cut.md, fix batch 3): `code`/`details` mirror the landed C#
|
||||
// reference (CultNetSchemaMessages.cs) exactly, including its always-five-key map shape
|
||||
// (`schemaVersion`, `error`, `routingHint`, `code`, `details`, every optional key nil rather
|
||||
// than omitted). `Error` is therefore encoded/decoded through the raw
|
||||
// `cultnet.schema.v0` path (parse_raw_cultnet_schema_message/encode_raw_cultnet_schema_message)
|
||||
// like the other messages that need exact wire control, not through this derive - the derive
|
||||
// attributes below exist only so the enum as a whole still compiles and round-trips through the
|
||||
// generic `serde_json::Value` path other tooling may still reach for.
|
||||
#[serde(rename = "cultnet.error.v0", rename_all = "camelCase")]
|
||||
Error {
|
||||
error: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
code: Option<CultNetErrorCode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
details: Option<CultNetErrorDetails>,
|
||||
},
|
||||
Error { error: String },
|
||||
#[serde(rename = "cultnet.sample.change_name.v0", rename_all = "camelCase")]
|
||||
SampleChangeName { name: String },
|
||||
#[serde(rename = "cultnet.sample.chat.v0", rename_all = "camelCase")]
|
||||
|
|
@ -398,56 +329,6 @@ pub enum CultNetMessage {
|
|||
message_id: String,
|
||||
documents: Vec<CultNetRawDocumentRecord>,
|
||||
},
|
||||
/// CultNet typed selection, Cut 1 (docs/cultnet-selection-cut.md section 2/7). Mirrors
|
||||
/// `CultNetSnapshotRequestV1Message`.
|
||||
#[serde(rename = "cultnet.snapshot_request.v1", rename_all = "camelCase")]
|
||||
SnapshotRequestV1 {
|
||||
message_id: String,
|
||||
selection: Selection,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
shard_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
shard_epoch: Option<i64>,
|
||||
},
|
||||
/// Mirrors `CultNetDatabaseSubscribeV1Message`.
|
||||
#[serde(rename = "cultnet.database_subscribe.v1", rename_all = "camelCase")]
|
||||
DatabaseSubscribeV1 {
|
||||
message_id: String,
|
||||
subscription_id: String,
|
||||
selection: Selection,
|
||||
#[serde(default = "true_default")]
|
||||
include_snapshot: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
consumer_runtime_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
body_ids: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
supported_body_transports: Option<Vec<String>>,
|
||||
},
|
||||
/// `cultnet.snapshot_response_raw.v1` (section 2: `SelectionPage`). Binary payload, so this is
|
||||
/// carried through `encode_raw_cultnet_schema_message`/`parse_raw_cultnet_schema_message` like
|
||||
/// `SnapshotResponseRaw` above - a JSON intermediate cannot losslessly carry `documents[].payload`
|
||||
/// or `edges[].payload`. Mirrors `CultNetSnapshotResponseRawV1Message`.
|
||||
#[serde(rename = "cultnet.snapshot_response_raw.v1", rename_all = "camelCase")]
|
||||
SnapshotResponseRawV1 {
|
||||
message_id: String,
|
||||
matched: u32,
|
||||
as_of: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
next: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
headers: Option<Vec<RawDocumentHeader>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
documents: Option<Vec<SelectionDocumentRecord>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
edges: Option<Vec<Edge>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
shard_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
shard_epoch: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
shard_log_sequence: Option<i64>,
|
||||
},
|
||||
#[serde(rename = "cultnet.schema_catalog_request.v0", rename_all = "camelCase")]
|
||||
SchemaCatalogRequest {
|
||||
message_id: String,
|
||||
|
|
@ -514,10 +395,6 @@ fn messagepack_base64_encoding() -> String {
|
|||
"messagepack-base64".to_string()
|
||||
}
|
||||
|
||||
fn true_default() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn parse_cultnet_message(
|
||||
wire_value: &rmpv::Value,
|
||||
contract: CultNetWireContract,
|
||||
|
|
@ -525,12 +402,9 @@ pub fn parse_cultnet_message(
|
|||
match contract {
|
||||
CultNetWireContract::CultNetSchemaV0 => {
|
||||
let message = match schema_version_from_wire_value(wire_value)? {
|
||||
Some(
|
||||
"cultnet.document_put_raw.v0"
|
||||
| "cultnet.snapshot_response_raw.v0"
|
||||
| "cultnet.snapshot_response_raw.v1"
|
||||
| "cultnet.error.v0",
|
||||
) => parse_raw_cultnet_schema_message(wire_value)?,
|
||||
Some("cultnet.document_put_raw.v0" | "cultnet.snapshot_response_raw.v0") => {
|
||||
parse_raw_cultnet_schema_message(wire_value)?
|
||||
}
|
||||
_ => {
|
||||
let json_value: Value = rmp_serde::from_slice(&rmp_serde::to_vec(wire_value)?)?;
|
||||
serde_json::from_value(json_value)?
|
||||
|
|
@ -550,10 +424,9 @@ pub fn encode_cultnet_message_for_wire(
|
|||
validate_message(message)?;
|
||||
match contract {
|
||||
CultNetWireContract::CultNetSchemaV0 => match message {
|
||||
CultNetMessage::DocumentPutRaw { .. }
|
||||
| CultNetMessage::SnapshotResponseRaw { .. }
|
||||
| CultNetMessage::SnapshotResponseRawV1 { .. }
|
||||
| CultNetMessage::Error { .. } => encode_raw_cultnet_schema_message(message),
|
||||
CultNetMessage::DocumentPutRaw { .. } | CultNetMessage::SnapshotResponseRaw { .. } => {
|
||||
encode_raw_cultnet_schema_message(message)
|
||||
}
|
||||
_ => Ok(rmp_serde::from_slice(&rmp_serde::to_vec(
|
||||
&serde_json::to_value(message)?,
|
||||
)?)?),
|
||||
|
|
@ -640,7 +513,7 @@ fn validate_message(message: &CultNetMessage) -> Result<()> {
|
|||
require_non_empty(nonce, "nonce")?;
|
||||
require_non_empty(session, "session")?;
|
||||
}
|
||||
CultNetMessage::Error { error, code: _, details: _ } => require_non_empty(error, "error")?,
|
||||
CultNetMessage::Error { error } => require_non_empty(error, "error")?,
|
||||
CultNetMessage::SampleChangeName { name } => require_non_empty(name, "name")?,
|
||||
CultNetMessage::SampleChat { text } => require_non_empty(text, "text")?,
|
||||
CultNetMessage::DocumentPut {
|
||||
|
|
@ -693,57 +566,6 @@ fn validate_message(message: &CultNetMessage) -> Result<()> {
|
|||
validate_raw_document_record(document)?;
|
||||
}
|
||||
}
|
||||
CultNetMessage::SnapshotRequestV1 {
|
||||
message_id,
|
||||
// The selection's own declared-alias semantics need a schema registry this wire-level
|
||||
// deserialization does not have; the C# reference does not validate CultNetSelection at
|
||||
// this layer either (CultNetSchemaMessageSerialization.cs just deserializes it). A
|
||||
// server-side caller runs `selection::validate` against its RowSet before evaluating.
|
||||
selection: _,
|
||||
shard_id,
|
||||
shard_epoch: _,
|
||||
} => {
|
||||
require_non_empty(message_id, "messageId")?;
|
||||
require_optional_non_empty(shard_id.as_deref(), "shardId")?;
|
||||
}
|
||||
CultNetMessage::DatabaseSubscribeV1 {
|
||||
message_id,
|
||||
subscription_id,
|
||||
selection: _,
|
||||
include_snapshot: _,
|
||||
consumer_runtime_id,
|
||||
body_ids,
|
||||
supported_body_transports,
|
||||
} => {
|
||||
require_non_empty(message_id, "messageId")?;
|
||||
require_non_empty(subscription_id, "subscriptionId")?;
|
||||
require_optional_non_empty(consumer_runtime_id.as_deref(), "consumerRuntimeId")?;
|
||||
require_optional_string_vec(body_ids.as_deref(), "bodyIds")?;
|
||||
require_optional_string_vec(
|
||||
supported_body_transports.as_deref(),
|
||||
"supportedBodyTransports",
|
||||
)?;
|
||||
}
|
||||
CultNetMessage::SnapshotResponseRawV1 {
|
||||
message_id,
|
||||
matched: _,
|
||||
as_of: _,
|
||||
next: _,
|
||||
headers,
|
||||
documents,
|
||||
edges: _,
|
||||
shard_id,
|
||||
shard_epoch: _,
|
||||
shard_log_sequence: _,
|
||||
} => {
|
||||
require_non_empty(message_id, "messageId")?;
|
||||
if headers.is_some() == documents.is_some() {
|
||||
return Err(anyhow!(
|
||||
"CultNet field exactly one of headers/documents must be present"
|
||||
));
|
||||
}
|
||||
require_optional_non_empty(shard_id.as_deref(), "shardId")?;
|
||||
}
|
||||
CultNetMessage::SchemaCatalogRequest {
|
||||
message_id,
|
||||
include_schema_json: _,
|
||||
|
|
@ -981,136 +803,12 @@ fn parse_raw_cultnet_schema_message(input: &rmpv::Value) -> Result<CultNetMessag
|
|||
documents,
|
||||
})
|
||||
}
|
||||
"cultnet.snapshot_response_raw.v1" => {
|
||||
let headers = get("headers")
|
||||
.filter(|value| !value.is_nil())
|
||||
.map(|value| {
|
||||
value
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow!("headers must be an array"))?
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| {
|
||||
require_typed::<RawDocumentHeader>(Some(value), &format!("headers[{index}]"))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()
|
||||
})
|
||||
.transpose()?;
|
||||
let documents = get("documents")
|
||||
.filter(|value| !value.is_nil())
|
||||
.map(|value| {
|
||||
value
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow!("documents must be an array"))?
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| {
|
||||
require_typed::<SelectionDocumentRecord>(
|
||||
Some(value),
|
||||
&format!("documents[{index}]"),
|
||||
)
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()
|
||||
})
|
||||
.transpose()?;
|
||||
let edges = get("edges")
|
||||
.filter(|value| !value.is_nil())
|
||||
.map(|value| {
|
||||
value
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow!("edges must be an array"))?
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| {
|
||||
require_typed::<Edge>(Some(value), &format!("edges[{index}]"))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
Ok(CultNetMessage::SnapshotResponseRawV1 {
|
||||
message_id: require_legacy_string(get("messageId"), "messageId")?,
|
||||
matched: require_legacy_u32(get("matched"), "matched")?,
|
||||
as_of: require_legacy_u64(get("asOf"), "asOf")?,
|
||||
next: require_legacy_optional_string(get("next"), "next")?,
|
||||
headers,
|
||||
documents,
|
||||
edges,
|
||||
shard_id: require_legacy_optional_string(get("shardId"), "shardId")?,
|
||||
shard_epoch: require_legacy_optional_i64(get("shardEpoch"), "shardEpoch")?,
|
||||
shard_log_sequence: require_legacy_optional_i64(
|
||||
get("shardLogSequence"),
|
||||
"shardLogSequence",
|
||||
)?,
|
||||
})
|
||||
}
|
||||
"cultnet.error.v0" => {
|
||||
let code = get("code")
|
||||
.filter(|value| !value.is_nil())
|
||||
.map(|value| {
|
||||
value
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("ErrorMessage.Code must be a string"))
|
||||
.and_then(CultNetErrorCode::from_wire_str)
|
||||
})
|
||||
.transpose()?;
|
||||
let details = require_error_details(get("details"), "ErrorMessage.Details")?;
|
||||
Ok(CultNetMessage::Error {
|
||||
error: require_legacy_string(get("error"), "ErrorMessage.Error")?,
|
||||
code,
|
||||
details,
|
||||
})
|
||||
}
|
||||
_ => Err(anyhow!(
|
||||
"Unsupported raw cultnet.schema.v0 schemaVersion {schema_version}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn require_error_details(
|
||||
value: Option<&rmpv::Value>,
|
||||
field_name: &str,
|
||||
) -> Result<Option<CultNetErrorDetails>> {
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
if value.is_nil() {
|
||||
return Ok(None);
|
||||
}
|
||||
let object = value
|
||||
.as_map()
|
||||
.ok_or_else(|| anyhow!("{field_name} must be an object"))?;
|
||||
let get = |name: &str| -> Option<&rmpv::Value> {
|
||||
object.iter().find_map(|(key, value)| {
|
||||
key.as_str()
|
||||
.filter(|candidate| *candidate == name)
|
||||
.map(|_| value)
|
||||
})
|
||||
};
|
||||
Ok(Some(CultNetErrorDetails {
|
||||
field: require_legacy_optional_string(get("field"), "ErrorMessage.Details.Field")?,
|
||||
value: require_legacy_optional_string(get("value"), "ErrorMessage.Details.Value")?,
|
||||
as_of: require_legacy_optional_u64(get("asOf"), "ErrorMessage.Details.AsOf")?,
|
||||
current: require_legacy_optional_u64(get("current"), "ErrorMessage.Details.Current")?,
|
||||
}))
|
||||
}
|
||||
|
||||
fn require_legacy_optional_u64(
|
||||
value: Option<&rmpv::Value>,
|
||||
field_name: &str,
|
||||
) -> Result<Option<u64>> {
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
if value.is_nil() {
|
||||
return Ok(None);
|
||||
}
|
||||
value
|
||||
.as_u64()
|
||||
.map(Some)
|
||||
.ok_or_else(|| anyhow!("{field_name} must be a non-negative integer"))
|
||||
}
|
||||
|
||||
fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result<rmpv::Value> {
|
||||
Ok(rmpv::Value::Map(match message {
|
||||
CultNetMessage::DocumentPutRaw {
|
||||
|
|
@ -1147,99 +845,6 @@ fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result<rmpv::V
|
|||
rmpv::Value::Array(documents.iter().map(encode_raw_document_record).collect()),
|
||||
),
|
||||
],
|
||||
CultNetMessage::SnapshotResponseRawV1 {
|
||||
message_id,
|
||||
matched,
|
||||
as_of,
|
||||
next,
|
||||
headers,
|
||||
documents,
|
||||
edges,
|
||||
shard_id,
|
||||
shard_epoch,
|
||||
shard_log_sequence,
|
||||
} => vec![
|
||||
(
|
||||
rmpv::Value::from("schemaVersion"),
|
||||
rmpv::Value::from("cultnet.snapshot_response_raw.v1"),
|
||||
),
|
||||
(
|
||||
rmpv::Value::from("messageId"),
|
||||
rmpv::Value::from(message_id.as_str()),
|
||||
),
|
||||
(rmpv::Value::from("matched"), rmpv::Value::from(*matched)),
|
||||
(rmpv::Value::from("asOf"), rmpv::Value::from(*as_of)),
|
||||
(
|
||||
rmpv::Value::from("next"),
|
||||
next.as_deref().map(rmpv::Value::from).unwrap_or(rmpv::Value::Nil),
|
||||
),
|
||||
(
|
||||
rmpv::Value::from("headers"),
|
||||
headers
|
||||
.as_deref()
|
||||
.map(|headers| rmpv::Value::Array(headers.iter().map(encode_typed).collect()))
|
||||
.unwrap_or(rmpv::Value::Nil),
|
||||
),
|
||||
(
|
||||
rmpv::Value::from("documents"),
|
||||
documents
|
||||
.as_deref()
|
||||
.map(|documents| rmpv::Value::Array(documents.iter().map(encode_typed).collect()))
|
||||
.unwrap_or(rmpv::Value::Nil),
|
||||
),
|
||||
(
|
||||
rmpv::Value::from("edges"),
|
||||
edges
|
||||
.as_deref()
|
||||
.map(|edges| rmpv::Value::Array(edges.iter().map(encode_typed).collect()))
|
||||
.unwrap_or(rmpv::Value::Nil),
|
||||
),
|
||||
(
|
||||
rmpv::Value::from("shardId"),
|
||||
shard_id.as_deref().map(rmpv::Value::from).unwrap_or(rmpv::Value::Nil),
|
||||
),
|
||||
(
|
||||
rmpv::Value::from("shardEpoch"),
|
||||
shard_epoch.map(rmpv::Value::from).unwrap_or(rmpv::Value::Nil),
|
||||
),
|
||||
(
|
||||
rmpv::Value::from("shardLogSequence"),
|
||||
shard_log_sequence
|
||||
.map(rmpv::Value::from)
|
||||
.unwrap_or(rmpv::Value::Nil),
|
||||
),
|
||||
],
|
||||
// R-N: matches the landed C# `CultNetErrorMessage`
|
||||
// (src/GameCult.Networking/CultNetSchemaMessages.cs) key for key and in its declared
|
||||
// order - schemaVersion, error, routingHint, code, details - with every optional key
|
||||
// present as nil rather than omitted. Rust never mints a routingHint today, so that key is
|
||||
// always nil on encode; a peer that sent one is ignored on decode, same as any other v0
|
||||
// peer that does not know a field it did not ask for.
|
||||
CultNetMessage::Error {
|
||||
error,
|
||||
code,
|
||||
details,
|
||||
} => vec![
|
||||
(
|
||||
rmpv::Value::from("schemaVersion"),
|
||||
rmpv::Value::from("cultnet.error.v0"),
|
||||
),
|
||||
(rmpv::Value::from("error"), rmpv::Value::from(error.as_str())),
|
||||
(rmpv::Value::from("routingHint"), rmpv::Value::Nil),
|
||||
(
|
||||
rmpv::Value::from("code"),
|
||||
code.as_ref()
|
||||
.map(|code| rmpv::Value::from(code.as_wire_str()))
|
||||
.unwrap_or(rmpv::Value::Nil),
|
||||
),
|
||||
(
|
||||
rmpv::Value::from("details"),
|
||||
details
|
||||
.as_ref()
|
||||
.map(encode_error_details)
|
||||
.unwrap_or(rmpv::Value::Nil),
|
||||
),
|
||||
],
|
||||
_ => {
|
||||
return Err(anyhow!(
|
||||
"Message is not a raw cultnet.schema.v0 binary replication message"
|
||||
|
|
@ -1248,35 +853,6 @@ fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result<rmpv::V
|
|||
}))
|
||||
}
|
||||
|
||||
fn encode_error_details(details: &CultNetErrorDetails) -> rmpv::Value {
|
||||
rmpv::Value::Map(vec![
|
||||
(
|
||||
rmpv::Value::from("field"),
|
||||
details
|
||||
.field
|
||||
.as_deref()
|
||||
.map(rmpv::Value::from)
|
||||
.unwrap_or(rmpv::Value::Nil),
|
||||
),
|
||||
(
|
||||
rmpv::Value::from("value"),
|
||||
details
|
||||
.value
|
||||
.as_deref()
|
||||
.map(rmpv::Value::from)
|
||||
.unwrap_or(rmpv::Value::Nil),
|
||||
),
|
||||
(
|
||||
rmpv::Value::from("asOf"),
|
||||
details.as_of.map(rmpv::Value::from).unwrap_or(rmpv::Value::Nil),
|
||||
),
|
||||
(
|
||||
rmpv::Value::from("current"),
|
||||
details.current.map(rmpv::Value::from).unwrap_or(rmpv::Value::Nil),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
fn require_raw_document_record(
|
||||
value: Option<&rmpv::Value>,
|
||||
field_name: &str,
|
||||
|
|
@ -1385,65 +961,6 @@ fn encode_raw_document_record(document: &CultNetRawDocumentRecord) -> rmpv::Valu
|
|||
])
|
||||
}
|
||||
|
||||
// R-M: `SelectionDocumentRecord`, `RawDocumentHeader`, `Edge` and `RecordRef` already derive
|
||||
// `Serialize`/`Deserialize` (selection.rs) with the same camelCase field names this hand-written
|
||||
// codec used to spell out by hand - about 335 lines duplicating what those derives already do.
|
||||
//
|
||||
// `rmpv::ext::to_value`/`from_value` looked like the natural generic bridge, but they serialize a
|
||||
// struct positionally (`serialize_struct` as a MessagePack array, one slot per field in
|
||||
// declaration order, breaking as soon as a `skip_serializing_if` field is actually skipped) -
|
||||
// wrong for these types twice over: it is not the named-map encoding this wire always uses
|
||||
// (section 10's rule; `rmp_serde::to_vec_named` elsewhere in this crate), and it does not
|
||||
// round-trip at all once a field is omitted. These two helpers instead go through
|
||||
// `rmp_serde::to_vec_named`/`from_slice` (named-map bytes, proven both ways by
|
||||
// `tests/selection.rs`'s `snapshot_response_raw_v1_round_trips_*` tests) and hand the bytes to
|
||||
// `rmpv::Value`'s own `Serialize`/`Deserialize` (already used throughout this file, e.g. to decode
|
||||
// the outer message envelope) to shuttle to/from the `rmpv::Value` tree the rest of this codec
|
||||
// builds by hand.
|
||||
|
||||
fn require_typed<T: serde::de::DeserializeOwned>(
|
||||
value: Option<&rmpv::Value>,
|
||||
field_name: &str,
|
||||
) -> Result<T> {
|
||||
let value = value.ok_or_else(|| anyhow!("{field_name} is required"))?;
|
||||
let bytes = rmp_serde::to_vec(value).map_err(|err| anyhow!("{field_name}: {err}"))?;
|
||||
rmp_serde::from_slice(&bytes).map_err(|err| anyhow!("{field_name}: {err}"))
|
||||
}
|
||||
|
||||
fn encode_typed<T: Serialize>(value: &T) -> rmpv::Value {
|
||||
let bytes = rmp_serde::to_vec_named(value).expect("selection wire types always encode to MessagePack");
|
||||
rmp_serde::from_slice(&bytes).expect("named-map MessagePack bytes always decode into rmpv::Value")
|
||||
}
|
||||
|
||||
fn require_legacy_u32(value: Option<&rmpv::Value>, field_name: &str) -> Result<u32> {
|
||||
let number = value
|
||||
.and_then(rmpv::Value::as_u64)
|
||||
.ok_or_else(|| anyhow!("{field_name} must be a non-negative integer"))?;
|
||||
u32::try_from(number).map_err(|_| anyhow!("{field_name} does not fit in u32"))
|
||||
}
|
||||
|
||||
fn require_legacy_u64(value: Option<&rmpv::Value>, field_name: &str) -> Result<u64> {
|
||||
value
|
||||
.and_then(rmpv::Value::as_u64)
|
||||
.ok_or_else(|| anyhow!("{field_name} must be a non-negative integer"))
|
||||
}
|
||||
|
||||
fn require_legacy_optional_i64(
|
||||
value: Option<&rmpv::Value>,
|
||||
field_name: &str,
|
||||
) -> Result<Option<i64>> {
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
if value.is_nil() {
|
||||
return Ok(None);
|
||||
}
|
||||
value
|
||||
.as_i64()
|
||||
.map(Some)
|
||||
.ok_or_else(|| anyhow!("{field_name} must be an integer"))
|
||||
}
|
||||
|
||||
fn parse_gamecult_networking_message(input: &rmpv::Value) -> Result<CultNetMessage> {
|
||||
let items = input.as_array().ok_or_else(|| {
|
||||
anyhow!("gamecult.networking.v0 messages must be a 2-element union array")
|
||||
|
|
@ -1481,11 +998,6 @@ fn parse_gamecult_networking_message(input: &rmpv::Value) -> Result<CultNetMessa
|
|||
}),
|
||||
4 => Ok(CultNetMessage::Error {
|
||||
error: require_legacy_string(payload.first(), "ErrorMessage.Error")?,
|
||||
// gamecult.networking.v0 is the legacy union tag/array contract, unrelated to R-N's
|
||||
// cultnet.error.v0 map shape; it never carried code/details before and does not gain
|
||||
// them now.
|
||||
code: None,
|
||||
details: None,
|
||||
}),
|
||||
5 => Ok(CultNetMessage::SampleChangeName {
|
||||
name: require_legacy_string(payload.first(), "ChangeNameMessage.Name")?,
|
||||
|
|
@ -1569,7 +1081,7 @@ fn encode_gamecult_networking_message(message: &CultNetMessage) -> Result<rmpv::
|
|||
legacy_bytes(session, "LoginSuccessMessage.Session")?,
|
||||
]),
|
||||
],
|
||||
CultNetMessage::Error { error, .. } => {
|
||||
CultNetMessage::Error { error } => {
|
||||
vec![
|
||||
rmpv::Value::from(4),
|
||||
rmpv::Value::Array(vec![error.as_str().into()]),
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ mod replication;
|
|||
mod rudp;
|
||||
mod schema_discovery;
|
||||
mod security;
|
||||
mod selection;
|
||||
mod shard_catalog;
|
||||
mod transport;
|
||||
|
||||
|
|
@ -36,6 +35,5 @@ pub use replication::*;
|
|||
pub use rudp::*;
|
||||
pub use schema_discovery::*;
|
||||
pub use security::*;
|
||||
pub use selection::*;
|
||||
pub use shard_catalog::*;
|
||||
pub use transport::*;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -259,7 +259,7 @@ fn hmac_sha256(key: &[u8], input: &[u8]) -> Result<Vec<u8>> {
|
|||
Ok(mac.finalize().into_bytes().to_vec())
|
||||
}
|
||||
|
||||
pub(crate) fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
|
||||
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
|
||||
if left.len() != right.len() {
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
|||
use anyhow::{Result, anyhow};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::{CultNetDocumentRegistry, CultNetMessage, CultNetRawDocumentRecord, Row, Selection};
|
||||
use crate::{CultNetDocumentRegistry, CultNetMessage, CultNetRawDocumentRecord};
|
||||
|
||||
/// Read-only backing surface for a CultNet snapshot server.
|
||||
///
|
||||
|
|
@ -76,31 +76,15 @@ pub fn serve_read_only_raw_snapshot<S: CultNetRawSnapshotSource>(
|
|||
return Err(anyhow!("expected cultnet.snapshot_request.v0"));
|
||||
};
|
||||
require_non_empty(message_id, "message_id")?;
|
||||
// R-Q3/R-R (docs/cultnet-selection-cut.md, Self's rulings for fix batch 3, 2026-09-22 -
|
||||
// corrects this comment's own earlier ruling from the commit 2 fix batch, which lowered an
|
||||
// empty v0 list to "no filter"): `None` (the field omitted) still means "no filter", but a
|
||||
// *present* v0 list - even one that empties out after trimming/deduping blanks - must lower to
|
||||
// `Some(possibly-empty)`, never collapse to `None`. `matches_schema_keys_fields` already
|
||||
// treats a present-but-empty selection list as "match nothing" (R-E), which is v0's own
|
||||
// pre-cut behaviour (`recordKeys: []` answers empty, `CultNetDocumentRegistry.
|
||||
// CreateRawSnapshotResponse` tests `filter?.RecordKeys != null`, not a length); collapsing to
|
||||
// `None` here used to turn that into "match everything" instead. Unlike the C# reference, this
|
||||
// function never calls `validate`, so there is no v1 door for a present-but-empty list to hit
|
||||
// (R-R's fixup on the C# side does not apply here).
|
||||
// R-M: `lower_v0_list` already dedups (Soul: it dedups before `reject_duplicates` ever ran,
|
||||
// making that function's own duplicate check dead code - deleted below along with its calls).
|
||||
let schema_ids = lower_v0_list(schema_ids);
|
||||
let record_keys = lower_v0_list(record_keys);
|
||||
reject_duplicates(schema_ids.as_deref(), "requested schema id")?;
|
||||
reject_duplicates(record_keys.as_deref(), "requested record key")?;
|
||||
|
||||
// D3/D4's v0 lowering (docs/cultnet-selection-cut.md section 4): the two allowlists are not a
|
||||
// selector of their own, they are cultnet.snapshot_request.v0 lowered into a Selection with no
|
||||
// fields/cites/cited, matched through the one evaluator (selection::matches) instead of a fifth
|
||||
// hand-rolled copy of "is this schema/key requested".
|
||||
let selection = Selection {
|
||||
schemas: schema_ids.clone(),
|
||||
keys: record_keys.clone(),
|
||||
..Selection::default()
|
||||
};
|
||||
let requested_schemas = schema_ids
|
||||
.as_ref()
|
||||
.map(|values| values.iter().map(String::as_str).collect::<BTreeSet<_>>());
|
||||
let requested_keys = record_keys
|
||||
.as_ref()
|
||||
.map(|values| values.iter().map(String::as_str).collect::<BTreeSet<_>>());
|
||||
let mut selected = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
|
||||
|
|
@ -126,60 +110,27 @@ pub fn serve_read_only_raw_snapshot<S: CultNetRawSnapshotSource>(
|
|||
if !policy.allows(&document.schema_id, &document.record_key) {
|
||||
continue;
|
||||
}
|
||||
if !crate::matches(&RawSnapshotRow(&document), &selection) {
|
||||
if requested_schemas
|
||||
.as_ref()
|
||||
.is_some_and(|schemas| !schemas.contains(document.schema_id.as_str()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if requested_keys
|
||||
.as_ref()
|
||||
.is_some_and(|keys| !keys.contains(document.record_key.as_str()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
selected.push(document);
|
||||
}
|
||||
|
||||
// R-R: pre-cut v0 iterated the requested record keys in the caller's own order (a
|
||||
// `HashSet<string>` built from the array, which enumerates in insertion order); the shared
|
||||
// evaluator carries no such tiebreak of its own, so this v0 lowering restores the requested
|
||||
// order here rather than leaking plain snapshot-source order when the caller asked for
|
||||
// particular keys. A stable sort keeps the relative order of documents that share a requested
|
||||
// key (e.g. across schemas) as `raw_snapshot()` produced them.
|
||||
if let Some(keys) = &record_keys {
|
||||
selected.sort_by_key(|document| {
|
||||
keys.iter()
|
||||
.position(|key| key == &document.record_key)
|
||||
.unwrap_or(usize::MAX)
|
||||
});
|
||||
}
|
||||
|
||||
Ok(CultNetMessage::SnapshotResponseRaw {
|
||||
message_id: message_id.clone(),
|
||||
documents: selected,
|
||||
})
|
||||
}
|
||||
|
||||
/// A `CultNetRawDocumentRecord` carries only identity (schema id, record key) at this layer - no
|
||||
/// declared index values, no ordinal, no reference edges, because it is already-serialized bytes
|
||||
/// from a read-only source, not a live document. The v0 lowering above never sets `fields`, `cites`
|
||||
/// or `cited`, so `matches_schema_keys_fields` never calls `ordinal`/`values`/`number`/`references`
|
||||
/// on this row; the unreachable stubs exist only to satisfy the `Row` trait's shape.
|
||||
struct RawSnapshotRow<'a>(&'a CultNetRawDocumentRecord);
|
||||
|
||||
impl Row for RawSnapshotRow<'_> {
|
||||
fn schema_id(&self) -> &str {
|
||||
&self.0.schema_id
|
||||
}
|
||||
fn record_key(&self) -> &str {
|
||||
&self.0.record_key
|
||||
}
|
||||
fn ordinal(&self) -> i64 {
|
||||
unreachable!("v0's lowered selection carries no fields/cites/cited; ordinal is not read")
|
||||
}
|
||||
fn values(&self, _index: &str) -> Vec<String> {
|
||||
unreachable!("v0's lowered selection carries no fields; values is not read")
|
||||
}
|
||||
fn number(&self, _index: &str) -> Option<String> {
|
||||
unreachable!("v0's lowered selection carries no fields; number is not read")
|
||||
}
|
||||
fn references(&self) -> Vec<(String, String, Option<Vec<u8>>)> {
|
||||
unreachable!("v0's lowered selection carries no cites/cited; references is not read")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct CultNetSnapshotSourceExpectation {
|
||||
pub runtime_id: Option<String>,
|
||||
|
|
@ -366,30 +317,23 @@ fn require_expected_metadata(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn reject_duplicates(values: Option<&[String]>, label: &str) -> Result<()> {
|
||||
let Some(values) = values else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut seen = BTreeSet::new();
|
||||
for value in values {
|
||||
require_non_empty(value, label)?;
|
||||
if !seen.insert(value) {
|
||||
return Err(anyhow!("duplicate {label} {value:?}"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_non_empty(value: &str, field: &str) -> Result<()> {
|
||||
if value.trim().is_empty() {
|
||||
return Err(anyhow!("{field} must be non-empty"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v0's own cleaning, matching the reference's `CultNetV0SelectionLowering.Lower` exactly
|
||||
/// (568e8e3): an absent list, an empty list, or a list made only of blank entries all lower to
|
||||
/// `None` (no filter); a mixed list keeps its non-blank entries, deduplicated (order-preserving,
|
||||
/// first occurrence wins - `Distinct(StringComparer.Ordinal)`'s behaviour). A v0 compatibility
|
||||
/// rule the lowering owns, not a meaning the door or the evaluator carries for v1's own lists
|
||||
/// (those refuse `[]` and any blank entry outright, in `selection::validate`).
|
||||
fn lower_v0_list(list: &Option<Vec<String>>) -> Option<Vec<String>> {
|
||||
// R-Q3: `None` (the field omitted) still means "no filter"; a present list is always lowered
|
||||
// to `Some`, even when trimming/deduping empties it out (R-R) - see this function's caller for
|
||||
// why collapsing that case to `None` was wrong.
|
||||
let values = list.as_ref()?;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let filtered: Vec<String> = values
|
||||
.iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.filter(|value| seen.insert(value.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
Some(filtered)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,9 +18,6 @@ use cultnet_rs::CultNetDocumentPutOptions;
|
|||
use cultnet_rs::CultNetDocumentRegistry;
|
||||
use cultnet_rs::CultNetMessage;
|
||||
use cultnet_rs::CultNetMutationAuthority;
|
||||
use cultnet_rs::CultNetRawDocumentRecord;
|
||||
use cultnet_rs::CultNetRawPayloadEncoding;
|
||||
use cultnet_rs::CultNetReadOnlySnapshotPolicy;
|
||||
use cultnet_rs::CultNetReactiveDocumentOptions;
|
||||
use cultnet_rs::CultNetReconnectController;
|
||||
use cultnet_rs::CultNetReconnectPolicyOptions;
|
||||
|
|
@ -46,7 +43,6 @@ use cultnet_rs::CultNetTransportDescriptor;
|
|||
use cultnet_rs::CultNetTransportFrame;
|
||||
use cultnet_rs::CultNetTransportOrdering;
|
||||
use cultnet_rs::CultNetTransportProfile;
|
||||
use cultnet_rs::serve_read_only_raw_snapshot;
|
||||
use cultnet_rs::CultNetTransportProtocol;
|
||||
use cultnet_rs::CultNetWireContract;
|
||||
use cultnet_rs::LengthPrefixedMessageFramer;
|
||||
|
|
@ -1763,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")
|
||||
}));
|
||||
|
||||
|
|
@ -2038,174 +2034,6 @@ fn raw_snapshot_replication_preserves_messagepack_payload_bytes() -> Result<()>
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// R-Q3/R-R (docs/cultnet-selection-cut.md, Self's rulings for fix batch 3, 2026-09-22): corrects
|
||||
// this test's own earlier ruling from the commit 2 fix batch, which lowered an *explicit* empty
|
||||
// or all-blank v0 list to "no filter". Only an *omitted* list (`None`) means "no filter" now - a
|
||||
// present list, even one that empties out after trimming/deduping blanks, answers empty
|
||||
// (`recordKeys: []` matches v0's own pre-cut server, which tested `filter?.RecordKeys != null`,
|
||||
// not a length). A v1 Selection with the same shape is refused outright
|
||||
// (validation_refuses_a_blank_entry_in_schemas_or_keys,
|
||||
// validation_refuses_undeclared_index_role_and_empty_lists in tests/selection.rs) - that is a v1
|
||||
// door rule this v0 lowering never reaches (`serve_read_only_raw_snapshot` never calls `validate`).
|
||||
#[test]
|
||||
fn serve_read_only_raw_snapshot_omitted_v0_schema_list_is_no_filter_present_empty_or_blank_answers_empty()
|
||||
-> Result<()> {
|
||||
let mut registry = CultNetDocumentRegistry::new();
|
||||
registry.register(CultNetDocumentBinding::for_entry_with_schema_id::<
|
||||
GhostlightAgentStateFixture,
|
||||
>("ghostlight.agent-state".to_string(), None));
|
||||
|
||||
let mut policy = CultNetReadOnlySnapshotPolicy::new();
|
||||
policy.allow("ghostlight.agent-state", "row-1")?;
|
||||
policy.allow("ghostlight.agent-state", "row-2")?;
|
||||
|
||||
let source = vec![
|
||||
CultNetRawDocumentRecord {
|
||||
schema_id: "ghostlight.agent-state".to_string(),
|
||||
record_key: "row-1".to_string(),
|
||||
stored_at: "now".to_string(),
|
||||
payload_encoding: CultNetRawPayloadEncoding::Messagepack,
|
||||
payload: vec![1, 2, 3],
|
||||
source_runtime_id: None,
|
||||
source_agent_id: None,
|
||||
source_role: None,
|
||||
tags: None,
|
||||
},
|
||||
CultNetRawDocumentRecord {
|
||||
schema_id: "ghostlight.agent-state".to_string(),
|
||||
record_key: "row-2".to_string(),
|
||||
stored_at: "now".to_string(),
|
||||
payload_encoding: CultNetRawPayloadEncoding::Messagepack,
|
||||
payload: vec![4, 5, 6],
|
||||
source_runtime_id: None,
|
||||
source_agent_id: None,
|
||||
source_role: None,
|
||||
tags: None,
|
||||
},
|
||||
];
|
||||
|
||||
// The field omitted entirely: no filter, both rows.
|
||||
let omitted_request = CultNetMessage::SnapshotRequest {
|
||||
message_id: "req-1".to_string(),
|
||||
schema_ids: None,
|
||||
record_keys: None,
|
||||
};
|
||||
let omitted_response =
|
||||
serve_read_only_raw_snapshot(®istry, &policy, &source, &omitted_request)?;
|
||||
let CultNetMessage::SnapshotResponseRaw { documents, .. } = omitted_response else {
|
||||
panic!("expected a raw snapshot response");
|
||||
};
|
||||
assert_eq!(documents.len(), 2, "schema_ids omitted must lower to no filter, not zero rows");
|
||||
|
||||
// A present list - empty, or made only of blanks - answers empty (R-R), not "no filter".
|
||||
for schema_ids in [Some(Vec::new()), Some(vec![" ".to_string()])] {
|
||||
let request = CultNetMessage::SnapshotRequest {
|
||||
message_id: "req-1".to_string(),
|
||||
schema_ids: schema_ids.clone(),
|
||||
record_keys: None,
|
||||
};
|
||||
let response = serve_read_only_raw_snapshot(®istry, &policy, &source, &request)?;
|
||||
let CultNetMessage::SnapshotResponseRaw { documents, .. } = response else {
|
||||
panic!("expected a raw snapshot response");
|
||||
};
|
||||
assert_eq!(
|
||||
documents.len(),
|
||||
0,
|
||||
"schema_ids = {schema_ids:?} is present and empty after trimming, so it must answer \
|
||||
empty, not fall back to every row"
|
||||
);
|
||||
}
|
||||
|
||||
// A mixed list keeps its non-blank entries, deduplicated (matches
|
||||
// CultNetV0SelectionLowering.Lower exactly, 568e8e3: Distinct(StringComparer.Ordinal)).
|
||||
let mixed_request = CultNetMessage::SnapshotRequest {
|
||||
message_id: "req-2".to_string(),
|
||||
schema_ids: Some(vec![
|
||||
"ghostlight.agent-state".to_string(),
|
||||
" ".to_string(),
|
||||
"ghostlight.agent-state".to_string(),
|
||||
]),
|
||||
record_keys: None,
|
||||
};
|
||||
let mixed_response = serve_read_only_raw_snapshot(®istry, &policy, &source, &mixed_request)?;
|
||||
let CultNetMessage::SnapshotResponseRaw { documents, .. } = mixed_response else {
|
||||
panic!("expected a raw snapshot response");
|
||||
};
|
||||
assert_eq!(
|
||||
documents.len(),
|
||||
2,
|
||||
"a mixed list keeps its one distinct non-blank schema id, still selecting both rows of that schema"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// R-R: v0's pre-cut server preserved the caller's requested key order (a `HashSet<string>` built
|
||||
// from the array, which enumerates in insertion order), not plain snapshot-source order.
|
||||
#[test]
|
||||
fn serve_read_only_raw_snapshot_keeps_the_requested_record_key_order() -> Result<()> {
|
||||
let mut registry = CultNetDocumentRegistry::new();
|
||||
registry.register(CultNetDocumentBinding::for_entry_with_schema_id::<
|
||||
GhostlightAgentStateFixture,
|
||||
>("ghostlight.agent-state".to_string(), None));
|
||||
|
||||
let mut policy = CultNetReadOnlySnapshotPolicy::new();
|
||||
policy.allow("ghostlight.agent-state", "row-1")?;
|
||||
policy.allow("ghostlight.agent-state", "row-2")?;
|
||||
policy.allow("ghostlight.agent-state", "row-3")?;
|
||||
|
||||
// Source order is row-1, row-2, row-3; the request asks for them in the opposite order.
|
||||
let source = vec![
|
||||
CultNetRawDocumentRecord {
|
||||
schema_id: "ghostlight.agent-state".to_string(),
|
||||
record_key: "row-1".to_string(),
|
||||
stored_at: "now".to_string(),
|
||||
payload_encoding: CultNetRawPayloadEncoding::Messagepack,
|
||||
payload: vec![1],
|
||||
source_runtime_id: None,
|
||||
source_agent_id: None,
|
||||
source_role: None,
|
||||
tags: None,
|
||||
},
|
||||
CultNetRawDocumentRecord {
|
||||
schema_id: "ghostlight.agent-state".to_string(),
|
||||
record_key: "row-2".to_string(),
|
||||
stored_at: "now".to_string(),
|
||||
payload_encoding: CultNetRawPayloadEncoding::Messagepack,
|
||||
payload: vec![2],
|
||||
source_runtime_id: None,
|
||||
source_agent_id: None,
|
||||
source_role: None,
|
||||
tags: None,
|
||||
},
|
||||
CultNetRawDocumentRecord {
|
||||
schema_id: "ghostlight.agent-state".to_string(),
|
||||
record_key: "row-3".to_string(),
|
||||
stored_at: "now".to_string(),
|
||||
payload_encoding: CultNetRawPayloadEncoding::Messagepack,
|
||||
payload: vec![3],
|
||||
source_runtime_id: None,
|
||||
source_agent_id: None,
|
||||
source_role: None,
|
||||
tags: None,
|
||||
},
|
||||
];
|
||||
|
||||
let request = CultNetMessage::SnapshotRequest {
|
||||
message_id: "req-1".to_string(),
|
||||
schema_ids: None,
|
||||
record_keys: Some(vec!["row-3".to_string(), "row-1".to_string(), "row-2".to_string()]),
|
||||
};
|
||||
let response = serve_read_only_raw_snapshot(®istry, &policy, &source, &request)?;
|
||||
let CultNetMessage::SnapshotResponseRaw { documents, .. } = response else {
|
||||
panic!("expected a raw snapshot response");
|
||||
};
|
||||
let keys: Vec<&str> = documents.iter().map(|d| d.record_key.as_str()).collect();
|
||||
assert_eq!(keys, vec!["row-3", "row-1", "row-2"], "the requested key order must survive, not the source's");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_snapshot_replication_hydrates_same_schema_rust_aliases() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
|
|
@ -2457,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,
|
||||
|
|
|
|||
|
|
@ -1,158 +0,0 @@
|
|||
//! R-N (docs/cultnet-selection-cut.md, fix batch 3): `cultnet.error.v0`'s `code`/`details` fields,
|
||||
//! pinned byte-for-byte against the landed C# reference
|
||||
//! (`CultNetErrorMessage.ForSelectionInvalid`/`ForCursor`/`ForReferenceOutsideTarget`,
|
||||
//! `src/GameCult.Networking/CultNetSchemaMessages.cs`, commit `e8a3dec`).
|
||||
//!
|
||||
//! The expected bytes below are not hand-derived: they were captured by running MessagePack C#
|
||||
//! 3.1.7 (the exact package/version `GameCult.Networking` depends on) against a standalone
|
||||
//! `CultNetErrorMessage`/`CultNetErrorDetails` pair carrying the landed `[Key(...)]` shape, using
|
||||
//! `MessagePackSerializerOptions.Standard.WithSecurity(MessagePackSecurity.UntrustedData)` - the
|
||||
//! same options `CultNetSchemaMessageSerialization.Options` uses - then
|
||||
//! `Convert.ToHexString(MessagePackSerializer.Serialize(message, options))`. The probe program lived
|
||||
//! at `%TEMP%\claude\probe-msgpack` (outside this repo) and was run in the `dotnet` Yggdrasil verify
|
||||
//! image via `ygg-verify.sh`; it is not part of this crate. Each case below documents the C# call
|
||||
//! that produced its bytes.
|
||||
//!
|
||||
//! This confirms the one thing that matters for byte parity here: MessagePack C#'s
|
||||
//! `[Key("name")]`-attributed map formatter always writes every declared key, in declaration order,
|
||||
//! nil for a null value - it never omits a key. `CultNetMessage::Error` is therefore encoded through
|
||||
//! the same `rmpv::Value::Map`-literal path as the crate's other raw `cultnet.schema.v0` messages
|
||||
//! (`encode_raw_cultnet_schema_message`/`parse_raw_cultnet_schema_message` in `src/contracts.rs`)
|
||||
//! rather than through the generic `serde_json::Value` round trip every other `CultNetMessage`
|
||||
//! variant uses - that path's `serde_json::Value::Object` is a `BTreeMap` (no `preserve_order`
|
||||
//! feature enabled in this crate's `serde_json` dependency), so it would emit keys sorted
|
||||
//! alphabetically and silently break this exact byte match.
|
||||
|
||||
use cultnet_rs::{
|
||||
CultNetErrorCode, CultNetErrorDetails, CultNetMessage, CultNetWireContract,
|
||||
decode_cultnet_message_from_slice, encode_cultnet_message_to_vec,
|
||||
};
|
||||
|
||||
fn hex_decode(hex: &str) -> Vec<u8> {
|
||||
assert!(hex.len() % 2 == 0, "odd-length hex string");
|
||||
(0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("valid hex byte"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct Case {
|
||||
name: &'static str,
|
||||
/// Captured via `Convert.ToHexString(MessagePackSerializer.Serialize(msg, options))` in the
|
||||
/// probe program described in this file's header comment.
|
||||
csharp_hex: &'static str,
|
||||
message: fn() -> CultNetMessage,
|
||||
}
|
||||
|
||||
fn cases() -> Vec<Case> {
|
||||
vec![
|
||||
Case {
|
||||
name: "selection_invalid_schemas_null",
|
||||
// C#: ForSelectionInvalid(new CultNetSelectionInvalidException("schemas", null,
|
||||
// "must not be empty"))
|
||||
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92473656C656374696F6E5F696E76616C69643A206D757374206E6F7420626520656D707479AB726F7574696E6748696E74C0A4636F6465B173656C656374696F6E5F696E76616C6964A764657461696C7384A56669656C64A7736368656D6173A576616C7565C0A461734F66C0A763757272656E74C0",
|
||||
message: || CultNetMessage::Error {
|
||||
error: "selection_invalid: must not be empty".to_string(),
|
||||
code: Some(CultNetErrorCode::SelectionInvalid),
|
||||
details: Some(CultNetErrorDetails {
|
||||
field: Some("schemas".to_string()),
|
||||
value: None,
|
||||
as_of: None,
|
||||
current: None,
|
||||
}),
|
||||
},
|
||||
},
|
||||
Case {
|
||||
name: "selection_invalid_field_value",
|
||||
// C#: ForSelectionInvalid(new CultNetSelectionInvalidException("keys", " ",
|
||||
// "blank key entry"))
|
||||
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92273656C656374696F6E5F696E76616C69643A20626C616E6B206B657920656E747279AB726F7574696E6748696E74C0A4636F6465B173656C656374696F6E5F696E76616C6964A764657461696C7384A56669656C64A46B657973A576616C7565A3202020A461734F66C0A763757272656E74C0",
|
||||
message: || CultNetMessage::Error {
|
||||
error: "selection_invalid: blank key entry".to_string(),
|
||||
code: Some(CultNetErrorCode::SelectionInvalid),
|
||||
details: Some(CultNetErrorDetails {
|
||||
field: Some("keys".to_string()),
|
||||
value: Some(" ".to_string()),
|
||||
as_of: None,
|
||||
current: None,
|
||||
}),
|
||||
},
|
||||
},
|
||||
Case {
|
||||
name: "cursor_stale",
|
||||
// C#: ForCursor(new CultNetSelectionCursorException("cursor_stale",
|
||||
// "cursor is stale", asOf: 1, current: 2))
|
||||
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72BD637572736F725F7374616C653A20637572736F72206973207374616C65AB726F7574696E6748696E74C0A4636F6465AC637572736F725F7374616C65A764657461696C7384A56669656C64C0A576616C7565C0A461734F6601A763757272656E7402",
|
||||
message: || CultNetMessage::Error {
|
||||
error: "cursor_stale: cursor is stale".to_string(),
|
||||
code: Some(CultNetErrorCode::CursorStale),
|
||||
details: Some(CultNetErrorDetails {
|
||||
field: None,
|
||||
value: None,
|
||||
as_of: Some(1),
|
||||
current: Some(2),
|
||||
}),
|
||||
},
|
||||
},
|
||||
Case {
|
||||
name: "cursor_invalid",
|
||||
// C#: ForCursor(new CultNetSelectionCursorException("cursor_invalid",
|
||||
// "cannot parse cursor")) - no asOf/current, so Details stays null.
|
||||
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D923637572736F725F696E76616C69643A2063616E6E6F7420706172736520637572736F72AB726F7574696E6748696E74C0A4636F6465AE637572736F725F696E76616C6964A764657461696C73C0",
|
||||
message: || CultNetMessage::Error {
|
||||
error: "cursor_invalid: cannot parse cursor".to_string(),
|
||||
code: Some(CultNetErrorCode::CursorInvalid),
|
||||
details: None,
|
||||
},
|
||||
},
|
||||
Case {
|
||||
name: "reference_outside_target",
|
||||
// C#: ForReferenceOutsideTarget(...) - built directly with the final Error text here
|
||||
// (the constructor path itself double-prefixes "reference_outside_target: ", see this
|
||||
// crate's SelectionRefusal -> CultNetMessage mapping doc comment in src/selection.rs
|
||||
// for the discrepancy); the wire shape under test does not depend on the text's content.
|
||||
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D9317265666572656E63655F6F7574736964655F7461726765743A206F757473696465206465636C6172656420746172676574AB726F7574696E6748696E74C0A4636F6465B87265666572656E63655F6F7574736964655F746172676574A764657461696C73C0",
|
||||
message: || CultNetMessage::Error {
|
||||
error: "reference_outside_target: outside declared target".to_string(),
|
||||
code: Some(CultNetErrorCode::ReferenceOutsideTarget),
|
||||
details: None,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_encode_matches_csharp_bytes_for_every_r_n_code() {
|
||||
for case in cases() {
|
||||
let expected = hex_decode(case.csharp_hex);
|
||||
let actual = encode_cultnet_message_to_vec(&(case.message)(), CultNetWireContract::CultNetSchemaV0)
|
||||
.unwrap_or_else(|err| panic!("{}: encode failed: {err}", case.name));
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"{}: Rust-encoded bytes do not match the captured C# encoding",
|
||||
case.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_decodes_the_csharp_written_bytes_back_into_the_same_typed_message() {
|
||||
for case in cases() {
|
||||
let bytes = hex_decode(case.csharp_hex);
|
||||
let decoded = decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0)
|
||||
.unwrap_or_else(|err| panic!("{}: decode failed: {err}", case.name));
|
||||
assert_eq!(decoded, (case.message)(), "{}: decoded value mismatch", case.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_is_stable() {
|
||||
for case in cases() {
|
||||
let message = (case.message)();
|
||||
let encoded = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0)
|
||||
.unwrap_or_else(|err| panic!("{}: encode failed: {err}", case.name));
|
||||
let decoded = decode_cultnet_message_from_slice(&encoded, CultNetWireContract::CultNetSchemaV0)
|
||||
.unwrap_or_else(|err| panic!("{}: decode failed: {err}", case.name));
|
||||
assert_eq!(decoded, message, "{}: round trip mismatch", case.name);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
//! R-AB: `schema_discovery.rs` used to `include_str!("../../../contracts/cultnet/…")`, reaching
|
||||
//! three levels above `src/` and out of `packages/cultnet-rs` entirely. cargo-mutants builds this
|
||||
//! crate alone (there is no workspace `Cargo.toml` tying it to its siblings, so its own manifest
|
||||
//! directory is the whole tree cargo-mutants copies) - a path that escapes the package is a path
|
||||
//! that does not exist in that copy, and the unmutated baseline build failed with "No such file or
|
||||
//! directory" on every one of the 22 schemas (`rust-mut3.log`, captured on Soul's whole-cut pass).
|
||||
//!
|
||||
//! The fix vendors byte-identical copies of the 22 schemas this crate embeds under
|
||||
//! `packages/cultnet-rs/contracts/cultnet/`, git-tracked so any tool that copies just this
|
||||
//! package's directory carries them along, and repoints every `include_str!` at that vendored copy
|
||||
//! instead of the repo-root `contracts/cultnet/`. A build script staging files into `OUT_DIR` was
|
||||
//! the other option R-AB named, but it does not actually solve this: the script itself would still
|
||||
//! need to read from outside the package to find its source, and a build running inside
|
||||
//! cargo-mutants' copy would fail exactly as `include_str!` did. Vendoring - "another route that
|
||||
//! keeps every source path inside the package" - is what R-AB's ruling names as the alternative,
|
||||
//! and it is what actually keeps the crate self-contained.
|
||||
//!
|
||||
//! Vendoring is a second copy of files this repo already treats as canonical
|
||||
//! (`contracts/cultnet/*.schema.json`, the shared source multiple runtimes compile against per
|
||||
//! `docs/cultnet-selection-cut.md` section 1). A second copy can drift silently, so this test is
|
||||
//! the thing that makes that impossible without a loud, immediate failure: every vendored file is
|
||||
//! compared byte-for-byte against its repo-root original. It skips (not fails) when the canonical
|
||||
//! `contracts/` tree is not present beside the package - i.e. exactly the isolated-copy situation
|
||||
//! R-AB exists to survive - so this check does not reintroduce the same escape it is guarding
|
||||
//! against.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Every schema this crate's `schema_discovery::builtin_schema_registry` embeds via
|
||||
/// `include_str!("../contracts/cultnet/…")` - kept in sync with `schema_discovery.rs` by hand;
|
||||
/// a schema added there and not here is still caught, because the crate would then fail to
|
||||
/// compile against a file this list never copied to `contracts/cultnet/`.
|
||||
const VENDORED_SCHEMAS: &[&str] = &[
|
||||
"cultnet.hello.schema.json",
|
||||
"cultnet.document-mutation-contract.schema.json",
|
||||
"cultnet.transport-profile.schema.json",
|
||||
"cultnet.login.schema.json",
|
||||
"cultnet.register.schema.json",
|
||||
"cultnet.verify.schema.json",
|
||||
"cultnet.login-success.schema.json",
|
||||
"cultnet.error.schema.json",
|
||||
"cultnet.sample-change-name.schema.json",
|
||||
"cultnet.sample-chat.schema.json",
|
||||
"cultnet.document-put.schema.json",
|
||||
"cultnet.document-delete.schema.json",
|
||||
"cultnet.raw-document-record.schema.json",
|
||||
"cultnet.document-put-raw.schema.json",
|
||||
"cultnet.snapshot-request.schema.json",
|
||||
"cultnet.snapshot-response.schema.json",
|
||||
"cultnet.snapshot-response-raw.schema.json",
|
||||
"cultnet.schema-catalog-request.schema.json",
|
||||
"cultnet.schema-catalog-response.schema.json",
|
||||
"cultnet.shard-catalog-request.schema.json",
|
||||
"cultnet.shard-catalog-response.schema.json",
|
||||
"ghostlight.agent-state.schema.json",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn vendored_contract_schemas_are_present_and_match_every_include_str_call() {
|
||||
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let vendored_root = manifest_dir.join("contracts/cultnet");
|
||||
for name in VENDORED_SCHEMAS {
|
||||
let path = vendored_root.join(name);
|
||||
assert!(
|
||||
path.is_file(),
|
||||
"{name} is embedded by schema_discovery.rs but missing from the vendored \
|
||||
contracts/cultnet/ copy at {path:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vendored_contract_schemas_match_the_canonical_repo_root_source() {
|
||||
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let vendored_root = manifest_dir.join("contracts/cultnet");
|
||||
// packages/cultnet-rs -> packages -> repo root -> contracts/cultnet
|
||||
let canonical_root = manifest_dir.join("../../contracts/cultnet");
|
||||
|
||||
if !canonical_root.is_dir() {
|
||||
// R-AB: exactly the isolated-copy case this crate's own vendored files exist to survive
|
||||
// (cargo-mutants, or any tool that copies only this package's directory). Nothing to
|
||||
// compare against, and that is expected, not a failure.
|
||||
eprintln!(
|
||||
"skipping: {canonical_root:?} not present beside the package (isolated build, \
|
||||
the case R-AB's vendored copies are for)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for name in VENDORED_SCHEMAS {
|
||||
let canonical = fs::read_to_string(canonical_root.join(name))
|
||||
.unwrap_or_else(|error| panic!("reading canonical contracts/cultnet/{name}: {error}"));
|
||||
let vendored = fs::read_to_string(vendored_root.join(name))
|
||||
.unwrap_or_else(|error| panic!("reading vendored contracts/cultnet/{name}: {error}"));
|
||||
assert_eq!(
|
||||
canonical, vendored,
|
||||
"packages/cultnet-rs/contracts/cultnet/{name} has drifted from the canonical \
|
||||
contracts/cultnet/{name} - re-copy it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_schema_registry_loads_every_vendored_schema() {
|
||||
let registry = cultnet_rs::builtin_schema_registry().expect("registry builds from the vendored schemas");
|
||||
let listed = registry.list(&cultnet_rs::CultNetSchemaCatalogOptions::default());
|
||||
assert_eq!(
|
||||
listed.len(),
|
||||
VENDORED_SCHEMAS.len(),
|
||||
"one registration per vendored schema file"
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,209 +0,0 @@
|
|||
//! R-AP (docs/cultnet-selection-cut.md, "Self's rulings for fix batch 6"): `RawDocumentHeader` and
|
||||
//! `Edge`, pinned byte-for-byte against the C# reference, the same way `tests/error_message.rs`
|
||||
//! pins `cultnet.error.v0` - a hardcoded `csharp_hex` captured from a real MessagePack C# run,
|
||||
//! compared against this crate's own encoding of the identical value.
|
||||
//!
|
||||
//! Unlike `error_message.rs`'s bytes (captured on Yggdrasil via `ygg-verify.sh` per that file's own
|
||||
//! header), the bytes below were captured **locally**, in this worktree (`C:\ws7`), against
|
||||
//! MessagePack C# 3.1.7 - the exact version `GameCult.Networking` depends on
|
||||
//! (`src/GameCult.Networking/GameCult.Networking.csproj`) - using the project's own
|
||||
//! `CultNetRawDocumentHeader`/`CultNetEdge`/`CultNetSchemaMessageSerialization.Options`
|
||||
//! (`MessagePackSerializerOptions.Standard.WithSecurity(MessagePackSecurity.UntrustedData)`), then
|
||||
//! `Convert.ToHexString(MessagePackSerializer.Serialize(value, options))`. This session had no
|
||||
//! access to the Yggdrasil verify image, so unlike the error-message vectors this was not run
|
||||
//! through `ygg-verify.sh`; the capture itself is still a real MessagePack C# 3.1.7 run on this
|
||||
//! machine, not hand-derived bytes.
|
||||
//!
|
||||
//! What this proves: `RawDocumentHeader`/`Edge`'s `to_vec_named` encoding (R-AP: `skip_serializing_if`
|
||||
//! removed, every optional field always present and `nil` when absent, camelCase, declared order)
|
||||
//! matches the C# reference's `[MessagePackObject]`/`[Key("name")]` map encoding byte-for-byte, both
|
||||
//! at a value carrying every optional absent and - R-AX (docs/cultnet-selection-cut.md, "Self's
|
||||
//! rulings for fix batch 7"), because an all-absent value proves nothing about `string[]` vs
|
||||
//! `Vec<String>` or `byte[]` vs `serde_bytes` - at a value carrying `tags`/`payload` populated. R-AX
|
||||
//! also lands the whole `cultnet.snapshot_response_raw.v1` envelope byte-identical against the C#
|
||||
//! reference (`snapshot_response_raw_v1_envelope_matches_the_csharp_reference_byte_for_byte`,
|
||||
//! below): Soul's earlier finding that this was "blocked on unbuilt page-assembly ownership" named
|
||||
//! the wrong thing - the *encoders* producing identical bytes for the same page value never needed
|
||||
//! that move, only the *evaluators* producing the same page value from the same rows does (that
|
||||
//! half is `select_page`'s own vectors in `selection.rs`, a separate claim R-AR still owns).
|
||||
|
||||
use cultnet_rs::{
|
||||
CultNetMessage, CultNetWireContract, Edge, RawDocumentHeader, RecordRef,
|
||||
encode_cultnet_message_to_vec,
|
||||
};
|
||||
|
||||
fn hex_decode(hex: &str) -> Vec<u8> {
|
||||
assert!(hex.len() % 2 == 0, "odd-length hex string");
|
||||
(0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("valid hex byte"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// C#: new CultNetRawDocumentHeader { SchemaId = "sha256:ff35f3...4cff", RecordKey = "a-eq",
|
||||
// StoredAt = "1970-01-01T00:00:00.000Z" } - SchemaName/SchemaVersion/SchemaContentHash/
|
||||
// SourceRuntimeId/SourceAgentId/SourceRole/Tags all left null (absent).
|
||||
// Convert.ToHexString(MessagePackSerializer.Serialize(header, CultNetSchemaMessageSerialization.Options)):
|
||||
const CSHARP_HEADER_HEX: &str = "8AA8736368656D614964D9477368613235363A66663335663364396436646533663130363662393336396537336665366337363438653838333638643138656663323237356339393437333063643834636666AA736368656D614E616D65C0AD736368656D6156657273696F6EC0B1736368656D61436F6E74656E7448617368C0A97265636F72644B6579A4612D6571A873746F7265644174B8313937302D30312D30315430303A30303A30302E3030305AAF736F7572636552756E74696D654964C0AD736F757263654167656E744964C0AA736F75726365526F6C65C0A474616773C0";
|
||||
|
||||
// C#: new CultNetEdge { From = { SchemaId = "sha256:9d0bc9...608dc", RecordKey = "citer-1" },
|
||||
// Role = "Design", To = { SchemaId = "sha256:ff35f3...4cff", RecordKey = "a-eq" } } -
|
||||
// PayloadEncoding/Payload left null (absent, header projection).
|
||||
const CSHARP_EDGE_HEX: &str = "85A466726F6D82A8736368656D614964D9477368613235363A39643062633961656263333631396230376161626635363466393836363761666465343134343463316238363636363164373737363837643434333630386463A97265636F72644B6579A763697465722D31A4726F6C65A644657369676EA2746F82A8736368656D614964D9477368613235363A66663335663364396436646533663130363662393336396537336665366337363438653838333638643138656663323237356339393437333063643834636666A97265636F72644B6579A4612D6571AF7061796C6F6164456E636F64696E67C0A77061796C6F6164C0";
|
||||
|
||||
#[test]
|
||||
fn raw_document_header_matches_the_csharp_reference_byte_for_byte() {
|
||||
let header = RawDocumentHeader {
|
||||
schema_id: "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff".to_string(),
|
||||
schema_name: None,
|
||||
schema_version: None,
|
||||
schema_content_hash: None,
|
||||
record_key: "a-eq".to_string(),
|
||||
stored_at: "1970-01-01T00:00:00.000Z".to_string(),
|
||||
source_runtime_id: None,
|
||||
source_agent_id: None,
|
||||
source_role: None,
|
||||
tags: None,
|
||||
};
|
||||
|
||||
let actual = rmp_serde::to_vec_named(&header).expect("encodes");
|
||||
let expected = hex_decode(CSHARP_HEADER_HEX);
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"RawDocumentHeader's Rust-encoded bytes do not match the captured C# encoding"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_matches_the_csharp_reference_byte_for_byte() {
|
||||
let edge = Edge {
|
||||
from: RecordRef::new(
|
||||
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc",
|
||||
"citer-1",
|
||||
),
|
||||
role: "Design".to_string(),
|
||||
to: RecordRef::new(
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
|
||||
"a-eq",
|
||||
),
|
||||
payload_encoding: None,
|
||||
payload: None,
|
||||
};
|
||||
|
||||
let actual = rmp_serde::to_vec_named(&edge).expect("encodes");
|
||||
let expected = hex_decode(CSHARP_EDGE_HEX);
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"Edge's Rust-encoded bytes do not match the captured C# encoding"
|
||||
);
|
||||
}
|
||||
|
||||
// R-AX (docs/cultnet-selection-cut.md, "Self's rulings for fix batch 7"): the two vectors above
|
||||
// pin `RawDocumentHeader`/`Edge` at a single value with every optional absent - exactly the shape
|
||||
// that proves nothing about `tags` (`string[]` vs `Vec<String>`) or `payload` (`byte[]` vs
|
||||
// `serde_bytes`), since an absent optional never exercises either mapping. These two extend the
|
||||
// same byte-for-byte proof to a header with `tags` populated and an edge with a `payload`
|
||||
// populated, captured the same way (`Convert.ToHexString(MessagePackSerializer.Serialize(...))`
|
||||
// against the real MessagePack C# 3.1.7 GameCult.Networking depends on, via ygg-verify.sh on
|
||||
// Yggdrasil). Both matched byte-for-byte on capture - no divergence found.
|
||||
|
||||
// C#: new CultNetRawDocumentHeader { SchemaId = LEAF_A, RecordKey = "a-eq",
|
||||
// StoredAt = "1970-01-01T00:00:00.000Z", Tags = ["alpha", "beta"] } - every other optional absent.
|
||||
const CSHARP_HEADER_WITH_TAGS_HEX: &str = "8AA8736368656D614964D9477368613235363A66663335663364396436646533663130363662393336396537336665366337363438653838333638643138656663323237356339393437333063643834636666AA736368656D614E616D65C0AD736368656D6156657273696F6EC0B1736368656D61436F6E74656E7448617368C0A97265636F72644B6579A4612D6571A873746F7265644174B8313937302D30312D30315430303A30303A30302E3030305AAF736F7572636552756E74696D654964C0AD736F757263654167656E744964C0AA736F75726365526F6C65C0A47461677392A5616C706861A462657461";
|
||||
|
||||
// C#: new CultNetEdge { From = { SchemaId = CITER_ID, RecordKey = "citer-1" }, Role = "Design",
|
||||
// To = { SchemaId = LEAF_A, RecordKey = "a-eq" }, PayloadEncoding = "messagepack",
|
||||
// Payload = [1, 2, 3] }.
|
||||
const CSHARP_EDGE_WITH_PAYLOAD_HEX: &str = "85A466726F6D82A8736368656D614964D9477368613235363A39643062633961656263333631396230376161626635363466393836363761666465343134343463316238363636363164373737363837643434333630386463A97265636F72644B6579A763697465722D31A4726F6C65A644657369676EA2746F82A8736368656D614964D9477368613235363A66663335663364396436646533663130363662393336396537336665366337363438653838333638643138656663323237356339393437333063643834636666A97265636F72644B6579A4612D6571AF7061796C6F6164456E636F64696E67AB6D6573736167657061636BA77061796C6F6164C403010203";
|
||||
|
||||
#[test]
|
||||
fn raw_document_header_with_tags_matches_the_csharp_reference_byte_for_byte() {
|
||||
let header = RawDocumentHeader {
|
||||
schema_id: "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff".to_string(),
|
||||
schema_name: None,
|
||||
schema_version: None,
|
||||
schema_content_hash: None,
|
||||
record_key: "a-eq".to_string(),
|
||||
stored_at: "1970-01-01T00:00:00.000Z".to_string(),
|
||||
source_runtime_id: None,
|
||||
source_agent_id: None,
|
||||
source_role: None,
|
||||
tags: Some(vec!["alpha".to_string(), "beta".to_string()]),
|
||||
};
|
||||
|
||||
let actual = rmp_serde::to_vec_named(&header).expect("encodes");
|
||||
let expected = hex_decode(CSHARP_HEADER_WITH_TAGS_HEX);
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"RawDocumentHeader with tags populated differs from the C# reference - string[] vs Vec<String> diverges"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_with_payload_matches_the_csharp_reference_byte_for_byte() {
|
||||
let edge = Edge {
|
||||
from: RecordRef::new(
|
||||
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc",
|
||||
"citer-1",
|
||||
),
|
||||
role: "Design".to_string(),
|
||||
to: RecordRef::new(
|
||||
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
|
||||
"a-eq",
|
||||
),
|
||||
payload_encoding: Some("messagepack".to_string()),
|
||||
payload: Some(vec![1, 2, 3]),
|
||||
};
|
||||
|
||||
let actual = rmp_serde::to_vec_named(&edge).expect("encodes");
|
||||
let expected = hex_decode(CSHARP_EDGE_WITH_PAYLOAD_HEX);
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"Edge with payload populated differs from the C# reference - byte[] vs serde_bytes diverges"
|
||||
);
|
||||
}
|
||||
|
||||
// R-AX: the whole `cultnet.snapshot_response_raw.v1` envelope, byte-identical against the same C#
|
||||
// reference message (`CultNetSnapshotResponseRawV1Message { MessageId = "m", Matched = 1, AsOf = 1,
|
||||
// Headers = [ { SchemaId = LEAF_A, RecordKey = "a-eq", StoredAt = "1970-01-01T00:00:00.000Z" } ] }`,
|
||||
// every other field absent). This is R-AX landed, not blocked: `select_page`'s output type
|
||||
// (`SelectionPage`) is carried inside the caller's own `CultNetMessage::SnapshotResponseRawV1`
|
||||
// envelope, and this proves the two runtimes' *encoders* agree on that whole shape, byte for byte
|
||||
// - the rows-to-bytes single-call path R-AR would add is a separate, smaller claim (Self's
|
||||
// ruling: "R-AR remains its own cut").
|
||||
const CSHARP_ENVELOPE_ALL_ABSENT_HEX: &str = "8BAD736368656D6156657273696F6ED92063756C746E65742E736E617073686F745F726573706F6E73655F7261772E7631A96D6573736167654964A16DA76D61746368656401A461734F6601A46E657874C0A768656164657273918AA8736368656D614964D9477368613235363A66663335663364396436646533663130363662393336396537336665366337363438653838333638643138656663323237356339393437333063643834636666AA736368656D614E616D65C0AD736368656D6156657273696F6EC0B1736368656D61436F6E74656E7448617368C0A97265636F72644B6579A4612D6571A873746F7265644174B8313937302D30312D30315430303A30303A30302E3030305AAF736F7572636552756E74696D654964C0AD736F757263654167656E744964C0AA736F75726365526F6C65C0A474616773C0A9646F63756D656E7473C0A56564676573C0A773686172644964C0AA736861726445706F6368C0B073686172644C6F6753657175656E6365C0";
|
||||
|
||||
#[test]
|
||||
fn snapshot_response_raw_v1_envelope_matches_the_csharp_reference_byte_for_byte() {
|
||||
let message = CultNetMessage::SnapshotResponseRawV1 {
|
||||
message_id: "m".to_string(),
|
||||
matched: 1,
|
||||
as_of: 1,
|
||||
next: None,
|
||||
headers: Some(vec![RawDocumentHeader {
|
||||
schema_id: "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff".to_string(),
|
||||
schema_name: None,
|
||||
schema_version: None,
|
||||
schema_content_hash: None,
|
||||
record_key: "a-eq".to_string(),
|
||||
stored_at: "1970-01-01T00:00:00.000Z".to_string(),
|
||||
source_runtime_id: None,
|
||||
source_agent_id: None,
|
||||
source_role: None,
|
||||
tags: None,
|
||||
}]),
|
||||
documents: None,
|
||||
edges: None,
|
||||
shard_id: None,
|
||||
shard_epoch: None,
|
||||
shard_log_sequence: None,
|
||||
};
|
||||
|
||||
let actual = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).expect("encodes");
|
||||
let expected = hex_decode(CSHARP_ENVELOPE_ALL_ABSENT_HEX);
|
||||
assert_eq!(actual.len(), 376);
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"the whole cultnet.snapshot_response_raw.v1 envelope differs from the C# reference"
|
||||
);
|
||||
}
|
||||
3
packages/cultnet-ts/.gitignore
vendored
3
packages/cultnet-ts/.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
@ -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"],
|
||||
|
|
@ -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"],
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue