diff --git a/.gitattributes b/.gitattributes index e75b066c..dfe07704 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,31 +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 - -# The committed CultMesh QUIC native binaries (cut 6, packaging) ride the -# deploy `git archive` as raw bytes. `* text=auto` already autodetects them as -# binary by content, but that autodetection is a heuristic; say so explicitly -# so no future text-processing rule (a diff driver, an export filter) can -# apply text conversion and poison the committed digests in SHA256SUMS. -packages/cultmesh-ts/native/**/*.dll binary -packages/cultmesh-ts/native/**/*.so binary -packages/cultmesh-ts/native/**/*.so.* binary - -# The digest list and manifest beside them keep LF on every checkout. Left to -# `text=auto`, a Windows clone writes them with carriage returns, and the -# documented `sha256sum -c SHA256SUMS` then fails on every line (each filename -# ends in `\r`) while CI, checking out on Linux, stays green. -packages/cultmesh-ts/native/SHA256SUMS text eol=lf -packages/cultmesh-ts/native/MANIFEST.txt text eol=lf diff --git a/.github/workflows/cultmesh-portability.yml b/.github/workflows/cultmesh-portability.yml index 520a4836..ce361fc8 100644 --- a/.github/workflows/cultmesh-portability.yml +++ b/.github/workflows/cultmesh-portability.yml @@ -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" diff --git a/.github/workflows/cultnet-interop.yml b/.github/workflows/cultnet-interop.yml index 5f8936bf..6876a76a 100644 --- a/.github/workflows/cultnet-interop.yml +++ b/.github/workflows/cultnet-interop.yml @@ -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 diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 60cc6a01..6061ca7a 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -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 diff --git a/.github/workflows/quic-native.yml b/.github/workflows/quic-native.yml deleted file mode 100644 index 9b16cbeb..00000000 --- a/.github/workflows/quic-native.yml +++ /dev/null @@ -1,202 +0,0 @@ -name: CultMesh QUIC native - -# Builds and, on a tag, releases the CultMesh QUIC native binaries that -# packages/cultmesh-ts/native/{win32-x64,linux-x64} commits (cut 6, -# docs/typescript-quic-realtime-cut.md:2108-2159). Under Q8 Option 1 the -# operator downloads a tagged run's Release assets, verifies every digest -# against this workflow's own logs and SHA256SUMS, and commits the bytes; -# nothing here writes to the tree. Under Q6 A, the Linux artifact comes only -# from the pinned debian:13 container below, never from a local or Yggdrasil -# build. - -on: - push: - branches: - - main - tags: - - "cultmesh-quic-native-v*" - pull_request: - workflow_dispatch: - -permissions: - contents: read - -jobs: - # Push to main and pull_request run this job only: it proves the tree's - # committed bytes still match the manifest they shipped with. A tag push - # also runs it, alongside the two builds and the release. - verify-committed: - name: Verify committed binaries match SHA256SUMS - if: >- - github.event_name == 'pull_request' || - (github.event_name == 'push' && - (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v'))) - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Check out CultLib - uses: actions/checkout@v4 - - - name: sha256sum -c SHA256SUMS - run: | - set -euo pipefail - if [ ! -f packages/cultmesh-ts/native/SHA256SUMS ]; then - echo "packages/cultmesh-ts/native/SHA256SUMS is missing" >&2 - exit 1 - fi - cd packages/cultmesh-ts/native - sha256sum -c SHA256SUMS - - # workflow_dispatch and a cultmesh-quic-native-v* tag run the two builds. - # A dry (workflow_dispatch) run uploads artifacts and creates no release. - build-win32-x64: - name: Build win32-x64 - if: >- - github.event_name == 'workflow_dispatch' || - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v')) - runs-on: windows-latest - timeout-minutes: 20 - steps: - - name: Check out CultLib - uses: actions/checkout@v4 - - - name: Build the native QUIC bridge - shell: pwsh - run: .\scripts\build-quic-native.ps1 -OutputDirectory artifacts\quic-native\win32-x64 - - - name: Print toolchain version and digests - shell: pwsh - run: | - $ErrorActionPreference = "Stop" - $compilerCmake = Get-ChildItem -Recurse -LiteralPath "artifacts\quic-native-build\x64\Release" -Filter "CMakeCXXCompiler.cmake" | - Select-Object -First 1 - if ($compilerCmake) { - Select-String -LiteralPath $compilerCmake.FullName -Pattern "CMAKE_CXX_COMPILER_VERSION" | - ForEach-Object { Write-Host $_.Line.Trim() } - } - Get-FileHash -Algorithm SHA256 artifacts\quic-native\win32-x64\gamecult_mesh_quic_native.dll - Get-FileHash -Algorithm SHA256 artifacts\quic-native\win32-x64\msquic.dll - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: quic-native-win32-x64 - path: artifacts/quic-native/win32-x64 - if-no-files-found: error - - build-linux-x64: - name: Build linux-x64 - if: >- - github.event_name == 'workflow_dispatch' || - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v')) - runs-on: ubuntu-latest - # Q6 A: this pinned Debian 13 container is the only permitted Linux - # release builder. The digest is the pin; the `debian:13` tag is only a - # label. The artifact links against this image's glibc (2.41); a bare - # ubuntu-latest runner carries glibc 2.39, which is not the deploy host's - # OS (docs/typescript-quic-realtime-cut.md:2192-2197). - container: - image: debian:13@sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9ffc4b1 - timeout-minutes: 20 - steps: - - name: Check out CultLib - uses: actions/checkout@v4 - - - name: Install build tools - run: | - set -euo pipefail - apt-get update - apt-get install -y --no-install-recommends cmake ninja-build g++ curl ca-certificates dpkg - - - name: Build the native QUIC bridge - run: ./scripts/build-quic-native.sh artifacts/quic-native/linux-x64 - - - name: Print toolchain version and digests - run: | - g++ --version - sha256sum artifacts/quic-native/linux-x64/libgamecult_mesh_quic_native.so - sha256sum artifacts/quic-native/linux-x64/libmsquic.so.2 - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: quic-native-linux-x64 - path: artifacts/quic-native/linux-x64 - if-no-files-found: error - - # Only this job has write permission, and only a tag push runs it. It - # attaches a draft MANIFEST.txt: the fields both build jobs cannot know - # (host names, MSVC version, the pinned dependency sources) are left for - # the operator to fill in from this run's own logs before committing the - # binaries, per 11.5's manifest paragraph. - release: - name: Release native binaries - needs: [build-win32-x64, build-linux-x64] - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v') - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: write - steps: - - name: Check out CultLib - uses: actions/checkout@v4 - - - name: Download win32-x64 artifact - uses: actions/download-artifact@v4 - with: - name: quic-native-win32-x64 - path: release/win32-x64 - - - name: Download linux-x64 artifact - uses: actions/download-artifact@v4 - with: - name: quic-native-linux-x64 - path: release/linux-x64 - - - name: Write SHA256SUMS and a draft MANIFEST.txt - run: | - set -euo pipefail - cd release - # Relative paths matching packages/cultmesh-ts/native/ exactly, so - # this file is the one the operator commits verbatim (11.5, 11.7). - sha256sum \ - win32-x64/gamecult_mesh_quic_native.dll \ - win32-x64/msquic.dll \ - linux-x64/libgamecult_mesh_quic_native.so \ - linux-x64/libmsquic.so.2 \ - > SHA256SUMS - cat > MANIFEST.txt <.v9 and resolves through both runtimes under that one rule. hashAlias is .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 serializes a CultRecordRef 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" } ] - } - ] -} diff --git a/contracts/cultnet/interop/selection-vectors.rs-written.json b/contracts/cultnet/interop/selection-vectors.rs-written.json deleted file mode 100644 index a9b635c4..00000000 --- a/contracts/cultnet/interop/selection-vectors.rs-written.json +++ /dev/null @@ -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": [] - } - ] -} \ No newline at end of file diff --git a/docs/cultcache-publication-cut.md b/docs/cultcache-publication-cut.md deleted file mode 100644 index 8a7cb2fc..00000000 --- a/docs/cultcache-publication-cut.md +++ /dev/null @@ -1,840 +0,0 @@ -# CultCache publication: the cache owns "published once", and order stays data - -Status, 2026-09-29: Cuts 1-3 landed on `main`. Cut 3 merged at `ae8576d`. Imagination pass 4 re-took Cuts 4-6 -against `3bf1c0c`. Q-P5 is ruled (A). Cut 4 is ready for Hands. -- F4, pinning `Held`'s publish-on-throw guard, is done: Cut 3 pinned it. -- F3 (observer removal on unsubscribe) and F1 (the false "getters run once" claim for conditional commits on variant - stores) were Cut 3 follow-ups; confirm against the Cut 3 merge. - -Recorded: -- F2, the Q-P2 hazard. A rethrown observer exception makes a committed write look cancelled or refused. The - only live typed catch around a cache write is AetheriaEve `Aetheria.State.Daemon/Program.cs:3722`. It needs a - fail-fast handler, and no production code installs one. -- F5: subscribe and unsubscribe cost O(n). -- F6: writes after `Dispose` are dropped silently. - -Map written by Imagination pass 1, 2026-09-29. The ends are the -operator ruling of 2026-09-30: every committed cache change reaches subscribers -exactly once. This document owns the means. - -- **Body.** CultLib `main` `65311c9ad770828adafe40c2c14b2d3da517628e`. Every - `file:line` is against it unless it names the branch. -- **Superseded attempt.** `origin/hands/cultnet-publish-commits`, tip - `28248eb`, base `f9f107a`. Soul pass 3 said do not merge it, and that stands. - - Main has not touched `CultCache.cs`, `CultNetDatabase.cs`, the composition - doc or the networking tests since `f9f107a`: `git diff --stat` is empty. - - `git merge-tree 65311c9 28248eb` is clean. -- **Scope.** The C# cache's publication and CultNetDatabase's use of it. There - is no wire-format change and no other runtime changes (§6). - - -## Rulings (operator, 2026-09-30) - -- **Q-P1 A: order as data.** Each change is published exactly once, on its writer's thread, before the write - returns, and carries its `Sequence`. The shard log is written in cache order by the gate-held journal. Wire - peers drop changes that are stale by `Sequence`. The turnstile (`bcae483`/`4562340`) and the drainer are - both rejected. -- **Q-P2 A:** a throwing observer does not stop the others. Every observer runs, the exception is rethrown to - the writer afterwards, and the observer stays subscribed. The commit stands. -- **Q-P3 A:** a public `CultCache.AddJournal(...)`, with reentry guarded. -- **Q-P4 A:** Cut 6, which drops stale changes per key on the wire, lands in this campaign. -- **Q-P5 A (2026-09-29):** a primary that cannot log a committed change throws a typed `CultNetShardLogException` - naming the shard and the burned sequence, after publication, not derived from `InvalidOperationException`. - -## The finding that shapes this map - -The direction Imagination was handed was: "ordered delivery belongs in the -cache's own publication, on the writer's thread, so a writer delivers its own -changes before returning". That design **has been built before, and it was -deleted**. - -- `bcae483` (2026-09-13 20:09) made the cache a delivery scheduler: tickets, a - delivery gate, turn waits and per-thread queues for reentrant writes. -- `4562340`, 34 minutes later, deleted it: "It deadlocked inside CultLib: a - load's delivery reaching a CultNetDatabaseSubscriptionServer observer that - needs _lifecycleGate, while a subscribe's demand handler holding that gate - writes the cache and waits for the delivery's turn." -- It replaced it with **order as data**: a per-cache `Sequence`, and no - delivery scheduler. -- The regression tests it left, `ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries` - (`ConditionalCommitTests.cs:763`) and - `SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock` - (`NetworkingTests.cs:2244`), are live on main. - -This pass rebuilt that scheduler as a prototype. It is the per-cache turnstile -with same-thread trampolining at `79f4639`, and it reproduces the deletion: - -- Caching tests: 278 of 279 pass, and `ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries` - fails. -- The networking suite hangs, and Yggdrasil's idle watchdog killed it at 900 s. - The blame-hang run that names the hung test is recorded below. - -The reason is structural, not an implementation slip. Once a writer waits for -another thread's delivery, any lock that an observer takes and a writer holds -becomes a deadlock: - -- total cross-thread order; -- a writer delivering before it returns; -- no writer waiting on another thread. - -Pick two. The branch's drainer chose order plus no-wait, and lost -before-return (F3 and F4). The turnstile chose order plus before-return, and -lost no-wait (the `bcae483` deadlock). - -**So this map keeps order as data (the `4562340` ruling) and moves the one -order-sensitive consumer, the CultNet mutation log, to the place order is -decided: under the cache gate.** - -- Every writer keeps delivering its own changes, on its own thread, before it - returns, exactly as main does. F3 is satisfied by not regressing it. -- The log becomes a **journal** written in the admitting hold, in Sequence - order. -- The reorder buffer and drainer die, and no scheduler replaces them. This - departs from Soul's stated direction on purpose, and **Q-P1** puts it to the - operator. - -## Probe evidence - -All of this ran on Yggdrasil via `ygg-verify.sh`, from scratch commits in a -throwaway worktree that no branch holds. - -- Probe file: `tests/GameCult.Caching.Tests/PublicationProbeTests.cs` in scratch commit `79f4639`. The worktree has been removed. The commit is unreferenced in the local object store, where gc can collect it; it also survives on the Yggdrasil mirror as `refs/verify/79f4639f8f5b95db7ce16f244a081c1ec101a468`. -- `9c5abf5` is the probes on main. `79f4639` is the same probes plus the - turnstile prototype in `CultCache.cs`. - -| # | Probe | main `65311c9` | Turnstile prototype `79f4639` | -|---|---|---|---| -| Q1 | A batch `{ok, boom}` where boom's `[CultName]` getter throws | `TargetInvocationException`. `_sequence` goes from 0 to 1, because ok was minted. **Both ok and boom are in memory**, and `GetByName("ok")` finds ok. **Nothing is published**, and the next write gets Sequence 2. F1 is worse than stated: memory is corrupt and unpublished. In a store-backed cache, `land` (`:2325`) already ran, so the store holds it too. | No stall, but memory is still corrupt. | -| Q2 | Two `Watch` observers, the first throws | With the default R3 handler, the second observer sees 2 of 2 and the writer sees nothing. **With a rethrowing handler, the second observer sees 0 of 2 and every writer gets the exception.** The comment at `CultCache.cs:2374`, "OnNext does not throw", is false under fail-fast. | With its own observer array and a per-observer catch, the second observer sees 2 of 2 and the writer gets the exception afterwards. | -| Q3 | Writer A's observer blocks on `a`, then B writes `b` | The recorder sees `b(2)` before `a(1)`. There is no cross-thread order, by design since `4562340`. | Sees `a` then `b`, because B waited for A. | -| Q4 | An observer on `x` writes `y` | `y(2)` before `x(1)`: nested, depth-first, and the nested write returns after `y` is published. | `x` then `y`, and the nested write returns before `y` is published. | -| Q5 | `OnUpdate` on Upsert and on Commit | 0 fires, so loads only. `Watch` sees 2. | Unchanged. | -| Q6 | 8 threads × 1000 writes, plus reentrant writes | 67 ms, 2645 of 8800 cross-thread inversions, and 0 writes returned undelivered. | 516 ms, 0 inversions, 0 undelivered. | -| Q7 | Soul's P2 shape: a handler waits 1 s for another thread's write | Observed in time. | Not observed in time. | -| Suites | Caching, Networking and Mesh | (green on main) | Caching fails `ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries`, and Networking **hangs** until the 900 s idle kill. Rerun with `--blame-hang-timeout 90s`: 286 passed, then the test host hung and was dumped. The blame sequence file was not extracted, so **the hung test is not named**. It is consistent with, but not proven to be, `SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock`, whose own comment says "disposal would hang too". | - -## Imagination pass 4: Cut 4 re-taken on the landed journal - -Cut 3 landed at `ae8576d`. Its Soul left two questions that Cut 4 had to settle before relying on the -journal: (a) what a throwing journal means for the log, and (b) whether a journal needs the change's origin. -Both have one coherent answer. The one fork, Q-P5, was about who hears of a log failure; the operator ruled it A (2026-09-29). - -### Probe evidence (pass 4) - -- Probe file: `Pub4ProbeTests.cs` (Networking tests). It was run on Yggdrasil from scratch commits: - - `0d7397d`: main plus the probes; - - `6ec2f73`: the same, merged with the branch `28248eb`; - - `376cd74`: main plus probe C. -- The commits survive on the Yggdrasil mirror. The scratch worktree is gone. -- The probes report facts and assert nothing. `FlakyLogStore` is an in-memory `ICultNetShardMutationLogStore` - that can refuse `Append` or run a hook inside `GetCompactedThrough`. - -| # | Probe | main `3bf1c0c` | branch `28248eb` (queue) | -|---|---|---|---| -| A1 | Primary with a durable log. Put 1, Put 2 (its `Append` throws), Put 3. A replica pulls, then retries. | Writer gets `IOException`. The cache holds 1, 2 and 3, but **2 is never published** (2 of 3). Memory log `[1,2,3]`, durable `[1,3]`. The replica pull throws "log has a gap. Expected 2, received 3", and so does **every retry**: the replica is stuck for good. | **Writer sees nothing**: R3 swallowed the drainer's exception. 2 is not published. The replica is stuck in the same way. | -| A2 | The same failure at the tail, then the primary restarts over the same cache and log store. | The replica applied 1. After restart, note 3 is **minted sequence 2**. The replica takes it, reports applied 2, and holds `1,3` against the primary's `1,2,3`: **silent divergence**, a reused sequence. | Same. | -| A3 | A replica whose log `Append` throws once. | The pull throws and the cursor stays at 0. The retry converges (`1,2`), but the memory log is `[1,1,2]`: a duplicate, because memory is written before disk and kept as a `List`. | The pull "succeeds": the exception was swallowed and the cursor went to 2. The durable log is `[2]`, so **entry 1 is lost**, and a chained replica sees a gap. | -| B1 | Primary with a file store. Another writer puts `pulled` into the file, the primary runs `PullAll`, and a replica pulls the log. | `pulled` is published but **not logged**. The replica gets nothing. | Logged. The replica converges on `pulled`. | -| B2 | A store attached after the database exists. | Nothing is logged. | All three hydrated records are logged. | -| S | `CreateShardSnapshotResponse` with a write landing between its document read and its sequence read (hooked in `GetCompactedThrough`). | The snapshot holds `[1]` at log sequence 2. The replica applies it and pulls, and still lacks 2: **divergence**. | Same. | -| C | Compact through 2, restart, then Put 3. | Note 3 is **minted sequence 1**, and `GetLatestMutationLogSequence` returns 1, below the floor of 2. | (not run; the code is unchanged) | - -What the probes establish: -- A1, A2, S and C are pre-existing defects on main. Cut 4 inherits them and must not build on them: the - resync path it relies on for (a) is itself broken by S and C. -- On a replica, the pull loop already retries. The cursor advances only after an entry applies - (`CultNetDatabase.cs:1038-1083`), so a replica needs no new mechanism. It needs only to keep the exception - reaching that loop, which the branch's queue broke (A3). -- Loads on a primary must be logged, or replicas diverge (B1). - -### (a) A journal that cannot log a committed change: one answer - -The facts: -- The log store is a second durable store written after the cache's store. Without an atomic commit across - the two, the log can lag the store. It must never lead: a replica would then apply a change the primary - refused. -- So lag is unavoidable, and it has to be detectable and repaired. -- The protocol already has a repair, and nothing else needs to be invented. - - "History the primary cannot serve" is `compacted_history`: `CultNetDatabaseServer.cs:189-201` answers - `ResyncRequired`. - - `CultNetShardReplication.cs:199-214` then snapshot-resyncs. - -**Rule: a primary that cannot log a committed change burns that change's sequence and compacts through it.** - -- In the database journal, on a primary, for each change that is not `NoLog`: - 1. Mint `S`. - 2. Build the entry: the wire message from the door, or `ToLogEntryMessage`. - 3. `Append` durably, **then** record it in memory. Disk goes first, so memory never serves what disk lacks. -- If step 2 or 3 throws: - - record nothing; - - raise the database's in-memory floor for the shard to `S`; - - try `store.CompactThrough(shard, S)`; - - still set `_lastWriteSequence` to `S`, because the row was written at `S`; - - carry on with the rest of the admission; - - throw `CultNetShardLogException` once at the end of the journal call (Q-P5 A). -- Every replica behind `S` is then told `compacted_history` and resyncs by snapshot. The sequence gap at `S` - is never served, because the floor covers it. -- This **replaces** the old §4 step 3 claim, "a serialization failure then mints nothing, so the log can never - hold a gap". Minting nothing hides a replication gap behind a dense sequence. That is exactly A2's silent - divergence. -- **Replica:** there is no floor. The journal's exception reaches `ApplyShardLogResponseAsync` and the cursor - does not advance. The next pull re-applies the entry: a fresh instance, so the door matches again, and the - entry is re-recorded under the primary's sequence. The branch's absent-key direct log follows the same - disk-first order. -- **Required fixes** that the rule depends on, all in Cut 4: - - `GetCompactedMutationLogSequence` (`CultNetDatabase.cs:597-602`) returns - `max(store floor, in-memory floor)`. - - `InitializeLogSequencesFromStore` (`:1363-1395`) resumes at - `max(highest retained, store.GetCompactedThrough(shard)) + 1`. This is probe C. - - `GetLatestMutationLogSequence` (`:607-616`) never reports below the floor. - - `CreateShardSnapshotResponse` (`:624-670`) reads `GetLatestMutationLogSequence` **before** `SelectAll`, on - both returns, `:645` and `:668`. The documents then reflect at least that sequence, and replaying later - entries converges: they are whole-document puts and deletes, applied in order. This is probe S. - - `RecordMutationLogEntry` (`:1331-1352`) writes disk first. The in-memory log becomes - `SortedList` per shard, replacing by sequence as the durable store does. This fixes A3's - duplicate. -- **Residual, recorded rather than forked.** Suppose the store refuses both `Append` and `CompactThrough`, and - then the process restarts. - - The durable log does not know about the hole, so A2's tail reuse can recur. - - Fixing that needs a durable write per mint, a sequence reservation, which doubles log I/O under the gate. - - The thrown exception names both failures, so the outage is loud. - - Refusing later writes ("fail closed") is not coherent. The database can refuse only its own doors, while - bare cache writes are journaled regardless: that would be split authority. - -### (b) A pulled or loaded change on a primary: no origin needed - -- On a primary the log is the shard's **state history**, whoever wrote the state. B1 shows that leaving loads - out of the log leaves replicas permanently diverged. -- The only origins that change what is logged are already carried by the door table, keyed by instance - identity and matched by shape. They are: - - `Replica` (log the primary's entry); - - `NoLog` (prediction, snapshot resync); - - `Put`/`Delete` (the wire message); - - `Predicts`/`Authoritative`. -- A load or pull has no door, so on a primary it is logged like any unattributed commit. On a replica it logs - nothing, like any local write. That is what the branch does, and B1 converges under it. -- Cost: - - B2: a store attached **after** the database logs its hydration. - - `CultNetHost` opens the cache before building the database (`CultNetLocal.cs:146-152`), so production - hydration is not journaled. - - A re-pull admits only the records that changed. In B1 the seed was not re-logged. -- `AddJournal` keeps its signature. If a future consumer (audit, say) needs "load or commit", the fact goes on - `CultCacheDocumentChange` as an additive property, as `Sequence` did. The cache already knows it: `Admit`'s - `source != null` at `CultCache.cs:2420`. It never needs a new `AddJournal` shape. -- This is recorded, not forked: no consumer needs it today. - -### The two recorded hazards, settled for Cut 4 - -- **Q-P2 typed-exception hazard.** Cut 4 makes it frequent. Every cache write on a primary with a durable log - now does log I/O inside its admission. - - AetheriaEve opts into durable logs (`Aetheria.State/AetheriaStateNode.cs:144`). - - Its `AetheriaHangarCommandJournal.ValidateAsync` writes an envelope (`Program.cs:6509`) inside the `catch - (InvalidOperationException)` at `Program.cs:3722`. - - A log failure surfacing as `InvalidOperationException` would therefore produce a "denied" receipt for a - committed envelope. - - Q-P5 A (ruled): the writer gets a typed `CultNetShardLogException` that **does not derive from - `InvalidOperationException`**. -- **Journal deadlock (the Cut 3 note).** The database journal waits only on `_logGate` and on the log store's - own lock. - - Lock order: cache gate, then `_logGate`, then the log store's lock. - - Invariant: **no code holding `_logGate` calls the cache, and no log store calls the cache or waits on a - thread that does.** - - Readers that take `_logGate` read only log state: `GetMutationLog`, `LastWriteSequence`, `CurrentAsOf`. - Readers already inside a cache read, such as the selection evaluator's `ordinalOf`, nest `_logGate` under - the cache gate, which is the same order. - - The store rule goes into the `ICultNetShardMutationLogStore` XML doc (`CultNetShardMutationLogStorage.cs:13-34`), - because a user store runs under the cache's gate. - - A log store that writes the cache already throws, through Cut 3's guard. Cut 4 pins it with a test. - -## Authority map - -- **Owner.** `CultCache`. The hold that mints a Sequence range also: - 1. journals the range under the gate, in Sequence order (new); - 2. publishes the range to every observer, isolated, on the minting writer's - thread, after it releases the gate and before it returns (main's model, - now isolated). - - Nothing waits on another call's delivery. -- **Inputs.** - - The changes returned by `Apply` (`CultCache.cs:2707`), the single mutation - site. - - The pre-mint projection of names and indexes (Cut 1). - - The journal list, the observer array and `OnUpdate`. -- **Outputs.** - - Journal calls: one per admission, under the gate, in Sequence order. - - `Watch` / `WatchRecord` notifications that carry `Sequence`. - - `OnUpdate` for loads. - - Observer and journal exceptions, rethrown to the writer. -- **Derived state.** - - `Sequence` is order as data. - - CultNet's primary log sequence is minted in the journal, so log order is - cache order. - - A replica's log sequence is copied from the primary entry. - - The database change kind (`Predicted`, `Reconciled`, `Added`, `Updated`, - `Removed`) is decided in the journal. - - `CultNetDatabaseChange.Sequence` (new) is copied from the cache change. - - Wire projections drop stale by it (Cut 6). -- **Forbidden writers.** - - Nothing re-sequences or schedules cache changes. The branch's `_parked`, - `_ready`, `_nextSequence`, `_draining`, `PendingChange` and the drain body - of `OnCacheChange` die, and so do the turnstile, `bcae483` and the - prototype. - - No R3 `Subject` fans out cache changes (Q2). - - `Apply` and `Index` run no user code. - - Doors never read or write prediction state: the branch's `HasPrediction` - and `_predictionGate` die. - - The database's Watch observer never logs; only the journal logs. - - A journal never enters the cache. -- **Shared paths.** Every path goes `Held` → `Admit` → `Resolve` → **Project** - → `land` → `Apply` → **journal** → leave gate → **publish**: - - loads: `AddBackingStore` `:1909`, `Loaded` `:1940`, `PullAll*` `:1959` and - `:3052`; - - `Commit` / `TryCommit` (`Land`, `:2272`); - - `UpsertAsync`, `UpsertVariantAsync`, `FlattenAsync` and `Remove` - (`:2004-2056`, `Write` `:2252`); - - a variant dependent's re-resolution (`VariantPlan.Dependents`, in the same - `Apply`); - - direct calls on an attached store (`:2924`); - - `Dispose` (`:2142`); - - the database doors, replica apply and snapshot resync, which only write - the cache and hand context to the journal. - - The single exception is the replica's absent-key delete, which has no cache - change. It logs directly (§4). -- **Deletion line.** - - In the cache: `Subject _changes` (`:1809`), the `_changes.OnNext` - call and its false comment (`:2374-2375`), `_changes.Dispose()` - (`:2154`), and the accessor calls in `Index` (`:2798-2804`). - - In the database, on the salvaged branch: the reorder buffer and drainer, - `_predictionGate` and `HasPrediction`, the logging in `Deliver`, and the - `Subject _changes` fan-out. - -## The design, question by question - -### 1. Validation before minting: a refused change mints nothing - -- **Where.** Add **Project** to `Admit` (`:2313`), between `Resolve` (`:2324`) - and `land` (`:2325`). -- **What.** Over `plan.Admitted ∪ plan.Dependents`, it evaluates once per - record: `NameAccessor(document)` and every `IndexAccessors[alias](document)`. - It keeps `(Name, (Alias, Value)[])` per key on the plan. -- **A throwing getter refuses the whole admission.** It raises an - `InvalidOperationException` that names the key, the member and the unwrapped - message; today the caller sees a bare `TargetInvocationException`. The - refusal comes before `land`, so the admission writes no store, touches no - memory and mints nothing. -- **Readers take the projection, not the document.** - - `Index` (`:2793-2806`). - - `RefuseVariantIndexSharing` (`:2659-2660`). - - The variant-name rule in `ResolveOne` (`:2616`). Its ancestor read at - `:2621` reads held documents, pre-land, and may stay. - - `Resolve` currently runs the refusal rules before any projection exists. - Hands makes Project the first pass over the resolved plan and moves both - rules after it, so rules read the projection and only the projection reads - documents. -- **Apply becomes user-code-free.** It then does only dictionary and - `ConditionalWeakTable` work on precomputed data. -- **Loads refuse too.** A store whose record's getter throws fails to open, - loudly. That is the rule "hydration failure on open is loud" (composition doc - `:88-92`). Today it half-indexes silently. - -### 2. Observer isolation - -- **Replace the `Subject` with the cache's own observer array**, - copy-on-write under a small lock. - - `Watch` (`:1887`) becomes `Observable.Create(register)` followed - by the existing `Where` and `Select`. - - `Dispose` calls `OnCompleted` on each observer, which is what - `Subject.Dispose` did. -- **Publish loops change → observer, and wraps each `OnNext` in its own - try/catch.** - - R3's `Observer` already catches handler exceptions (`OnErrorResume`, then - the unhandled handler). So an exception reaches the cache only when fail-fast - was asked for: a rethrowing `ObservableSystem` handler, or a custom observer. - - Escaped exceptions join the `OnUpdate` failures. They are rethrown to the - writer after every observer has received every change of the hold. That is - the existing `OnUpdate` policy (composition doc `:77-82`). - - The observer stays subscribed (**Q-P2**). The commit stands either way. -- **Prototype.** At `79f4639` (Q2) this took about 15 lines. Hands keeps that - part and drops the turnstile around it. - -### 3. Delivery thread, order and reentrancy - -- **Unchanged from main, on purpose.** Each outermost hold publishes exactly its - own changes, on its thread, after it leaves the gate and before it returns - (composition doc `:59-65`, `Held` `:2339`). - - A write from inside a handler is its own outermost hold. It delivers - depth-first before returning, as Q4 shows on main. - - There is no cross-thread order. Every change carries `Sequence`, and - latest-state consumers drop stale changes by it (doc `:68-73`, and the Mesh - mirrors at `CultMesh.cs:1534-1540`). -- **Why this is not a regression of the ruling.** The ruling asks for - exactly-once, not for a total delivery order. - - Exactly-once in the cache comes from the single mint site plus isolation - plus Cut 1 (§1-2). - - Order is needed by exactly one consumer, the shard log. It gets that order - from the journal (§4), not from delivery. -- **The journal is the new mechanism.** - - Signature: `IDisposable CultCache.AddJournal(Action>>)`. - Its name and shape are **Q-P3**. - - It is called in `Admit` right after `Apply` (`:2328-2329`), under the gate, - once per admission, with that admission's changes in Sequence order. - Journals run in registration order. - - A journal that enters the cache, meaning any hold on this cache while a - journal runs, throws `InvalidOperationException`. It is guarded by a flag - checked in `Held`'s `Monitor.IsEntered` branch (`:2341`). This is the - store rule ("a store call must not wait for a write to its own cache"), - enforced. - - Journal exceptions follow the observer policy: the admission stands, it is - still published, and the exception is rethrown to the writer after - publication. - - Adding or removing a journal takes the gate, so registration is atomic with - respect to admissions. -- **Lock order.** Cache gate, then journal-internal leaf locks (the database's - `_logGate`). Observers still run outside the gate, so the `bcae483` cycle - (an observer needing `_lifecycleGate` while a gate holder writes the cache) - cannot form. No journal takes `_lifecycleGate`, and no writer waits on a - delivery. -- **Soul's P2 passes.** A handler waiting on another thread's write sees it, - because nothing orders deliveries (Q7 on main). - -### 4. What CultNetDatabase keeps - -The database is rebuilt on the journal. It has no queue. - -- **Journal (under the cache gate, per admitted change, in Sequence order):** - 1. `TakeDoor(change)`, with the shape rule salvaged from the branch - (`:1219-1234`). A put door matches a change whose new document is its - instance and whose previous document is not. A removal door matches a - change whose previous document is its instance and which has no new one. - - The consumption now happens inside the door's own hold, before any - observer runs. So an observer's later write to the door's key or - instance can never wear the context (the `f3343c8` defect), and - unconditional `Dispose`-removal stays correct. - 2. Predictions. A `Predicts` door adds the prediction. On an `Authoritative` - door (`ApplyPutAsync`, replica put), if a prediction exists for the key, - the kind is `Reconciled` and the prediction is removed. - - The gate serializes this, so `_predictionGate` and `HasPrediction` are - deleted (F5). - - Soul's P6 cannot happen: the prediction is recorded before - `PutPredictedAsync` returns. - 3. Primacy. - - On a shard primary, where the door is not `NoLog`: mint, build, append disk-first and record, with a failed - append burning the sequence and compacting through it. See "(a) A journal that cannot log a committed - change" in Imagination pass 4; the earlier claim that a serialization failure mints nothing is withdrawn. - - On a replica, record the primary's entry from the `Replica` door, - under its own sequence. - - For a local write on a replica, log nothing. - 4. Stash `(cache Sequence → CultNetDatabaseChange)` under `_logGate`, for the - publisher. -- **Watch observer (after the gate, on the writer's thread, before it - returns):** it removes the stash entry for `change.Sequence` and publishes it. - - A change with no stash was admitted before the database existed and is - ignored. - - Register the observer first and the journal second, in the constructor, so - a stash can never be orphaned. -- **Log-before-publish** is structural: the journal runs before the gate opens. -- **The replica's absent-key delete (F6)** logs directly under `_logGate`. It - is in order because every earlier entry of the same pull loop was journaled - inside its own hold, before that `UpsertAsync`/`Remove` returned. This holds - even when a pull runs inside a handler, since nested holds journal - synchronously. -- **The in-memory log is keyed and ordered by sequence.** It becomes a - `SortedList` per shard, to match the durable store - (`CultNetShardMutationLogStorage.cs:55-64, 75-89`: that store replaces by - sequence and orders by sequence). -- **Kept from the branch:** - - the door table keyed by instance identity, and the shape rule; - - "only a primary mints; replicas publish"; - - log-before-publish; - - the snapshot resync "logs nothing" rule. Its removal door (branch `:733`) - must set `Removal = true`, or the shape rule never matches it. -- **Database fan-out isolation.** The database's `Subject _changes` - (main `:487`) has the Q2 defect one layer up: the subscription server's - per-peer handlers sit on it. Replace it with the same isolated - observer-array pattern as §2 (about 20 lines). Its exceptions reach the - writer through the cache's own per-observer catch. -- **`CultNetDatabaseChange` gains `long Sequence`**, the cache change's - Sequence, as an optional constructor argument, exactly as - `CultCacheDocumentChange` did (composition doc `:57-58`). Without it, - in-process latest-state consumers cannot drop stale changes. -- **Gone:** F3 (no deferred delivery), F4 (no drainer), F7 (no queue to - strand) and F8 (no guard). - -### 5. Backpressure and memory bounds - -- **No buffer exists.** A writer delivers its own changes, and they are held - only in its `mine` list for the length of its own call. -- **The journal stash** holds at most the changes of holds between their - journal and their publication. That is one hold per writing thread, and each - entry is removed by the same thread that added it. -- **A slow observer slows only the writer whose change it is handling.** That - is main's behaviour. It does not slow other writers, and it does not grow - memory. This replaces the branch's unbounded drainer backlog (F4, Soul P5). -- **The journal runs under the gate, so a slow journal slows every writer.** - - The database journal does in-memory work plus one durable - `mutationLogStore.Append` per logged change. `Append` rewrites the shard - file (`CultNetShardMutationLogStorage.cs:83-89`). - - That is I/O under the gate, which the locking doctrine already allows for - stores ("A store doing I/O on behalf of its cache ... holds the gate", - composition doc `:98-101`). It is still a new cost, so Cut 4's - verification measures commit latency with a file log store. - - The whole-file rewrite is a pre-existing monolith. It is recorded as a - follow-up, not fixed here. - -### 6. Cross-runtime impact - -The Eyes survey was run at `65311c9`. - -- **`cultcache-rs`, `cultcache-ts` and `cultcache-py` have no publication - surface:** no watch, no listener and no Sequence. - - Rust: `lib.rs:1970-1975, 2213-2420`. - - TypeScript: `cult-cache.ts:164-181`, "Observer delivery is not scheduled - here". - - Python: `cache.py:30-45`, which refuses reentrant mutation. -- **`cultnet-rs`, `cultnet-ts` and `cultnet-py` have no CultNetDatabase - equivalent and no cache watcher.** The only server-side shard log is the - Python interop test peer (`interop_peer.py:565, 583`), which mints - `len(log)+1` under its own lock, inline with the write. That is already the - journal's shape. -- **Wire parity is unaffected.** - - Cache `Sequence` never goes on the wire (composition doc `:47-49`). - - `CultNetShardLogEntryMessage` does not change. - - `CultNetDatabaseChangeRawMessage` (`CultNetSchemaMessages.cs:1016`) carries - no sequence, and this map adds none: stale protection for wire - subscriptions is server-side (Cut 6). - - No vector in `tests/vectors/` encodes publication order. -- **No other runtime must change.** -- **Follow-up.** `cultmesh-py`'s `CultMeshDatabase` watch (`node.py:421-505, - 1059-1065`) has F2 (a throwing callback skips the rest and reaches the - writer), and it has no Sequence. When Python grows cache publication, it - takes this contract. - -## Cuts - -Every cut is on CultLib, branch `hands/cultcache-publication` from `main` -`65311c9`, in a worktree Self creates. Cuts 1-3 touch only the cache and land -first. Cuts 4-6 depend on Cut 3. Heavy verification runs on Yggdrasil. - -**The negative checks that stay green in every cut** are the `4562340` tests: - -- `ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries`; -- `SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock`; -- `SequenceIncreasesInAdmissionOrder`; -- `WatchRecordIgnoresStaleSequence`. - -They are the proof that no scheduler has come back. - -### Cut 1. Project before mint (cache, behaviour) - -- **First.** Commit Q1 as a failing test named for the rule. -- **Deletes first.** - - Accessor calls in `Index` (`:2798-2804`). - - Direct accessor reads in `RefuseVariantIndexSharing` (`:2659-2660`) and - `ResolveOne` (`:2616`). -- **Adds.** - - `Project` in `Admit` (`:2324-2325`). - - A per-key projection on `VariantPlan` (`:2456`). - - The early returns in `Resolve` (`:2477-2478`, `:2530-2534`) still pass - through `Project`, because it is called from `Admit`. -- **Authority.** Project owns "this admission's name and index values", and - the rules and `Index` read them. `Apply` runs no user code. -- **Verification.** - - Tests: - - **A refused getter mints nothing and changes nothing.** `GetWithSequence` - is unchanged, both keys are absent, the name lookup misses, nothing is - published, and the next write is previous+1. - - **Store-backed batch:** the store file is byte-identical, and a reopen - holds neither key. - - **A load whose getter throws** refuses the open and leaves the file - byte-identical. - - **Once per landed record:** a counting getter runs exactly once per - landed record, including a variant dependent re-resolved by a base edit. - - **The refusal names** the key and the member. - - Soul P4. - - Negative grep: `rg -n "NameAccessor\?\.Invoke|IndexAccessors" src/GameCult.Caching/CultCache.cs` - has no hit in `Apply`, `Index` or `Unindex`. `:315` is store-side and - legitimate. - - Stryker.NET on the diff. -- **Ledger.** About −15 and +45. - -### Cut 2. Observer isolation (cache, behaviour) - -- **Deletes first.** - - `Subject _changes` (`:1809`). - - `_changes.OnNext` and the false comment (`:2374-2375`). - - `_changes.Dispose()` (`:2154`). -- **Adds.** - - The observer array and `Observable.Create` in `Watch` (`:1887-1897`). - - The per-observer catch in `Publish` (`:2369`), with escapes joining - `failures`. - - `OnCompleted` on `Dispose`. -- **Docs.** Composition doc `:77-82`: escaped observer exceptions follow the - `OnUpdate` policy. -- **Verification.** - - Tests: - - **Q2 with a rethrowing handler:** the second observer receives every - change. The writer gets the escaped exception after delivery, and the - thrower is still subscribed (per Q-P2). - - **Subscription order:** subscribing and unsubscribing during a delivery - leaves every other observer's stream complete. - - **Dispose** completes observers. - - Soul P3. - - The four `4562340` tests stay green. - - Stryker on the diff, especially removing the catch and breaking out of - the loop on the first failure. -- **Ledger.** About −5 and +30. - -### Cut 3. The journal (cache, behaviour) - -- **Adds.** - - `AddJournal` (Q-P3), with registration under the gate. - - The invocation in `Admit` after `Apply` (`:2328`). - - The no-reentry guard in `Held` (`:2341`). - - Exceptions routed like observer failures. -- **Docs.** A composition-doc paragraph in "Store composition" and in - "Locking": journals run under the gate, in Sequence order, and must not - enter the cache or wait for anything that needs it. -- **Verification.** - - Tests: - - **Order:** 8 threads × 1000 writes. The journal sees contiguous, - strictly increasing Sequences, and each call's changes arrive together. - - **Timing:** the journal runs before any observer of the same change, and - after `Apply` (`Get` inside the journal sees the new document). - - **Reentry:** a journal that writes the cache throws, and the admission - stands. - - **Refusal:** a refused admission journals nothing. - - **Dependents:** a variant dependent is journaled in the same call as its - base edit. - - **Failure:** a throwing journal still lets the change publish, and the - writer gets the exception. - - The `4562340` tests stay green. - - Stryker on the diff. -- **Ledger.** About +50. - -### Cut 4. Salvage the branch and rebuild CultNetDatabase on the journal (networking) - -- **4a (history).** `git merge origin/hands/cultnet-publish-commits` into the cut branch from main - `3bf1c0c`. - - It is clean: the probe merge `6ec2f73` auto-merged `NetworkingTests.cs` and the composition doc, then - built and ran. - - The merge brings the tests. The queue dies in 4b. - - Self may squash-salvage instead, with the same content. -- **4b (subtraction first).** Delete at branch `28248eb` `CultNetDatabase.cs`: - - `_parked`, `_ready`, `_nextSequence` and `_draining` (`:502-505`); - - `_predictionGate` (`:506-507`); - - the Watch subscription and handshake (`:529-531`); - - `OnCacheChange` (`:1080-1135`); - - `PendingChange` (`:1265-1275`); - - `HasPrediction` (`:1209-1213`) and its calls (`:916`, `:1574`); - - the logging and prediction bookkeeping in `Deliver` (`:1137-1201`). - - Main's `PublishCacheUpdate` / `OnUpdate +=` (`:505`, `:1157`, `:1161-1191`) is already gone on the branch. - Hands confirms it is absent after the merge. -- **4c (the journal).** Register the Watch observer first and the journal second (`AddJournal`), in the - constructor. Per admission, under the cache gate, per change in Sequence order: - 1. `TakeDoor(change)`, keeping the branch's shape rule (`:1219-1234`). - 2. Resolve the shard and kind. Predictions: a `Predicts` door adds its key. An `Authoritative` door - (`ApplyPutAsync`, replica put) holding a prediction for the key makes the kind `Reconciled` and removes - the key. - 3. Log, per §(a): - - a replica door records the primary's entry, disk first; - - a primary change that is not `NoLog` mints, builds, appends and records, with failure going to the - floor; - - anything else logs nothing. - 4. Stash `(cache Sequence → CultNetDatabaseChange)` under `_logGate`. - 5. If step 3 failed, throw once at the end of the call, after all changes are handled (Q-P5). -- **4c, publication.** The Watch observer removes the stash entry for `change.Sequence` and publishes it. A - missing stash means the change was admitted before this database existed, so it is ignored. `Dispose` - removes the journal and the observer and clears the stash. -- **4d (the §(a) fixes).** - - The floor: `GetCompactedMutationLogSequence`, `GetLatestMutationLogSequence`, and resume in - `InitializeLogSequencesFromStore`. - - Disk-first `RecordMutationLogEntry`. - - A `SortedList` per shard. - - The snapshot reads its sequence first (`:660` moves below `:668`'s read). - - `_logGate` around `_lastWriteSequence`/`_nextLogSequences` readers (`:1302`, `:1314`, `:1325`). -- **Door changes.** - - Flags: `Predicts` and `Authoritative`. - - The snapshot removal door (branch `:733`) sets `Removal = true`. - - The replica absent-key delete logs directly and publishes nothing (branch `:1612-1621`). - - Doors neither log nor publish. They register context, write the cache, and dispose the registration. -- **Verification.** - - Every salvaged test passes: `CultNetPublishesCommitsTests` (13) and `CultNetPublicationOrderTests` (S1, - S1b, S2, S3, S4, S6, S7, log-before-publish, in-place re-upsert). - - Soul pass 3's P1-P7 are committed and passing. - - The pass 4 probes become tests named for the rule: - - **A1:** a refused primary `Append` publishes the change. The writer gets `CultNetShardLogException`, - which names the shard and `S`. A replica pull answers `compacted_history`, and a snapshot resync - converges. - - **A2:** the same at the tail with a restart: the replica converges after resync, and no sequence is - reused. The store's `CompactThrough` succeeded. - - **A3:** a refused replica `Append` leaves the cursor unchanged. The retry converges, and the memory log - holds `[1,2]`. - - **B1:** a pull on a primary is logged, and the replica converges. - - **S:** a snapshot racing a write converges. - - **C:** after compacting through 2 and restarting, the next mint is 3. - - New tests: - - a reentrant `database.PutAsync` from inside a database handler is logged with its context and published - once; - - a reentrant `ApplyShardLogResponseAsync` from inside a handler keeps the replica log in primary order; - - a log store whose `Append` writes the cache fails with Cut 3's reentry guard, wrapped, and the admission - stands. - - Commit latency with `CultNetFileShardMutationLogStore`, main against Cut 4. Also measure a `CultMesh` node - with `EnableDurableShardLogs`, where every bookkeeping write is now logged. Report it; do not gate on it. - - Negative grep: `rg -n "_parked|_ready|_draining|_nextSequence|_predictionGate|HasPrediction|PublishCacheUpdate" src/GameCult.Networking` - returns nothing. - - Review check: no `_cache.` call inside a `lock (_logGate)` block. - - The `4562340` tests stay green. - - Test runs use `--blame-hang-timeout 2m`. - - Stryker on the diff: dropping the floor raise, memory-before-disk, and the snapshot read order must each - kill a test. -- **Docs.** - - Composition doc `:50-53`: replace the "streams derive from `OnUpdate`" sentence. CultNet derives from - `Watch` plus one journal, and the log is written in cache order. - - A new paragraph: a primary that cannot log a change compacts through it. - - The log-store rule under Locking. -- **Blockers.** None. Q-P5 is ruled A. -- **Ledger.** Against the branch, about −130 and +95. The extra over the old estimate is the floor, the - disk-first order, the `SortedList`, the snapshot order, and the exception type. - -### Cut 5. Database fan-out isolation and Sequence (networking, behaviour) - -- **Deletes first.** The database's `Subject _changes` (main `:485`, branch `:487`) and its - `Dispose` (`:1158`). -- **Adds.** - - The isolated observer array, the same pattern as the cache's `Register`/`Publish` - (`CultCache.cs:1929-1944`, `:2470-2503`), behind `Watch`, `WatchAllChanges` and the rest (`:1089-1145`). - - `CultNetDatabaseChange.Sequence` (`:319-371`), as an optional constructor argument. The journal's stash - sets it from the cache change. A change with no cache change behind it carries 0. -- **Docs.** The binary-signature note, as the composition doc gave for `CultCacheDocumentChange` (`:57-58`). -- **Verification.** - - With a rethrowing handler and one throwing database subscriber, the other subscriber and a live - subscription-server peer receive every change. The writer gets the exception after delivery (Q-P2). - - The disposed-database test. - - `Sequence` equals the cache change's. -- **Blockers.** Cut 4. -- **Ledger.** About −10 and +35. - -### Cut 6. Stale protection on wire projections (networking, behaviour; Q-P4 A) - -- **The rule.** A projection applies a change to a key only if its `Sequence` is above what the projection - last applied for that key. A snapshot or reconcile applies everything at or below the cache sequence it read - **before** reading documents. - - That read is `Cache.GetWithSequence().Sequence`, the branch's idiom (branch `:531`). No new API - is needed. -- **Subscription server** (`CultNetDatabaseSubscriptionServer.cs`): - - `SubscriptionProjectionState` (`:630`) gains `long Floor` and `Dictionary Applied`. - - The single-row fast path in `Watch` (`:296-340`) reads the change's `Sequence` (reflection, like `Key`). - Under `_lifecycleGate` it drops the change if `Sequence <= max(Floor, Applied[key])`. Otherwise it applies - the change and records it. Removals are included. - - `Reconcile` (`:347`) and the subscribe snapshot (`:168-181`) read `seq0` before `CreateProjectedSnapshot` - (`:402`), set `Floor = seq0`, and clear `Applied`. - - This is sound: every change applied earlier was applied under the same gate, after its admission, so - its `Sequence` is at most `seq0`. - - Hop and unauthorized paths already reconcile from current state, and they set the floor too. -- **`CultNetDatabaseServer.CreateSubscription`** (`:501-513`): - - A per-subscription `(Floor, Applied)` under a per-subscription lock, held across check, record and send. - - `HandleSubscribeAsync`'s snapshot (`:362-369`) takes `seq0` first and sets the floor. -- **Verification.** - - Two threads write one key, with a blocking cache observer that forces the inverted delivery (Q3 shape). - On both servers, the peer's last state for the key equals the cache's. - - A removal overtaken by an older upsert leaves the key absent at the peer. - - The negative check: without the drop, both tests fail. -- **Blockers.** Cut 5, which provides `Sequence` on database changes. -- **Ledger.** About +45. - -### Salvage from `hands/cultnet-publish-commits` (revised) - -- **Keep:** - - both test files; - - the `NetworkingTests.cs` edits; - - the door table and the shape rule; - - "replicas never mint"; - - log-before-publish; - - "the snapshot logs nothing"; - - "loads and pulls on a primary are logged" (B1); - - the composition-doc paragraph, rewritten to name the journal. -- **Drop:** - - the reorder buffer and the drainer; - - `_predictionGate`; - - logging from the observer; - - the swallowed exception path (A1 and A3 on the branch). - -## Subtraction ledger (estimate) - -| Cut | Removed | Added | Other | -|---|---|---|---| -| 1 | ~15 | ~45 | none | -| 2 | ~5 | ~30 | the cache no longer uses R3 `Subject` | -| 3 | 0 | ~50 | one public method (Q-P3) | -| 4 | ~130 vs branch | ~95 | the merge brings +1109/-243, of which about 780 are tests; the floor, disk-first order, `SortedList`, snapshot order and `CultNetShardLogException` are the extra over the first estimate | -| 5 | ~10 | ~35 | the database `Subject` goes, and one public property | -| 6 | 0 | ~45 | none | - -- **Source, net against main:** about +245. -- **Source against the branch:** no scheduler anywhere. -- The budget is pressure, not a metric. - -## Operator questions - -- Q-P1..Q-P4 are ruled (see Rulings). Their option analysis is in the map's git history. - -- **Q-P5 A (ruled 2026-09-29): a primary that cannot log a committed change throws a typed - `CultNetShardLogException` naming the shard and the burned sequence, after publication, not derived from - `InvalidOperationException`.** - - The commit stands, the change is published, and replicas behind it snapshot-resync (Imagination pass 4 (a)). - - The writer hears of it. AetheriaEve `Program.cs:3722` cannot mistake it for a denial. - -Recorded, not forked: -- (b): no origin on the journal. Loads and pulls are logged on primaries. -- (a)'s restart residual when the store refuses both writes. -- The replica keeps retry-by-cursor. -- A throwing getter refuses loads too. -- Reentrant writes keep main's depth-first delivery. Order is data. -- Journal I/O under the gate follows the store precedent. - -## Follow-ups outside this campaign - -- **`cultmesh-py` watch.** It has no isolation and no Sequence - (`node.py:1059-1065`). -- **Durable log whole-file rewrite.** `CultNetShardMutationLogStorage.cs:83-89` - rewrites the whole file per `Append`. It is a monolith, and under the gate - its cost is now every writer's. -- **Mesh database-backed handles.** `CultMesh.cs:2362` and `:3396` can drop - stale changes once `CultNetDatabaseChange.Sequence` exists. -- **Skill scar.** The Eureka SKILL.md already says "a ticket system that - serialized delivery was built, deadlocked, and was deleted". The Soul pass 3 - direction re-proposed it anyway. The Soul brief template should tell Soul to - check `git log -S` for a proposed mechanism before recommending it. -- **A chained replica after a snapshot.** A replica that snapshot-resyncs has no log entries below the snapshot - sequence. It should raise its own floor to that sequence, so a replica chained on it resyncs instead of seeing - a gap (`ApplyShardSnapshotResponseAsync` `:675-736`). It predates this campaign. -- **AetheriaEve `Program.cs:3722`.** It reads a committed write's `InvalidOperationException` as a denial. Under - Q-P5 A, Cut 4's log failures cannot reach it. Observer and `OnUpdate` exceptions still can, so the Cuts 1-2 - recording stands.- **Cut 4 Soul, recorded 2026-09-30 (Self), not fixed in Cut 4:** - - **File log store is not crash-safe (hard prerequisite).** `CultNetFileShardMutationLogStore.Append` and - `CompactThrough` use `File.WriteAllBytes` (truncate, rewrite, no temp+rename, no fsync). A failure mid-write - truncates the log; every later append burns a sequence and a restart cannot construct the database. It also - rewrites the whole file per append, which Cut 4 now does under the cache gate (2.2 ms per write at 100 - entries, 33 ms at 950, a 2.49 s stall). An atomic, append-only store must land before any live daemon - enables durable shard logs. Today only AetheriaEve's offline tools do; the live Aetheria daemon passes - `enableDurableShardLogs: false`. - - **A slow log append blocks cache readers** (`CultCache.Get` takes the gate). Accepted with a note in the - store's XML doc. - - **Crash between the store write and the log append** leaves the log lagging the store, and nothing detects - it; a restarted primary reuses that sequence (A2's divergence reached by a plain kill). The spec called this - lag detectable; for a crash it is not. -- **Cut 4 final Soul (4d), recorded 2026-09-30 (Self), not fixed in Cut 4:** - - **F2, read cost.** `CultNetDatabase.Serves` (`:1980-1989`) runs per row in `GetAll`/`GetByName`/`GetByIndex` for every - layout except a single owns-everything shard: a reflection `GetTrackedKey`, a gate lock and a shard re-scan per row. - `database.GetAll` measured 24.8x `cache.GetAll` on 20,000 rows under a key-prefixed shard. Fix: filter by key inside - the cache enumeration (the `rowFilter` path already has keys) and hoist the owns-everything check. - - **F4, dispose drain edges.** An observer that removed its stash entry before the drain can publish after `Dispose` - returns (dropped); a throwing `_cacheJournal.Dispose()` skips the drain. Neither double-publishes or deadlocks. - - **F5, refusal naming.** A write whose schema is owned but whose key is outside the shard's prefix also answers - `unowned_schema` with details `{field:"schemaId"}`; the peer cannot tell a key refusal from a schema refusal. - - **Pre-existing, separate task.** Python emits error code `unsupported_schema_version` - (`cultnet-py/.../interop_peer.py:415`, `cultmesh-py/.../server.py:161`), which is not in the error-code enum; TS and - Rust refuse the whole message. diff --git a/docs/cultnet-error-contract-cut.md b/docs/cultnet-error-contract-cut.md deleted file mode 100644 index 923cf51a..00000000 --- a/docs/cultnet-error-contract-cut.md +++ /dev/null @@ -1,644 +0,0 @@ -# CultNet error contract: cut map - -Status: cut map, not started. Imagination pass 1, 2026-09-30, against CultLib `main` at `8fc74c70`. -This document owns the means. There is no separate target doc: section 1 holds the ends until the -operator rules on section 3. After that, Self moves the rulings and invariants into -`contracts/cultnet/cultnet-distributed-database.md` ("Error Codes") and the schema's own description. - -Open: operator questions Q1-Q8 (section 3). No cut is dispatched until Q1, Q2, Q3 and Q8 are ruled. Q4-Q7 -change only single emit sites and can be ruled during Cut 1. - -Depends on: `hands/cultmesh-put-serve-bound` merging first. It introduces `document_unservable` in Python -(`fd394d54`), a codeless unservable refusal in cultmesh-ts (`0eb7434a`) and `CultMeshRudpRejectionReason` in -cultmesh-rs (`c9cdad12`, `85288a5a`). The cuts below convert those sites. Their anchors are by commit, not by -line, because the branch is not on main. - -Map branch: `main`. - ---- - -## 0. What the Body does today (probed) - -Every claim in this table was established by running code. The C#, Rust and TypeScript probes ran on -Yggdrasil through `ygg-verify.sh` at `8fc74c70`. The Python bytes came from `msgpack.packb` on the exact -dicts `cultmesh_py/server.py:573-589` and `cultmesh-ts/src/index.ts:5921-5926` build. The six probe inputs: - -| Input | Bytes from | -|---|---| -| `cs_cursor_stale` | Rust `tests/error_message.rs` captured C# hex (`cursor_stale`, details `{asOf:1,current:2}`) | -| `cs_routing` | C# `new CultNetErrorMessage { Error, RoutingHint = { Reason="not_primary", Shard=... } }` serialized with `CultNetSchemaMessageSerialization` | -| `cs_codeless` | C# `new CultNetErrorMessage { Error = "boom" }` | -| `cs_unknown_code` | C# with `Code = "document_unservable"`, empty `CultNetErrorDetails` | -| `py_snapshot_byte_limit` | the Python `_error_response` shape: `messageId`, `code: "snapshot_byte_limit_exceeded"`, `details {responseBytes, maxSnapshotBytes}` | -| `ts_two_key` | the cultmesh-ts shape `{schemaVersion, error}` | - -Decoded by: - -| Input | C# `CultNetSchemaMessageSerialization.Deserialize` | Rust `decode_cultnet_message_from_slice` | TS `parseCultNetMessage` | -|---|---|---|---| -| `cs_cursor_stale` | ok | ok, typed | ok | -| `cs_routing` | ok | ok, **routingHint dropped** (`code: None, details: None`) | ok (untyped passthrough) | -| `cs_codeless` | ok | ok | ok | -| `cs_unknown_code` | ok (free string) | **Err: "not a recognized cultnet.error.v0 code"** | **throws: /code must be one of the allowed values** | -| `py_snapshot_byte_limit` | ok, but **details silently become an all-null object** (`responseBytes`, `maxSnapshotBytes` lost), `messageId` dropped | **Err: not a recognized code** | **throws** (missing routingHint, extra messageId, code enum, details keys) | -| `ts_two_key` | ok | ok | **throws: missing routingHint, code, details** — TypeScript cannot parse the errors TypeScript sends | - -Captured C# bytes for the two shapes Rust's vector file does not hold (for Hands' reuse): - -- `cs_codeless`: `85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72A4626F6F6DAB726F7574696E6748696E74C0A4636F6465C0A764657461696C73C0` -- `cs_routing`: see the probe; the shard descriptor writes ten keys, nil included. - -Other findings, from reading source at `8fc74c70`: - -1. **The interop CI lane has been red for 40 consecutive `main` runs.** It fails at "Verify CultNet schema - contracts stayed consolidated" (`.github/workflows/cultnet-interop.yml:67-90`). That step forbids every - `*.schema.json` outside `contracts/cultnet/`, and R-AB deliberately vendors 22 copies into - `packages/cultnet-rs/contracts/cultnet/`. Those copies are held byte-equal by - `packages/cultnet-rs/tests/schema_discovery_contracts.rs:74-99`. Nothing after that step (every - cross-runtime interop test) has run on main in that time. Run `36714768445` is the latest. -2. **The schema contradicts itself.** `contracts/cultnet/cultnet.error.schema.json:29` says the code is - "Additive: a v0 peer that does not know this field ignores it" and, in the same sentence, "a closed - enum: Rust and TypeScript refuse". The closed-enum text came from Soul pass 4d (`3f942a57`). It - described what the code did; no operator ruled it. `docs/` holds no ruling on unknown codes. -3. **Emitters that break the schema:** - - cultmesh-ts sends `{schemaVersion, error}` only, at `packages/cultmesh-ts/src/index.ts:5875-5878`, - `5885-5888`, `5913-5916` and `5922-5925`, and on the branch at `0eb7434a`. - - cultmesh-py `_error_response` (`packages/cultmesh-py/src/cultmesh_py/server.py:573-589`) adds a - top-level `messageId`, omits `routingHint`, omits `code` and `details` rather than sending nil, and - sends eight out-of-enum codes: `unsupported_schema_version` (`:161`), `malformed_document_put` - (`:358`), `unregistered_document_put` (`:365`), `malformed_document_delete` (`:386`), - `unregistered_document_delete` (`:393`), `snapshot_document_limit_exceeded` (`:558`), - `snapshot_byte_limit_exceeded` (`:567`) and `simulation_observations_disabled` (`:596`). The branch - adds a ninth, `document_unservable` (`fd394d54`). - - `packages/cultnet-py/src/cultnet_py/interop_peer.py:409-417` also sends `unsupported_schema_version`. - R-2 (`docs/cultnet-selection-cut.md:2046-2053`) required that code, but it never entered the schema - enum. -4. **A second schema authority.** `packages/cultnet-py/src/cultnet_py/schema_catalog.py:17`, `:272` and - `:357-362` publish a hand-built `cultnet.error.v0` descriptor under the canonical `$id`. It has a - different shape: `messageId`, `code` as any string, `details` open, and only `schemaVersion` and `error` - required. The same file does this for every wire message (about 350 lines). This is recorded as - follow-up F-1, not cut here. -5. **Silent refusals.** cultmesh-rs's RUDP document server returns every rejection to the local caller only - (`packages/cultmesh-rs/src/rudp_document_server.rs:470-590`, `CultMeshRudpApplicationRejection`). The - peer gets nothing and times out. On the branch this includes the new `DocumentUnservable`. -6. **Receivers drop the code.** - - Every C# receiver turns an error into `new IOException(error.Error)` or - `new InvalidOperationException(error.Error)`, so a caller cannot branch on the code. The sites: - `src/GameCult.Mesh/CultMeshBodySessions.cs:310`, `CultMeshContentSessions.cs:178/256-260`, - `CultMeshPeerExchange.cs:430/466`, `CultMeshSnapshots.cs:91/107/222-226/743` and - `CultMeshVerseDiscovery.cs:273`, plus `src/GameCult.Networking/CultNetShardReplication.cs:440/574`. - - Rust reads `Error { error, .. }` at `packages/cultmesh-rs/src/lib.rs:823`. - - TS types `CultNetErrorMessage` as `{schemaVersion, error}` only (`packages/cultnet-ts/src/contracts.ts:167-170`). - - Kotlin has no error type. A request that gets an error back fails a - `require(response.schemaVersion == ...)` (`packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt:1278`, - `:1334`), and the peer's text is lost. -7. **No correlation.** Only Python echoes `messageId`. - - `cultmesh-browser` rejects an error only when exactly one operation is pending - (`packages/cultmesh-browser/src/index.ts:724-731`); otherwise the error is dropped and the operation - times out. - - C# `CultMeshContentSessions.OnError` (`:256-260`) fails every pending request on any error. -8. **The reference is inconsistent with itself.** `src/GameCult.Networking/CultNetDatabaseServer.cs:560` - refuses a variant change with a codeless error. The subscription server refuses the same condition with - `ForVariantUnsupported` (`CultNetDatabaseSubscriptionServer.cs:211/333/366`). -9. **routingHint is never consumed.** No receiver in any runtime reads it. C# builds it at - `CultNetDatabaseServer.cs:638-650`, and Rust's decode discards it. -10. **Consumer audit** (read-only, all of `F:\Projects` except CultLib; no git in Aetheria): - - Outside CultLib, nothing sets `code`, `details` or `routingHint`, and nothing branches on a code. - - Emitters: - - Huginn: `crates/huginn-daemon/src/serve.rs:288/307/312`, Rust struct literals - `Error { error, code: None, details: None }`, pinned at cultnet-rs `8fc74c70`. - - AetheriaEve: `Aetheria.State.Daemon/Program.cs:1737/1778`, C# object initializers. - - Decoders read `.error` only: Gjallar `Program.cs:574`, Mimir - `MimirOdinMoveProofEvidenceRingProvider.cs:80`, Hermodr `hermodr-daemon.cjs:1380` (hand-rolled), - AetheriaEve's smoke test (text match against its own emit text), and Huginn's tests (text match). - - Stale vendored TS: - - `StreamPixels/vendor/CultLib` carries the current closed schema, so any change here breaks its - TS error decode until it is re-vendored. - - `Heimdall/vendor/CultLib` carries a pre-R-X schema with `additionalProperties:false` and no - `routingHint`, so it already refuses every error C# sends today. - - Odin, Idunn, Muninn, Ghostlight and voidbot: zero hits. - -## 1. Ends - -- **One schema.** `contracts/cultnet/cultnet.error.schema.json` is the only authority on the shape and on the - code registry. C# embeds it (`GameCult.Networking.csproj:19`), TS mirrors it at build time, and Rust - vendors a copy held byte-equal by test. Nothing else publishes a `cultnet.error.v0` shape (F-1 covers - Python). -- **Every runtime emits only schema-valid errors.** This is proven per emit path against the schema, and - byte-equal to the C# reference for every registry code. -- **Every runtime decodes every error it can receive into a typed value.** A known code becomes its typed - variant with its typed details. An unknown code, if Q1 says so, becomes a typed "other" that keeps the - code string and the raw details. No well-formed error is ever refused, and no field is silently dropped - (routingHint, messageId). -- **Code and details have one owner.** The details a message carries are decided by its code. The types in - every runtime make "cursor_stale with a field" unrepresentable. No free-standing code string plus a - grab-bag details object. -- **A refusal is an answer, not a silence** (R-2 precedent, and Q4). A server that refuses a peer's request, - and can still send, sends a coded error that correlates to the request. -- **The public surface is complete in every runtime**, per the consumer-expectation doctrine: - - a typed error value; - - a constructor per registry code; - - a typed exception or error that request APIs raise when the peer refuses, carrying the typed value. - -## 2. Identity, lifecycle and authority - -| Kind | What names it | Lifecycle | Who decides | -|---|---|---|---| -| Error message | `schemaVersion: cultnet.error.v0`; correlated to its request by `messageId` (Q3) | Sent once, in reply to one request or subscription. Never persisted. | The server that refused. | -| Error code | A snake_case string in the schema's registry (`$defs/knownCode`) | Added by one coordinated change: schema, C# constructor, a C#-written vector, and a typed variant in every runtime. Never renamed and never reused. A retired code stays in the registry marked retired, so an old reader still decodes it. | The operator rules meaning; Self keeps the registry. | -| Details for a code | The code | Frozen with the code. Adding a key to an existing code's details is a new code (or a v1), never an in-place edit (see Q2). | Same as the code. | -| Unknown code, at a reader | The raw string | Preserved as typed `Other`. The reader acts on the `error` text and the absence of a known code. | The reader (Q1). | -| Vector file | `contracts/cultnet/interop/error-vectors.cs-written.json` | Regenerated only by the C# writer test under `CULTNET_WRITE_VECTORS=1` and committed. Every other runtime judges it read-only. | C#, the reference. | -| routingHint | Shard authority refusal | Sent with a null code (unchanged). Typed in every decoder. | CultNetDatabaseServer's authority path. | - -## 3. Operator questions - -**Rulings (operator, 2026-09-30):** "Q1: B / Q2: B / Q3: A / Q8: A". -- **Q1:** closed for emitters, open for readers. An unknown code decodes to a typed `Other`. -- **Q2:** per-code details. -- **Q3:** a nullable top-level `messageId`. -- **Q8:** v0 is corrected in place, with the semver bumps the policy requires. -- Q4-Q7 are still open. -- Cut 1 waits for `hands/cultmesh-put-serve-bound` to merge. - -### Q1. What does a reader do with an error code it does not know? - -Today Rust and TypeScript refuse the whole message. The probes above show the result: the peer's error text -is lost and the caller sees "not a recognized code". In cultmesh-rs's snapshot client the `?` at `lib.rs:812-815` -even turns a peer refusal into a local decode failure. So under a closed enum every new code breaks every -reader that has not upgraded. By `docs/semver-policy.md:86-91` that makes each new code a breaking wire -change, and nine codes are already in flight. - -- **A. Closed enum (today).** Readers refuse unknown codes. Every code addition is a coordinated, - semver-major wire change across all runtimes and consumers. -- **B. Closed for emitters, open for readers.** The schema keeps the registry, and every runtime's emit - constructors can produce only registry codes (tests prove each emit path valid against the schema). - Readers decode an unrecognized code (matching `^[a-z][a-z0-9_]*$`) into a typed `Other { code, details }`. - A new code is an additive, minor change: old readers still surface the text and know "an error I cannot - classify". This is how gRPC status codes and protobuf open enums behave. -- **C. Fully open.** No registry; any string, emit and decode. - -**Recommended: B.** A refusal must never fail to arrive because it is more specific than the reader -expected. B keeps the registry as the single meaning authority. C loses the meaning authority, and A makes -the error channel the most brittle message in CultNet. - -Depends on this: schema `code` shape, every decoder, the doc text at `cultnet-distributed-database.md:107-115`. - -### Q2. How are details shaped on the wire? - -Today C# writes all four detail keys (`field`, `value`, `asOf`, `current`), nil included, because -`CultNetErrorDetails` (`CultNetSchemaMessages.cs:592-603`) is a MessagePack map-mode object. The codes in -flight need new keys: `responseBytes`, `maxSnapshotBytes`, `documentCount`, `maxSnapshotDocuments` and -`schemaVersion`. - -- **A. One shared details record; grow it.** Every detail key of every code sits on one record, written as - nil when unused. Each new key changes the bytes of every existing error that has details, so all of the - C# vectors must be recaptured on every addition. It also breaks every strict reader: TS Ajv, and - StreamPixels' vendored schema, have `additionalProperties:false`. And the type cannot tie a key to its - code. -- **B. Per-code details.** `details` carries exactly the keys its code defines. A defined-but-optional key - is present as nil; another code's keys are absent. Keys are named for their meaning, which retires the - `field: "schemaId"` / `field: "subscriptionId"` indirection that exists only because the record is - shared. Adding a code never changes an existing code's bytes. In every runtime, code and details are - fused into one tagged type: - - Rust: an enum with data. - - C#: sealed subclasses, with a custom formatter for `CultNetErrorMessage`. There is precedent in - `src/GameCult.Mesh/CultMeshRouteRecordFormatter.cs`. - - TS: a discriminated union. - - Kotlin: a sealed class. - - Python: constructors per code over a frozen dataclass. - - The churn is one time. The five existing C# cases with details change bytes, and all eight Rust hex - cases move into the shared vector file anyway (Cut 1). -- **C. Opaque string map.** Untyped; no runtime can offer a typed accessor. - -**Recommended: B.** A is the churn the brief warned of, and it recurs on every future code. B pays it once, -at the moment every vector is being recaptured regardless. - -Depends on this: the schema's `details`, the C# model and formatter, `encode_error_details` -(`packages/cultnet-rs/src/contracts.rs:1339-1366`), and every decoder. - -### Q3. Does an error carry the `messageId` of the request it refuses? - -Python already sends `messageId` (`server.py:583`); the schema forbids it. Without it: - -- `cultmesh-browser` drops an error whenever two operations are pending (`index.ts:724-731`); -- C# content sessions fail every pending request on any error (`CultMeshContentSessions.cs:256-260`). - -Adding a top-level key changes the bytes of every C# error. That happens once, in the same recapture as Q2. - -- **A.** Add a nullable top-level `messageId`. A reply to a request echoes the request's id, and an error - with no request (a subscription push, an unsolicited refusal) sends nil. Receivers correlate by it and - fall back to their current behavior when it is nil. -- **B.** No correlation. Python deletes the field. - -**Recommended: A.** Correlation is a capability any multiplexing client needs, and one runtime already -depends on it. - -### Q4. Do servers send `document_unservable` (and every other refusal they can) to the peer? - -Self ruled that "unservable" must be distinguishable as a typed code in every runtime. On the branch: -Python sends it coded, TypeScript sends it codeless, and Rust tells only its local caller, so the Rust -peer times out. The same Rust silence covers every other document-server rejection: a sink refusal, a -failed snapshot source, too many snapshot documents, and a snapshot response that is too large. - -- **A.** Every refusal of a peer's request that the server can still send is answered with a coded error - carrying the request's `messageId`, and is also reported locally (Rust's `ApplicationRejected` stays as - the local report). The refusals that cannot be answered are the ones where the answer itself cannot be - queued: Rust `PayloadBudgetFull` and `ResponseQueueFailed`. Those stay local-only, and the doc says so. -- **B.** Local-only in Rust; peer replies only where a runtime already sends them. - -**Recommended: A.** R-2 already ruled that a silence is worse than a missing feature -(`docs/cultnet-selection-cut.md:2049-2053`), and the operator's ruling that CultMesh "refuses" puts only -means something to the party that sent the put. - -### Q5. Does Python's `unregistered_document_put` / `unregistered_document_delete` become `unowned_schema`? - -Python sends these when the node has no registered document type for the record's schemaId -(`cultmesh_py/node.py:653-666`). C# `unowned_schema` means "no shard owns this schema and key" -(`CultNetSchemaMessages.cs:537-544`). To a peer both mean "this server will not take that schema here; route -elsewhere". The put and delete variants duplicate what the peer already knows (it sent the message). - -- **A.** Both collapse into `unowned_schema { schemaId }`. The registry text broadens to "this server - stores no document for that schema (and key)". -- **B.** Join as a separate `schema_unregistered { schemaId }`. -- **C.** Join both codes as they are. - -**Recommended: A.** It is one meaning at the peer, and there is one action to take. - -### Q6. Does Python's `malformed_document_put` / `malformed_document_delete` become one `message_invalid { field }`? - -These are sent when the request lacks a required field (`server.py:355-359`, `383-387`). C# has the same -condition, codeless, at `CultNetDatabaseServer.cs:373/415` and `CultNetDatabaseSubscriptionServer.cs:148` -("requires a subscriptionId or messageId"). - -- **A.** One `message_invalid { field }`, naming the missing or ill-typed field. It is used by Python's four - sites and C#'s three. -- **B.** Join both per-message codes and leave C# codeless. - -**Recommended: A.** The per-message suffix repeats the request's own schemaVersion, and C# gains a code for -free. - -### Q7. Does `simulation_observations_disabled` become `unsupported_schema_version`? - -Python sends it when the observation hub is off (`server.py:593-598`), with -`details { schemaVersion: "cultnet.simulation_observation.v0" }`, which is exactly the -`unsupported_schema_version` shape. cultmesh-ts's "no cache for snapshot requests" and "no operation -handler" refusals (`index.ts:5875`, `5913`) mean the same thing: this server does not serve that message. - -- **A.** Collapse. `unsupported_schema_version` means "this server does not serve messages of this - schemaVersion: unknown, a newer version, or not enabled here". The three TS sites and Huginn's "answers - only X, Y, Z" adopt it. -- **B.** Keep a separate `feature_disabled`-style code. - -**Recommended: A.** A peer cannot act differently on "not enabled" and "not known". Python's -`verify.py:335` check follows the rename. - -### Q8. Is this a correction of `cultnet.error.v0` in place, or a new `cultnet.error.v1`? - -Under Q2-B and Q3-A, strict v0 readers (TS Ajv and vendored copies) refuse the new bytes. Tolerant readers -(C#, Rust, Python) keep working, because the probes show they ignore unknown keys and treat a missing key -as null. - -- **A. In place.** The v0 contract has never been decodable across all five runtimes (section 0). No - consumer outside CultLib reads code or details (finding 10). Every publishing package takes the semver - bump its policy requires: - - `org.gamecult.cultlib` major, because the public `CultNetErrorDetails` properties change; - - `cultnet-py` and `cultmesh-py` major, because emitted codes change; - - Rust pins move deliberately. - - StreamPixels and Heimdall re-vendor. -- **B. v1.** Add `cultnet.error.v1`, keep v0 emit for peers that did not advertise v1 in hello, and run - two error encoders in every runtime until v0 retires. - -**Recommended: A.** B builds a version-negotiation path for a message nobody outside CultLib parses beyond -`.error`. The one thing every old reader does need, the `error` text, survives A in every tolerant runtime. - -## 4. The registry (as recommended; rewrite on each ruling) - -| Code | Meaning | Details (per Q2-B; `?` = present-as-nil when absent) | Emitted by after this map | -|---|---|---|---| -| `selection_invalid` | A selection field failed the door. | `{ field, value? }` | C#, Rust (selection) | -| `cursor_stale` | The cursor predates the server's retained history. | `{ asOf, current }` | C#, Rust | -| `cursor_invalid` | The cursor could not be parsed. | none (details nil) | C#, Rust | -| `reference_outside_target` | A hop reference left the declared target. | none | C#, Rust | -| `variant_unsupported` | The selection selects a document variant CultNet does not carry. | `{ subscriptionId? }` | C# (incl. `DatabaseServer.cs:560`) | -| `unowned_schema` | This server stores no document for that schema (and key). | `{ schemaId }` | C#, Python (Q5) | -| `unsupported_schema_version` | This server does not serve messages of this schemaVersion. | `{ schemaVersion }` | Python, TS, Huginn (Q7) | -| `message_invalid` | The request lacks a required field or has an ill-typed one. | `{ field }` | C#, Python (Q6) | -| `snapshot_document_limit_exceeded` | The snapshot answer would exceed the document bound. | `{ documentCount, maxSnapshotDocuments }` | Python, Rust (Q4) | -| `snapshot_byte_limit_exceeded` | The snapshot answer would exceed the byte bound. | `{ responseBytes, maxSnapshotBytes }` | Python, Rust (Q4) | -| `document_unservable` | A put was refused because no snapshot could ever return the stored document under the byte bound. | `{ responseBytes, maxSnapshotBytes }` | Python, TS, Rust (Q4) | - -A null code is legal and means "unclassified". Examples: an exception message from a failed snapshot -source, an authority refusal carrying `routingHint`, and "not authorized" -(`CultNetDatabaseSubscriptionServer.cs:165`). This map does not invent codes for those. - -`snapshot_byte_limit_exceeded` and `document_unservable` have identical details. That is deliberate, and it -is the fixture trap named in the verification rules. - ---- - -## 5. Cuts - -All cuts are made on one campaign branch, `hands/error-contract`, from `main` after -`hands/cultmesh-put-serve-bound` merges. The branch merges to `main` once, after Cut 5's Soul pass. The -reason: Cut 1 changes C# bytes, and Rust's static hex cases would stay green against the stale bytes -(finding: `tests/error_message.rs` pins constants, not the reference). A partial merge would ship a -wire split while every suite passed. Each cut gets its own worktree, created by Self. Heavy verification -runs through `ygg-verify.sh`, never on Starfire. - -### Cut 0. Unblock the interop lane - -- **Repo/branch:** CultLib, `hands/interop-guard` from `main`, merged alone ahead of the campaign. It - does not depend on the rulings. -- **Deletes first:** nothing. -- **Per-file changes:** `.github/workflows/cultnet-interop.yml:72`. The `$stray` filter also accepts - `^packages/cultnet-rs/contracts/cultnet/`, with a comment naming R-AB and - `packages/cultnet-rs/tests/schema_discovery_contracts.rs:74-99` as the equality guard. The namespace - check is unchanged. -- **Authority map:** no ownership change. The canonical owner stays `contracts/cultnet/`, and the Rust - mirror is derived from it, with drift judged by the Rust test. -- **Verification:** - - Push the branch and run the workflow. Report every step that goes red after the guard. None of those - steps has run on main for 40 runs, so expect more red, and treat each failure as its own finding; - do not fix it in this cut. - - Negative: edit one byte of a vendored copy locally and check that the Rust equality test fails. - Reach it on Yggdrasil with `cd packages/cultnet-rs && cargo test --test schema_discovery_contracts`. -- **Ledger:** +3 lines. - -### Cut 1. The contract and the C# reference - -- **Repo/branch:** CultLib `hands/error-contract`, worktree named by Self. -- **First:** capture the current C# bytes for all eight factory cases, plus `cs_codeless` and `cs_routing`, - into the Hands report. This is the before-image a Soul pass compares against. -- **Deletes first:** - - `CultNetErrorDetails` (`src/GameCult.Networking/CultNetSchemaMessages.cs:588-603`, 16 lines) as a - shared record. - - The `field`/`value`/`asOf`/`current` property block and the `[MessagePackObject]`/`[Key]` attributes - on `CultNetErrorMessage` (`:497-521`). Its serialization moves to one formatter. - - `SoulP4dProbeTests.Wire_UnownedSchemaBytes` (`tests/GameCult.Networking.Tests/SoulP4dProbeTests.cs:191-199`). - It is an `[Explicit]` hex printer, superseded by the vector writer. - - The closed-enum paragraph at `contracts/cultnet/cultnet-distributed-database.md:107-115` and - the `code`/`details` descriptions in the schema (`:28-42`). -- **Adds:** - - **Schema** (`contracts/cultnet/cultnet.error.schema.json`, rewritten; the Rust mirror is re-copied - byte-equal): - - Top level: `schemaVersion`, `error` and `routingHint` as today, plus `messageId: string|null` (Q3) - and `code`. All five are required, present-as-nil (R-X is unchanged at the top level). - - `$defs/knownCode` is the registry enum from section 4. - - `code` is `null`, or a string matching `^[a-z][a-z0-9_]*$` (Q1-B). - - `details` is decided by a top-level `oneOf`, one branch per known code, each fixing `code` by - `const` and `details` by exact `properties`/`required`/`additionalProperties:false`. Two more - branches: a null code with null details, and an unknown code (`not: {$ref knownCode}`) with details - `object|null`, open. - - Use `oneOf`/`not`/`const` only. `tests/GameCult.Networking.Tests/MiniJsonSchemaValidator.cs:30-35` - implements those and **not** `if`/`then`/`allOf`, and it refuses unimplemented keywords. TS - compiles with Ajv2020 (`packages/cultnet-ts/src/contracts.ts:1,674`), which handles both. - - **C# model** (`GameCult.Networking`): - - `CultNetErrorMessage { SchemaVersion, Error, MessageId?, RoutingHint?, Refusal? }`, where - `Code => Refusal?.Code` is derived and not settable. - - `abstract CultNetRefusal { string Code }`, with one sealed subclass per registry code carrying its - typed details (`CultNetCursorStaleRefusal(ulong AsOf, ulong Current)` and so on), plus - `CultNetUnknownRefusal(string Code, IReadOnlyDictionary? Details)` for Q1-B. - - `CultNetErrorCodes`: string constants, one per registry code. - - `CultNetErrorMessageFormatter : IMessagePackFormatter`. It writes the five - top-level keys in order (schemaVersion, error, messageId, routingHint, code, details), and details - as exactly the refusal's keys. It reads by key name, tolerates unknown keys at both levels, and - maps an unknown code to `CultNetUnknownRefusal`. Register it through `[MessagePackFormatter]` on - the type, the way `CultMeshRouteRecordFormatter` is wired. - - Factories: keep `ForSelectionInvalid`, `ForCursor`, `ForReferenceOutsideTarget`, - `ForVariantUnsupported` and `ForUnownedSchema` as thin constructors over the refusal types, and add - one per new code. Each takes an optional `messageId`. - - `CultNetPeerErrorException : IOException`, carrying the `CultNetErrorMessage`. - - **Vector writer:** `ErrorVectorTests.WriteErrorVectors`, gated on `CULTNET_WRITE_VECTORS=1` like - `SelectionParityVectorTests.cs:460-552`. It writes `contracts/cultnet/interop/error-vectors.cs-written.json`: - one entry per registry code (both optional-key states where a key is optional), `cs_codeless`, - `cs_routing`, one unknown code with open details, and one error with `messageId` set. Each entry - holds `{ name, messagePackBase64, expected: { error, messageId, code, details, routingHint } }`, - where `expected` is a JSON projection every runtime compares its typed decode against. -- **Per-file changes** (`8fc74c70`): - - `CultNetDatabaseServer.cs:560`: `ForVariantUnsupported(refusal.Message, subscriptionId)`. - - `CultNetDatabaseServer.cs:373`, `:415` and `CultNetDatabaseSubscriptionServer.cs:148` become - `message_invalid { field: "subscriptionId" }` (Q6-A). - - Every `SendCultNet(CultNetErrorMessage...)` that answers a request passes the request's `MessageId`. - - Each receiver listed in finding 6 throws `new CultNetPeerErrorException(error)` instead of - wrapping `error.Error`. `CultMeshContentSessions.OnError` (`:256-260`) fails only the pending entry - whose id matches `error.MessageId`, and falls back to all entries only when the id is nil. - - `CultNetSchemaContractTests.cs:165-214`: the per-factory tests are replaced by one test that validates - every vector's decoded JSON against the schema with `MiniJsonSchemaValidator`, and one test that - serializes each registry factory and asserts byte equality with its committed vector. - - Rewrite `contracts/cultnet/cultnet-distributed-database.md` "Error Codes" to the registry, and state - the Q1 and Q2 rules. -- **Authority map:** - - Owner: the schema's registry owns code meaning and detail shape. `CultNetErrorMessageFormatter` owns - C# bytes. The refusal subclass owns the code-to-details binding. - - Inputs: the refusal instance, messageId, routingHint. - - Outputs: bytes, and `cs-written.json` for the other runtimes. - - Derived state: `CultNetErrorMessage.Code` becomes derived; the refusal decides it. - - Forbidden writers: any `Code = "..."` assignment (it cannot compile); `[Key]` attribute serialization - of the message; hand-typed hex anywhere. - - Shared paths: every server send uses the factories; every receiver uses `CultNetPeerErrorException`. - - Deletion line: `CultNetErrorDetails` and the settable `Code`. -- **Verification** (`dotnet` image): - - Builds: `tests/GameCult.Networking.Tests`, `tests/GameCult.Mesh.Tests`. - - `ErrorVectorTests` with `CULTNET_WRITE_VECTORS=1`, then without, and check that the committed file - reproduces byte-for-byte. - - Tests and the rules they pin: - - vector round trip: bytes, decode, bytes are equal for every entry; - - a registry factory is byte-equal to its vector; - - every vector validates against the schema; - - an unknown code decodes to `CultNetUnknownRefusal` with its raw details intact; - - a Python-shaped error (`py_snapshot_byte_limit` above, with the Q2/Q3 shape) decodes typed with - `responseBytes` and `maxSnapshotBytes` intact. Today's C# silently empties those. - - content-session correlation: two pending requests, an error for the second, and the first still - completes. - - Mutation: Stryker.NET on the formatter and factories. The survivors that must die: - - swapping two known codes whose details are identical (`snapshot_byte_limit_exceeded` and - `document_unservable`); - - padding a nil key into details; - - dropping `messageId` on encode; - - mapping an unknown code to null. - - Negative greps (checked at HEAD, where they hit the sites being cut): - - `git grep -nE 'new (IOException|InvalidOperationException)\(error\.Error\)' -- src` must return 0 - (9 hits at HEAD). - - `git grep -nE 'Field = "(schemaId|subscriptionId)"' -- src` must return 0 (2 at HEAD). - - Operator: none. -- **Ledger:** about -60 C# (the details record, the settable code, the probe printer, and the per-factory - schema tests), +250 (the formatter, the refusal types, the vector writer). The schema grows by about 90 - JSON lines. The net is positive, bought by Q1/Q2 typing in the reference. - -### Cut 2. Rust: cultnet-rs and cultmesh-rs - -- **Deletes first:** - - `CultNetErrorCode` and `CultNetErrorDetails` as separate types (`packages/cultnet-rs/src/contracts.rs:252-310`, - 59 lines); - - `require_error_details` (`:1121-1146`); - - `encode_error_details` (`:1339-1366`); - - the eight hex `Case`s and the header comment in `packages/cultnet-rs/tests/error_message.rs:1-160`. - That file is rewritten to read the vector file. - - Keep `require_legacy_optional_u64` (`:1149`). Check with `git grep` for other callers first; at HEAD, - only the error details use it. -- **Adds:** - - `CultNetMessage::Error { error: String, message_id: Option, routing_hint: Option, refusal: Option }`. - - `enum CultNetRefusal`, with one variant per registry code and its typed fields, plus - `Other { code: String, details: Option }`. - - `CultNetRefusal::code(&self) -> &str`. - - A constructor `CultNetMessage::error(text)` and builder-style `with_refusal` / `with_message_id`, so - consumers stop writing struct literals. Huginn has three. - - Decode reads every top-level key by name and routes unknown codes to `Other` (Q1-B). - - Encode writes the Cut 1 key order. - - `CultNetShardRoutingHint` is typed, reusing the crate's existing shard descriptor type. -- **Per-file changes:** - - `packages/cultnet-rs/src/contracts.rs:361-368` (variant), `:1097-1115` (decode), `:1300-1336` (encode). - - `packages/cultnet-rs/src/selection.rs:1210-1255`: the four refusals build `CultNetRefusal` variants. - - `packages/cultnet-rs/src/content.rs:316`: uses `CultNetMessage::error`. - - `packages/cultmesh-rs/src/lib.rs:812-827`: a peer error becomes a typed error carrying the refusal, - instead of `anyhow!` text. Name it `CultMeshPeerError`, next to the crate's existing error types. - - `packages/cultmesh-rs/src/rudp_document_server.rs` (Q4-A): each `CultMeshRudpRejectionReason` that - can be answered sends the matching coded error, correlated by the request's message id, before - returning `ApplicationRejected`. `PayloadBudgetFull` and `ResponseQueueFailed` stay local. -- **Authority map:** - - Owner: `CultNetRefusal` owns code and details. The schema owns the registry. - - Forbidden writers: string codes (`from_wire_str` survives only inside decode); struct literals of - details. - - Shared paths: selection refusals, document-server refusals and content errors all go through the one - encoder. -- **Verification** (`rust` image, `cd packages/cultnet-rs` then `cd ../cultmesh-rs`): - - `cargo test --workspace`. - - `tests/error_message.rs` pins: every vector decodes to `expected`; encoding the decoded value - reproduces the vector's bytes; the unknown-code vector decodes to `Other`; the routing vector keeps - `routing_hint`. - - cultmesh-rs: a put over the bound makes the peer receive `document_unservable` with the request's - message id, and details equal to the computed size and the bound. Run it at two bounds so that a - constant or an offset in `maxSnapshotBytes` dies. - - A peer that sends a Python-shaped `snapshot_byte_limit_exceeded` produces a `CultMeshPeerError`, not - a decode failure. - - Mutation: `cargo mutants --in-diff` on the cut's diff. Triage every survivor by name. - - Negative: `git grep -n 'CultNetErrorDetails' -- packages` must return 0. -- **Consumer note:** Huginn's `serve.rs:288/307/312` and its tests stop compiling at the next pin bump. - The migration is mechanical (`CultNetMessage::error(...)`), and `:312` adopts `unsupported_schema_version` - (Q7). Self routes it as a Huginn change; it is not part of this branch. - -### Cut 3. TypeScript: cultnet-ts, cultmesh-ts and cultmesh-browser - -- **Deletes first:** - - the two-field `CultNetErrorMessage` interface (`packages/cultnet-ts/src/contracts.ts:167-170`); - - the four literal error constructions in `packages/cultmesh-ts/src/index.ts:5875-5878`, `5885-5888`, - `5913-5916` and `5922-5925`, and the branch's codeless unservable site (`0eb7434a`); - - `rejectCorrelated`'s single-pending heuristic (`packages/cultmesh-browser/src/index.ts:724-731`). -- **Adds:** - - `CultNetErrorMessage` with every field. - - `type CultNetRefusal` as a discriminated union on `code`, with an `{ code: string; details: Record | null; known: false }` arm. - - `createCultNetError(text, { messageId, refusal })` as the only constructor. - - `class CultNetPeerError extends Error { message: CultNetErrorMessage }`. - - `parseCultNetMessage` keeps Ajv validation against the canonical schema; it now accepts every schema - branch, including unknown codes. -- **Per-file changes:** - - cultmesh-ts: the three "does not serve this" sites (`5875`, `5913`, `5922`) send - `unsupported_schema_version` (Q7-A); the snapshot catch (`5885`) sends a null code; the unservable site - sends `document_unservable` with details. - - cultmesh-ts `6527-6529` throws `CultNetPeerError`. - - cultmesh-browser `163`, `584` and `690`: correlate by `messageId`, falling back to today's behavior only - for a nil id. - - `packages/cultnet-ts/src/idunn-odin-presence-publisher.ts:118-120` throws `CultNetPeerError`. - - The legacy `gamecult.networking.v0` path (`contracts.ts:840-844`, `:899-900`) is unchanged. That is a - separate legacy contract. -- **Verification** (`kotlin` image, which carries Node 24; `npm install`, then build cultcache-ts before - cultnet-ts): - - A new `test/error-vectors.test.ts` pins that every vector parses and deep-equals `expected`, and that - `encode(createCultNetError(...))` is byte-equal to each registry vector. - - The cultmesh-ts document-server tests assert that each refusal parses with TypeScript's own - `parseCultNetMessage`. That is the probe's `ts_two_key` failure, pinned. - - Browser: two pending operations, an error for the second, and the first still resolves. - - Mutation: StrykerJS on the diff. - - Negative: `git grep -nE 'schemaVersion: "cultnet\.error\.v0",' -- packages/cultmesh-ts/src packages/cultmesh-browser/src` - must return 0. Exclude `cultnet-ts/src/schema-discovery.ts:302` (a descriptor) and - `contracts.ts:842` (legacy decode); both are legitimate hits for the unscoped pattern. - -### Cut 4. Python: cultnet-py and cultmesh-py - -- **Deletes first:** - - `_error_response` (`packages/cultmesh-py/src/cultmesh_py/server.py:573-589`); - - `unsupported_schema_version_error` (`packages/cultnet-py/src/cultnet_py/interop_peer.py:409-417`); - - every string literal code in `server.py` (lines in finding 3). -- **Adds:** - - `cultnet_py/errors.py`, holding: - - `CultNetErrorCode(StrEnum)`, the registry; - - a frozen `CultNetError` dataclass (`error`, `message_id`, `routing_hint`, `code: CultNetErrorCode | str | None`, - `details: Mapping | None`); - - one constructor function per code, taking exactly that code's keys; - - `to_wire()`, which writes the five top-level keys in the Cut 1 order, nil included; - - `parse_cultnet_error(dict)`. - - `CultNetPeerError` (`client.py:46-50`) keeps `.response` and gains `.error: CultNetError`. -- **Per-file changes:** - - Sites `server.py:158-163`, `355-394`, `555-569` and `593-598` and the branch's `document_unservable` - site use the constructors, with the Q5-A/Q6-A/Q7-A codes. - - `interop_peer.py` uses the constructor. - - `verify.py:335` reads `error.error.code`. - - Tests `packages/cultmesh-py/tests/test_cultmesh.py:2088`, `3791`, `4323` and `4363-4371`, and - `packages/cultnet-py/tests/test_cultnet.py:2342`, follow the renames. -- **Verification** (the image the campaign already uses for Python; state it in the report): - - A vector test pins that every vector parses to `expected` and that each constructor's `msgpack.packb` - is byte-equal to its vector. - - Mutation: mutmut on `errors.py` and the changed server sites. - - Negative: `git grep -nE '"schemaVersion": "cultnet\.error\.v0"' -- packages/cultnet-py/src packages/cultmesh-py/src` - returns only `errors.py`. -- **Not here:** F-1 (`schema_catalog.py` publishes its own shapes). - -### Cut 5. Kotlin: cultmesh-kotlin - -- **Adds:** - - A `CultNetErrorMessage` data class and a `sealed class CultNetRefusal` (known codes plus `Other`), - with a `CultNetMessage.asError(): CultNetErrorMessage?` view. - - `class CultNetPeerException(val error: CultNetErrorMessage) : IOException`. - - At `CultMesh.kt:1278` and `:1334`, and at every `receiveSchemaMessage` consumer that expects a - response, an error response throws `CultNetPeerException` before the `require`. -- **Verification** (`kotlin` image, `pwsh -File build.ps1`): - - A vector test pins that every vector decodes to `expected`. - - Kotlin emits no errors today, so encode parity applies only if a Kotlin server path is added. Say - so in the report; do not invent one. - - There is no mutation tool. Behavioural tests cover it: an error reply to a schema-catalog request - raises `CultNetPeerException` carrying the code. - -### Cut 6. Merge, release, consumers - -- Soul runs over the whole branch before the merge: - - rerun every vector suite; - - decode each runtime's live emissions with every other runtime. The interop lane from Cut 0 is the - harness; add an error round to `tests/GameCult.Networking.InteropPeer` and - `packages/cultnet-ts/test/interop` if Soul finds a runtime pair unreached. -- Release per `docs/semver-policy.md`, with a Soul pass before tags: - - `org.gamecult.cultlib` major, and the tracked Unity DLLs rebuilt, since they embed the schema; - - `cultnet-py` and `cultmesh-py` major. -- Consumers, routed by Self as separate repo changes: - - Huginn: pin bump plus Cut 2's note. - - StreamPixels and Heimdall: re-vendor. Heimdall is already broken by R-X. - - AetheriaEve, Gjallar and Mimir compile against the live CultLib path and read only `.Error`, so they - need no change. That is inferred from the audit; the next build of each confirms it. - - Hermodr: none. - -## 6. Subtraction ledger - -| Cut | Removed (estimate) | Added (estimate) | Formats / targets | -|---|---|---|---| -| 0 | 0 | 3 | none | -| 1 | ~60 C#, schema text | ~250 C#, ~90 schema | + `error-vectors.cs-written.json`; - `[Explicit]` hex printer | -| 2 | ~150 (types, two helpers, 160-line hex test) | ~200 | hex constants gone; vectors read from file | -| 3 | ~40 | ~120 | none | -| 4 | ~40 | ~120 (`errors.py`) | 8 ad-hoc codes → registry | -| 5 | 0 | ~80 | none | - -The net is positive, by roughly 500 lines, across five runtimes. It buys typed decode where there was -none (Kotlin, TS and Python), a correlation capability, and one vector file in place of hand-copied hex. -The pressure point: if Cut 1's formatter passes 150 lines, report it rather than trimming typing. - -## 7. Follow-ups outside this map - -- **F-1.** `packages/cultnet-py/src/cultnet_py/schema_catalog.py` publishes about 350 lines of hand-built - schemas under canonical `$id`s. Python should package and serve `contracts/cultnet/*.schema.json`, the - way Rust vendors them. It is a separate cut, because it covers every message, not errors. It can wait - because the catalog is advisory: no runtime validates a peer's message against a published descriptor. -- **F-2.** Codes for the remaining codeless refusals (authorization, failed snapshot sources, routing). - Wait for a consumer that branches on them. -- **F-3.** Whatever the Cut 0 lane reveals once its later steps run again. diff --git a/docs/cultnet-rudp-ack-cut.md b/docs/cultnet-rudp-ack-cut.md deleted file mode 100644 index 9770e1e1..00000000 --- a/docs/cultnet-rudp-ack-cut.md +++ /dev/null @@ -1,1614 +0,0 @@ -# CultNet RUDP: Acknowledged means delivered, one watermark owns ordered delivery, and a write belongs to its session - -Status: cut map, Imagination pass 3, 2026-09-30. Pass 3 maps the four rulings below, re-maps Cuts 1c and -4, and adds Cut F (FORWARD-TSN), Cuts 6a-6c (receipts), Cut 7 (expiry, if Q-A8), Cut K (Kotlin), Cut 2c (the -C# fragment bound) and Cut D (a failed send to one peer stays that peer's loss). The text of Cuts 1, 1b, 2 -and 3 is pass 2's; Hands are executing Cuts 1 and 1b (`hands/ack-cut1`) and Cut 2 (`hands/ack-cut2`) from -it. One thing moves for them: Cut 2 merges only together with Cut F (the Cut 2 Soul finding below). - -**Rulings (operator, 2026-09-30).** -- **Q-A1 (a):** Delivered is derived at the sender from a correct receiver's cumulative watermark. The - acknowledgement wire does not change. (Cut F adds one notice to the wire; it carries abandonment, not - delivery.) -- **Q-A2:** "I think this is telling us we don't actually need both received and delivered." - - There is one public meaning: Acknowledged means delivered. - - Receipts go Pending, then Acknowledged (delivered), or Invalidated. No public Received state exists. - - Retransmission still stops on each packet's own ack, so resending stays bounded. - - Flush waits for Acknowledged. -- **Scope added from the stray-packet Soul passes 3 and 4:** session generation. One rule: a write belongs - to the session it was issued in. Mapped as **Cut 1b** (the ending rule) and **Cut 1c** (how a new - generation is recognised), separate from Cut 1 (§8 says why). -- **Q-A3 B:** FORWARD-TSN (RFC 3758) now. On expiry the sender sends an abandonment notice, Ack-typed with - flag `0x10` so old peers ignore it. New receivers treat the sequence as received with no frame and - acknowledge it. The notice's resend has its own bound. A new wire cut in every receiver, before Cut 4. - Mapped as **Cut F** (§6). The recommendation was A; the operator chose B. (Supersedes pass 2's Cut 4 - expiry half, which assumed A.) -- **Q-A4 A:** port per-send receipts (Pending, Acknowledged, Invalidated, and a wait for Acknowledged) to C#, - TypeScript, Python and Kotlin in this campaign, after Cut 4. Mapped as **Cuts 6a-6c** and **K4** (§9). - The recommendation was B. -- **Q-A5 A:** Kotlin is in, as trailing **Cut K**, verified on a Kotlin image added to Yggdrasil first - (Cut K0). -- **Q-A6 A:** the Connect's sequence identifies the session; the default initial sequence becomes random in - [1, 2^31), as TCP's ISN. No wire change; the public default changes. Cut 1c is no longer blocked on a - question. -- **Cut 2 Soul finding (2026-09-30):** Cut 2's never-evict rule refuses a session whose sender expires - fragmented reliable frames (the default Rust `media` channel). Pass 3 absorbs it: Cut F's receiver drops - every incomplete fragment set a notice covers; Cut 2 merges only together with Cut F; C# gets the bound - it never had (Cut 2c). What a receiver does with a sender that never sends notices is **Q-A9**. -- **Ruled after pass 3 (operator, 2026-09-30), each the recommendation:** - - **Q-A7 A:** a notice names one sequence, not a cumulative point. - - **Q-A8 A:** C#, TypeScript, Python and Kotlin gain reliable expiry (Cut 7, K5). - - **Q-A9 B:** a capability bit on Connect and Accept; Cut 2's never-evict rule applies only to peers - that advertise it, and other peers keep pre-Cut-2 eviction. - Cut F is unblocked once Cuts 1 and 3 merge; the text below mapped these answers already. - -- **Body.** CultLib `main` at `e9469cb3`. Every transport file is byte-identical to `3bf1c0c` there - (`git diff 3bf1c0c e9469cb3 -- src packages` is empty), so every `file:line` below is against both unless - it names another revision. - - Odin `origin/main` (`5c37860`) pins `3bf1c0ce` (`crates/odin-core/Cargo.toml:14-17`). A stale local - checkout at `379b826` still shows `a8aeddac`; read Odin's pin from `origin/main`. - - Pass 3 probes: `3e2c7efb` (FN) and `c6f1012c` (CAP) on the Yggdrasil mirror `probe3` (§ Probe - evidence, pass 3). -- **Base of every cut.** `main` at `e9469cb3` or later. -- **Scope.** The RUDP session and socket transports in all five runtimes: C# - `src/GameCult.Networking/CultNetTransport.cs`, Rust `packages/cultnet-rs/src/rudp.rs` (plus - `packages/cultmesh-rs`), TypeScript `packages/cultnet-ts/src/rudp.ts` (plus the two hand-rolled - publishers and `cultmesh-ts`'s document server), Python `packages/cultnet-py/src/cultnet_py/transport.py` - (plus `cultmesh-py`'s server), and Kotlin - `packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt` (Cut K). -- **Wire.** One addition, in Cut F: an Ack packet with flag bit 4 (`0x10`) is an abandonment notice for the - sequence in its sequence field. Every current decoder ignores the bit and every current session ignores - an Ack's sequence field (P7, re-probed as FN in pass 3 in all five runtimes). If Q-A9 is B, the same bit - on Connect and Accept advertises support (probe CAP: old peers ignore it there too). Nothing else changes - on the wire. Cut 1c changes the default initial sequence, which is a value, not a format. - - -## The ends (operator, 2026-09-30) - -- A frame the peer has buffered but not delivered must not look delivered. Today an ordered frame that - arrives behind a gap is held and acknowledged; if the session ends it is lost, and the sender saw - "Acknowledged". -- Resending stays bounded. The sender stops retransmitting a packet when the peer acknowledges that packet, - exactly as today. Delivery-only acknowledgement was rejected: "I don't like the possibility of unbounded - resending on an unresponsive peer application. TCP sounds like it has the right idea." -- One public meaning: **Acknowledged means delivered** to the peer's application boundary (§2). Unordered - reliable frames are delivered on receipt. Receipts go Pending, then Acknowledged, or Invalidated. -- A write belongs to the session it was issued in. When that session ends, a write not yet Acknowledged is - Invalidated. It is never carried into, retransmitted in, or acknowledged by a later session. - - -## The finding that shapes this map - -Self's first proposal put a second acknowledgement on the wire: a per-channel "delivered through N" -watermark beside the received ack. Probing the receivers changed the picture in two ways. - -**1. The ordered receivers are broken in every runtime, and the break is bigger than the session-end case.** -A held frame is not only lost at session end. In a live session it can be stranded forever, or dropped, -while the sender sees it acknowledged. The per-channel `next` pointer (`CultNetTransport.cs:918`, -`rudp.rs:196`, `rudp.ts:160`, `transport.py:424`, `CultMesh.kt:2643`) has been patched twice (`436654de` -added the first-use initialisation, `4335ab10` added the skip over other channels' sequences) and is still -wrong: - -- **P1: a gap filled by another channel strands the ordered channel.** Ordered `o1`, reliable `u` on - another channel (lost), `o2`, `o3`. When `u` arrives, only `u`'s channel is drained. `o2` and `o3` are - never delivered, and the sender's pending set is empty. The same holds with two ordered channels (P1b). -- **P6: a channel first used after other traffic loses frames.** `C1`, `C2` on a new channel, then `X` on - another. `X` arrives, then `C2`, then `C1`. The channel initialises its `next` from - `min(highest+1, frame)`, so `C2` is delivered first and `C1` is **dropped as old** - (`frame.sequence < next`). It was acknowledged. -- Both reproduce at `3bf1c0c` in C#, Rust, TS and Python (pass 2), and did at `d85877a7`, Odin's old pin - `a8aeddac` and Muninn's pin `c2a9a6e5` (pass 1). - -**2. Once the receiver is correct, "delivered" is not new information.** A conforming receiver holds an -ordered frame for exactly one reason: some reliable sequence below it has not arrived. When the last -missing sequence arrives, the same `Receive` call drains the held frames, before the acknowledgement is -built. So: - -> An ordered frame is delivered exactly when every reliable sequence up to its last one has been -> acknowledged. - -The sender already knows that. It is the sender's own **acknowledged-through** watermark: one below the -lowest sequence that is still pending, queued or abandoned. TCP's cumulative ACK means the same thing, -"every byte below N is in the receive buffer", and it sits beside SACK. Here the sender can compute the -cumulative value itself, because every sequence is acknowledged individually and removed from its pending -set. The operator ruled this (Q-A1 a), and then ruled that the per-packet ack is not a public meaning at -all (Q-A2): it only stops retransmission. - -The derivation is sound only if nothing the receiver acknowledged is later dropped while the session -lives, and nothing one session acknowledges is credited to another. The probes found these places where -that fails: - -| # | Where an acknowledged frame is lost, or credited to the wrong session | Runtimes | Fixed by | -|---|---|---|---| -| P1, P1b | A cross-channel gap strands the ordered channel | all five | Cut 1 | -| P6 | Late channel initialisation drops the earlier frame | all five | Cut 1 | -| P5 | A reliable fragment set is evicted after its fragments were acknowledged | Rust, TS, Python (C# and Kotlin have no eviction) | Cut 2 | -| P8 | A retransmit more than 4,096 sequences behind the receiver's highest counts as a duplicate: acknowledged and dropped | all five | Cut 3 (the sender never lets its span reach it) | -| G2 | After `connect()` again on the same transport, a new server session's frames are duplicates of the old one's sequences: acknowledged and dropped | C#, Rust, TS, Python | Cut 1c | -| G3 | A write left owed when a session ends is retransmitted into the next session and delivered there, and in Rust its receipt reads `Acknowledged` | Rust (local `disconnect()`, timeout); C#, TS, Python (every ending) | Cut 1b | - -And places where the sender loses track, or is locked out, without the receiver's help: - -| # | Sender-side lie or lockout | Runtime | Fixed by | -|---|---|---|---| -| G1 | A restarted client on the same endpoint and Connect payload gets an Ack, never an Accept, and stays unconnected | Rust hub, C# listener and C# server mode, TS and Python server mode (Rust server mode resets on every Connect instead) | Cut 1c | -| G1b | C# and Python server-mode transports drop a Connect from any endpoint but the first, so a client that restarts on a new port is locked out for good | C#, Python | Cut 1c | -| P2 | An expired reliable packet leaves a permanent hole. Ordered frames after it are held forever, and `flush_reliable` succeeds (outstanding 0) | Rust (the only runtime with reliable expiry) | Cut F (FORWARD-TSN, Q-A3 B); Cut 4 reports it honestly where a receiver never acknowledges the notice | -| P9 | A receipt for an expiring send reads `Acknowledged` once the packet expires unacknowledged | Rust | Cut 4 | -| P10 | `send_reliable` with a full window queues the frame, then returns an error ("receipts require a non-empty reliable packet set"), so a caller that retries sends it twice | Rust | Cut 4 | - -And two faults found by Soul passes during pass 3: - -| # | Fault | Where | Fixed by | -|---|---|---|---| -| D1 | One peer whose datagrams cannot be sent fails every poll for every peer: the first failed `send_to` aborts `maintain` and `poll_once` (Odin Soul pass) | cultmesh-rs document server, Rust hub, C# listener; the cultmesh-py server's thread dies | Cut D (§10) | -| F2 | After Cut 2, a sender with reliable expiry pins one reliable fragment set per lossy frame, and the 65th refuses the session (Cut 2 Soul pass, default Rust `media`) | Rust, TS, Python receivers with Cut 2 | Cut F releases the sets; Cut 2 merges with it; Q-A9 for senders without notices | - - -## Probe evidence - -Everything ran on Yggdrasil through `ygg-verify.sh`, from local-only probe branches in throwaway -worktrees. Each probe commit survives on the Yggdrasil mirror as `refs/verify/`. - -Pass 1 (base `d85877a7` and the pins; mirror `CultLib-imag-ack`): - -- `de2bdfe8`, `09318d13`, `03eac6ff`: Rust session and socket probes (`packages/cultnet-rs/tests/probe_ack.rs`). -- `b70a69ca`, `2320a4e5`, `7fe5d726`: the C#, TS and Python probes (`ProbeAckTests.cs`, - `probe-ack.test.ts`, `probe_ack.py`). -- `f0e7136c`: the Rust probes at Odin's old pin `a8aeddac`. `bf8fc826`: all four at Muninn's pin `c2a9a6e5`. -- `db7d2886`: **the prototype**, the Rust receiver rebuilt on one watermark plus sender-derived receipt - states (about +130/−125 in `rudp.rs`). Its `Received`/`Delivered` receipt states are superseded by Q-A2; - its receiver is what Cut 1 builds. - -Pass 2 (base `3bf1c0c`; mirror `CultLib-imag-ack2`): - -- `a383e27d`, then `1519a20a` (the C# server-mode G1 probe acknowledges the Accept before the restart, so - it tests a restart and not a lost Accept): the pass-1 probe files restored unchanged, plus - `packages/cultnet-rs/tests/probe_gen.rs`, `packages/cultnet-ts/test/probe-gen.test.ts`, - `packages/cultnet-py/tests/probe_gen.py` and `tests/GameCult.Networking.Tests/ProbeGenTests.cs`. -- Rust and C# G1 run over real loopback UDP (hub, listener, server-mode transport), with the restarted - client bound to the first client's exact address. Python G1 runs over UDP against the server-mode - transport. TS G1 calls `acceptConnect` on the connected session, which is what the socket transport does - for a same-endpoint Connect (`rudp.ts:1034-1049` calls it without a reset, and `rudp.ts:249-251` answers - it as a repeat). G2 and G3 are session-level: the socket transports' `connect()` is `create_connect` and - nothing else (`rudp.rs:1874-1883`, `CultNetTransport.cs:1836-1846`, `rudp.ts:891-897`, - `transport.py:994-998`). - -| # | Probe | Pass 1 (`d85877a7`, pins) | Pass 2 (`3bf1c0c`) | Prototype `db7d2886` | -|---|---|---|---|---| -| P1 | `o1`, reliable `u` lost, `o2`, `o3`; then `u`, then `o3` | Delivered `[o1, u]`; `o2`, `o3` never; sender pending `[]`. Rust, C#, TS, Python; same at both pins | Same, all four | `[o1, u, o2, o3]` | -| P1b | `A1`, `B1` (second ordered channel) lost, `A2`, `A3` | `[A1, B1]`, all four | Same, all four | `[A1, B1, A2, A3]` | -| P6 | `C1`, `C2` on a new channel, then `X`; arrival `X`, `C2`, `C1` | `[C2]`; `C1` dropped. All four | Same, all four | `[C1, C2, X]` | -| P2 | Rust: reliable media with expiry 1 ms, lost; then `s1`, `s2` on `schema` | Nothing delivered. Sender outstanding 0, expired 1, no resends | Same | Unchanged (held behind the hole). Cut 4 makes the sender say so | -| P3 | `s1` lost, `s2` arrives and is acknowledged | `s2` not delivered, but acknowledged: sender pending `[s1]` only | Same | Receipt for `s2` stays short of delivered until `s1` arrives | -| P4 | Rust: `s0` delivered, `s1` lost, then a 5 MiB ordered frame in 60 kB fragments | `RUDP ordered hold buffer is full`: the session is refused. With no gap, delivered | Same | The prototype held every ordered frame, so a 5 MiB frame was refused **even with no gap**: hold only what is not yet deliverable (Cut 1) | -| P5 | 3-fragment reliable frame, 2 fragments acked; 70 lossy fragment sets; last fragment arrives | Rust, TS, Python: never delivered, sender pending `[]` | Same, Rust, TS, Python | Unchanged (Cut 2) | -| P7 | An Ack with flag bit `0x10`, a nonzero sequence field, or a 5-byte payload | Decoded and applied in Rust, C#, TS, Python | Same, all four | n/a | -| P8 | Reliable `g` lost while 4,200+ later sequences are sent and acked; then `g` arrives | `g` not delivered, and acknowledged. All four | Same, all four | Unchanged (Cut 3) | -| P9 | Rust socket: `send_reliable("media")` with expiry 1 ms, never read | `Pending`, then `Acknowledged` | Same | `Pending` | -| P10 | Rust socket: 40 `schema` sends, then `send_reliable` | Error "receipts require a non-empty reliable packet set", after the frame was queued | Same (the probe's `?` fails on that error) | Not addressed (Cut 4) | -| G1 | Client A connects, sends one ordered frame (acknowledging the Accept), and exits. Client B, a fresh session with the same initial sequence, binds A's exact address and sends the same Connect payload five times | n/a | **Locked out.** Rust hub (payload `"svc"` and empty): 5 Acks, 0 Accepts, B unconnected, the hub still holds A's generation. C# listener: same, no `PeerDisconnected`. C# server mode: 5 Acks. Python server mode: 5 Acks. TS session: the answer is an Ack. Rust server mode (control): B gets an Accept and its frame is delivered, because it resets on every Connect | Not probed | -| G1b | C# and Python server mode: a third client on a new port sends a Connect | n/a | No reply; the datagram is counted dropped | Not probed | -| G2 | Client connects to server 1 (initial sequence 1) and receives `old1..3`. Server 1 is gone; the client connects again on the same session to server 2 (initial sequence 1), which sends `new1`, `new2` | n/a | **`new1`, `new2` never delivered, and acknowledged** (server 2's pending set empties). C#, Rust, TS, Python | Not probed | -| G3 | Client sends one ordered write that is lost; the session ends; the client connects to a new server; the client's resends run | n/a | Owed after the end, and **delivered in the next session**: C#, TS, Python on peer Disconnect, local disconnect and timeout; Rust on local disconnect and timeout. Rust on peer Disconnect: owed 0, nothing delivered (`5f1e883d`) | Not probed | -| G3R | Rust socket: `send_reliable`, the write is lost, `disconnect()`, `connect()` on the same transport to a new peer | n/a | Receipt `Pending` after the disconnect; the write is delivered to the new peer; **receipt `Acknowledged`** (the Soul R1 shape) | Not probed | - -Pass 3 (base `e9469cb3`, transport files identical to `3bf1c0c`; mirror `probe3`, commits `3e2c7efb` for FN -and `c6f1012c` for CAP): - -- `packages/cultnet-rs/tests/probe_fn.rs`, `packages/cultnet-ts/test/probe-fn.test.ts`, - `packages/cultnet-py/tests/probe_fn.py`, `tests/GameCult.Networking.Tests/ProbeFnTests.cs` and - `packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/ProbeFn.kt`. -- Images: `eureka-verify-rust`, `mcr.microsoft.com/dotnet/sdk:10.0`, `node:24`, `python:3.12`, and for - Kotlin `eclipse-temurin:21-jdk` with `kotlin-compiler-2.2.21.zip` fetched in the job (the recipe Cut K0 - bakes into an image). - -| # | Probe | Result at `e9469cb3`, all five runtimes | -|---|---|---| -| FN | Sender `a` sends ordered `o1`, `o2`, `o3`; `o2` is lost, so the receiver `b` holds `o3`. `b` sends reliable `x`, which `a` receives. `a` then sends the Cut F notice exactly as specified: `create_ack()` encoded, flag byte `\|= 0x10`, sequence field = `o2`'s sequence. `b` decodes and receives it; then `o2` arrives late | **Old receivers ignore the notice and apply its acks.** It decodes as an Ack; `b`'s pending `[501]` (`x`) empties, so the header's acks were applied; nothing is delivered; `reply` is empty; `b` stays connected; `o2` is **not** marked received (not in `b`'s ack or mask). The late `o2` then delivers `[o2, o3]`: the hole was untouched. Identical in Rust, C#, TypeScript, Python and Kotlin | -| FN-T | Transport path (code, not run) | Every socket transport acks only a reliable, Accept or Data packet, or (TS, Python, Kotlin) one whose receive delivered a frame (`rudp.rs:2152-2155`, `:1713-1715`, `CultNetTransport.cs:2281-2283`, `:2787-2788`, `rudp.ts:1089-1091`, `transport.py:1086-1088`, K:3243-3245). An old receiver delivers nothing on a notice (FN), so the notice draws no reply datagram and is not counted dropped. Every transport already forwards a session's `reply` (`rudp.rs:2135-2137`, `:1708`, `CultNetTransport.cs:2277-2278`, `:2748-2749`, `rudp.ts:1067-1068`, `transport.py:1070-1071`, K:3229, `rudp_document_server.rs:334-336`, `cultmesh-ts/src/index.ts:5828-5830`, `operation-service.ts:186`, `cultmesh-py/.../server.py:246-247`), which is how a new receiver answers a notice | -| CAP | Q-A9 B's capability bit on old peers: `a`'s Connect and `b`'s Accept are each encoded, given flag bit 4, and decoded before delivery; then `a` sends one ordered frame (`probe_cap.rs`, `probe-cap.test.ts`, `probe_cap.py`, `ProbeCapTests.cs`, `ProbeCap.kt`) | **Old peers ignore the bit on the handshake.** Both sides connect, the frame is delivered, and the Connect is acknowledged (the sender's pending holds only the data frame). Identical in all five runtimes | -| K-img | Kotlin self-tests on Linux | `kotlinc-jvm 2.2.21` on OpenJDK 21.0.12 compiles `CultMesh.kt` plus a probe file, and `java -cp out.jar:kotlin-stdlib.jar org.gamecult.cultmesh.CultMeshKt` (the self-tests, K:3349-3373) exits 0 | - -**Suites on the prototype `db7d2886`** (pass 1; baseline `03eac6ff` was all green): - -- `cultnet-rs`: 110 + 4 + 24 pass; `tests/cultnet.rs` 51 pass and 2 fail. Both failures are test shape, - not mechanism: - - `rudp_session_suppresses_duplicates_and_delivers_reliable_ordered_payloads_in_sequence` - (`tests/cultnet.rs:1000`) hands the receiver an Accept with sequence 91 from a sender whose data starts - at 1. That handshake is impossible. Under a watermark seeded by the Accept, every data sequence is - "already below" it. The test must use a consistent handshake. - - `rudp_ordered_channel_tracking_is_bounded` (`:2655`) sends 256 ordered channels with no gap. The - prototype keeps state only for channels with held frames, so nothing is refused. The bound becomes - "channels with held frames", and the test must hold frames behind a gap. -- `cultmesh-rs` did not compile only because the prototype added a status variant. Under Q-A2 the status - enum keeps its three variants, so that break does not recur. - -**Wire compatibility (P7).** Every decoder ignores flag bits 4-7 and byte 35. Every session ignores an -Ack's sequence field and payload (`CultNetTransport.cs:1177`, `rudp.rs:578-592`, `rudp.ts:397`, -`transport.py:612`, `CultMesh.kt:2738`). Every decoder rejects an unknown packet type -(`CultNetTransport.cs` `Enum.IsDefined`, `rudp.rs` `packet_type_from_code`, `rudp.ts:1313`, -`transport.py:1212`, `CultMesh.kt` `fromCode`) and an unknown version byte. So: - -- An additive field fits in an Ack: flag bit 4 set, the value in the Ack's unused sequence field, zero - extra bytes. Old peers apply the ack and ignore the rest. Q-A1 ruled out a watermark there; Cut F puts - the FORWARD-TSN notice there (Q-A3 B), and pass 3's FN probe confirms the old-peer behaviour in all five - runtimes. -- A new packet type or a version bump is not additive: old peers refuse the datagram and count it dropped. -- Cut F's notice needs no negotiation to be safe: it reads as a plain ack to every current peer. Q-A9 asks - whether to advertise support anyway, for the receiver's sake (§6). - - -## Authority map - -- **Owner of ordered delivery (receiver): the session's contiguous received-through watermark `R`.** - - Every reliable sequence up to `R` has been received since the peer state was last reset. - - A held ordered frame whose first sequence is at most `R + 1` is delivered, in sequence order, in the - same call that advanced `R`. - - `R` is seeded by the handshake. `AcceptConnect` seeds it with the Connect's sequence, and receiving - the Accept seeds it with the Accept's sequence. A session connected without a handshake (Rust - `assume_connected`, `rudp.rs:243`, test-only today) seeds it from its first reliable packet. - - It advances in `RememberReceived` and resets with the rest of the peer state (`ResetPeerState`). -- **Owner of retransmission (sender): the pending and queued sets**, as today. A sequence leaves them when - an ack names it. Retransmission stops there. This is not a public meaning. -- **Owner of abandonment (sender, Cut F): the abandoned set.** An expired sequence is announced by notices - until an ack names it or 16 sends pass; then it is stranded (`first_stranded`). The receiver's `Abandon` - branch is the only path that marks a sequence received without its packet, and it drops every incomplete - fragment set the sequence falls in. -- **Owner of Acknowledged (sender): the acknowledged-through watermark `A`**, one below the lowest sequence - that is pending, queued, awaiting its abandonment notice's ack, or stranded in the current generation. An ordered send is Acknowledged when `A` - reaches its last sequence. A reliable unordered send is Acknowledged when every one of its sequences is - acked. -- **Owner of the session boundary: the session's generation.** One primitive ends a generation: the - session stops being connected, every write not yet Acknowledged is Invalidated and dropped from pending - and queued, and the generation counter advances. Every ending path calls it: a peer's Disconnect, a - local disconnect, a timeout and a refused packet (Cut 1b), and from Cut 1c a client's `connect()` on a - session that already had a peer, and a server accepting a Connect that does not repeat the current one. - - What was learned from the peer (`R`, the received set, held frames) is forgotten only where both sides - start fresh: a refusal (as today) and, from Cut 1c, a non-repeated handshake. Forgetting it at a - one-sided ending (a timeout the peer did not see) would let the peer's retransmits be delivered twice. -- **Owner of "is this Connect a repeat": the session**, from the Connect's sequence (Cut 1c; Q-A6). -- **Inputs.** Received sequences and handshake packets (receiver); acks, expiry and ending events - (sender). Nothing else. -- **Outputs.** Delivered frames, in order (receiver). Receipt status and flush completion (sender). -- **Derived, not owners.** - - The per-channel `next` map is **deleted**, not demoted: `_orderedNextSequenceByChannel` - (`CultNetTransport.cs:918`), `ordered_next_sequence_by_channel` (`rudp.rs:196`), - `#orderedNextSequenceByChannel` (`rudp.ts:160`), `_ordered_next_sequence_by_channel` - (`transport.py:424`), `orderedNextSequenceByChannel` (`CultMesh.kt:2643`). - - `expectedSequenceIfUninitialized` and its equivalents are deleted (`CultNetTransport.cs:1151`, - `rudp.rs:540`, `rudp.ts:375`, `transport.py:595`, `CultMesh.kt:2728`). - - `PendingOrderedFrame.NextSequence` is deleted (`rudp.rs:160-163` and equivalents). - - `OutstandingReliablePacketCount` stays, and means "not yet acked in this generation". It is a - retransmission count, not a delivery claim. - - Rust `session_scope: Uuid` (`rudp.rs:135`, `:179`, `:206`, rotated at `:345` and `:601`) is no longer an - owner. It is replaced by the generation (Cut 1b) and then by receipts resolved at their source - (Cut 4). - - TS `#generation`, `#endedGeneration` and `#endGeneration` in the socket transport (`rudp.ts:830-831`, - `:937-940`) are no longer owners; the session's generation replaces them. - - `DisconnectReason` / `disconnect_reason` / `#endedReason` are report-only: why the last generation - ended. They no longer gate flush. -- **Forbidden writers.** - - No path delivers an ordered frame except the drain after `R` advances. - - No receive path drops a frame whose sequence it acknowledged while the generation lives. - - No sender path reports Acknowledged except through `A` (or the unordered rule). - - No ending path leaves a write owed. No path retransmits a write, or sends a notice, into a generation - after the one it was issued in. - - No path marks a sequence received without a packet, except the `Abandon` branch (Cut F). - - No per-peer send failure ends a poll, a loop, a thread or another peer's session (Cut D). - - No transport decides whether a Connect repeats: not by payload (Rust hub `rudp.rs:1637-1647`, C# - listener `CultNetTransport.cs:2670-2688`), not by `Connected` (C# `:2226-2236`, Python - `transport.py:1047-1053`, TS `rudp.ts:249-251`, `:1031-1049`), not by server key or payload - (cultmesh-rs document server `rudp_document_server.rs:413-427`, cultmesh-ts document server - `index.ts:5794-5802`), and not by resetting on every Connect (Rust server mode `rudp.rs:2105-2116`, - cultmesh-py server `server.py:225-240`). -- **Shared paths.** Accept, reliable data, fragments, duplicates and retransmits all advance `R` through - `RememberReceived` and then call the drain. Direct sends, fragmented sends, queued promotion and resends - all feed the pending and queued sets that `A` reads. Every ending path goes through the one generation - primitive. -- **Deletion line.** `DeliverOrdered`, `DrainOrdered` and `SkipReceivedNonChannelSequences` and their - equivalents go before the drain is added: `CultNetTransport.cs:1628-1700`, `rudp.rs:1138-1246`, - `rudp.ts:737-800`, `transport.py:868-920`, `CultMesh.kt:2939-2978`. The transport-level repeat decisions - above go before the session's is added. - - -## The design, question by question - -### 1. Where Acknowledged comes from (Q-A1 a, ruled) - -- Derived at the sender from `A` (§ The finding). No wire change. -- **Why it is sound after Cuts 1-3.** A conforming receiver: - - drops nothing it acknowledged while the session lives (P1, P6: Cut 1; P5: Cut 2; P8: made unreachable - by Cut 3); - - holds an ordered frame only while a sequence below it is missing (Cut 1); - - drains held frames in the call that fills the last gap, before it acknowledges (Cut 1). - - So every sequence up to `s` acked means `s` was delivered. -- **And after Cuts 1b and 1c,** an ack can only be credited to the generation that issued the write - (G3, G2). -- **Mixed versions.** A new sender with an old receiver can be wrong only when that receiver strands a - frame (P1, P6). That needs reliable traffic on more than one channel of one session. The C#, TS, Python - and Kotlin socket transports send reliably only on `schema` (`CultNetTransport.cs:2372-2376`, - `rudp.ts:1254-1262`), so they cannot trigger it. Rust sends reliable `media`/`audio` too - (`rudp.rs:2523-2562` `channel_send_options`). The pass-1 Eyes survey found no live session that mixes - those with `schema`: Muninn's media session carries no `schema` traffic, and neither does Sleipnir's HID - stream. The old receiver loses such frames today in any case. Bumping the pin is the fix. -- **Every Odin-bound publisher's receiver is Odin.** Acknowledged means delivered for Idunn health, - presence and catalog publishing only once Odin pins a CultLib with Cuts 1-3. - -### 2. What "delivered to the application" means - -- **Delivered means the frame left the session's hold and was handed to the transport's application - boundary.** That boundary is: - - C#: enqueued in the socket transport's `_deliveredFrames` (read by `TryReceiveOnce`), or returned in - `CultNetRudpReceiveResult.Delivered` from a bare session; - - Rust: pushed to `delivered_frames` (`rudp.rs:2147`) or the hub's `pending_events`, or handed to the - document server's sink (`cultmesh-rs/src/rudp_document_server.rs:343-360`), which acknowledges only - after the sink accepted; - - TS: the `"frame"` event was emitted (`rudp.ts:1076-1082`); - - Python and Kotlin: the delivered queue. -- **It is not "consumed".** The operator rejected application-driven acknowledgement, and TCP's cumulative - ACK has the same boundary: in the receive buffer, readable, not read. -- A listener or sink that throws or rejects has still been delivered to. The handler-rejection paths from - the stray-packet work end the session, which is the application's answer, not the transport's. - -### 3. The public API (Q-A2, ruled: one meaning) - -- **Rust receipts** (the only runtime that has them today; §9 ports them to the other four): - - `CultNetRudpReliableSendStatus` keeps its three variants, `Pending`, `Acknowledged`, `Invalidated` - (`rudp.rs:146-150`). No variant is added, removed or renamed. `Acknowledged` changes meaning from - "every packet was acked" to "delivered". - - The visible difference: an ordered send behind a missing sequence reads `Pending` until the gap - fills (P3), where today it reads `Acknowledged`. - - Every external caller already means delivered, so none changes code: `cultmesh-rs/src/lib.rs:832` - and `:892`, `tests/rudp_stray_packets.rs:309`, `cultmesh-rs/tests/rudp_document_server.rs:408,415`, - CodexConnector `src/idunn_health.rs:727-745`, Ghostlight - `crates/ghostlight-dungeon/src/idunn_health.rs:1126-1145`. - - **A receipt is resolved once, by its session.** It becomes `Acknowledged` when `A` passes its last - sequence (ordered) or when all its sequences are acked (unordered). It becomes `Invalidated` when its - generation ends first. After that it never changes. - - Mechanism: the receipt carries its sequences, whether it was ordered, and a shared status cell - (`Arc`). The session keeps the unresolved cells, at most one per outstanding send, and - writes each once. `reliable_send_status(&receipt)` (`rudp.rs:1931-1936`) reads the cell; its - signature does not change. - - This deletes the session scope (`rudp.rs:135`, `:179`, `:206`, `:283-285`, `:291-293`, `:345`, - `:601`) and needs no history of ended generations. Today a delivered write reads `Invalidated` after - any reset, because the scope rotates (`rudp.rs:291-293`). The pass-1 design needed a frozen snapshot - of one ended epoch and still misreported older ones. - - `PartialEq` on the receipt compares its sequences, which are unique within a session object because - issued sequences survive resets. - - `send_reliable` receipts cover queued packets (P10). The receipt lists every sequence the send - assigned, not only the ones admitted to the wire (`send_many`, `rudp.rs:456-528`, returns only the - admitted ones; `non_expiring_reliable_receipt`, `:305-318`, refuses an empty set). - - `send_reliable` refuses a channel with reliable expiry (P9). The comment at `rudp.rs:1915-1916` says - this lineage has no expiry, which is false since `bac5be88`. Restore the guard it describes. - - New: `CultNetRudpSocketTransportConnection::wait_acknowledged(&receipt, timeout) -> Result<()>`. It is - the ack wait: it pumps receive and resends like `flush_reliable` and returns when the receipt is - `Acknowledged`. It fails at once when the receipt is `Invalidated`, naming the end reason. It replaces - the hand-rolled loops at `cultmesh-rs/src/lib.rs:806-838` and `:870-900`; CodexConnector and - Ghostlight may adopt it at their next bump, and their existing polling stays correct without it. -- **Flush: one wait, and it means every send of this generation is Acknowledged.** - - A flush belongs to the generation it started in. It fails when that generation ends, whatever comes - after. Rust binds it to the scope today (`rudp.rs:2001-2014`) and TS to its transport generation - (`rudp.ts:911-935`). C# (`CultNetTransport.cs:1914-1931`) and Python (`transport.py:1101-1124`) check - only the disconnect reason, which the next Connect clears (`CultNetTransport.cs:1843`, `:2235`; - `transport.py:997`, `:1054`). Once Cut 1b drops owed writes at every ending, "outstanding is 0" after a - reconnect would read as success, so every runtime binds flush to the session's generation in Cut 1b. - - When every sent sequence is acked, `A` is the highest sequence sent, so every send is Acknowledged. - Under Cut F an abandoned sequence is resolved by its notice's ack, so flush waits for that too. The - only exception is a stranded sequence (a notice the receiver never acknowledged). There - `flush_reliable` (`rudp.rs:1992-2040`) fails at once when an ordered send follows it: that send can - never be Acknowledged, and today flush reports success (P2). - - C# `FlushReliable`, TS `flush`, Python `flush_reliable` and Kotlin `flushReliable` - (`CultMesh.kt:3186`) keep their success condition. Their contract text becomes "every reliable frame - sent in this session has been delivered to the peer's application". These runtimes have no expiry - unless Q-A8 gives them some (Cut 7). -- **Defaults.** Plain flush. No parameter. No second flush. -- **Compatibility.** No signature changes in any runtime except additions (`wait_acknowledged`; TS - `connectAndWait`, §4; the receipts of §9, where C# and Kotlin `SendSchema*` return a receipt instead of - nothing, which is source-compatible). The Rust enum is unchanged. No downstream caller has to change code at its next - bump. - -### 4. Consumers, each assigned - -Surveyed across `F:\Projects` in pass 1; the rows that moved are re-checked at the revision named. Every -consumer that waits wants delivered, which is now just Acknowledged. - -| Consumer | What it waits on today | Change | -|---|---|---| -| `cultmesh-rs/src/lib.rs:781-838` `request_raw_snapshot_from_rudp_catalog` (Idunn `src/drivers.rs:5527` uses it) | a hand-rolled loop until `Acknowledged`, then the response | Cut 4: `wait_acknowledged` | -| `cultmesh-rs/src/lib.rs:870-900` `publish_cultnet_message_to_rudp_catalog` (Odin `odin-daemon`) | a hand-rolled loop until `Acknowledged`, then disconnect. The doc at `:869` claims "application admission" | Cut 4: `wait_acknowledged`; the doc says delivered to the document server's sink (`rudp_document_server.rs:343-372`) | -| `cultmesh-rs/src/lib.rs:918-938` `publish_cultnet_messages_to_rudp_catalog` (Ghostlight `mesh.rs:323`) | `flush_reliable`, then drop. **Several ordered frames, then close** | None; flush now means it | -| `cultmesh-ts/src/index.ts:6070-6098` `publishRudpDocumentOnce` (Heimdall, StreamPixels, Stonks, weksa, Bifrost, VoidBot, Hermodr) | `peer.flush` (`cultnet-ts/src/peer.ts:109-110`), then close | None. Cut 5 moves its connect-and-wait helper (`index.ts:6603-6650`) into cultnet-ts | -| `cultnet-ts/src/idunn-odin-presence-publisher.ts:89-215` `publishDocument`, behind `createIdunnRuntimePresencePublisher`. **StreamPixels' service runs this**: `apps/service/scripts/idunn-entry.mjs` runs `deployment/idunn/runtime-presence.mjs`, which calls `createIdunnRuntimePresencePublisher` (`:46`) from its vendored CultLib `542ddd3` (StreamPixels `a49f7d8`; pass 2's `30ee8b9` is stale), where it lives under its older name `cultnet-ts/src/idunn-runtime-authority.ts:206` | a hand-rolled session loop (`:186-208`) until `outstandingReliablePacketCount === 0`. It does resend (`:205`). It drops `readyToSend` (`:198`), so promoted queued packets wait for the resend timer. It pins `initialSequence: 1` (`:103`) and never sends a Disconnect, so Odin's hub keeps each publish's peer until it times out (each publish binds a new port, so each is a new peer) | Cut 5: the socket transport, `connectAndWait`, `send("schema")`, `flush`, `close` (which sends the Disconnect, `rudp.ts:956-969`) | -| `cultnet-ts/src/signed-daemon-health.ts:104-162` `publishSignedDaemonHealth` (Hermodr, Odin `idunn-rudp.cjs`) | a hand-rolled loop: sends the packets `sendMany` admitted, waits up to 500 ms for the **first** Ack packet of any content (`:146-153`), swallows the timeout, closes. Resends run inside that wait (`:303`) but promoted queued packets are never sent | Cut 5, as above. Today a write not acked within 500 ms, or any payload over 32 packets, is reported published | -| `cultnet-ts/src/operation-service.ts` | the operation response | none (the response is the proof) | -| C# `FlushReliable` callers: Gjallar `Program.cs:3055-3080` (two ordered frames, flush, dispose) | flush | None | -| Heimdall `src/idunn-rudp-health.ts:55-87` (vendored CultLib `b6b1d9c`, Heimdall `d5acc94`) | the first Ack packet | Heimdall's own cut at its bump: call cultnet-ts's publisher after Cut 5 | -| CodexConnector `idunn_health.rs:727-745`, Ghostlight `idunn_health.rs:1126-1145` | `Acknowledged` | None required. Optional `wait_acknowledged` at their next bump | -| Muninn `main.rs:9446-9458`, Sleipnir `main.rs:880-904` | nothing (send and drop) | Follow-up: fire-and-forget health can be silently lost | -| Kotlin `flushReliable` | no callers | Contract text only | - -### 5. The hold buffer and the 4 MiB cap - -- **It belongs here**, because the held frame is exactly the state this map names. -- **Mechanism (P4).** Rust caps held ordered frames at 1,024 frames and 4 MiB (`rudp.rs:47-48`, checked - at `:1181-1187`). A refusal ends the session. The default payload limit is 16 MiB (`rudp.rs:33`). With - one lost packet under a large ordered transfer, a legal 5 MiB frame is refused and kills the session. - - The sender's window counts pending packets, not span or bytes (`rudp.rs:910-942`). While one packet - stays lost, acked packets keep freeing slots, so the held data behind the gap grows without limit. -- **Fix: a sender-side flow window, TCP's receive window without a wire field** (Cut 3). A reliable - packet is admitted to the wire only while: - - its sequence is at most 1,023 above the lowest unacked sequence; and - - the payload bytes of sequences above the lowest unacked one stay within 4 MiB, with at least one - packet always admissible. - - Otherwise it waits in the existing queue. -- **What this buys:** - - a conforming sender never makes a Rust receiver hold more than 4 MiB, so the cap stops refusing legal - traffic; - - a conforming sender never gets 1,024 sequences ahead of the receiver's highest, so the Rust far-ahead - refusal (`rudp.rs:46`, checked at `:626-630`) never drops its packets; - - a conforming sender never leaves a sequence 4,096 behind the receiver's highest, so P8 cannot happen. - The receive-history window stays 4,096 in every runtime (`CultNetTransport.cs:884`, `rudp.rs:35`, - `rudp.ts:20`, `transport.py:404`, `CultMesh.kt:2625`). -- **What is kept.** The cap and the far-ahead refusal stay. They protect against hostile peers. -- **Rejected:** raising the cap to the payload limit or above (16-64 MiB per hostile session instead of - 4); lowering the ordered payload limit to 4 MiB (an API regression for large snapshots). -- **Cost.** Normal flow never has a span beyond the 32-packet window, so the new limits bind only while a - packet is lost. -- **Old senders** (the pinned and vendored ones) can still trip the Rust cap until they bump. -- **Not in scope.** C#, TS, Python and Kotlin receivers have no hold cap. Follow-up. - -### 6. Reliable expiry and FORWARD-TSN (P2; Q-A3 B, ruled) - -- **The hole.** Only Rust has reliable expiry (`rudp.rs:39`, `:898-908`; the Python README's claim of - expiry at `packages/cultnet-py/README.md:103-111` is false, `transport.py` has none). An expired packet's - sequence never reaches the receiver, `R` stops below it, and every ordered frame after it on the session - is held until the session ends. -- **Who expires.** Every Rust `media` sender that does not opt into lossy delivery: the profile's media - delivery defaults to Reliable (`channel_delivery`, `rudp.rs:2515-2521`) and its expiry to 75 ms - (`rudp.rs:39`, set on the client and hub transports at `:1303` and through the profile at `:1548-1549`). - Media frames fragment. Muninn's reliable `audio` also expires; `hands/media-fec-cut3` deletes that profile - entry and moves Muninn's media to lossy delivery, but the default stays reliable-with-expiry for every - other caller. - -**What RFC 3758 does, and what this map keeps.** - -- The sender treats an abandoned chunk as "finally acked and no longer outstanding" (§3.5 A2) and advances - an `Advanced.Peer.Ack.Point` over consecutive abandoned TSNs. When that point passes the peer's - cumulative ack it sends FORWARD TSN (C1-C3), and tries again whenever T3-rtx expires (A5). -- FORWARD TSN carries a **New Cumulative TSN** plus (stream, stream sequence) pairs. The receiver "MUST - consider any missing TSNs earlier than or equal to this value as received" (§3.2), advances its - cumulative point, releases the listed streams' reordering queues, and acks "as if a DATA chunk had been - received" (§3.6). A FORWARD TSN at or behind its cumulative point is out of date, changes nothing, and - still draws a SACK. -- PR-SCTP is negotiated: an endpoint that did not see Forward-TSN-Supported in the peer's INIT must never - send FORWARD TSN (§3.1, §3.3). - -**How CultNet adapts it.** - -- **The notice names one sequence, not a cumulative point.** SCTP's SACK is cumulative, so a cumulative - FORWARD TSN is confirmed by the next SACK. CultNet's ack is not: it names the highest received sequence - plus a 32-bit mask, or one exact older sequence (`create_ack_for_received`, `rudp.rs:751-758`). A - cumulative notice could not be confirmed by that ack, which would mean a second wire field. So each - abandoned sequence gets its own notice, and the receiver answers it with the ack a reliable data packet - with that sequence would draw. That ack removes the sequence from the sender's abandoned set exactly as it - removes a data packet from pending. **Q-A7** puts the cumulative alternative to the operator. -- **No stream pairs.** One watermark `R` owns ordered delivery for every channel (Cut 1), so advancing `R` - and draining releases every channel's held frames. There is nothing per channel to name. -- **Negotiation: only if Q-A9 is B.** The ruling puts the notice in an Ack so an old peer reads it as an - ack (FN), and bounds the resend: an old receiver never acks the notice, and the sender stops after a - fixed number of sends. The Cut 2 Soul finding adds a reason to negotiate after all (Q-A9): a *receiver* - needs to know whether its peer will ever send notices. If Q-A9 is B, the sender also sends notices only - to a peer that advertised the bit (RFC 3758 §3.3), and an old receiver's sequence is stranded at once - instead of after the bound. The abandoned sequence is then *stranded*: the Cut 4 honest - report applies to it (ordered sends after it stay Pending, become Invalidated at the end, and flush fails - fast). That is the behaviour ruling A would have given everywhere; under B only old receivers get it. - -**Wire.** Packet type `4` (ack), flag bit 4 (`0x10`, "abandon"), and no other flag bit. Sequence field: the -abandoned sequence. Ack and ack mask: the sender's current ack state, so the notice is also a genuine ack. -Channel `control`, fragment fields `0`, empty payload. Decoders map type 4 with bit 4 to a new packet type, -**`Abandon`** (Rust `CultNetRudpPacketType::Abandon`, C# `CultNetRudpPacketType.Abandon`, TS `"abandon"`, -Python `CultNetRudpPacketType.ABANDON`, Kotlin `CultNetRudpPacketType.Abandon`), and the encoder maps it -back. Bit 4 on any other type is ignored, as today. No public packet struct gains a field, and no -downstream repo constructs packets or matches the type exhaustively (surveyed: Muninn tests compare -`== Data`; Mimir `CultMeshMedia` compares `Connect` and `Accept`). - -**Golden** (added beside the existing fixture in every runtime and in `docs/cultnet-transport-parity.md` -after the canonical fixture, `:505-517`): - -```text -packetType=abandon (wire: type 4, flags 0x10) -connectionId=0x01020304 -sequence=0x0000002a -ack=0x00000029 -ackMask=0x80000001 -channelId=control -payload=(empty) -hex=434e52300004102b010203040000002a0000002980000001000000000000000000000700636f6e74726f6c -``` - -**Sender (Rust now; any runtime that gains expiry under Q-A8).** - -- An expired packet, pending or queued, leaves the pending and queued sets as today and enters an - **abandoned set**: sequence, sends so far, last sent. It is due at once. -- `due_resends` emits a fresh notice for each due entry (built from the current ack state, not stored), - once per resend delay. After `RUDP_ABANDON_NOTICE_SENDS = 16` sends without an ack the entry leaves the set - and the session records the lowest such sequence as **`first_stranded`**. Sixteen sends at the default - 250 ms is four seconds. At 50% loss in each direction a round trip fails with probability 3/4, and all - sixteen with about 1%; at 20% each way, about 10^-7. The bound is for deaf receivers, not bad links, and - a new receiver that misses every notice degrades to the honest report, never to a lie. -- Any ack naming the sequence removes it from the abandoned set. That ack means the receiver holds the - sequence, from the original packet arriving late or from the notice; either way it is no longer a hole. -- Notices take no send-window slot and no payload bytes. For Cut 3's span rule, an abandoned sequence with - an outstanding notice counts as unacked (the receiver's `R` is held below it); a stranded one does not - (an old receiver has no `R` to hold, and counting it would stop the session after 1,023 more sequences). -- `outstanding_reliable_packet_count` counts abandoned entries, so a flush waits for their notices. -- The session-ending primitive (Cut 1b) and `reset_peer_state` clear the abandoned set and - `first_stranded`: they belong to the generation. - -**Receiver (all five runtimes).** - -- `receive` handles `Abandon` after applying its acks, as RFC 3758 §3.6 does: - - outside `[highest − 4,095, highest + 1,024]`: far-ahead is ignored without remembering (as Rust data - is, `rudp.rs:626-641`), far-behind is a duplicate; - - every incomplete fragment set whose sequence span contains the sequence is dropped (a set's span is - `sequence − fragment_index` for any fragment it holds, for `fragment_count` sequences), whether or not - the sequence was received: that frame can never complete; - - if the sequence was not received, it is remembered through `RememberReceived`, which advances `R` - (Cut 1), and held frames up to `R + 1` drain into `delivered`; - - `reply` is `create_ack_for_received(sequence)`, whether or not anything changed (the out-of-date case - still acks). -- Transports send the reply as they already do, and skip their own ack for an `Abandon` packet - (`rudp.ts:1089`, `transport.py:1087`, K:3243 would otherwise ack it a second time when the drain - delivered). - -**Interaction with the other cuts.** - -- **Cut 1 (`R`).** The notice is the only receive path besides data and Accept that calls - `RememberReceived`; it shares the drain. A late original of an abandoned packet is then a duplicate: acked - and dropped, as PR-SCTP drops it. -- **Cut 1b (ending).** An abandoned entry is a write of its generation; the ending primitive drops it. No - notice is ever sent into a later generation. -- **Cut 1c (generations).** A stale notice from an earlier generation of the same sender carries a - sequence below the new generation's Connect (issued sequences survive resets), so it is a duplicate. A - stale notice from a dead process on the same endpoint and connection id could mark a sequence of the new - process's session, exactly as a stale data packet can be delivered into it today; the random initial - sequence makes both land inside the 5,120-sequence acceptance range with probability about 2^-18. -- **Cut 2 (fragment eviction), from the Cut 2 Soul pass.** Cut 2 never evicts a reliable fragment set and - refuses the session when every pending set is reliable. Its claim that a conforming sender cannot reach - the 64-set bound is **false for an expiring sender**: fragment 0 arrives and is acked, the deadline drops - the rest, and the set is pinned for the session. Soul reproduced the refusal on the 65th lossy default - `media` frame (`soul_probe_expiring_reliable_frames_kill_receiver`, `packages/cultnet-rs/tests/cultnet.rs:3192` - at probe `3be97b34`). Before Cut 2, eviction kept that session alive. - - **With a Cut F sender** the notice for each dropped fragment lands inside the set's span and the - receiver drops the set: the rule above, explicitly "every incomplete set whose span covers a notified - sequence". Dead sets then live one round trip, not the session. - - **With a sender that never sends notices** (every build before Cut F) the receiver cannot tell an - expiring set from a set whose missing fragment is still coming. **Q-A9** decides what the receiver does - then; its recommendation is PR-SCTP's own answer, a capability bit on Connect and Accept (RFC 3758 - §3.1, Forward-TSN-Supported): Cut 2's never-evict rule applies to a peer that advertised it, and the - pre-Cut-2 stalest-set eviction stays for a peer that did not. - - So Cut 2 does not merge alone: it merges together with Cut F, which carries the capability and the - gate (§ Sequencing). -- **Cut 4.** Reads `first_stranded` where pass 2 read `first_abandoned`, and counts outstanding notices as - not yet Acknowledged. - -### 7. Interaction with the merged stray-packet work - -The ack cuts build on these behaviours and do not alter them: - -- A refusing session ends, and its goodbye is built after the reset (`EndRefused` - `CultNetTransport.cs:1315-1319`, `end_refused_session` `rudp.rs:364-367`). The refused sequence is never - acked, so a refusal never becomes a false Acknowledged. -- Issued sequences survive a reset in every runtime (`rudp.rs:341-343`, `CultNetTransport.cs:1292-1308`, - `rudp.ts:214-227`, `transport.py:458-471`). Cut 1c relies on it: a client's second Connect always - carries a new sequence. -- One Accept per generation, and a repeated Connect answered with the Accept still owed, or an Ack. Cut 1c - keeps the answer and moves the decision of what repeats into the session. -- An ended session fails its flush. Cut 1b generalises the TS and Rust binding to the generation into all - four runtimes. -- `5f1e883d` made a peer's Disconnect end Rust receipts (`rudp.rs:596-615`) and removed the - transport-level Pending→Invalidated override. Cut 1b makes the other ending paths agree with it. -- The TS operation service's expiry and handler rejection are above the transport and do not change. - -### 8. Session generation: why it is two cuts, and how a new one is recognised - -- **Why not inside Cut 1.** Cut 1 is a receiver change with a deletion line: one watermark replaces five - per-channel state machines. Its falsifiers are P1, P1b and P6. The generation work touches the ending - paths, the handshake, flush and receipts, and its falsifiers are G1-G3. Both touch `ResetPeerState` and - the Accept path, which is a merge cost, not a shared owner. Keeping them apart lets Soul falsify each - against its own probes. -- **Why two cuts.** The ending rule (Cut 1b) is ruled and needs no mechanism choice: every ending path - drops what it owed. How a new generation is recognised (Cut 1c) needs one (Q-A6). The two are separable - because 1b does not forget peer state: the peer may not know the session ended, and forgetting what was - received would let its retransmits be delivered twice. - **Correction (Cut 1/1b Soul, 2026-09-30): they are separable as specs, not as merges.** 1b drops owed - writes, which leaves holes the sender will never fill; the receiver keeps `R` across the ending, so if the - next handshake does not reset it, every later ordered frame is held and acked, and flush succeeds while - nothing is delivered. Only 1c's reset on a non-repeated handshake removes that. **1b merges only together - with 1c.** Soul also found `R` seeded by the first reliable packet rather than the handshake; ruled: - `R` is seeded only by the handshake, and reliable data before the Accept is neither acked nor delivered. -- **What must hold after both.** A write is Acknowledged or Invalidated within the generation it was - issued in. A restarted client is admitted. A new server session's frames are delivered after a - reconnect. A retransmitted Connect is still a repeat. -- **Recognising a new generation (Cut 1c, Q-A6 A, ruled).** The Connect's sequence identifies the - generation, and the initial sequence is random by default, as TCP's initial sequence number is - (RFC 9293 §3.4.1, RFC 6528). - - A Connect repeats exactly when it carries the sequence of the Connect that started the current - generation. Its retransmits do; a client's second `connect()` cannot, because issued sequences - survive resets; a restarted process almost never does, because it drew a fresh random initial - sequence. - - A stale duplicate of the accepted Connect, delayed in the network, still repeats: same sequence. - - Anything else ends the current generation and starts a new one, at both ends: the client forgets peer - state when it sends a Connect on a session that had a peer, and the server when it accepts a Connect - that does not repeat. - - A client honours an Accept only while its Connect is pending, and only if the Accept's ack field names - that Connect. A stale Accept from an earlier generation cannot seed `R`. - - The initial sequence is drawn from [1, 2^31), which leaves at least 2^31 sequences before the - exhaustion error (`rudp.rs:944-962`) in every runtime. An explicit `initial_sequence` still works, for - tests. - - The Connect payload stays application evidence (the provider layer's `clientSessionId`, - `cultnet-rs/src/provider_session.rs:70-79`), carried to the application as today. It no longer decides - anything in the transport. - -### 9. Receipts in C#, TypeScript, Python and Kotlin (Q-A4 A, ruled) - -- **One design, ported.** Cut 4 builds it in Rust: a receipt carries the sequences its send assigned, - whether it was ordered, and a status cell the session writes once. The session keeps the unresolved - receipts and resolves them after every ack it applies (Acknowledged: ordered when `A` reaches its last - sequence, unordered when every sequence is acked) and in the ending primitive (Invalidated). The four - ports copy that shape; none invents a second one. -- **What every runtime has to change first** (the receipts survey, `e9469cb3`): - - `SendMany`/`sendMany`/`send_many` returns only the packets admitted to the 32-packet window - (`CultNetTransport.cs:1439-1465`, `rudp.ts:586-592`, `transport.py:776-786`, K:2856-2862). A receipt - must be built from every sequence the send *assigned*, queued ones included, which is Rust's P10. - - `ApplyAcknowledgements` only deletes from the pending map (`CultNetTransport.cs:1503-1516`, - `rudp.ts:610-617`, `transport.py:803-807`, K:2882-2889). Resolution hooks in after it. - - Invalidation needs the ending primitive. C#, TS and Python get it in Cut 1b; Kotlin in K2. -- **Where the receipt lives.** On the single-peer socket transport connection, as in Rust - (`CultNetRudpSocketTransportConnection::send_reliable`, `rudp.rs:1903-1930`). The Rust hub has no - receipts, so neither do the C# listener (`CultNetRudpSocketTransportServer`, `:2487`), `cultmesh-ts`'s - document server or `cultmesh-py`'s server. Hub receipts are a follow-up for all five together. -- **Public API, per runtime.** Every runtime's RUDP surface is synchronous-with-timeout except TypeScript's, - which is Promise-and-event. Each port matches its own runtime: - -| | Send | Receipt | Status | Wait | -|---|---|---|---|---| -| Rust (Cut 4) | `send_reliable(&mut self, channel, payload) -> Result` | `packet_sequences()` | `reliable_send_status(&receipt) -> CultNetRudpReliableSendStatus` | `wait_acknowledged(&receipt, timeout) -> Result<()>` | -| C# (6a) | `CultNetRudpSendReceipt SendReliable(string channelId, byte[] payload)`; `SendSchema(...)` and `SendSchemaMessage(...)` return the receipt instead of `void` (source-compatible) | sealed class, `IReadOnlyList PacketSequences` | `receipt.Status` (`CultNetRudpSendStatus { Pending, Acknowledged, Invalidated }`), read without the transport | `void WaitAcknowledged(CultNetRudpSendReceipt receipt, TimeSpan? timeout = null)`, the `FlushReliable` loop (`:1914-1955`); throws `InvalidOperationException` naming the end reason when Invalidated, `TimeoutException` at the deadline | -| TypeScript (6b) | `sendReliable(channelId: string, payload: Uint8Array): CultNetRudpSendReceipt`. `send` keeps `void`, because the transport interface declares it (`transport.ts:132-142`) | `readonly packetSequences: readonly number[]` | `receipt.status: "pending" \| "acknowledged" \| "invalidated"` (string union, as packet types are) | `waitAcknowledged(receipt, timeoutMs = 30_000): Promise`, polling like `flush` (`rudp.ts:911-935`); rejects with the end reason when invalidated | -| Python (6c) | `send_reliable(self, channel_id: str, payload: bytes) -> CultNetRudpSendReceipt` | frozen-looking object, `packet_sequences: tuple[int, ...]` | `receipt.status` (`CultNetRudpSendStatus` enum: `PENDING`, `ACKNOWLEDGED`, `INVALIDATED`) | `wait_acknowledged(self, receipt, timeout_seconds: float = 30.0) -> None`, the `flush_reliable` loop (`:1101-1131`); raises `ConnectionError` when invalidated, `TimeoutError` at the deadline | -| Kotlin (K4) | `fun sendReliable(channelId: String, payload: ByteArray): CultNetRudpSendReceipt`; `sendSchema`/`sendSchemaMessage` return it | `val packetSequences: List` | `receipt.status` (`enum class CultNetRudpSendStatus { Pending, Acknowledged, Invalidated }`), `@Volatile` | `fun waitAcknowledged(receipt, timeoutMs: Long = 30_000, pollIntervalMs: Long = 5)`, the `flushReliable` loop (K:3186-3205); throws `IOException` when invalidated, `SocketTimeoutException` at the deadline | - - - The status is on the receipt, not a transport query, in the four ports: a C#, TS, Python or Kotlin - caller reading `receipt.Status` is the idiom there, and it is what Rust's cell already is underneath. - Rust keeps `reliable_send_status` because its signature is public and used (Cut 4, Unchanged). - - `SendReliable` refuses a channel whose send options are not reliable (as Rust's `send_reliable` does, - `rudp.rs:1903-1930`). If Q-A8 brings expiry to a runtime, it refuses an expiring channel too, as Rust's - does after Cut 4 (P9). - - A receipt from an ended session is Invalidated and stays so; `WaitAcknowledged` on it fails at once. -- **Thread safety.** C# resolves under `_pendingReliableGate` (`:915`) and publishes the status with a - volatile write; Kotlin marks the field `@Volatile`; TS and Python are single-threaded per transport. -- **Docs.** Each runtime's README RUDP section gains the three states and the wait - (`src/GameCult.Networking/README.md`, `packages/cultnet-ts/README.md:24-25`, - `packages/cultnet-py/README.md:103-111`, `packages/cultmesh-kotlin/README.md:229-305`), and the parity - doc's flush paragraph (`docs/cultnet-transport-parity.md:260-273`) names receipts in all five. - -### 10. A failed send to one peer is that peer's lost datagram (Cut D; from the Odin Soul pass) - -- **Finding.** `CultMeshRudpDocumentServer::poll_once` begins with `self.maintain()?` - (`cultmesh-rs/src/rudp_document_server.rs:264`). `maintain` sends every peer's due resends and returns on - the first failed `send_to` (`:392-398`, through `send_packet` `:628-632`). Every per-peer send in - `poll_once` does the same: the reply (`:334-336`), the Accept (`:451`), a snapshot response (`:586-588`), - the goodbye (`:597-604`) and the ack (`:366-373`). - - So one peer whose datagrams cannot be sent fails every poll for every peer. Its resends come due every - 50 ms (`:158`); a poll loop that pauses longer than that between attempts never gets past `maintain` - again while the peer lives, and a peer that keeps sending never idles out (`:384-388`). - - Odin `origin/main` (`crates/odin-daemon/src/main.rs:661-675`) propagates any `poll_once` error, so - today the daemon exits on the first one; the Odin Soul pass reports a branch that retries every 100 ms - and exits after 30 s of failures. Either way one bad peer takes Odin down. - - The same socket is **non-blocking** (`:230`), so `send_to` also fails with `WouldBlock` when the send - buffer is full: a burst of snapshot responses to one peer can fail a poll for everyone with no bad peer - at all. -- **Rule: a send failure is a lost datagram, attributed to its peer.** It is counted and the poll goes on. - RUDP already recovers lost datagrams: the reliable packet stays pending and is resent; an unreliable one - was allowed to be lost. The peer's session ends by the rules that already end sessions: idle timeout, - maximum lifetime, a refusal or a Disconnect. This is TCP's treatment of ICMP unreachables as soft errors - (RFC 1122 §4.2.3.9): report them, do not abort the connection on them. - - Only failures that are not about one peer stay errors: `recv_from` failing with anything but - `WouldBlock` or `ConnectionReset` (Windows reports an earlier ICMP port-unreachable on the *next* - receive; the socket transport already treats it as nothing, `rudp.rs:2059-2066`, the document server does - not, `:266-272`), and encode failures, which are the server's own bug. - - Rejected: ending the peer's session on its first failed send. `WouldBlock` is transient and not the - peer's fault; ending on it would drop healthy peers under load. -- **Same shape elsewhere** (multi-peer servers only; a single-peer transport's failed send is already only - that peer's): - -| Server | Where one peer's failed send stops the others | Change | -|---|---|---| -| cultmesh-rs document server | as above | Cut D | -| Rust hub `CultNetRudpServerHub` | `poll_resends` returns on the first failure (`rudp.rs:1762-1777`); the receive path's sends to one peer abort `receive_event_once` (`:1644`, `:1673`, `:1708`, `:1711`, `:1714`, `:1759`, through `send_packet` `:1799-1804`) | Cut D | -| C# `CultNetRudpSocketTransportServer` (and `RudpCultNetSchemaServer` over it) | `PollResends` walks every peer and the first `SocketException` from `SendTo` (`CultNetTransport.cs:2829-2834`) escapes the loop (`:2796-2805`); `TryReceiveOnce`'s sends are unguarded except the refusal goodbye (`:2687`, `:2704`, `:2749-2750`, `:2788`; guarded `:2739-2745`) | Cut D | -| cultmesh-py server | worse: an `OSError` from `sendto` (`server.py:302-307`) in `_poll_rudp_resends` (`:277-285`) or the receive path escapes `_rudp_loop` (`:203-…`, which catches only `recvfrom`'s errors), and the RUDP thread dies for every peer | Cut D | -| cultmesh-ts document server | none: Node's `socket.send` is asynchronous and reports failures on the socket's `error` event (`cultmesh-ts/src/index.ts:5858`), which `reportError` logs (`:5778-5785`) | none | -| Kotlin interop server | none that matters: a catch-all logs and continues (K:4025), and it is interop-only | none (Cut K1 reviews it) | - - -## Cuts - -Every cut is on CultLib, on its own branch (`hands/ack-cut`) from `main` at `e9469cb3` or later, in a -worktree Self creates. Heavy verification runs on Yggdrasil. Each cut starts by committing its probes as failing -tests named for the rule, in every runtime it touches. The pass-1 and pass-2 probe files -(`refs/verify/1519a20a` on the `CultLib-imag-ack2` mirror) are the starting point; they print, so each -must become an asserting test. - -Kotlin: every numbered cut below is mapped for C#, Rust, TS and Python. Cut K (Q-A5 A) brings Kotlin to -the same state on a Yggdrasil Kotlin image, in steps K0-K5. - -**Negative checks that stay green in every cut:** - -- the cross-runtime reorder, drop and fragment proofs in the TS interop harness - (`docs/cultnet-transport-parity.md:227-248`); -- the stray-packet tests (`packages/cultnet-rs/tests/rudp_stray_packets.rs`, and their C#, TS and Python - equivalents), except the ones each cut names as encoding the old rule; -- the codec fixture vectors: the existing fixture's bytes never change. Cut F adds one fixture. - -### Cut 1. One watermark owns ordered delivery (receiver) — in flight (`hands/ack-cut1`) - -- **First.** Failing tests P1, P1b and P6 in each runtime. -- **Deletes first:** - - the per-channel `next` map and its resets: `CultNetTransport.cs:918`, `:1305`; `rudp.rs:196`, `:223`, - `:354`; `rudp.ts:160`, `:223`; `transport.py:424`, `:468`; `CultMesh.kt:2643`; - - `expectedSequenceIfUninitialized`: `CultNetTransport.cs:1151-1153`, `rudp.rs:540-543` (used at - `:679`), `rudp.ts:375-377` (used at `:450`), `transport.py:595-599`, `CultMesh.kt:2728`; - - `DeliverOrdered`, `DrainOrdered` and `SkipReceivedNonChannelSequences`: `CultNetTransport.cs:1628-1700`, - `rudp.rs:1138-1246`, `rudp.ts:737-800`, `transport.py:868-920`, `CultMesh.kt:2939-2978`; - - `PendingOrderedFrame.NextSequence` (`rudp.rs:160-163` and equivalents). -- **Adds:** - - `R`, seeded in `AcceptConnect` (`CultNetTransport.cs:1012`, `rudp.rs:383`, `rudp.ts:242`, - `transport.py:485`, `CultMesh.kt:2667`) and on Accept receipt (`CultNetTransport.cs:1155`, - `rudp.rs:544`, `rudp.ts:379`, `transport.py:601`, `CultMesh.kt:2730`); - - `R` advances in `RememberReceived` (`CultNetTransport.cs:1518`, `rudp.rs:973`, `rudp.ts:619`, - `transport.py:809`, `CultMesh.kt:2891`) and resets in `ResetPeerState`; - - a reassembled ordered frame is delivered at once when its first sequence is at most `R + 1` and its - channel holds nothing below it. Otherwise it is held. The caps apply only to what is held: the - prototype held everything and so refused a 5 MiB frame with no gap; - - after every reliable receipt (data, fragment, duplicate, Accept), drain every channel's held frames - whose first sequence is at most `R + 1`, in sequence order; - - Rust only: the 64-channel cap (`rudp.rs:42`) counts channels with held frames. -- **Tests changed:** `packages/cultnet-rs/tests/cultnet.rs:1000` (consistent handshake) and `:2655` - (hold frames behind a gap). The equivalents in the other runtimes are whatever the change breaks. Each - must be read before it is edited, because a test that encodes `min(highest+1, frame)` encodes P6. -- **Verification:** - - P1, P1b and P6 pass in every runtime; - - the full suites: `cargo test` in `cultnet-rs` and `cultmesh-rs`, `dotnet test - tests/GameCult.Networking.Tests`, `npm test` in `cultnet-ts`, the Python suite; - - the TS interop harness across runtimes; - - negative grep: `rg -n "orderedNextSequence|ordered_next_sequence|expectedSequenceIfUninitialized|expected_sequence_if_uninitialized|SkipReceivedNonChannel|skip_received_non_channel" src packages` - returns nothing; - - mutation testing on the diff: cargo-mutants, Stryker.NET, StrykerJS, and mutmut for Python. -- **Ledger.** Per runtime about −70 and +40. Across four, about −280 and +160. - -### Cut 1b. A write belongs to the session it was issued in: every ending drops what it owed — in flight (`hands/ack-cut1`) - -- **First.** Failing tests G3 (each ending path: peer Disconnect, local disconnect, timeout, refusal) in - C#, TS and Python, and local disconnect and timeout in Rust; G3R in Rust; and in every runtime, a flush - started before a timeout or disconnect fails even after a later `connect()` succeeds. -- **The owner.** Each session gets a generation counter and one private primitive that ends the current - generation: not connected; pending and queued dropped; generation advanced. In Rust it also invalidates - the generation's receipts (by the scope today; Cut 4 moves that into the receipts). It does not touch - what was received from the peer (§8). -- **Deletes first:** - - Rust: the inline clearing in the Disconnect branch (`rudp.rs:596-615`) and the scope rotation in - `reset_peer_state` (`:345`), both replaced by the primitive; the receipt doc that says local endings - carry writes (`rudp.rs:128-133`); the flush's scope capture (`:2001-2014`), replaced by the generation; - - TS: the transport's `#generation`, `#endedGeneration`, `#endGeneration` (`rudp.ts:826-831`, - `:937-940`) and their uses in `connect` (`:895`), `flush` (`:913-920`), `checkTimeout` (`:949`), - `#endRefusedSession` (`:976`), the server Connect path (`:1037-1046`) and the disconnect result - (`:1084`). `#endedReason` stays, as the report of why; - - C# and Python: flush's reliance on the disconnect reason (`CultNetTransport.cs:1924-1929`, - `transport.py:1110-1116`). The reason stays, as a report. -- **Adds:** - - the primitive, called from every ending path: - - peer Disconnect: `CultNetTransport.cs:1190-1199`, `rudp.rs:596-615`, `rudp.ts:406-414`, - `transport.py:619-625`; - - local disconnect: `CreateDisconnect` `CultNetTransport.cs:1324-1328`, `create_disconnect` - `rudp.rs:771-781`, `createDisconnect` `rudp.ts:508-515`, `create_disconnect` `transport.py:695-697`; - - timeout, when it fires: `CultNetTransport.cs:1333-1348`, `rudp.rs:783-796`, `rudp.ts:517-526`, - `transport.py:699-706`; - - refusal, through `ResetPeerState` (`CultNetTransport.cs:1292-1308`, `rudp.rs:344-358`, - `rudp.ts:214-227`, `transport.py:458-471`); - - a read-only generation on each session, which each transport's flush captures at start and fails on - when it changes (`CultNetTransport.cs:1914-1945`, `rudp.rs:1992-2040`, `rudp.ts:911-935`, - `transport.py:1101-1124`). -- **Hubs.** The Rust hub and the C# listener already discard a peer's session at its end, so its owed - writes die with it. No change. -- **Tests changed:** `tests/rudp_stray_packets.rs:638-690` keeps passing unchanged (peer Disconnect already - obeys the rule). Tests that expect a write to survive a local disconnect or timeout encode the old rule; - read each before editing. -- **Verification:** - - G3 in every runtime: after every ending path, owed 0 and nothing delivered to the next session; - - G3R: the receipt reads `Invalidated` after `disconnect()`, and stays so after reconnecting; - - the flush test above in every runtime; - - negative: `rg -n "endedGeneration|#endGeneration" packages/cultnet-ts/src` returns nothing; a TS - reconnect from inside a `disconnect` listener still cannot make an ended flush succeed (the case the - TS generation pair was written for, `rudp.ts:826-829`); - - mutation testing on the diff. -- **Ledger.** About −40 and +45 across four runtimes. TS is net negative. - -### Cut 1c. A Connect's sequence says whether it repeats — ready after Cut 1b (Q-A6 A, ruled) - -Pass 3 changes against pass 2: the ruling; three more servers that decide repeats themselves (the -cultmesh-rs and cultmesh-ts document servers and the cultmesh-py server); the second C# default of 100; -the exact default mechanism; the full caller survey; `cultmesh-rs/src/lib.rs:1409` removed from the -production list (it is inside `#[cfg(test)]`, which starts at `:1140`). - -- **Repo/branch:** CultLib `hands/ack-cut1c` from `main` after Cut 1b merges. It uses the ending primitive. -- **First.** Failing tests: - - G1: a restarted client on the same address, connection id and payload is admitted and its frames - delivered, against the Rust hub, the C# listener, server mode in every runtime, the cultmesh-rs - document server (same `(addr, connection_id)` key), the cultmesh-ts document server (same payload) and - the TS operation service; - - G1b: C# and Python server mode admit a Connect from a new endpoint; - - G2: after `connect()` on the same session, a new server session's frames are delivered; - - a stale Accept (its ack field does not name the pending Connect) is ignored; - - the cultmesh-py server and Rust server mode: a *retransmitted* Connect after data has flowed does not - reset the session (today both reset on every Connect). -- **Deletes first:** - - the transport-level repeat decisions: Rust server mode's reset on every Connect (`rudp.rs:2105-2116`); - the Rust hub's payload comparison (`rudp.rs:1637-1647`); C# server mode's `_session.Connected` branch - (`CultNetTransport.cs:2226-2236`); the C# listener's payload comparison (`:2670-2688`); TS's - `movedEndpoint`/`repeated` computation (`rudp.ts:1011-1049`) and the session's `connected` shortcut - (`rudp.ts:249-251`); Python's `connected` branch (`transport.py:1047-1053`); - - **new in pass 3:** the cultmesh-rs document server's "same key is a repeat" arm - (`cultmesh-rs/src/rudp_document_server.rs:413-427`, and its comment at `:414-415` that asks a fresh - client to choose a fresh id); the cultmesh-ts document server's `connectPayload` comparison and field - (`cultmesh-ts/src/index.ts:5794-5802`, `:5806`); the cultmesh-py server's replace-on-every-Connect - (`cultmesh-py/src/cultmesh_py/server.py:225-240`); - - C# and Python server mode's refusal of a Connect from a new endpoint (`CultNetTransport.cs:2212-2216`, - `transport.py:1042-1044`), for Connect packets only. -- **Adds:** - - the session records the sequence of the Connect that started its generation. A Connect repeats exactly - when the session is connected and the Connect carries that sequence. `AcceptConnect` answers a repeat - as `AnswerRepeatedConnect` does today, and otherwise ends the generation, resets peer state and accepts - (`CultNetTransport.cs:1012-1059`, `rudp.rs:383-435`, `rudp.ts:242-290`, `transport.py:485-525`). - `AnswerRepeatedConnect` stops being public; a public predicate `ConnectRepeats(packet)` / - `connect_repeats` / `connectRepeats` serves servers that keep one session per peer and must report the - old one ended (the Rust hub `rudp.rs:1648-1690`, the C# listener `:2690-2710`, the cultmesh-rs document - server). Servers that only call `AcceptConnect` (TS operation service `operation-service.ts:167-175`, - cultmesh-ts document server, cultmesh-py server, server-mode transports) need nothing else: the session - decides; - - `CreateConnect` on a session that has had a peer ends the generation and resets peer state first - (`CultNetTransport.cs:1001-1010`, `rudp.rs:369-381`, `rudp.ts:229-240`, `transport.py:473-483`); - - on Accept receipt, the session honours it only while its Connect is pending and only if the Accept's - ack field names that Connect (`CultNetTransport.cs:1155-1160`, `rudp.rs:544-557`, `rudp.ts:379-390`, - `transport.py:601-604`); - - **the default initial sequence is drawn from a CSPRNG in [1, 2^31)** wherever a default is produced. - The option types do not change (`u32`/`uint`/`number`/`int`), so every explicit value keeps working and - no caller changes code. One private helper per runtime: - - Rust: `rand::random_range(1..1u32 << 31)` (`rand` 0.9 is already a dependency, - `packages/cultnet-rs/Cargo.toml`), in `CultNetRudpSessionOptions::default` (`rudp.rs:108`), - `CultNetRudpSocketTransportOptions::client()` (`:1310`) and `::server()` (`:1329`), - `CultNetRudpServerHubOptions::new()` (`:1377`), and `CultMeshRudpSocketOptions::default` - (`cultnet-rs/src/cultmesh.rs:259`); - - C#: `RandomNumberGenerator.GetInt32(1, int.MaxValue)` as the property initializer at - `CultNetTransport.cs:773`, `:850`, **`:2404` (the listener's 100)**, **`CultNetRudpSchemaServer.cs:48` - (a second 100)** and `src/GameCult.Mesh/CultMesh.cs:547`; - - TypeScript: `crypto.getRandomValues` in the session constructor's `options.initialSequence ?? …` - (`rudp.ts:172`); `cultmesh-ts`'s optional `initialSequence` (`index.ts:4336`) already falls through to it; - - Python: `secrets.randbelow(2**31 - 1) + 1` as a `default_factory` at `transport.py:298`, `:323`, and - as the default of `client.py:396` and `cultmesh-py/src/cultmesh_py/facade.py:160`, `:194` (keyword - defaults become `None` meaning "draw one", since a Python default is evaluated once); - - Kotlin: in Cut K2; - - CultLib's own production pins take the default: `cultmesh-rs/src/lib.rs:964` (client), - `cultmesh-rs/src/rudp_document_server.rs:432` (server). The two TS publishers - (`signed-daemon-health.ts:116`, `idunn-odin-presence-publisher.ts:103`) lose theirs in whichever of - Cut 5 or Cut 1c lands second. Interop peers, the parity tool and tests keep explicit values on purpose. -- **Tests changed** (they encode payload identity or a fixed default): - - `packages/cultnet-rs/tests/rudp_server_hub.rs:171-240` calls `connect()` again with the same payload - and expects no replacement; under this rule that is a new generation. Its retransmit half stays covered - by `rudp_stray_packets.rs:487`; - - `tests/GameCult.Networking.Tests/NetworkingTests.cs:1507` (replace a peer by payload) passes but must - say it relies on distinct sequences; `:700` asserts `PendingReliableSequences == {100}` against the - listener's default; - - asserts that may rely on a default Connect sequence of 1 (read each; pin `1` in the test if it does not - already): `cultnet-rs/tests/cultnet.rs:574`, `:820`, `:851-852`; `cultnet-ts/test/cultnet.test.ts:472`, - `:576`, `:592-593`, `:1536`; `cultnet-py/tests/test_cultnet.py:596`, `:764`, `:779-780`; - - `cultnet-ts/test/cultnet.test.ts:2225` (another endpoint replaces the peer) stays. -- **Verification:** - - G1 (every server above), G1b, G2, the stale Accept and the retransmitted-Connect tests; - - the retransmit tests stay green: `rudp_stray_packets.rs:487`, `:511`, `NetworkingTests.cs:1483`, - `cultnet.test.ts:2119`, `:2134`, `test_cultnet.py:1134`; - - a test per runtime that two default-constructed sessions draw different initial sequences in [1, 2^31); - - negative grep: `rg -n "SequenceEqual\(connectPayload\)|connect_payload == packet.payload|movedEndpoint|byteArraysEqual\(record.connectPayload" src packages` - returns nothing; - - mutation testing on the diff. -- **Ledger.** About −90 and +85 across four runtimes and the three servers. Two CultLib production pins go. - -**Pin-bump notes: callers outside CultLib that pin an initial sequence** (survey, 2026-09-30). A pinned -value keeps a deterministic Connect sequence. It matters on a **client that can restart on the same -endpoint and connection id** (the G1 lockout survives there); on a server or a one-shot client that binds a -fresh port per run it only forgoes the stale-packet protection. Recommendation at each repo's bump: delete -the value. - -| Repo@rev | Site | Value | Role | Consumes CultLib by | -|---|---|---|---|---| -| Muninn@`bd69598` | `crates/muninn-daemon/src/main.rs:5975`; `crates/sleipnir-daemon/src/main.rs:2223` | 1 | client (restart risk) | Cargo pin `c2a9a6e5` | -| Muninn@`bd69598` | `muninn-daemon/src/main.rs:780`, `:5426`; `sleipnir-daemon/src/main.rs:2265` | 1 | server-mode transports | same | -| Gjallar@`6a92976` | `src/Gjallar/Program.cs:431`, `:2955`, `:3027` | 1 | clients (restart risk) | sibling `../CultLib` | -| Mimir@`d88f2e6` | `src/Mimir.EveBrowserReference/Program.cs:682`, `src/Mimir.EveDashboard/Program.cs:801` | 1 | clients (restart risk) | sibling ProjectReference | -| Mimir@`d88f2e6` | `src/Mimir.CultMeshMedia/Program.cs:485` | `peers.Count + 1` | hand-rolled per-peer relay; its Connect handling (`:365`) is its own repeat decision | same | -| weksa@`d16cff5` | `scripts/weksa-rudp-command.cjs:88` (per-peer server loop), `:192` (client); `scripts/idunn-rudp-health.cjs:43` | 1 | server loop and clients | sibling CultLib | -| Bifrost@`42728a6` | `tools/persona-discord-rudp.mjs:9` | 100 | server-mode transport | sibling CultLib | -| Heimdall@`e8e2832` | `src/idunn-rudp-health.ts:65` | 1 | one-shot health client (replaced at its bump, § Sequencing) | submodule `b6b1d9c` | -| Stonks@`981c484`, Vili@`8773c15`, VoidBot@`46d891b` | `src/idunn-rudp.cjs:43`, `scripts/idunn-rudp.cjs:33`, `scripts/publish-idunn-rudp-health.cjs:64` | 1 | one-shot health clients | `file:../CultLib` / sibling path | - -- None in Odin `origin/main` (`5c37860`; Sleipnir now lives in Muninn), Idunn, Hermodr, CodexConnector, - Ghostlight, Huginn or Epiphany. StreamPixels' own code has none; its vendored CultLib (`542ddd3`, at - StreamPixels `a49f7d8`) carries CultLib's own old defaults and pins, which a bump replaces. -- Sibling-path consumers (Gjallar, Mimir, Stonks, Vili, VoidBot, weksa, Bifrost) take the random default - as soon as the local CultLib checkout moves; their pinned sites do not change behaviour until edited. - -### Cut 2. A reliable fragment set is never evicted (receiver: Rust, TS, Python) — in flight (`hands/ack-cut2`); merges with Cut F - -- **First.** Failing test P5 in each of the three. -- **Change:** - - eviction picks the stalest set that holds no reliable fragment (`rudp.rs:1027-1047`, - `rudp.ts:679-692`, `transport.py:836-842`); - - if every pending set holds reliable fragments, the packet is refused and the session ends, as every - other refusal does; - - ~~a conforming sender cannot reach that bound~~ (false for an expiring sender; see the pass 3 - correction below): its window keeps at most 32 reliable packets in flight, and the bound is 64 sets. -- **Verification:** P5 delivers the reliable frame. `d3bc6e66`'s test (lossy fragment sets keep being - evicted) stays green. `fragment_sets_evicted` still counts evictions. -- **Ledger.** About −3 and +12 per runtime. -- **Pass 3 correction (from the Cut 2 Soul pass on `hands/ack-cut2` `c5026aa1`).** The third bullet above - is false for an expiring sender: the default Rust `media` channel expires reliable packets after 75 ms, - a frame's first fragment is delivered and acked, the rest are dropped by the deadline, and the set is - pinned; Soul's `soul_probe_expiring_reliable_frames_kill_receiver` (`packages/cultnet-rs/tests/cultnet.rs:3192` - at `3be97b34`) refuses the session on the 65th such frame, where eviction used to keep it alive. - - **What changes for the branch in flight:** nothing in its code. Its rule is right for every sender that - does not expire. What changes is when it merges: **Cut 2 merges only together with Cut F**, which - releases dead sets through notices and (Q-A9 B) keeps pre-Cut-2 eviction for a peer that never sends - notices. Soul's probe becomes Cut F's regression test. - - C# (`CultNetTransport.cs:921`) and Kotlin (K:2645) have no fragment-set bound at all. **Cut 2c** brings - C# to the same bound; Kotlin gets it in K1 and the gated rule in K3. - -### Cut 2c. C# gets the fragment-set bound (with Cut 2's rule) — merges with Cuts 2 and F - -- **Why in this campaign.** Rust, TS and Python bound pending fragment sets at 64 (`d3bc6e66` lineage; - `rudp.rs:328-340`, `rudp.ts:167`, `transport.py:430`); C# keeps `_fragmentBuffers` - (`CultNetTransport.cs:921`) unbounded, so a hostile peer grows it without limit and the parity doc cannot - call C# conforming. A runtime missing a sibling's protection is a gap to fill. -- **Repo/branch:** CultLib `hands/ack-cut2c`, from `main`; merges together with Cuts 2 and F (the gate - lives in F). -- **First.** C# ports of `d3bc6e66`'s lossy-set test and Cut 2's P5 test. -- **Adds:** `MaxPendingFragmentSets` (default 64, a setter as Rust's `set_max_pending_fragment_sets`, - `rudp.rs:328-340`), a per-set last-touched ordinal, eviction of the stalest set in `Reassemble` - (`:1556`), `FragmentSetsEvicted` beside `OutstandingReliablePacketCount` (`:993`), and Cut 2's rule (a - reliable set is not evicted; if every set is reliable the session is refused through `EndRefused`), - gated as in Cut F. -- **Verification:** the two tests; `GameCult.Networking.Tests` green; Stryker.NET on the diff. -- **Ledger.** About +45 and 0. - -### Cut 3. The sender's flow window: 1,023 sequences and 4 MiB above the lowest unacked — ready (merge after Cut 1) - -- **First.** Failing tests P8 (all runtimes) and P4 (Rust receiver, with a sender from each runtime). -- **Change.** Admission (`AdmitReliablePackets` / `PromoteQueuedReliable` at - `CultNetTransport.cs:1439-1490`, `rudp.rs:910-942`, `rudp.ts:586-600`, `transport.py:776-796`, - `CultMesh.kt:2856-2872`) admits a packet only while: - - the window has a slot (as today); - - its sequence is at most the lowest unacked sequence plus 1,023; - - the admitted payload bytes above the lowest unacked sequence stay within 4 MiB. - - The first packet is always admissible. Expired packets (Rust) do not count as unacked here: they are - gone from the wire. (Cut F refines this: an abandoned sequence counts while its notice is outstanding, - and stops counting once acknowledged or stranded.) -- **Contract.** `docs/cultnet-transport-parity.md` (after `:263`) states the receive window: a receiver - must hold 1,023 sequences and 4 MiB beyond its contiguous watermark, and a conforming sender never - exceeds that. -- **Verification:** - - P8: `g` is delivered; - - P4: the 5 MiB frame is delivered after the gap fills, and the session is never refused; - - the existing large-snapshot tests (`c398bad1`) and the fragment interop stay green; - - the Rust benchmark or media probe shows no throughput change on a lossless link. -- **Ledger.** About +25 per runtime, and nothing removed. - -### Cut D. A failed send to one peer is that peer's lost datagram (Rust document server and hub, C# listener, cultmesh-py server) — ready - -Design in §10. Independent of every other cut; it touches send sites, not session logic. - -- **Repo/branch:** CultLib `hands/ack-cutD` from `main` at `e9469cb3` or later. Merge cost only with Cut 1c - in the Rust hub's and C# listener's Connect arms. -- **First.** Failing tests, one per server, each with two peers where every send to peer X fails: - - cultmesh-rs document server: `poll_once` never returns `Err`; peer Y connects, publishes, is acked and - gets a snapshot response; `maintain` returns `Ok`; the failure count rises; X's session ends by idle - timeout, not by the failure. Also: `recv_from` returning `ConnectionReset` is `Idle`; - - Rust hub: `poll_resends` and `receive_event_once` return `Ok` and keep serving Y; - - C# listener: `PollResends` reaches Y after X throws; `TryReceiveOnce` serves Y; - - cultmesh-py server: the RUDP thread survives an `OSError` from `sendto` and keeps serving Y. - - **The failure seam.** The Rust servers own a `UdpSocket` directly, and a loopback peer's address - cannot be made to fail on demand. Add the narrowest seam at the one send function: a - `#[cfg(test)]` set of addresses whose sends fail with a synthetic `io::Error` in `send_packet` - (`rudp_document_server.rs:628-632`, `rudp.rs:1799-1804`), and the same as an internal test hook in C# - `SendPacket` (`CultNetTransport.cs:2829-2834`). Python patches `socket.sendto` in the test. A socket - port trait would be the full dependency-injection answer; it is not bought here, because the only - consumer is this test. -- **Changes:** - - cultmesh-rs `rudp_document_server.rs`: `send_packet` (`:628-632`) keeps encode errors as errors and turns - a `send_to` failure into a counted loss; every per-peer call site drops its `?` on the send - (`:335`, `:373`, `:397`, `:451`, `:587`, `:603`); a `send_failures()` accessor beside `packets_dropped()` - (`:246-248`); `recv_from` treats `ConnectionReset` like `WouldBlock` (`:266-272`), as the socket - transport does (`cultnet-rs/src/rudp.rs:2059-2066`); - - cultnet-rs hub: `send_packet` (`rudp.rs:1799-1804`) likewise, counted in `CultNetTransportStats` - as a new `send_failures` field; `poll_resends` (`:1762-1777`) and the receive path (`:1644`, `:1673`, - `:1708`, `:1711`, `:1714`, `:1759`) stop propagating it; - - C# `CultNetRudpSocketTransportServer`: `SendPacket` (`:2829-2834`) catches `SocketException`, counts it - in `CultNetTransportStats.SendFailures`, and returns; `PollResends` (`:2796-2805`) and `TryReceiveOnce`'s - sends (`:2687`, `:2704`, `:2749-2750`, `:2788`) need no other change; the refusal goodbye's own catch - (`:2739-2745`) becomes redundant and goes; - - cultmesh-py `server.py`: `_send_rudp_packet` (`:302-307`) catches `OSError` and counts it. -- **Not changed:** single-peer socket transports (a failed send already concerns only their one peer, and - their callers, flush among them, get the error); `cultmesh-ts`'s document server (Node reports send - failures on the socket's `error` event, `index.ts:5858`). -- **Authority map:** - - Owner: each peer's session owns its peer's fate (idle timeout, lifetime, refusal, Disconnect). A send - failure is an input to nothing but a counter. - - Forbidden writers: no per-peer send failure ends a poll, a loop, a thread or another peer's session. -- **Verification:** - - the four tests; the full `cultmesh-rs`, `cultnet-rs`, `GameCult.Networking.Tests` and `cultmesh-py` - suites; - - negative: `rg -n "send_packet\(.*\)\?;" packages/cultmesh-rs/src/rudp_document_server.rs` returns - nothing (the pattern matches only a propagated send; encode errors propagate inside `send_packet`); - - operator: an Odin build on this CultLib keeps serving presence while one peer's address is - unroutable. -- **Ledger.** About −15 and +40 (the counters, the seams); tests about +120. - -### Cut F. Abandonment notices (FORWARD-TSN, RFC 3758): every receiver honours one, Rust sends one — blocked on Cuts 1 and 3, on Q-A7 and Q-A9; merges together with Cut 2 - -Design in §6. Mapped for the recommended answers to Q-A7 (one sequence per notice) and Q-A9 (a capability -bit gates Cut 2's rule). - -- **Repo/branch:** CultLib `hands/ack-cutF` from `hands/ack-cut2` rebased on `main` after Cuts 1 and 3 - merge. Cut 1 gives the receiver `R` and the drain the notice feeds; Cut 2 is the fragment code the notice - drops sets from and the rule the capability gates; Cut 3 is the admission rule the notice amends. Cut 2 - and Cut F merge to `main` together (the Cut 2 Soul finding: Cut 2 alone refuses sessions with expiring - senders). Kotlin's half is Cut K3. -- **First.** Failing tests, in C#, Rust, TypeScript and Python: - - the golden: `Abandon` encodes to the §6 bytes and decodes back, beside the existing fixture - (`packages/cultnet-rs/tests/cultnet.rs:319`, `packages/cultnet-ts/test/cultnet.test.ts:311`, - `packages/cultnet-py/tests/test_cultnet.py:409`, `tests/GameCult.Networking.Tests/NetworkingTests.cs:366`); - - a type-4 packet with bit 4 decodes as `Abandon`; bit 4 on a Data packet is still ignored; - - receiver: a notice for a hole advances `R`, drains the held ordered frames behind it, and replies with - the ack for that sequence (FN, inverted: `[o3]` delivered by the notice, `o2` marked received); - - receiver: a notice for a received sequence changes nothing and still replies; - - receiver: a notice inside an incomplete fragment set's span drops the set; - - receiver: a notice more than 1,024 above the highest received is ignored with no reply; - - receiver: the late original after its notice is a duplicate: acked, not delivered; - - Rust sender: an expired pending or queued packet produces a notice at once and then once per resend - delay; an ack naming the sequence stops it; after 16 unacknowledged sends it stops and records - `first_stranded`; every ending clears both; - - Rust end to end (P2 inverted): reliable `media` with expiry is lost, then ordered `s1`, `s2` on - `schema`; after the notice both are delivered and `flush_reliable` succeeds; - - Rust, Cut 3's window: an outstanding notice holds the lowest-unacked point; a stranded sequence does not. - - **the Cut 2 regression**, from Soul's `soul_probe_expiring_reliable_frames_kill_receiver` - (`packages/cultnet-rs/tests/cultnet.rs:3192` at `3be97b34`): 100 default `media` frames, each losing - every fragment but the first. With a Cut F sender the receiver's pending sets return to zero after the - notices and the session lives; with a stand-in sender that never advertises the capability nor sends - notices (Q-A9 B), the receiver evicts the stalest set as before Cut 2 and the session lives; with a - capable peer that stops sending notices, the 65th pinned set still refuses (Cut 2's rule holds where it - is true); - - (Q-A9 B) a Connect or Accept with flag bit 4 records the peer as capable; one without does not; an old - peer's accept of a flagged Connect works (the pass 3 CAP probe, kept as a test). -- **Deletes first:** - - Rust: the plain drop in `purge_expired_reliable` (`rudp.rs:898-908`; the `retain` calls become a move - into the abandoned set; the expired counter stays); - - the doc on `reliable_expire_after_ms` (`rudp.rs:121-124`), which says the packet is dropped; - - `packages/cultnet-py/README.md:103-111`'s claim that the Python session has reliable expiry. It has - none; the claim is false today. -- **Adds, receiver (C#, Rust, TS, Python):** - - the `Abandon` packet type and its codec mapping: Rust enum `rudp.rs:51-59`, `encode_flags` - `:2466-2475`, `packet_type_to_code` `:2477`, `decode_rudp_packet` `:2448-2455`, `packet_type_from_code` - `:2571`; C# `CultNetRudpPacketType` (`CultNetTransport.cs:509`) and `CultNetRudpPacketCodec` `EncodeFlags` `CultNetTransport.cs:697`, - `Decode` `:676-690` (its `Enum.IsDefined` check runs on the wire byte, which stays 4); TS union - `rudp.ts:26` and the codec at `:1264-1320`; Python enum `transport.py:250`, code table `:342`, codec - `:1175-1240`; - - the `Abandon` branch in `receive`, after acks are applied and beside the Ack/Pong branch - (`rudp.rs:578-592`, `CultNetTransport.cs:1177-1187`, `rudp.ts:397`, `transport.py:612`): the range - check, the fragment-set drop over the fragment buffers (`rudp.rs:1001-1114`, `CultNetTransport.cs:1556`, - `rudp.ts:643`, `transport.py:825`), `RememberReceived` and the Cut 1 drain, and - `reply = create_ack_for_received(sequence)` (`rudp.rs:751-758`, `CultNetTransport.cs:1273-1279`, - `rudp.ts:492-497`, `transport.py:685`); - - transports skip their own ack for an `Abandon` packet where a delivering receive would draw one: - `rudp.ts:1089`, `transport.py:1087` (Rust and C# ack only reliable, Accept or Data and need nothing). -- **Adds, capability (Q-A9 B; all four runtimes):** - - `Connect` and `Accept` carry flag bit 4; the session records whether its peer's handshake packet had - it, and resets that with peer state. `create_connect` (`rudp.rs:369-381`, `CultNetTransport.cs:1001-1010`, - `rudp.ts:229-240`, `transport.py:473-483`) and `accept_connect` (`rudp.rs:383-411`, - `CultNetTransport.cs:1012-1059`, `rudp.ts:242-290`, `transport.py:485-525`) set it; the codec keeps bit 4 - on those types (today every decoder drops it); - - the fragment-set bound (Cut 2's eviction, `rudp.rs:1027-1047`, `rudp.ts:679-692`, `transport.py:836-842` - as Cut 2 leaves them) applies the never-evict rule to a capable peer and the pre-Cut-2 stalest-set - eviction to one that is not; - - the sender sends notices only to a capable peer; for another, an expired sequence is stranded at once. -- **Adds, sender (Rust):** - - `RUDP_ABANDON_NOTICE_SENDS = 16` beside `DEFAULT_MEDIA_RELIABLE_EXPIRE_AFTER_MS` (`rudp.rs:39`); - - the abandoned set and `first_stranded` on the session (fields beside `pending_reliable`, - `queued_reliable`, `rudp.rs:193-195`); - - `purge_expired_reliable` moves expired pending and queued sequences into the set; - - `due_resends` (`:797-807`) emits due notices after due packets and retires entries at the bound; - - `apply_acknowledgements` (`:964-971`) removes named sequences from the set; - - `outstanding_reliable_packet_count` (`:270-272`) counts the set; - - the ending primitive (Cut 1b) and `reset_peer_state` (`:344-358`) clear both; - - Cut 3's lowest-unacked point (in `admit_reliable_packets` / `promote_queued_reliable`, `:910-942` as - Cut 3 leaves them) includes the set and excludes `first_stranded`. -- **Docs.** `docs/cultnet-transport-parity.md`: the packet-type and flags rows of the header table - (`:486-487`) gain "`4` with flag bit 4 = abandon"; the golden goes after the canonical fixture - (`:505-517`); the bounded-send paragraph (`:260-273`) gains the notice rule and its resend bound. -- **Authority map:** - - Owner: the sender session's abandoned set decides which sequences are announced and for how long; the - receiver session decides what a notice changes. - - Inputs: expiry (sender); a decoded `Abandon` packet in the current generation (receiver). - - Outputs: notices (sender); a remembered sequence, drained frames and one ack (receiver). - - Derived: `first_stranded` is derived from the set's retirements and read by Cut 4; the expired counter - is report-only. - - Forbidden writers: nothing marks a sequence received without a packet except the `Abandon` branch; no - notice outlives its generation; no transport interprets bit 4. - - Shared paths: data, fragments, duplicates, Accept and notices all advance `R` through - `RememberReceived` and the one drain. - - Deletion line: the plain drop in `purge_expired_reliable` goes before the set is added. -- **Verification:** - - the tests above in four runtimes; - - FN re-run against the new build as a **negative** check: an old-receiver stand-in (a session that drops - `Abandon` packets) never acks, and the Rust sender strands the sequence after 16 sends; - - the TS interop harness gains one lane: a Rust peer sends reliable `media` with expiry whose first - packet the bridge drops, then ordered `schema` frames; the TypeScript receiver delivers the `schema` - frames (`packages/cultnet-ts/test/interop/cultnet-interop.test.ts`, beside the drop lanes); - - negative grep: `rg -n "first_abandoned" packages src` returns nothing (pass 2's name must not appear); - - mutation testing on the diff. -- **Ledger.** Receiver about +40 per runtime (four); Rust sender about +60 and −6; the golden about +25 - per runtime; docs +15 and −8 (the false Python claim). - -### Cut 4. Rust receipts: Acknowledged means delivered, resolved once; honest stranding and queueing — blocked on Cuts 1, 1b and F - -Re-mapped in pass 3 for Q-A3 B. Pass 2's `first_abandoned` (every expiry leaves a permanent hole) is -replaced by Cut F's `first_stranded` (only a notice the receiver never acknowledged leaves one). The rest is -pass 2's cut. - -- **Repo/branch:** CultLib `hands/ack-cut4` from `main` after Cuts 1, 1b and F merge. -- **First.** Failing tests: - - P3: the receipt for `s2` reads `Pending` until `s1` arrives, then `Acknowledged`; - - P9 (`send_reliable` on an expiring channel is refused) and P10 (a window-full `send_reliable` returns a - receipt covering its queued packets, and the frame is sent once); - - a receipt `Acknowledged` before any ending stays `Acknowledged` after the ending and after a reconnect; - a `Pending` one becomes `Invalidated`; - - **P2 with a new receiver:** reliable `media` with expiry is lost, then ordered `s1`, `s2`; their - receipts read `Pending` while the notice is outstanding and `Acknowledged` once it is acked; - `flush_reliable` succeeds; - - **P2 with a deaf receiver** (a stand-in session that drops `Abandon` packets): after 16 notice sends the - receipts for `s1` and `s2` stay `Pending`, `flush_reliable` fails at once naming the stranded sequence, - and the ending makes them `Invalidated`. -- **Deletes first:** - - the session scope (`rudp.rs:135`, `:179`, `:206`, `:283-285`, the check at `:291-293`, and what Cut 1b - left of the rotations); - - the stale comment and the half guard at `rudp.rs:1915-1920`; - - the hand-rolled status loops at `cultmesh-rs/src/lib.rs:806-838` and `:870-900`. -- **Adds:** - - the receipt's status cell and ordered flag; the session's unresolved-receipt list, resolved once - (Acknowledged through `A`, Invalidated by the ending primitive); - - `acked_through` (`A`): one below the lowest sequence that is pending, queued, in Cut F's abandoned set, - or `first_stranded`; - - the receipt covers queued sequences: `send_many` (`:456`) reports every sequence it assigned; - - `send_reliable` (`:1903-1930`) refuses a channel with expiry; - - `wait_acknowledged`; - - `flush_reliable` fails fast when an ordered send follows `first_stranded`, and otherwise waits while - notices are outstanding (Cut F counts them in `outstanding_reliable_packet_count`). -- **Unchanged:** `CultNetRudpReliableSendStatus` (`:146-150`), `reliable_send_status`'s signature - (`:1931`), and every caller of `Acknowledged`: `tests/rudp_stray_packets.rs:309`, - `cultmesh-rs/tests/rudp_document_server.rs:408,415`, CodexConnector, Ghostlight. -- **Changes:** `cultmesh-rs/src/lib.rs:832` and `:892` become `wait_acknowledged`; the doc at `:869` says - delivered to the document server's sink. -- **Verification:** - - the new tests pass; - - `rg -n "session_scope|first_abandoned" packages/cultnet-rs` has no hit; - - both suites are green; - - cargo-mutants on the diff. -- **Ledger.** About −90 (two loops of about 30 each, the scope) and +85 (of which `wait_acknowledged` is - about 35). Five lines smaller than pass 2: the abandoned bookkeeping moved to Cut F. - -### Cut 5. CultLib-internal TS publishers and the contract text (TS, docs) — blocked on Cut 3, and on Cut 1b in TS - -- **Changes:** - - `idunn-odin-presence-publisher.ts:89-215` and `signed-daemon-health.ts:104-162` (with its helpers - `:244-252`, `:273-389`, except the exported `parseEndpoint` `:254-271`) send through - `CultNetRudpSocketTransportConnection`: `connectAndWait`, `send("schema")`, `flush`, `close`. Their - hand-rolled session loops are deleted. The presence publisher keeps rejecting a `cultnet.error.v0` - response, from the transport's `frame` events during the flush. - - `connectAndWait(payload, timeoutMs)` on the TS socket transport, matching C# `ConnectAndWait` - (`CultNetTransport.cs:1859-1880`). It is `cultmesh-ts/src/index.ts:6603-6650` `waitForRudpConnected` moved - down; `createRudpPeer` (`index.ts:6220-6245`) uses it and the cultmesh-ts copy is deleted. - - Flush contract text in all runtimes: "every reliable frame sent in this session has been delivered to - the peer's application". - - `docs/cultnet-transport-parity.md:236-240` (the control-packet guard paragraph) is replaced by the - watermark rule, and `:261-272` gains: Acknowledged means delivered; a write belongs to the session it - was issued in; how a repeated Connect is recognised (after Cut 1c); the receive window (after Cut 3). -- **Verification:** - - `runtime-presence-health.test.ts` and `signed-daemon-health.test.ts` stay green; - - a new test drops the first datagram and still publishes; - - a new test publishes a payload over 32 packets whole; - - a new test shows the publisher's peer receives a Disconnect. -- **Ledger.** About −270 and +50. - -### Cuts 6a, 6b, 6c. Receipts in C#, TypeScript and Python — blocked on Cut 4, and on Cut 1b in each runtime - -Design and public API in §9. One cut per runtime, in parallel worktrees, so Soul can falsify each alone. -Kotlin's is Cut K4. - -- **Repo/branch:** CultLib `hands/ack-cut6a` (C#), `hands/ack-cut6b` (TS), `hands/ack-cut6c` (Python), each - from `main` after Cut 4 merges. Cut 4 is the reference; Cut 1b supplies each runtime's ending primitive. -- **First.** Failing tests, in each runtime: - - an ordered receipt reads Pending while a sequence below it is missing, then Acknowledged (P3); - - a send made while the window is full returns a receipt that covers its queued packets and becomes - Acknowledged; the frame is sent once (P10's shape); - - an Acknowledged receipt stays Acknowledged after the session ends and after a reconnect; a Pending one - becomes Invalidated and stays so; - - the wait returns on Acknowledged, fails at once on Invalidated with the end reason, and times out; - - `SendReliable` on an unreliable channel is refused. -- **Deletes first:** nothing. These runtimes have no receipt code to replace. -- **Adds, per runtime:** - - C# (6a): `CultNetRudpSendReceipt`, `CultNetRudpSendStatus`; in `CultNetRudpSession`, a send path that - records every sequence `SendMany` assigns (`:1075-1140`, before `AdmitReliablePackets` `:1439-1465` - splits them), the unresolved list, resolution after `ApplyAcknowledgements` (`:1503-1516`) and in the - Cut 1b primitive, and `A` over `_pendingReliable` and `_queuedReliable`; on the socket transport, - `SendReliable`, `WaitAcknowledged` (the `FlushReliable` loop, `:1914-1955`), and `SendSchema` - (`:1960`, `:1968`) and `SendSchemaMessage` (`:1976`) returning the receipt; - - TS (6b): `CultNetRudpSendReceipt`, the status union; session changes at `sendMany` (`rudp.ts:304-368`), - `#admitReliablePackets` (`:586-592`), `#applyAcknowledgements` (`:610-617`); transport - `sendReliable` and `waitAcknowledged` beside `send` (`:899-909`) and `flush` (`:911-935`); exported from - the package index; - - Python (6c): `CultNetRudpSendReceipt`, `CultNetRudpSendStatus`; session changes at `send_many` - (`transport.py:538-588`), `_admit_reliable_packets` (`:776-786`), `_apply_acknowledgements` - (`:803-807`); transport `send_reliable` and `wait_acknowledged` beside `send` (`:1000-1009`) and - `flush_reliable` (`:1101-1131`); exported from `cultnet_py`; - - README sections per §9, and the parity doc's flush paragraph. -- **Authority map:** the session owns each receipt's status and writes it once; the transport reads it. - Forbidden writers: no transport, flush or timer writes a status; no receipt resolves from a packet's own - ack when it is ordered (only through `A`). -- **Verification:** the tests above; each runtime's full suite; the Cut 4 behaviour table held side by side - with Rust's (same inputs, same states); negative: in each runtime, the only assignment to the status is - in the session's resolve and end paths (`rg` for the status field outside those two functions returns - nothing); mutation testing on each diff (Stryker.NET, StrykerJS, mutmut). -- **Ledger.** About +90 per runtime, nothing removed; tests about +80 per runtime. - -### Cut 7. Reliable expiry in C#, TypeScript and Python — blocked on Q-A8 and Cut F - -Mapped only for Q-A8's recommended answer A. Each runtime gains Rust's send option (a per-send reliable -expiry, and the profile's `media_reliable_expire_after_ms`, `rudp.rs:2492-2508`) and Cut F's sender half -(the abandoned set, notices at the resend delay, the 16-send bound, `first_stranded`), and its receipts -(Cut 6) refuse an expiring channel. Kotlin's is K5. About +70 per runtime. If Q-A8 is B, this cut is not -built and the parity doc records Rust as the only runtime with partial reliability. - -### Cut K. Kotlin catches up (Q-A5 A, ruled) — K0 ready now; K1-K5 trail the four-runtime cuts - -`CultMesh.kt` (6,332 lines, the only RUDP file) has none of the `3bf1c0c` stray-packet hardening, the P1 -and P6 receiver, no reset path and no repeated-Connect answer. K is split so each step is falsifiable on its -own; the anchors below are `e9469cb3`, where `CultMesh.kt` is identical to `3bf1c0c`. Tests follow the -file's existing pattern (private self-test functions called from `main`, K:3349-3373); moving them out of -the production source is a follow-up. - -**K0. The Kotlin verify image and a Linux build path — ready now.** -- **Stopgap tooling** (`C:\Users\Meta\.claude\skills\eureka\tools\stopgap\`, owned by Self, not a CultLib - commit; it dies with `ygg-verify.sh`): - - `kotlin.Dockerfile`: `FROM eclipse-temurin:21-jdk`; `apt-get install unzip curl`; fetch - `https://github.com/JetBrains/kotlin/releases/download/v2.2.21/kotlin-compiler-2.2.21.zip` (the - version CI pins, `.github/workflows/cultnet-interop.yml:20`) into `/opt/kotlinc`; `ENV - KOTLIN_HOME=/opt/kotlinc PATH=/opt/kotlinc/bin:$PATH`; Node 24 copied from the `node:24` image - (`COPY --from=node:24 /usr/local /usr/local`) for the interop lane; PowerShell 7 (`pwsh`, Microsoft's - Debian package) for `build.ps1`. Pass 3's probe proved the JDK-plus-compiler half: the self-tests exit 0 - (K-img). - - `ygg-verify.sh`: a `kotlin` image alias tagged by that Dockerfile's hash, and the remote build step - generalised from `eureka-verify-rust:*` to `eureka-verify-:*` built from `.Dockerfile` - (today it builds and copies only `rust.Dockerfile`). Edit it by writing a temp file and renaming it over - the old one (the script's own rake). -- **CultLib:** - - `packages/cultmesh-kotlin/build.ps1` becomes portable PowerShell 7: `kotlinc` or `kotlinc.bat` by - platform, `[IO.Path]::PathSeparator` for the classpath, `java` or `java.exe`. One build script stays the - one owner of the Kotlin build; no `build.sh` beside it. - - The interop harness calls `pwsh` off Windows instead of `powershell` - (`packages/cultnet-ts/test/interop/cultnet-interop.test.ts:2799-2809`); it already picks `java` off - Windows (`:31-36`). - - `docs/cultnet-transport-parity.md` says, beside the runtime table (`:35`) and the RUDP claims - (`:205-273`), that Kotlin lacks the `3bf1c0c` hardening and the ack cuts until Cut K lands, so nobody - reads it as conforming meanwhile. -- **Verification:** `ygg-verify.sh kotlin 'cd packages/cultmesh-kotlin && pwsh -File build.ps1'` - runs the self-tests green; the interop lane - (`node --test --test-name-pattern "TypeScript and Kotlin|Kotlin and TypeScript"`) runs green on the same - image; CI on `windows-latest` stays green with the same script. - -**K1. Stray-packet parity** (the `3bf1c0c` behaviours the other four have, each with its test ported from -the C# or TS equivalent): -- a malformed, foreign-id or refused datagram is dropped and counted, never thrown out of `receiveOnce` - (K:3223, K:3228, K:2724, K:2567), with a `packetsDropped` stat (`CultNetTransportStats`, K:774); -- the connection id is checked before the endpoint is claimed, and in server mode only a Connect claims it - (K:3217-3222); -- `resetPeerState` and `endRefusedSession`: the refusing session resets, builds its goodbye after the - reset, and a server forgets its endpoint; a refused packet's sequence is remembered only after - reassembly accepts it (today K:2758 remembers before `reassemble` can throw, K:2915-2922); -- `answerRepeatedConnect`: a repeated Connect queues nothing and is answered with the owed Accept or an Ack - (today every Connect makes a new tracked Accept, K:2667-2676, K:3224-3226, K:4005-4006); -- a Connect from another endpoint replaces the peer in server mode (today ignored, K:3220-3221); -- a peer's Disconnect drops owed writes (K:2745-2748); `connect` clears a stale disconnect reason (K:3106); -- `initialSequence` of `0xFFFFFFFF` is refused at construction (K:2631) and sequence exhaustion is checked - before allocation (K:2841, K:2698-2711); -- the decoder's negative declared length (K:3306-3317) is an ordinary malformed drop; -- the fragment-set bound of 64 (K:2645, K:2911-2937 has none): the stalest set is evicted. Cut 2's - never-evict rule and its capability gate come in K3 with Cut F, as in the other runtimes. - -**K2. Cuts 1, 1b and 1c.** -- Cut 1: delete `orderedNextSequenceByChannel` (K:2643), `expectedSequenceIfUninitialized` (K:2728), - `deliverOrdered`/`drainOrdered`/`skipReceivedNonChannelSequences` (K:2939-2978); seed `R` in - `acceptConnect` (K:2667) and on Accept (K:2730); advance in `rememberReceived` (K:2891). -- Cut 1b: the ending primitive from a peer's Disconnect (K:2745), `createDisconnect` (K:2809), - `checkTimeout` (K:2814-2819) and K1's refusal; `flushReliable` (K:3186-3205) binds to the generation. -- Cut 1c: random defaults at K:2600, K:2996, K:3078 and the server's 100 at K:3016 - (`java.security.SecureRandom`); the session's repeat decision replaces server mode's - every-Connect `acceptConnect` (K:3224-3226) and the interop server's (K:4005); `createConnect` (K:2660) - and the Accept check (K:2730). - -**K3. Cuts 2, 3 and F.** Admission at K:2856-2872 gains the flow window; Cut 2's reliable-set rule behind -the Q-A9 capability; the `Abandon` type and receiver branch (K:2556-2567, K:2738, K:3296-3325), the golden beside `rudpPacketCodecUsesDeterministicReliableOrderedFixture` -(K:5236), and the transport skips its own ack for `Abandon` (K:3243-3245). - -**K4. Receipts** (§9): `sendReliable`, `CultNetRudpSendReceipt`, `CultNetRudpSendStatus`, `waitAcknowledged`, -at `sendMany` (K:2685-2719), `admitReliablePackets` (K:2856-2862), `applyAcknowledgements` (K:2882-2889) -and the transport (K:3132-3205). - -**K5. Reliable expiry and the notice sender** — only if Q-A8 is A (as Cut 7). - -- **Verification, each step:** the Kotlin image's self-tests and interop lane; the step's ported tests; - a negative grep per step (K2: `rg -n "orderedNextSequenceByChannel|expectedSequenceIfUninitialized" - packages/cultmesh-kotlin` returns nothing). -- **Ledger.** K1 about +180 and −40; K2 about −70 and +90; K3 about +80; K4 about +90; K5 about +70. - - -## Subtraction ledger (estimate) - -| Cut | Removed | Added | Other | -|---|---|---|---| -| 1 | ~280 | ~160 | four per-channel state machines become one watermark | -| 1b | ~40 | ~45 | one ending primitive; TS's transport generation pair goes | -| 1c | ~90 | ~85 | seven transport- and server-level repeat decisions become one session predicate; two CultLib production pins go | -| 2 | ~10 | ~35 | none; merges with Cut F | -| 2c | 0 | ~45 | C# gains the fragment-set bound the other runtimes have | -| 3 | 0 | ~100 | a documented receive-window contract | -| D | ~15 | ~40 | one failure counter in three servers; a test seam at each send function | -| F | ~15 | ~350 | **wire: one packet type (`Abandon` = Ack + flag bit 4) and one golden in every runtime; with Q-A9 B, the same bit on Connect and Accept**; the false Python expiry claim goes | -| 4 | ~90 | ~85 | one public method (`wait_acknowledged`); the session scope goes; no enum change | -| 5 | ~270 | ~50 | two hand-rolled RUDP loops and cultmesh-ts's connect wait go | -| 6a-6c | 0 | ~270 | public receipt API in three runtimes (Q-A4 A) | -| 7 | 0 | ~210 | only if Q-A8 is A: partial reliability in three runtimes | -| K0-K5 | ~110 | ~510 | Kotlin reaches parity; one verify image; `build.ps1` portable | - -- **Source, net, four runtimes without Cut 7:** about +325. Pass 2 was about −195; the rulings bought - FORWARD-TSN with its capability gate (about +335 net) and receipts in three runtimes (about +270); Cut D - adds about +25 and Cut 2c about +45. Tests are extra: - about 4 × 25 rule tests. -- **Kotlin:** about +400 net, most of it the `3bf1c0c` hardening the other four already carry. -- **Wire:** one additive packet type, ignored by every current peer (FN). -- **Downstream:** no caller has to change code at its next bump. Callers that pin `initial_sequence` keep - a deterministic Connect sequence until they drop the value (Cut 1c, pin-bump notes). - - -## Operator questions - -Each question stands alone. - -**Ruled (2026-09-30), recorded so they are not re-asked:** - -- **Q-A1. Where does delivered come from?** (a): derived at the sender from the cumulative watermark. -- **Q-A2. One flush, or a "received" flush beside it?** One meaning: Acknowledged means delivered. -- **Q-A3. Reliable expiry next to ordered traffic (P2)?** B: FORWARD-TSN now (Cut F). The recommendation - was A (report honestly, document "don't mix"). -- **Q-A4. Receipts in C#, TypeScript, Python and Kotlin?** A: port them in this campaign, after Cut 4 - (Cuts 6a-6c, K4). The recommendation was B (record a parity gap). -- **Q-A5. Is Kotlin in the campaign?** A: yes, as trailing Cut K on a Yggdrasil Kotlin image (K0). -- **Q-A6. How does a session recognise a new generation?** A: the Connect's sequence identifies it; the - default initial sequence is random in [1, 2^31) (Cut 1c). - -**Open:** - -- **Q-A7. Does an abandonment notice name one sequence, or a new cumulative point? (new in pass 3)** - RFC 3758's FORWARD TSN carries a *New Cumulative TSN*: "every missing sequence up to here counts as - received". SCTP can do that because its SACK is cumulative too, so the next SACK confirms it. CultNet's - ack is not cumulative: it names the highest received sequence plus the 32 below it, or one exact older - sequence. So the two shapes cost different things: - - **A. One sequence per notice.** The receiver answers exactly as it would answer a data packet with - that sequence, and that ack stops the sender's notice as it stops a retransmit. No new ack field. The - cost is one small notice per abandoned packet per resend: an expired 20-fragment frame sends 20 - notices, again every resend delay until acked. - - **B. A cumulative point.** One notice covers every abandoned sequence up to it. But nothing on the - wire can confirm it: an ack naming the point might mean the original of that one packet arrived late, - while lower holes are still open. So B needs a second addition, a confirmation Ack (another flag bit) - carrying the receiver's watermark, which puts a cumulative watermark on the wire after all, the thing - Q-A1 kept off it. - - **Recommended: A.** It keeps the wire change to one flag, reuses the ack path unchanged, and the extra - notices only flow while packets are expiring, which is a lossy moment on one session. - - **Blocks:** Cut F (and K3). Nothing else. - -- **Q-A8. Should C#, TypeScript, Python and Kotlin gain reliable expiry? (new in pass 3)** Rust is the only - runtime that can send a reliable packet that gives up after a deadline (partial reliability, for live - media). Cut F teaches every runtime to *receive* the notice, because any of them can talk to a Rust - sender; only Rust can *send* one. A reasonable consumer of the C# or TS library can expect the same send - option. Today the profile field that configures it (`media_reliable_expire_after_ms`, - `rudp.rs:2492-2508`) exists only in Rust and cultmesh-rs. - - **A. Yes, in this campaign** (Cut 7 for C#, TS, Python; K5 for Kotlin): the send option, the profile - field's behaviour and Cut F's sender half. About +70 per runtime. - - **B. Not now.** The parity doc records Rust as the only runtime with partial reliability. - - **Recommended: A.** CultLib is judged by what a reasonable consumer expects (operator, 2026-09-30), and - after Cut F the sender half is small and already specified. It is independent of every other cut, so it - can go last. - - **Blocks:** Cut 7 and K5 only. - -- **Q-A9. What does a receiver do with fragment sets from a sender that never sends abandonment - notices? (new in pass 3, from the Cut 2 Soul pass)** Cut 2 stops evicting reliable fragment sets, because - evicting one whose fragments were acknowledged loses a frame the sender saw acknowledged (P5). But a - sender with reliable expiry, which is every default Rust `media` sender, abandons the rest of a frame - after its first fragment was acknowledged, so the set can never complete; after 64 of them Cut 2 refuses - the session. Cut F's notices release those sets, but only a Cut F sender sends notices, and the receiver - cannot tell an old expiring sender's dead set from a live set still waiting for a retransmit. - - **A. No fallback; bump both ends together.** Cut 2's rule applies to every peer. Any session between a - Cut 2 receiver and a pre-F expiring sender dies after 64 lossy frames, so every such pair (Muninn's - media, Mimir's relay, browsers on vendored CultLib) must move in the same deploy. - - **B. A capability bit, as PR-SCTP negotiates (recommended).** Connect and Accept carry flag bit 4 - ("I send and honour abandonment notices", RFC 3758 §3.1 Forward-TSN-Supported). Old peers ignore it on - those packets (probe CAP, all five runtimes). Cut 2's never-evict rule applies to a peer that advertised - it; a peer that did not keeps today's stalest-set eviction, with today's P5 risk, which only its own - upgrade removes. The sender also stops sending notices to peers that cannot use them. Cost: one flag on - two packet types and one remembered bit per session. - - **C. Age-based release.** A reliable set untouched for some multiple of the resend delay is released - for every peer. It keeps sessions alive without a wire change, but it releases a live set whose missing - fragment is merely slow, which is the P5 lie Cut 2 exists to remove, now on a timer. - - **Recommended: B.** It is the literature's answer, it makes Cut 2's rule hold exactly where its premise - holds, and mixed versions keep working without coordinated deploys. - - **Blocks:** Cut F's capability half and therefore the merge of Cuts 2, 2c and F. Q-A7 is independent. - -**Not forks, recorded so they are not re-asked:** - -- `Acknowledged` keeps its name and now means delivered. No variant is added or removed. -- Delivered means handed to the transport's application boundary, not consumed. -- The 4 MiB cap stays; the receiver's 4,096 receive history stays. -- A receipt is resolved once. -- Session generation is its own two cuts, not part of Cut 1 (§8). -- The notice's resend bound is 16 sends at the resend delay (§6). It is a constant, not an option. -- Receipts live on the single-peer socket transport in every runtime, as in Rust; hub receipts are a - follow-up for all five together. -- A failed send to one peer is that datagram lost (Cut D, RFC 1122's soft errors); it does not end the - peer's session. - - -## Sequencing and blockers - -1. **Done.** `hands/rudp-stray-packets` merged at `3bf1c0c`. Odin `origin/main` pins `3bf1c0ce`. -2. **In flight** (pass 2 text, unchanged by pass 3): Cuts 1 and 1b on `hands/ack-cut1`, Cut 2 on - `hands/ack-cut2`. No ruling forces a change to their specs: - - Q-A3 B adds the abandoned set, but Cut F adds it and hooks it into Cut 1b's primitive; 1b need not know. - - Q-A4 A needs an ending primitive in C#, TS and Python, which 1b already builds. - - Q-A6 is Cut 1c's. - - **Cut 2's merge moves** (the Cut 2 Soul finding): its code stays as specified, but it refuses sessions - with expiring senders unless Cut F releases their dead sets, so it merges only together with Cut F - (and Cut 2c). Its spec's "a conforming sender cannot reach that bound" is corrected in place. -3. **Ready now:** Cut 3 (merge after Cut 1, as pass 2 said); **Cut D** (independent); **K0** (tooling and - `build.ps1`; independent). -4. **Cut 1c** after Cut 1b merges. -5. **Cut F** built on `hands/ack-cut2` after Cuts 1 and 3 merge, once Q-A7 and Q-A9 are ruled. **Cuts 2, - 2c and F merge to `main` together.** -6. **Cut 4** after Cuts 1, 1b and F. -7. **Cuts 6a, 6b, 6c** after Cut 4, in parallel. -8. **Cut 5** after Cut 3 and Cut 1b in TS, as pass 2 said. It may use 6b's `waitAcknowledged` if 6b lands - first; it does not need it (flush suffices). -9. **Cut 7** after Cut F, once Q-A8 is ruled A. -10. **Cut K** after K0: K1 anytime; K2 after Cuts 1, 1b, 1c; K3 after Cuts 3 and F; K4 after Cut 4 - (and Cuts 6 as the parity reference); K5 after Cut 7. -11. **Pin bumps:** - - **Odin** after Cuts 1, 1b and 3 (it is the receiver for every Idunn publisher, so this bump makes - their Acknowledged mean delivered) and Cut D (it is the document server's only production host). Cuts - 2 and F come together at whichever bump follows their merge. - - **Muninn** (and any consumer of the default reliable `media` channel) takes Cuts 2 and F together. - Under Q-A9 B its ends may move separately; under A they must move in one deploy. Its client and - Sleipnir pins of `initial_sequence: 1` should go at that bump (Cut 1c notes). - - **CodexConnector** and **Ghostlight** need no code change; they may adopt `wait_acknowledged` after - Cut 4. -12. **Vendored TS bumps after Cut 5** (they pick up honest flushes, the TS window and the rewritten - publishers at once): - - **StreamPixels** vendors CultLib `542ddd3` (StreamPixels `a49f7d8`; pass 2's `30ee8b9` is stale). - Its service runs `createIdunnRuntimePresencePublisher`, which that revision carries under its older - name (`cultnet-ts/src/idunn-runtime-authority.ts`, pin at `:333`). - - **Heimdall** vendors `b6b1d9c` and hand-rolls its publisher (`src/idunn-rudp-health.ts:55-87`, - `initialSequence: 1` at `:65`). At its bump it calls cultnet-ts's publisher instead. -13. **Parallel campaigns:** `hands/media-fec-cut3` touches `rudp.rs` only in the imports, the `audio` - profile entry and the `"media" | "audio"` send arm; no overlap with Cuts 1-4, D or F beyond a rebase. - Once it lands, reliable expiry is only `media` in Reliable mode. CultCache publication Cut 4 is - unrelated in either direction. - - -## Follow-ups outside this campaign - -- **Hold caps in C#, TS, Python and Kotlin.** Their ordered hold buffers and per-channel maps are unbounded - against a hostile peer. Rust has caps. The far-ahead refusal on data is Rust-only too. -- **The document server's per-session payload budget never shrinks** (`rudp_document_server.rs:321`, - `:584`). -- **Fire-and-forget health.** Muninn `main.rs:9446`, Sleipnir `main.rs:880-904` and Heimdall's first-ack - wait can lose a health record silently. -- **Receipts on hubs** (Rust hub, C# listener, cultmesh-ts and cultmesh-py servers) in all five runtimes - together; and a peer-level `send`-and-wait in TS's `CultNetPeer` (`peer.ts:98-110`). -- **Kotlin self-tests live in the production source** (`CultMesh.kt` K:5236-5633, run from `main`). Move - them to a test source set once the Kotlin image exists. -- **Callers outside CultLib that pin `initial_sequence`** (Cut 1c's table): drop the value at each bump. -- **Mimir `CultMeshMedia`** hand-rolls a per-peer relay with its own Connect handling (`Program.cs:365`, - `:485`); it should call the session's repeat predicate after its CultLib moves. -- **`CultNetTransportStats` parity.** Cut D adds `send_failures` in Rust and C#; Kotlin has no - `packetsDropped` until K1. Record the stats shape in the parity doc once both land. -- **The P4 probe at the old pins** predates its fix; rerun `09318d13`'s P4 against a pinned sender if - old-sender evidence is needed. - -**Follow-up (media FEC Cut 3 Hands, 2026-09-30):** `channel_send_options` in `rudp.rs` has no test for the -`"schema"` and `"latest"` channel arms. Deleting either falls to the default and every test stays green. Pin their send -flags on the wire the way the media arm is now pinned (raw peer socket), in whichever cut next touches channel -profiles. - -**Rulings after the Cut 1/1b/1c Soul pass (Self, 2026-09-30):** -- **Stale data after a reconnect.** A reliable sequence at or before the generation's seeded `R` (serial arithmetic - within the receive window) is a duplicate: acknowledged, not delivered. This is what §6/§8 already promised. -- **Stale Connect (RFC 5961's challenge ACK).** A Connect whose sequence precedes the current generation's Connect - within the receive window is stale: it is answered with an Ack naming the current generation and does not reset. - A client whose Connect gets no Accept before its connect attempt times out starts a fresh attempt with a newly - drawn initial sequence, so a restarted client whose random sequence lands in that window still connects. -- **Server mode.** A Connect from a different endpoint is never a repeat; the session predicate stays sequence-only. - (Hands' deviation 5 was rejected: pinned clients restarting on a new port were locked out.) -- **The random default belongs to the session.** A session draws its own initial sequence when none is set, so one - options object reused across sessions or reconnects never repeats a sequence. -- **Connect attempts (Self, 2026-09-30, after the Cut 1c fix-batch Soul pass).** While a Connect awaits its Accept, only an - Accept the client honours retires it; an Ack never does (an Ack naming the pending Connect had wedged the client for - good). A fresh attempt after the connect-attempt timeout uses exactly the abandoned sequence + (receive window − 1), - not a random draw: that keeps a late copy of the abandoned Connect inside the new one's stale window while escaping the - server's stale window (unless the server sits exactly there, in which case the next attempt escapes). Only a client's - first Connect draws randomly. -- **Recorded divergence (not this campaign's to fix yet).** Only Rust has a receiver receive-ahead window (1,024). TS, - C# and Python acknowledge a far-ahead reliable frame by name and hold it behind an ordered gap indefinitely, so a - stranded ordered channel is silent loss there and "still owed" in Rust. Map a receive window for the three runtimes. -- **Residuals after the final Cut 1c Soul pass (recorded 2026-09-30, low, not fixed):** - - A late copy of a Connect from two or more attempts back (reordered > ~3 s) still restarts the server; the +window−1 - rule keeps only the immediately previous attempt stale. Rust then times out and reconnects; TS/C#/Python hit the - receive-window divergence above (silent loss behind the gap). - - A client pinning `initial_sequence` that restarts while its old data is still in flight after the restarted Connect - can be matched to the old session's resent Accept (byte-identical; the client cannot tell). Pinning forfeits restart - detection; the random default closes it. Document on the option. - - Repeated failed attempts walk the sequence up 4095 per 3 s and clamp at `u32::MAX−1` after ~18-27 days on one session; - make exhaustion a hard error the caller sees (the clamp removal mutant survives). -- **Cuts 3 and D Soul pass (Self, 2026-09-30).** Cut 3: the 4 MiB bound counts every byte sent above the lowest - unacknowledged sequence, acked or not (the spec's wording; the first implementation counted only pending packets). - Cut D: a send failure is either permanent (the datagram can never be sent as built, e.g. `EMSGSIZE`) or transient; - a permanent failure is returned from a caller-directed send and, inside a poll, ends only that peer's session with a - typed reason; transient failures stay counted losses. Windows `ConnectionResetError` on receive is idle, never fatal, - in every server. Cut D's negative grep `send_packet(...)?;` is obsolete: the `?` now propagates encode errors only. -- **Cut 3 merged at `1210fdc7` (2026-09-30).** - - Batch 2 fixed Rust `send`, which checked the window before purging expired packets. It also added a - new-generation test in all four runtimes. - - Soul pass on batch 2: nothing found. 18 mutants were killed. Interop was 27/27, run in `ack1d-interop:2`, - whose Linux wrappers let the Windows-only Kotlin build run. - - Residual: `packages/cultmesh-rs/Cargo.lock` is stale. - - Also recorded: `packages/cultmesh-kotlin/build.ps1` is Windows-only, so Kotlin interop runs off Windows only - through an image with wrappers. - - P2 (the expiry hole) stays with Cut F. -- **Cut D batch 2, Soul pass 2: fix first.** - - cultnet-ts emits transient send failures as `"error"`, which crashes processes with no listener. Section 10's - claim that "Node reports send failures on the socket's error event" is false on Node 24 without a callback: - they are dropped silently. - - C# stale peer objects write into the replacing session. - - cultmesh-py dies on a non-ValueError per-peer failure. - - Six guards are unpinned. - - Batch 3 is in Hands. - - **K1 gains:** Kotlin's idle resend loop (`CultMesh.kt:4033-4036`) is one `try`, so one peer's permanent failure - stops resends for every later peer. That is in addition to a refused packet not ending that peer's session. -- **Recorded (liveness, not fixed):** a lowest reliable packet lost forever stalls the sender 1,023 sequences ahead - while the peer stays alive; session timeout doesn't end it. TCP ends such a connection after a retransmission - limit (R2). Decide with Cut F (abandonment) whether a reliable packet outstanding past a bound ends the session. diff --git a/docs/cultnet-selection-cut.md b/docs/cultnet-selection-cut.md deleted file mode 100644 index 1f64b2bf..00000000 --- a/docs/cultnet-selection-cut.md +++ /dev/null @@ -1,3574 +0,0 @@ -# CultNet typed selection: cut map - -Date: 2026-09-17. Imagination output. Intended path: `F:\Projects\CultLib\docs\cultnet-selection-cut.md`. -No code in this document has been written; nothing here is committed by -Imagination. This is its own campaign with its own map; it does not touch -`docs/typescript-quic-realtime-cut.md` or any code in that tree. - -**Progress, 2026-09-22.** Cut 1 is running (Hands, Sonnet) on the branch -`cultnet/selection-cut1`. It will not merge to `main` until Soul has passed it. - -- **Commit 0, `e420410`:** the cache's public read surface. Twelve tests, six - mutation entries, all killed. -- **Commit 1, `17d10e0`:** `CultNetSelection` and its evaluator, the three v1 - messages, and the three §1 selector engines deleted. v0 now lowers into a - selection. Test results: Networking 162/162, Mesh 252/253 (one skip that was - already there), Caching 188/188. Nine networking mutation entries, all killed. -- **Mutation harness:** `scripts/mutate-dotnet.ps1` is a shared dotnet/NUnit - harness and works for any NUnit project in this repository. -- **Not run through mutations yet:** S1, S2, S6, S9–S15, S17, S20 and S23. A - test pins each one, but none has a mutation entry. - -Two things were found that this map did not name: - -- **`CultNetDocumentBinding` can carry a schema id that differs from its - descriptor's.** The deleted engines checked for this; the evaluator reads - only the descriptor and cannot see it. The fix is at the call sites - (`WithBindingSchemaAlias`, `ExpandSchemaBindingAliases`), so the evaluator - stays independent of the registry. -- **A fourth copy of schema matching, at `CultNetDatabase.cs:1707`**, which - serves `CreateShardSnapshotResponse`. §1 counted three engines; there are four. - **Self's ruling, 2026-09-22: it collapses into the evaluator in commit 2.** - Leaving it would make the claim of one evaluator false. It is a deletion and - takes a mutation entry like the others. - -**Q-J was not implemented, and the map is the reason.** Commits 0 and 1 -shipped `number` as `double?` (`CultNetSelection.cs:71`), `CompareNumber` as -IEEE comparison (`CultNetSelectionEvaluator.cs:103-113`), a JSON schema that -says `"type": "number"`, and a cache `NumberOf` that returns `double`. The -operator had already ruled decimal strings, and §16 recorded it, but §2 and -D8 still specified float64, and nothing specified the canonical form or the -comparison that the ruling explicitly required. Hands built what §2 said. The -Rust Hands caught the conflict before writing a line and stopped, correctly. -**Self swept §2, D8 and the §7 table on 2026-09-22**, and §2 "Numbers" now -specifies the form. The Q-J fix lands on the branch before the Rust runtime, -as its own commits. Commit 1 is not wire-final until it does. - -**Commit 2 landed** (Sonnet) in five parts: -- `0787608` deletes `_projectRecord`. Its two tests now drive a real change of - identity instead of a projection. It also collapses the fourth matcher. -- `c5b03cb` collapses the CultMesh alias checks. -- `4167cc4` adds the fourth matcher's mutation entry. -- `5ba48f0` finishes the Mesh collapse. `CultMeshSnapshotRequestOptions` and - `CultMeshPeerSnapshotDocumentOptions` become records that carry one - `Selection`, and their clones collapse to `with`. `CleanSnapshotFilter` and - the triple rule give way to two owners, `ResolveDefaultSelection` and - `OverlaySelection`. `CultMeshHotBodySubscription` carries one `Selection`. - The four-tier read fallback becomes one read anchored on the record key, - with the two tiers that could return the wrong key's record deleted. -- `4c33a62` adds the Mesh entries: three reverts killed and two loosenings - **survived**. The survivals are fixture gaps, since no test passes an empty - non-null `recordKeys` or a record key with different casing. They go to the - fix batch. - -Mesh is net **−83** against §11's estimate of about −326. The shortfall has -four parts: -- about −41 is the payload-sniff fallback, which was kept; -- `IsSameCultDocumentSchema` stays, because it already delegates entirely and - inlining it would duplicate the resolution at four sites; -- `CultMeshClient`'s three subscription sites still call the v0 - `SubscribeAsync`, which this cut does not retype. They move with Cut 2, - the watch cut; -- the read keeps two tiers, because a test enforces that. - -This is an honest miss. - -**Two corrections to Hands' report.** First, the payload-sniff fallback was -**not "ruled kept by the operator"**. Hands kept it in `c5b03cb` as a -discrepancy, and Soul is judging whether it is a second selection decision or -a decode capability. Second, Hands reports that one Networking test crashes the -test host on a disposed-socket race in `CultNetRudpSchemaServer`/`CultNetTransport` -and calls it pre-existing. Networking was 162/162 at `17d10e0`, so that claim -must be checked against `17d10e0` and `main` before anyone believes it. It goes -to Soul by name. - -**Q-J fix landed** at `e5348d3` (commit 2f, C#). The Rust runtime is next. - -**Soul on commit 2, 2026-09-22** (Opus, pinned at `4c33a62`). **Commit 2 does -not pass.** - -- **S2-1, medium, predates the range.** The crash is real, and Hands was right - that it is older than this commit. It is a disposed-socket race: the ack - `SendTo` at `CultNetTransport.cs:2549/2593` runs on a test's background poll - thread. About 18 tests start `IsBackground` poll threads, and only one of - them (`:1885`) joins its thread before the server disposes. Crash rates: - - | Checkout | Suite | Crashes | - |---|---|---| - | `17d10e0` | full | 1 of 6 | - | `17d10e0` | subscription subset | 5 of 12 | - | `main` | subscription subset | 1 of 12 | - - "162/162" was luck. A second defect sits in the harness: - `mutate-dotnet.ps1:43/61`, under `$ErrorActionPreference='Stop'` on - PowerShell 5.1, throws the whole run away when a host crash writes to - stderr, where it should record the mutant as NO VERDICT. -- **S2-2, high: the rewritten reconcile tests lost what the old ones proved.** - With `_projectRecord` gone, a source key always equals its record key, and - the delete-then-put now arrives through the live `Watch` path, so - `Reconcile()` has nothing left to do. - - Two mutants that `17d10e0` killed now survive: dropping Reconcile's - identity branch (`:261`), and returning early from Reconcile. - - Dropping the add loop (`:274`) and dropping the live identity branch - (`:340`) survive both before and after the rewrite. - - The `RecordKey` comparisons at `:261` and `:340`, and the throw at - `:311`, are now **dead code**. -- **S2-3, high: the fourth matcher was collapsed into the alias matcher, not - into the evaluator.** `CreateShardSnapshotResponse` - (`CultNetDatabase.cs:633-670`) still loops on its own. - - A probe sent `{schemas:[], keys:[]}` to both paths: the shard path - returned 0 rows, `CreateRawSnapshotResponse` returned 2. - - The binding-alias expansion does not run on the shard path either. That - part is plausible, not probed. - - §2 says an empty list is **refused at the door**. The evaluator reads `[]` - as "no filter" (`CultNetSelectionEvaluator.cs:84`) and the shard path - reads it as "nothing". Neither follows the map. -- **S2-4, medium-high: the Mesh one-pass read returns the wrong schema's - record.** At `CultMesh.cs:2730-2750`, the first candidate at the key that - passes the exact match, the alias match or a trial decode wins. With - `[MeshOther, MeshNote]` at one key, a MeshNote read returned MeshOther. - Before `5ba48f0`, the exact schema was searched first. -- **S2-5, medium: Mesh has two decode rules that disagree.** The read accepts - a record by trial decode. `DecodeSnapshotDocuments` rejects the same record - by payload sniff. `RawSnapshotPayloadMatchesSchema` is a real decode - capability, since both of its tests are keys-only. **It is not a second - selection decision.** It is, though, a second copy of the registry's - resolver (`CultNetDocumentRegistry.TryResolveDescriptorByPayloadSchema` / - `TryReadSchemaVersion` / `InferSchemaName`, `:596-670`). -- **S2-6, high: the public `CultMeshSnapshotRequestOptions.Selection` honours - only `Schemas` and `Keys`.** Mesh sends v0 only. A request with a limit, a - field predicate and a header projection went out as `schemas=null - keys=null`, and two full rows came back. A caller's `Selection` also skips - the old cleaning, so `Keys=[""]` now returns nothing where it used to return - everything. -- **S2-7, low, predates the range: selection meaning is decided in two more - places.** `ResolveDefaultSelection` (`:835`) checks the raw `Count` before - cleaning, so `[" "]` selects everything. `ResolveDefaultSelection` reads - null as "no filter", and `OverlaySelection` (`:840`) reads it as "inherit". -- **S2-8, low.** Both options types changed from `class` to `record`, which - gives them value equality and is acceptable. - `CultMeshHotBodySubscription.Selection` exposes mutable arrays on a contract - that was immutable. The prebuilt Unity `GameCult.Mesh.dll` carries the old - API: a release-time follow-up, rebuilt at the next release cut. -- **S2-9 and S2-10:** a miscount in the entries header. For the two Mesh - survivors, Soul's fixtures kill both. -- **Held:** Mesh 3 killed and 2 survived, as reported. Networking 10 of 10 - killed. Every restore verified by hash. The alias-matcher routing is right. - `IsSameCultDocumentSchema` delegates. - -**Self's rulings, 2026-09-22, for the commit 2 fix batch:** - -- **Empty lists and blank keys are refused at the one door, as §2 already - says.** `CultNetSelection.Validate` refuses `keys: []`, `schemas: []`, and - any key or schema that is empty or whitespace, with `selection_invalid`. - `null` means no filter, everywhere. The evaluator never sees `[]`. v0 - lowering carries over v0's old cleaning: an empty v0 list, or one made only - of blanks, lowers to `null`. That is a v0 compatibility rule the lowering - owns, not a meaning the evaluator carries. `ResolveDefaultSelection` and - `OverlaySelection` stop deciding what null means. They build a - `CultNetSelection` and let the door decide. Overlay means "the caller's - selection replaces the default wholesale". -- **The shard snapshot answers through the evaluator**, binding aliases - included, with no loop of its own. -- **Mesh keeps sending v0 in this cut.** §9 says v0-only peers refuse v1, and - FU-v0 retires v0 later. So `CultMeshSnapshotRequestOptions` refuses, loudly - and typed at construction or send, any `Selection` term v0 cannot carry: - `fields`, `cites`, `cited`, `limit`, `cursor`, `descending`, `projection` - other than the default. **Refused, never silently dropped.** Mesh sending v1 - is a follow-up, **FU-Mesh-v1**, triggered by the first Mesh caller that - needs a term beyond schemas and keys, most likely the Huginn consumer or - Cut 2. -- **The Mesh read searches the exact schema first,** then the alias match, - then a foreign id decoded by payload. It uses **one** decode rule shared - with `DecodeSnapshotDocuments`, and that rule is the registry's resolver. - The Mesh copy in `CultMeshSnapshots.cs` is deleted in favour of - `CultNetDocumentRegistry`'s. -- **The dead identity code is deleted, not tested around.** `:261`, `:340` - and `:311` can no longer fire. Reconcile gets a test for what it still owns - after the deletion: flipping `authorizeRecord` per key while the request - stays authorized, and the add loop. Both surviving mutants must die. -- **Test hygiene belongs to this batch, because a flaky M0 is not a - control.** Every test that starts a background poll thread joins it before - its server is disposed. `mutate-dotnet.ps1` records a host crash as NO - VERDICT and carries on. -- **Also owed by Cut 1 and not yet done: R-2.** Python replies - `unsupported_schema_version` to v1 (§9, `interop_peer.py:360-387`, - `cultmesh_py/server.py:341`), with one test per site. - -**Rust commit 3 landed** at `b090ca9`. Its parity vectors wait on the fix -batch below. - -**The commit 2 fix batch landed** (Sonnet), `568e8e3` and `021e3f1`: - -- **Door and lowering.** The door refuses blank and whitespace entries, and - `CultNetV0SelectionLowering` owns the v0 cleaning. -- **Shard snapshot.** It answers through the evaluator - (`NET-D4-ShardMembership-Revert`), with a fixture for exclusion outside the - shard's prefix. -- **Mesh v0 terms.** `CultMeshSnapshotRequestOptions` refuses any term v0 cannot - carry, and `HotBodySubscription.Selection` returns a copy. -- **Mesh read order.** The read goes exact, then alias, then payload, under one - decode rule, and the Mesh copy of the resolver is deleted. The shared rule - is `CultNetDocumentRegistry.TryReadSchemaVersion`, now public, matched against - the caller's own descriptor. `TryResolveDescriptorByPayloadSchema` is - ambiguous when two CLR types alias one schema id, which the foreign-id - fixture does on purpose. -- **Dead code and test hygiene.** The dead identity code in Reconcile is - deleted, and the poll threads are joined. -- **Harness.** `mutate-dotnet.ps1` records a host crash as NO VERDICT. -- **R-2.** The Python v0 peer refuses v1. - -Tests: Networking 195/195 over ten consecutive runs, with no crash. Mesh -253/254 with one skip that was already there. Caching 192/192. cultnet-py -54/54. Mutation entries 22/22 killed across the fix-batch, Mesh and Networking -files, and both Mesh survivors now die. Delta: Mesh −3, Networking +62. - -**Open for Soul:** - -- It is unclear whether the door now refuses `[]`, as ruled, or only blank - entries. The report names blank entries only. -- Seven `cultmesh-py` daemon tests fail on `ModuleNotFoundError: cultcache_py` - in a spawned child process. Hands calls this pre-existing; `main` must - confirm it. -- The anchor-matching fragility of `mutate-dotnet.ps1` (separator spelling) is - recorded, not fixed. - -**Commit 4 landed** (Sonnet) at `00f4c02`, completing Cut 1's execution. -- **Parity vectors (S12):** `contracts/cultnet/interop/selection-vectors.{cs,rs}-written.json`, - 8 vectors each, read in both directions. Six are evaluated selections. Two - are door refusals: `schemas: []`, and a blank key. -- **Door:** `keys: []` and `schemas: []` **are** refused (`CultNetSelection.cs:326/328`), - as ruled. -- **Tests:** cultnet-rs 111 → 174, cultcache-rs 64 (unchanged), Networking - 196 + 1 skip, Caching 192, Mesh 252/253. -- **Mutation entries**, all killed: - - Rust 23/23; - - Caching 10/10, including 4 Q-J; - - Networking 15/15, including 5 Q-J. - - S6 first survived. Its fixture now carries two references to the same - target under different roles. S9–S15 and S17 do not apply to Rust - (Rust has no subscription client). -- **Open for Soul, highest first:** - - **No `cites` vectors cross the runtimes.** Hands says C#'s - `MatchesCitation` compares `Citation.Target.SchemaId` against the real - SHA-256 schema id with no alias fallback, so "this fixture pair cannot - agree". That is either a fixture limit or **a wire-parity gap in the - hop**, and parity is this cut's invariant. Soul settles which. - - **Size:** `selection.rs` is 1,407 lines and `contracts.rs` +595, about - 2,000 in all against §14's roughly 430. - - Hands calls S20 "structurally vacuous" and S23 without a surface in Rust. - Both are claims, not yet reached, and need checking. -- **A flaky test, recorded as a follow-up.** - `reactive_document_coalesces_direct_same_schema_alias_member_writes` - (`packages/cultnet-rs/tests/cultnet.rs`) fails under the harness's - invocation of the full binary and passes in isolation. The v0-lowering entry's - command was narrowed around it. It is not this cut's code. Its owner is the - reactive-document path. - -**Two pre-merge additions from the Huginn read-side map, 2026-09-22.** They -join the next selection fix batch, whatever the final Soul pass adds. - -- **P-1:** Rust's `Evaluation` gains `matched`. Rust cannot fill the page's - `matched` today. -- **P-2:** derive `Eq` on the selection wire types, because Huginn's request - derives it. - -**Load-bearing for Huginn:** `Cursor::parse` is public and exposes `as_of`. -The organ answers a cursor's page at the cursor's own `asOf`. If a finding -makes `parse` private, expose `cursor_as_of` instead. - -**Soul's whole-cut pass, 2026-09-22** (Opus, at `00f4c02`). **Cut 1 does not -merge.** The notes are in the session scratchpad at `soul-ss4-notes.md`. - -- **High, confirmed:** - - **The C# reference never answers v1.** No v1 listener is registered - (`CultNetDatabaseServer.cs:49-55`, `Server.cs:562-571`). - `CreateSelectionResponse` and `LastWriteSequence` have no callers. §7's - server handling, D6's subscription v1 and S9 were never built. Hands - reported none of it missing. - - **`cited` returns the wrong edges in both runtimes.** Edges are filtered by - whether the *citer* is on the page, but under `cited` the page rows are the - *cited* rows. `exists:true` gives no edges. `exists:false` gives edges into - rows that are not on the page. The committed vector pins the wrong answer. - - **The C# door accepts `"5\n"`.** .NET's `$` matches before a final - newline. Rust and the schema refuse it. -- **Parity broken on caller inputs:** - - **Float rendering at ties.** 394 `f32` and 48 `f64` values out of 200k - render differently, because .NET rounds half to even and Rust does not. - A shortest form is a stand-in for the value in any case: an `f32` of 3e20 - is stored as 300000006012263202816, bits `0x61821ab1`. (Corrected - 2026-09-22. Soul's report and this map first gave 300000002010536247296. - The Rust Hands and an independent Python `Decimal` check both give - ...6012263202816.) - - **`schemas`.** C# matches through the alias matcher and Rust matches exact - ids. `["leaf_a"]` gives 8 rows against 0. - - **Tiebreak.** C# compares UTF-16 code units and Rust compares UTF-8 - bytes, so astral keys sort in opposite orders. - - **`cited` edge order.** It comes from a Rust `HashMap` and is - nondeterministic. - - **`any_of` on a numeric alias.** C# goes through a culture-dependent - `ToString()`. -- **The door is advisory.** Both `select` implementations are public and never - validate. Rust's `matches` only `debug_assert`s the hop, so a release build - ignores `cites` and `cited`. Mesh `EnsureV0Compatible` lets `Keys=[]` and - `[""]` through, and v0 lowering turns them into "every key". -- **Not what the map specifies:** - - `Matched` counts the page, which breaks C5. - - v0 and shard snapshots re-evaluate the whole cache on every page, which is - O(N²/200), and cannot detect a write between pages. - - Cursor digests collide: `["a|b"]` digests the same as `["a","b"]`. - - S20 has no C# test. Two S20 mutants survive 196/196. Rust `select` - ignores `projection`, so the rule has no owner there. Nothing tests - `SelectPage` or `CreateSelectionResponse`. -- **The Networking host still crashes**, 2 in 11 runs. The flaky `HasConsumers` - assertion (`NetworkingTests.cs:1720`) predates this cut. Joins sit outside - `finally` in about 18 tests, so a failed assertion becomes a host crash. The - "10 runs, no crash" claim did not reproduce. -- **Low:** - - The JSON schema disagrees with the runtimes on `limit` bounds and on - `minItems`. - - Python R-2 refuses only v1, so v2 and later are still dropped. - - `TryReadSchemaVersion` is a public sniff heuristic, and two Mesh sites - repeat the same line around it. - - Rust v0 dedups before `reject_duplicates`, so that function is dead. - - `mutate-dotnet.ps1` throws on `\` against `/` in paths. -- **Deletions Soul named:** - - `contracts.rs:1206-1541`, about 335 lines of hand codecs that the serde - derives already round-trip, verified both ways; - - `expand_scientific_notation`, about 46 lines, dead: 400k renders produced - no exponent; - - `reject_duplicates`; - - `RawDocumentHeader::from_document`, which has no callers; - - C#'s `WithBindingSchemaAlias`/`ExpandSchemaBindingAliases` duplicate, - about 60 lines, and a shared lower-and-page loop, about 20 lines. -- **Settled:** `cites` is a fixture limit, not a parity gap. With real SHA-256 - ids, 40 selections agree byte for byte. The seven `cultmesh-py` failures - are environmental: with `PYTHONPATH` set, 78/78 pass. The flaky Rust test - waits 30×5 ms for a flusher, which is too short under contention, and it is - not this cut's. -- **Held:** - - Every mutation suite killed everything. - - Door refusals are byte-identical apart from `"5\n"`. - - The Q-J comparator agrees with Python `Decimal`. - - No float sits on either comparison path. - - Negation, limit clamping and v0 dedup are at parity. - - Mesh reads in the order exact, then alias, then payload. - - Mesh is actually 253/254. - -**Self's rulings for the Cut 1 fix batch, 2026-09-22:** - -- **R-A. Serving v1 is part of this cut.** Both C# servers register v1 - listeners. The snapshot answers through `CreateSelectionResponse` in - last-write order, and the subscription server answers v1 as D6 and §7 say, - with S9 tested. A v1 request that is not served is a defect, not a - follow-up. -- **R-B. Hop edges follow the hop's direction.** Under `cites`, the edges are - the ones *from* page rows. Under `cited`, they are the ones *into* page - rows. Every edge touches a page row. The order is deterministic: the - page-row order, then `(from, role, to)` in code-point order. Regenerate the - vectors. -- **R-C. Every string comparison in the vocabulary uses Unicode code-point - order.** In Rust that is UTF-8 byte order. C# compares by code point, not - by UTF-16 unit. This covers the tiebreak, edge order and anything else - ordered. -- **R-D. A floating-point member renders as its exact decimal expansion, - in canonical form, and not as a shortest form.** Every finite `f32` and - `f64` is an exact dyadic rational and has a finite decimal expansion. That - expansion is the stored value, it is the same in both runtimes by - construction, and it matches the reason for Q-J. Implement it from - mantissa and exponent, with `BigInteger` in C# and a small bignum in Rust. - Add no new dependency unless the expansion cannot be written in about 60 - lines. `decimal` and the integers are unchanged. **Flagged for the - operator's review:** this is Self's reading of Q-J, and it changes what - `ge "3e20"` means for an `f32` of 3e20. **Accepted by the operator, - 2026-09-22** ("I accept your recommendations"). -- **R-E. There is one schema-identity rule in both runtimes.** Rust gets the - alias matcher (the owner is `cultnet-rs`, ported from - `CultNetSchemaAliasMatching`). `schemas`, `cites.target.schemaId` and the - binding id emitted on records all go through it. A `cites` target that - matches no schema is refused at the door, never answered with an empty page. - `ToRawRecord` and `ToEdge` emit the same id. -- **R-F. The door is inside `select`.** `select` validates first, in both - runtimes, and returns the typed refusal. A public entry point that skips - validation does not exist. Rust's hop handling is not a `debug_assert`. - Mesh's `EnsureV0Compatible` runs the door first. -- **R-G. `matched` is the total number of matches, not the page count** (C5). - Rust's `Evaluation` carries it (P-1). A single evaluation answers v0 and - shard paging, taken as one snapshot. -- **R-H. Cursor digests use length-prefixed encoding** for every string and - list, in both runtimes. -- **R-I. The projection has an owner in both runtimes.** `cultnet-rs` gets - `select_page`, which produces `SelectionPage` under `projection`, with - `matched` and edges. C# `SelectPage` and `CreateSelectionResponse` get - tests, and so does S20. Page bytes are compared across the runtimes in the - parity vectors. -- **R-J. `any_of` on a numeric alias** compares the canonical rendering. -- **R-K. Test hygiene is finished, not claimed.** Every poll-thread join - moves into `finally`, and the `HasConsumers` race gets a deterministic - wait. The fix is proven by 20 consecutive full runs. -- **R-L. The contract follows the runtimes.** The schema gains - `minItems: 1` on `schemas` and `keys`, and `limit` is documented as - clamped. Python refuses every `cultnet.*.vN` with N > 0. - `TryReadSchemaVersion` goes back to internal, behind a registry method that - takes the descriptor. `mutate-dotnet.ps1` normalises path separators. -- **R-M. Soul's named deletions all land.** P-2 (`Eq`) lands too. - -**The C# half of the fix batch landed** (Sonnet), `7903853`..`3d32c67`. - -What changed: -- **R-A:** v1 is served by both servers, and D6 reconcile routing is in place. -- **R-C:** `CultNetCodePointComparer` gives code-point order. -- **R-D:** floats render as their exact decimal expansion, computed with - `BigInteger`. -- **R-E:** cites go through the alias matcher, and the wire id is one - `WireSchemaId`. -- **R-F:** the door is unconditional inside `Select`. -- **R-G:** `Matched` is the total, computed once through `EvaluateAll` and - `SelectAll`. -- **R-H:** the cursor digest is length-prefixed. -- **R-J:** numeric `any_of` compares the canonical form. -- **R-M:** `WithBindingSchemaAlias` and the duplicated paging loop are deleted. -- **R-I:** tests kill both S20 mutants. One real bug was fixed in the - process: `ToEdge` serialised a plain-typed payload as a document. -- **R-K:** every join is in `finally`. `HasConsumers` now waits for the - condition. -- **R-L:** the schema carries `minItems`. Python refuses every vN>0 (only - one site exists). The harness normalises path separators. -- **Vectors:** `cs-written.json` is regenerated and gains the float-tie, - cites-by-alias, astral-order and projection cases. - -Tests: Networking 201/202, where the one failure is the stale Rust vector -until the Rust half lands. Caching 192. Mesh 253/254. Python 54, 78 and 34. In -20 full runs there was no crash, but one run had an unexplained failure. - -**Not done, sent to a second Hands:** mutation entries for the fix-batch -rules (there are only regression tests), S9, and a diagnosis of the 1-in-20 -failure over 30 runs. - -**The Rust half of the fix batch landed** (Sonnet), `8b114ed`..`a088435`: -- 36/36 entries killed. -- Three real bugs were found while it ran, among them a v0 fast path that - had inherited the door's "empty matches all". -- The `contracts.rs` hand codecs are gone (−362/+35). -- The Rust Hands corrected `f32` 3e20 to `300000006012263202816`. - -**Shared parity fixture** (Sonnet), `3317ddd` and `defb9a1`. Both runtimes now -build their vectors from -`contracts/cultnet/interop/selection-vectors.fixture.json`, which uses real -SHA-256 ids. A test bug is fixed: both readers had compared the page size with -`matched`. Of 15 vectors in each direction, the following agree byte for byte: -exact ids, float tie, astral order, header and document projection, -`cites`/`cited`, and the refusals. **Two real parity defects:** -- **Two incompatible alias rules.** C#'s `CultNetSchemaAliasMatching.Matches` - (`CultNetDatabase.cs:74-87`) strips `.vN` and compares against the - descriptor's `SchemaName`. Rust's `schema_alias::matches` - (`selection.rs:452-459`) strips `.vN` and compares against the *hash*. No - alias resolves in both. -- **C# does not refuse an unresolved `cites.target`** (`CultNetSelection.cs:361-391`), - which R-E requires. Rust does refuse it. - -**Self's ruling, 2026-09-22: the C# reference's alias rule is the rule.** -CultLib keeps wire parity against the C# reference. An alias is a schema -*name* with a `.vN` suffix, resolved against the descriptor's name. A -hash-shaped alias is not a wire form. Rust ports the name rule, which means -its `RowSet` must expose each schema's name, and it deletes the hash-stripping -matcher. C# refuses an unresolved `cites.target` at the door. Each vector that -fails today becomes a vector both runtimes must pass. - -**The Rust alias port landed** (Sonnet). Rust's `schema_alias::matches` is -now `(candidate, schema_id, schema_name)`, and it matches the C# reference row -for row: exact id, bare name, `.vN` on the name, case-sensitive, the last -`.v` marker wins, and `.v` with no digits is taken literally. The C# table was -produced by running the reference source. The hash-stripping overload is -deleted. `SchemaVersion` and `CompatibleSchemaIds` are not ported, because no -Rust call site needs them; that is a stated reduction. Rust entries are 40/40 -killed. The whole suite had never run under `-Repo` before, and its commands -now carry `--manifest-path`. - -| Reader | Vectors from | Pass | -|---|---|---| -| Rust | `cs-written` | 15/15 | -| C# | `rs-written` | 14/16 | - -The two C# failures both come from the pending C# door refusal of an -unmatched `cites.target`, which sits with the matrix Hands. - -**The early Soul pass (commits 0–1, pinned at `17d10e0`) reported late, on -2026-09-22.** It was interrupted three times by API errors. Much of what it -found is already covered by R-A to R-M. These findings are **new, and still -open against the current branch:** - -- **F2, high: typed refusals never reach the wire.** `CultNetErrorMessage` - (`CultNetSchemaMessages.cs:~498-512`) has no `code` and no `details`. Nothing - maps the selection or cursor exceptions to an error. This means - `selection_invalid`, `cursor_stale`, `cursor_invalid` and - `reference_outside_target` exist only in-process. -- **F6, high: the cursor can be forged.** The digest is an unkeyed SHA-256 - that anyone can recompute, so any caller can mint a cursor. A record key - containing the U+241F delimiter makes a server's own cursor fail to parse. -- **F7, high: authorization sees two different ids.** On the snapshot path, - the authorizer receives the binding's wire id. On the live path it receives - the descriptor id (`CultNetDatabaseSubscriptionServer.cs:~309` against - `:~451`). A row delivered by the snapshot can therefore be refused on its - first live change, or leak through a denylist. No test pins the live id. -- **F8, medium: `LastWriteSequence` is not the order a snapshot reflects.** - - Replica apply paths (`CultNetDatabase.cs:~1416`, `:~1459`) never set it. - - Nothing rebuilds it after a restart. - - Sequences are per shard, but `asOf` is a single `ulong`. -- **F11, medium: v0 lowering changed what v0 answers.** A snapshot with - `recordKeys: []` used to answer empty, and now answers every row. v0 rows - now come back in schema-and-key order instead of the requested key order. -- **F12, medium: the committed schemas do not describe the bytes C# writes.** - - Headers `$ref` a record schema that requires `payload`. - - MessagePack-CSharp writes both `headers` and `documents`, one of them - nil, so every C# page fails the `oneOf`. - - `fieldPredicate` does not forbid `values` and `number` together. -- **F14, low, now reachable because v1 is served: field predicates on a - Removed change throw.** `TryGetIndexValue(null)`, at - `CultNetDatabaseServer.cs:~375`. -- **F15, low: `cites` skips the out-of-target check** for edges that do not - point at the requested key. `EnsureWithinDeclaredTarget` also returns early - when `TargetType` is null. -- **Surviving mutants:** - - S1-Loose (reachable becomes all descriptors); - - S2-AllOf; - - S17-FirstLeaf; - - Door-NoValidate, since fixed by R-F; - - S5-DigestNoFields; - - Auth-LiveSchemaId, which is F7; - - QL-OutOfTargetCites, which is F15. - - S4 has no entry. A clone at `C:\ss1` was left behind: the Soul pass could - not remove it. - -**Self's rulings for fix batch 3, 2026-09-22.** Batch 3 goes to Hands once the -C# matrix Hands and the Rust alias port have landed, so that no two C# Hands -edit `src/` at the same time. - -- **R-N, F2: refusals are wire messages.** `cultnet.error.v0` gains `code` and - `details`. That is an additive field, so the version is unchanged, and v0 - peers ignore it. Every selection and cursor refusal, and - `reference_outside_target`, maps to its code in both servers and in Rust. - Tests decode the bytes a peer actually receives. -- **R-O, F6: cursors are keyed.** The digest becomes an HMAC under a key the - answering server holds. The key is random per process, so a cursor does not - survive a restart and answers `cursor_invalid`. That is acceptable, because - cursors are short-lived and §2 already calls them "minted by the answering - server". The cursor body uses length-prefixed fields, not a delimiter. Rust - does the same, since Huginn mints cursors. -- **R-P, F7: one id reaches the authorizer, and it is the wire id**, on the - snapshot, live and reconcile paths alike. A test pins the id each path - passes. -- **R-Q, F8:** every path that commits a row, whether local or a replica - apply, sets the last-write sequence. It is rebuilt from the mutation log at - startup. A multi-shard selection's `asOf` is defined, or refused: a - selection that spans shards is answered only when every row comes from one - shard's log. Otherwise it is refused with `cursor_stale`/`selection_invalid` - as appropriate, and the choice is documented in §2. A single shard is the - case today. -- **R-R, F11: v0 lowering preserves v0's pre-cut answers exactly.** v0 is a - frozen contract for the TS, Python and Kotlin peers. **This corrects the - earlier ruling in the commit 2 fix batch**, which lowered an empty v0 list - to "no filter". Where the pre-cut v0 server answered empty for - `recordKeys: []`, it still answers empty. Where pre-cut v0 kept the - requested key order, it still does. The pre-cut behaviour comes from the - code at `b3d9cf7`, and tests prove it by running the same requests against - both. -- **R-S, F12: the schemas describe the bytes.** Add a header schema, make the - page shape match what MessagePack-CSharp writes (nil keys included), and - make `fieldPredicate` exclusive. A test decodes real C# bytes against the - schemas. -- **R-T, F14 and F15:** removed changes are evaluated without reading a - document. A removal matches through the selection's keys and schemas - alone, and field predicates count as unknown. `cites` checks every edge - against its declared target. -- **R-U: every surviving mutant from the early pass becomes an entry, and it - must die.** That covers S1-Loose, S2-AllOf, S17-FirstLeaf, - S5-DigestNoFields, Auth-LiveSchemaId, QL-OutOfTargetCites and an S4 entry. - -**Fix batch 3 is landing in pieces, 2026-09-22.** - -- **The harness is retired** (operator ruling). The branch's `mutate-dotnet.ps1`, - the four `*.entries.ps1` and the Rust entries file are deleted. Tests pin - behaviour, and Soul runs Stryker.NET and cargo-mutants on the diff. -- **C#, first pass** (`8d7bee9..f366791`): R-R (v0 lowering answers exactly - what pre-cut v0 answered, key order included) and the cites-refusal vectors. - C# now reads 16 of 16 Rust vectors. It stopped there, so R-N, R-O, R-P, R-Q, - R-S, R-T and R-U were left. - - **R-R meets R-F:** the raw v0 wire answers an explicit empty list with an - empty page, while Mesh's `EnsureV0Compatible` refuses that list before - sending. Both layers keep their own ruling, which is right. -- **Rust** (`5cf31d1..c606eb3`): R-O (keyed cursor), R-T, R-R, and the entries - file deleted. - - **The cursor API for Huginn:** `Cursor::parse` stays public and key-free - and still exposes `as_of`. Only digest verification needs the process key, - so no second helper was needed. - - Rust reads 17 of 17 C# vectors. - - **R-N is pending in Rust by design.** An earlier guess at the wire shape - was backed out; Rust matches C#'s field names byte for byte once they land. -- **C# pass A is in Hands:** R-N, R-P, R-T. **Pass B follows:** R-O, R-Q, R-S, - R-U. -- **C# pass A landed** (`66b5e60`, `e8a3dec`): R-N, R-P and R-T. - - **R-N's wire shape:** `CultNetErrorMessage` gains `code` and `details`. - `CultNetErrorDetails` carries `field`, `value`, `asOf` and `current`. The - codes are `selection_invalid`, `cursor_stale`, `cursor_invalid` and - `reference_outside_target`. The schema gains both fields additively. - - Two catch sites that previously let the exception escape now answer the - peer. - - **R-P:** the live fast path authorized on the descriptor id and now uses - the wire id, so all three paths agree. - - **R-T:** a removal skips field predicates instead of dereferencing a null - document. `cites` no longer early-returns on a null target, and **the root - cause was in CultCache**: a many-reference declared without an explicit - attribute left its target type null, even though the element type was - statically known, so the out-of-target check silently skipped. - - Networking 223 pass and 2 skip; Mesh 255. - - **The Caching claim was wrong.** There is one failure, not three, and it - is identical at the branch head, at `b3d9cf7` and on `main`. It expects a - write to a read-only directory to fail, which cannot happen in a container - running as root. **Environmental.** -- **Rust R-N landed** (`eff62e5`): 213 tests pass. Parity is pinned by - **captured C# bytes** for all four codes, taken from a probe run on the - dotnet image. - - **An encoding trap the vectors would not have caught:** MessagePack C# - always writes all five declared keys in declaration order, nil included, - while Rust's generic path sorts keys alphabetically. Rust now builds that - map by hand. **A key added or reordered in either error type must be - mirrored there.** - - **A defect in C#, sent to pass B:** `ForReferenceOutsideTarget` prefixes a - message that already carries its own prefix, so a peer reads - `reference_outside_target: reference_outside_target: …`. - - The parity-vector machinery carries no concept of an error message, so - R-N's parity lives in its own byte-level test. That is the right home. -- **C# pass B landed** (`a4cf54f`..`52082ec`): R-O, R-Q, R-S and R-U, plus the - doubled `reference_outside_target` prefix. - - Networking 238 pass and 2 skip; Mesh 255. - - **Hand probes: six of seven killed.** S1-Loose, S2-AllOf, S17, S5-DigestNoFields, - Auth-LiveSchemaId and S4 all die. - - **QL-OutOfTargetCites is a genuine gap.** Since pass A fixed - `CultCache.ResolveReferenceTarget`, the fixture's many-reference gets an - inferred target type, so the guard never fires for it. `TargetType` is now - null only for a scalar reference typed as the bare `ICultRecordRef`, and - no fixture reaches that shape. **Open for Soul:** is that shape - registerable at all? If it is, pin it. If it is not, the null branch is - dead and goes. - - **Two fixes found while writing the tests:** - - the cursor's length prefix counted UTF-16 chars, so an astral key did - not round-trip; - - `MiniJsonSchemaValidator` had no `not`, so the exclusivity test found - nothing. - - **`cultnet.raw-document-record.schema.json` was missing `schemaName`, - `schemaVersion` and `schemaContentHash` entirely.** Any real C# page would - have failed it on `additionalProperties: false` before R-S's own bugs even - mattered. - - **Self's correction: cursors need no cross-runtime byte parity.** The - brief for pass B said Rust and C# must encode a cursor identically, on the - premise that one runtime parses another's cursor. **That premise is - wrong.** A cursor is opaque, minted by the answering server and echoed - back by the caller, and the digests cannot verify across processes because - each server holds its own key. Both encodings are length-prefixed - five-field bodies with an HMAC-SHA256 digest, and they differ in base64 - flavour and separators. That is correct and stays. Rust's own doc said so - first. - - **Settled by Soul:** three Caching failures, identical at `b3d9cf7` and - `52082ec`. All three expect a write to a read-only directory to fail, and - the container is root. **Environmental.** Pass B's count was right. - -**Soul's whole-cut pass, 2026-09-22** (Opus, at `52082ec`). **Cut 1 may not -merge.** - -- **Held:** Networking 238 and 2 skips, Mesh 255, cultnet-rs 213. R-Q holds - (both replica applies, the startup rebuild, and a shard-spanning refusal). - R-O holds against a stranger and across a restart, and the astral key - round-trips. R-H's digest covers every selection term. R-R holds and is - honest. R-A holds. R-F's door is inside `EvaluateAll`, which every path - reaches. The canonical-number door is anchored `\A..\z`. -- **S-1, high: the hop indexes rows by record key alone.** `byKey` - (`CultNetSelectionEvaluator.cs:176-178`) and Rust's `HashMap<&str, &R>` - (`selection.rs:1457`) both keep the last row at a key. CultCache keys are - unique **per schema**, and the Mesh tests already put two schemas at one - key. With rows in one order the selection refuses valid data as - `reference_outside_target`; in the other order it succeeds. Under `cited` - the index is a set of bare keys, so a row of one schema counts as cited - because something cites another schema at the same key. -- **S-2, high: R-T's `cites` half did not land in C#.** C# filters edges by - the citation's key (`:355`) **before** `EnsureWithinDeclaredTarget` (`:359`); - Rust checks first. A many-reference declared to one leaf but holding an edge - into another is refused by Rust and accepted by C#. -- **S-3, high: a selection with both hops loses its `cites` edges in Rust.** - `selection.rs:1541` computes one direction for every edge; C# anchors each - edge by its own hop. No vector covers both hops. -- **S-4, high: the error message fails its own schema.** Nothing decodes real - `CultNetErrorMessage` bytes against `cultnet.error.schema.json`. - `routingHint` is undeclared under `additionalProperties: false`, and nil - `details.asOf`, `details.current`, `code` and `details` all fail their - declared types. The header schema shows the nullable pattern was understood - and simply not applied here. -- **S-5, medium: Stryker leaves 85 survivors** over the cut's two core files: - 396 mutants, 310 killed, 85 survived, **75 with no coverage at all**, score - 66%. The old "every entry killed" was an artifact of a hand-picked list. -- **S-6, medium: the cursor is malleable in its own position.** The digest - covers the selection but not the body's `asOf`, `ordinal`, `schemaId` or - `recordKey`. A caller holding one valid cursor can rewrite its position and - reuse the digest. -- **S-7, medium: R-I's claim is false.** The vectors compare ids, `matched`, - `hasNext` and edges. They never compare page bytes, `asOf`, `next`, or a - refusal's code, and `rs-written.json` carries no projection vector at all. -- **S-8, low: the out-of-target null branch is dead.** D11 refuses a bare - `ICultRecordRef` in both scalar and many form, so every surviving shape has - a non-null target. -- **S-9, low: `asOf` is the maximum across all shards**, so a single-shard - page advertises another shard's number, and an unrelated write invalidates - a live cursor. -- **S-10, low: `MiniJsonSchemaValidator` silently ignores** `minimum`, - `maximum`, `maxLength`, `maxItems`, `anyOf`, `allOf`, `if/then/else`, - `uniqueItems` and `format`. The committed schemas use `minimum` 34 times, - including `limit`'s bound. Its `pattern` also runs through .NET `Regex`, - whose `$` accepts a trailing newline. -- **S-11, low:** the v1 subscribe refuses a hop before running the door and - always names field `cites`. -- **S-12, low:** the fixture's prose still calls the alias vectors a - cross-runtime defect, which the alias port fixed. Rust's port also drops - `SchemaVersion` and `CompatibleSchemaIds`, so R-E is two rules agreeing on a - subset. -- **Where the evidence lives.** The pass ran against `52082ec`. Its nine hand - probes — eight C# and the Rust both-hops probe that proved S-3 on a matched - pair — are on branch `soul-probe` in the scratch clone `C:\ss5` - (`e0fd672..107ff5f`), **unpushed**. Nothing reached origin and the pinned - checkout at `F:\Projects\CultLib` was never touched. Notes are at - `scratchpad/soul-sel-final-notes.md`. Probes are scratch by doctrine and are - meant to be thrown away; this pointer exists because S-1 through S-3 were - proven there and fix batch 4's tests have to reproduce them independently. If - `C:\ss5` is gone, the probes are gone with it, and that is acceptable — the - findings are recorded here, which is the durable copy. -- **Soul's own corrections, same pass.** It first reported a wedged - `cargo mutants` job on Yggdrasil and asked for it to be killed. Checking the - box rather than its log, the work directory was already gone: what it had - lost was its detached ssh's output. Self had by then killed the process, - which was Soul's own run, so nothing else was affected. **The shape is worth - keeping: a log is not the machine, and Self acted before confirming the - process was actually stalled.** -- **The Stryker triage is owed, after four attempts, none of them Stryker's - fault.** The `dotnet/sdk:10.0` image has no `python3`; the JSON reporter threw - on `CreateDirectory`; two runs with a different output path ran about fifty - minutes without finishing, against three minutes for the mutation phase that - did complete. The completed run stands, and the command is in Soul's notes: - `dotnet-stryker --project GameCult.Networking.csproj --mutate "**/CultNetSelection*.cs" --reporter json --reporter progress --concurrency 4`. - **Read the HTML report Stryker writes beside the JSON**, rather than parsing - JSON in the container. -- **The triage landed on the fifth attempt, 2026-09-22, and it sharpens S-5 - rather than softening it.** Most of the 85 are message-string mutations and - are not worth chasing; roughly a dozen are real, and they sit on the rules - this cut exists to enforce: - - `CultNetSelection.cs:439`, `Any()` → `All()` survived. §2's "a comparison - on an alias any reachable schema declares non-numeric is refused" degrades - to "refused only if every reachable schema declares it non-numeric". No - fixture has an alias numeric on one reachable leaf and a string on - another — the exact case the rule exists for. - - `CultNetSelection.cs:387`, `||` → `&&` survived. A `cites.target` with a - schemaId and a blank recordKey, or the reverse, stops being refused. - - `member.IndexAlias ?? member.MemberName` → `member.MemberName` survived at - all four sites (`CultNetSelectionEvaluator.cs:352,384,404` and - `CultNetSelection.cs:445`). §2's role-naming rule for the hop is untested - everywhere it is implemented. - - `CultNetSelectionEvaluator.cs:203,204`, `ThenBy` ↔ `ThenByDescending` - survived on both paths: the `(ordinal, schemaId, recordKey)` tiebreak is - pinned by nothing. - - `CultNetSelectionEvaluator.cs:290-297`, five order flips in `EdgesFor`, all - survived. **R-B's "page-row order, then `(from, role, to)` in code-point - order" has no test pinning a single component**, though R-B exists because - that ordering was wrong once already. - - `CultNetSelectionEvaluator.cs:590-605` has no coverage at all: - `ComputeDigest`'s `cites` and `cited` branches, plus the number-presence - flag at `:584-585`. R-H's collision-freedom rests on a reading, not a test. - - `:673,678` survived: `Cursor.Parse`'s length-prefix bounds checks, on - attacker-supplied base64. `:307,322` survived: cursor positioning under - `descending`, and `ComparePosition`'s schema-vs-key tiebreak. `:710,715,729` - survived: the code-point comparator's surrogate path, despite R-C having a - vector for it. - - **Equivalent, not to be chased:** `Selection.cs:103` `<=`, `:118` `>=`, - `:121` `dot <= 0` are guarded upstream and unreachable for canonical input; - `:103,107` turn `sign * x` into `sign / x` with `x` in {-1,1}; `:109` - Max→Min is a no-op because `PadRight` to a shorter length does nothing; - `:80` is a `RegexOptions` flag; `Evaluator:272` is equivalent because - `full.Edges` is empty without a hop. -- **Soul withdrew a finding of its own in the same pass.** It had reported that - C#'s cursor digest does not sort its lists while Rust's does, offered as a - low-severity intra-runtime difference. `CultNetSelectionEvaluator.cs:637` - sorts by code point, same as Rust. The remark belongs in no fix list. -- **cargo-mutants cannot run on `cultnet-rs` at all.** `schema_discovery.rs` - uses `include_str!("../../../contracts/…")`, which escapes the package, so - the tool's copied tree fails its baseline build. **There is no mutation - evidence for the Rust half**, and the package's own source shape is why. - -**Self's rulings for fix batch 4, 2026-09-22:** - -- **R-V (S-1). A row's identity is `(schemaId, recordKey)`, never the key - alone.** Both runtimes index rows and incoming edges by the pair. `RecordRef` - already carries both. Tests: two schemas at one key, in both row orders, - give the same answer; `cited` does not match a row of another schema at the - same key. -- **R-W (S-2 and S-3). The hop behaves identically in both runtimes.** The - target check runs **before** any key filtering, and each edge is anchored by - its own hop, so a selection carrying both `cites` and `cited` returns both - sets. C# adopts Rust's ordering for the first; Rust adopts C#'s per-edge - anchoring for the second. Vectors cover both. -- **R-X (S-4 and S-10). A message that fails its own schema is a defect.** - `cultnet.error.schema.json` declares `routingHint` and every nullable field - the way the header schema does. A test decodes real bytes for every code. - `MiniJsonSchemaValidator` **refuses a schema containing a keyword it does - not implement**, rather than ignoring it, and implements `minimum`, - `maximum`, `maxLength` and `maxItems`. Its `pattern` anchors so a trailing - newline cannot pass. -- **R-Y (S-6).** The cursor's HMAC covers the body as well as the selection, - so the position cannot be rewritten. Test: a rewritten ordinal or key is - refused. -- **R-Z (S-7).** The vectors compare **page bytes**, plus `asOf`, `next` and a - refusal's code, and carry a header-projection and a document-projection - vector on both sides. Where a field is legitimately per-server, such as a - cursor, the vector states that and compares the rest. -- **R-AA (S-5).** Triage all 85 survivors by name. The 75 uncovered mutants - mean whole regions have no test; write behavioural tests for them. A - survivor that is genuinely equivalent gets a one-line reason in the report. - **The triage half is done** (see the list under S-5, 2026-09-22). What is - owed is a behavioural test for each named live survivor: the alias - disagreement across reachable schemas, the half-blank `cites.target`, the - four index-alias role sites, the row tiebreak, `EdgesFor`'s ordering, - `ComputeDigest`'s hop branches, `Cursor.Parse`'s bounds, descending cursor - positioning, and the surrogate comparator. The equivalent mutants are - recorded and are not to be chased. -- **R-AB. Make `cultnet-rs` self-contained enough to mutate.** Replace the - escaping `include_str!` with a build script that stages the contracts into - `OUT_DIR`, or another route that keeps every source path inside the package. - Prove it by running cargo-mutants on the crate. -- **S-8:** delete the dead branch. -- **S-9:** `asOf` is the watermark of the shards the selection actually - covers. -- **S-11:** run the door first, and name the field that is actually set. -- **S-12:** correct the fixture's prose, and state Rust's alias reduction - where R-E is described. - -**Fix batch 4, C# half, landed** (Sonnet) on `cultnet/selection-cut1`, -`52082ec..b7d77b7`: - -- `e99bf46` — R-V, R-W, S-8, R-Y: row identity is `(schemaId, recordKey)`; the - cursor's HMAC covers its own body. -- `b3a8f6d` — S-9, S-11: `asOf` is scoped to the shards the selection covers; - the v1 subscribe door runs before the hop refusal. -- `b7d77b7` — R-X, S-4, S-10: the error schema matches the wire; the validator - refuses keywords it does not implement. - -Counts on Yggdrasil: Networking 247 pass / 2 skip (was 238, so +9 tests), Mesh -255 pass. Caching's three failures are the read-only-directory tests that -cannot fail under the stopgap's root container — unproven, not passing. - -**R-V proven in both row orders.** Before the fix, a narrow reference declared -to `SelLeafA` resolving a key shared with a `SelLeafB` row depended on -insertion order: one order refused a valid reference as `reference_outside_target`, -the other silently resolved to the wrong schema's row for `cited` membership. -After it, both orders resolve through the leaf set and `cited` never matches -the other schema's row at that key. - -**What R-X's new refusal exposed.** Twelve uses of unimplemented keywords -across three committed schemas, none reached by any existing test: -`cultnet.database-change-raw.schema.json` uses `allOf` and `if`/`then`/`else`; -both `cultmesh.verse-catalog-*.schema.json` use `uniqueItems` six times and -`anyOf` once. **Those three schemas are now formally unvalidatable** until -either the keywords are implemented or the schemas are rewritten. Nothing -broke, because nothing ran them through the validator — which is the finding. - -**Two gaps Hands named rather than papered over:** - -- **S-11 has no dedicated regression test.** `HandleSubscribeV1Async` is - private and reachable only through a live LiteNetLib round trip. Fixed by - inspection, covered only by the suite passing. Owed. -- The three unvalidatable schemas above need a decision: implement the - keywords, or rewrite them. - -**Ledger correction.** Commits 0 and 1 came in at about twice the §14 -estimate: - -| Package | Actual (net) | §14 estimate | -|---|---|---| -| Caching | +297 | about +150 | -| Networking | +851 | about +400 | -| contracts | 163 | about 200 | - -The two new Networking files alone are 714 lines, against about 360. The hop's -incoming index, the cursor's digest and mint, and the per-field door messages -all cost more than the prose assumed. The binding-alias fix was not in the -ledger at all. The estimate was wrong; the scope did not grow. The deletions -in commits 2 to 4 (Mesh collapse, `_projectRecord`, the Rust -`snapshot_query.rs`) are still to be counted against the total. - -Status: cut map for two cuts. **Cut 1: the selection vocabulary in the C# -reference runtime and the Rust runtime** (sections 1-17), with the -follow-on runtime cuts and the Huginn consumer cut named and not mapped. -**Cut 2: watching a selection** (section 18), mapped to the same standard, -landing after Cut 1 and depending on it. Cut 1 goes to Hands without -waiting on anything in Cut 2. - -**Rulings this map is written under (operator, 2026-09-17):** - -- The vocabulary lands in CultLib at the CultNet layer, not in Huginn. It - fills a hole: the query side of the substrate was never dropped by - decision, only never asked for. `contracts/cultnet/cultnet-distributed-database.md:10-11` - names RethinkDB changefeeds with "point, table/schema, and **filtered - watches**" as prior art to keep; schema and key allowlists were built, - filtered selection never was. -- The first cut covers the C# reference (`src\GameCult.Networking`) and - Rust (`packages\cultnet-rs`, what Huginn consumes). TypeScript, Python and - Kotlin follow when they have a caller. -- Deletes first: the two untyped stand-ins are replaced, not sat beside. -- The CultMesh subtraction audit is a required part of the map, with real - line counts, and an honest miss beats a padded ledger. -- Nothing on the wire is serde-untagged (rule of this cut; evidence below). - -**What survives unchanged from the Huginn-targeted specification** -(`scratchpad/cut10b-read-side-spec.md`, D1-D4): the five capabilities, the -chosen representation and the rejected ones with their reasons, the order and -snapshot cursor, the role-typed hop, the one negation, and the bound. What -changes: owner, repo, the value model of a field predicate (strings, because -the cache's indexes are strings), the parity obligation, and the subtraction. - -## Pins - -| Repo | Branch | HEAD | State | -|---|---|---|---| -| CultLib | `main` | `51fb449` | dirty in `native/GameCult.Mesh.Quic.Native/*` and `scripts/mutate-cultmesh.mjs` from the live QUIC campaign; **not touched**. Every `file:line` below is against `51fb449`. | -| Huginn | `eureka/memory-organ` | `25a841d` | the consumer; its cut is named in section 12 and mapped in the Epiphany campaign map. | -| Epiphany leaf | `5cda0886` | — | untouched. | - -Baselines: `src/GameCult.Networking` 14,848 lines; `src/GameCult.Mesh` -21,720 lines; `tests/GameCult.Networking.Tests/NetworkingTests.cs` 141 -`[Test]`s; `packages/cultnet-rs` 111 `#[test]`s; `cultnet-rs` depends on -`serde`, `rmp-serde 1.3`, `rmpv`, `serde_json`, `cultcache-rs` and no -`schemars` (`packages/cultnet-rs/Cargo.toml:14-31`). - -## 1. Body facts, each by source read - -**The reference's read side today.** - -- `CultNetDatabase` (`src/GameCult.Networking/CultNetDatabase.cs`): change - kinds `:15-45`; `Watch` `:1052`, `WatchAllChanges` `:1063`, - `WatchRecord` `:1072`; `GetAll` `:776`; per-shard mutation log with - sequence (`:465-466`, `:528-590`, `:631` a snapshot carries - `ShardLogSequence`). `CultNetSchemaAliasMatching` `:47-97` is the one - schema-alias matcher, `internal static`. -- **Three separate selector engines evaluate the same two allowlists:** - `CultNetDocumentRegistry.CreateRawSnapshotResponse` `:263-349` (snapshot, - over `cache.AllEntries` by reflection at `:325-342`) with its own - `MatchesRequestedSchema` `:505-524`; `CultNetDatabaseServer.CreateChangeMessage` - `:347-384` with `Matches`/`MatchesSchema`/`InferSchemaName` `:401-468` - (the LiteNetLib path, the one production construction site, - `CultNetLocal.cs:154`); `CultNetDatabaseSubscriptionServer.CreateChangeCore` - `:442-485` with `MatchesRequestedSchema` `:493-500` (the RUDP path). Two - of them carry an identical reflection copy of `CreateRawRecord` - (`CultNetDatabaseServer.cs:386-399`, `CultNetDatabaseSubscriptionServer.cs:502-515`). -- **The two stand-ins.** `CultNetDatabaseSubscribeMessage` - (`CultNetSchemaMessages.cs:788-826`): `schemaIds` `:805`, `recordKeys` - `:809`, `includeSnapshot` `:813`, then `consumerRuntimeId`, `bodyIds`, - `supportedBodyTransports` (body-plane demand, not selection). The same - pair on `CultNetSnapshotRequestMessage` `:737-741`, - `CultNetShardCatalogRequestMessage` `:949-953` (shard routing, stays), and - `CultNetSchemaCatalogRequestMessage` `:1591` (catalog, stays). - `_projectRecord` on the subscription server (`:27`, `:44`, `:50`, - `:239-240`, `:314`): **no production caller constructs the subscription - server at all** — `grep "new CultNetDatabaseSubscriptionServer"` over - `src/` is empty; it is built only in tests (`NetworkingTests.cs:1596-1605` - passes `projectRecord`). Projection is authority that exists in the - reference and reaches no wire and no caller. -- **The cache already owns addressing.** `CultIndexAttribute` - (`src/GameCult.Caching/CultDocumentContracts.cs:34`, `Alias` `:41`) names - a member as an index; the cache's index values are **strings by - construction** (`CultCache.cs:1084` `Getter = document => getValue(document)?.ToString()`; - `GetByIndex(string alias, string value)` `:1503-1509`). - `CultReferenceAttribute` (`:45`, `TargetType` `:53`, `Many` `:55`) names a - member as a reference; the descriptor records `IsReference`, `IsMany`, - `TargetSchemaName` (`CultCache.cs:1069-1080`). So a field predicate's key - is an index alias and a hop's role is a reference member, both declared - by the document's owner. -- **The declarations are public; the values are not.** `ToCatalogEntry()` - (`CultCache.cs:151-176`) is public and carries every member's `Slot`, - `MemberName`, `TypeName`, `IsReference`, `IsMany`, `TargetSchemaName`, - `IsName`, `IndexAlias`. But `CultDocumentDescriptor.Members` (`:149`) and - `IndexAccessors` (`:148`) are **`internal`**, and `GameCult.Caching` - declares no `InternalsVisibleTo` for `GameCult.Networking` (the only - `InternalsVisibleTo` in `src/` outside the vendored Unity tree is - `GameCult.Caching.MessagePack` → `GameCult.Caching.Tests`). That is why - `CreateRawSnapshotResponse` reaches for reflection at - `CultNetDocumentRegistry.cs:325-342`. **An evaluator outside the cache - assembly cannot read a declared index's value today**, by any means the - cache offers. The earlier claim in this map that no cache change is - needed for addressing was wrong: addressing is declared publicly and - *readable* only inside the cache. Section 6 carries the read surface as - an add; sections 7 and 14 carry its cost. -- **Schemas are hand-written JSON** in `contracts/cultnet/*.schema.json`, - listed by file name in `CultNetSchemaRegistry.cs:360-385`; TypeScript - compiles them with Ajv and refuses unknown fields - (`cultnet.database-subscribe.schema.json` has `additionalProperties: false`); - Python lists required fields per version by hand - (`packages/cultnet-py/src/cultnet_py/schema_catalog.py:283`, `:407`); Rust - types are a serde enum tagged on `schemaVersion` - (`packages/cultnet-rs/src/contracts.rs:252`) with hand validation - (`require_optional_string_vec`, `:548-594`). C# encodes with - MessagePack-CSharp string keys (`[Key("name")]`), i.e. named maps; Rust - encodes with `rmp_serde::to_vec_named`. -- **Runtime coverage of the subscribe message today.** C# server ×2, C# - client (`CultNetDatabaseSubscriptionClient.cs:138-151`); TypeScript - contract only (`packages/cultnet-ts/src/contracts.ts:237-247`, no server - fanout, interop tests send it at `test/interop/cultnet-interop.test.ts:719,798`); - Python server ×2 (`cultnet_py/interop_peer.py:390-401` with - `DatabaseSubscription.matches` `:110-125`, and - `cultmesh_py/server.py:315-331` with `_subscription_matches_document` - `:449-470`, `_schema_matches_request` `:699-716`, and a third copy in - `cultmesh_py/node.py:1322-1345`); **Rust: none** — `cultnet-rs` has - `SnapshotRequest` with the two allowlists (`contracts.rs:314-321`) and no - `DatabaseSubscribe` variant; Kotlin: none (a snapshot-request builder, - `CultMesh.kt:2111`, and a `require(schemaVersion == "…snapshot_request.v0")` - at `:2195`). -- **How each runtime treats a message it does not know**, which is the - wire-parity fact section 9 rests on: C# throws - (`CultNetSchemaMessageSerialization.cs:85` `_ => throw`); TypeScript - `parseCultNetMessage` (`contracts.ts:728-744`) looks the version up in its - Ajv validators and throws when absent; Kotlin `require`s the exact version - (`CultMesh.kt:2195-2196`); Rust's tagged enum fails to deserialize on an - unknown tag; **Python returns `[]`** (`interop_peer.py:360-387` falls off - the chain; `cultmesh_py/server.py:341` delegates to `handle_message`) — a - silent drop, no reply, and for a *known* version with unknown fields - Python reads only the fields it expects (`message.get(...)`), so it would - answer a selector it did not understand with the wrong rows. -- **Every committed write has a shard-log sequence** — assumed from - `CultNetDatabase.cs:465` (`_nextLogSequences`) and the invariant "raw - snapshot/put/delete messages must pass through `CultNetDatabase`" - (`cultnet-distributed-database.md:115-116`); **Hands confirms** that a - local `PutAsync` outside any shard also takes a sequence, because the - order in section 3 rests on it. If it does not, the row's ordinal is its - `storedAt` and the cursor refuses on any advance (section 3 says what - that costs). - -**The Aetheria catalog as a hostile example, verified at -`F:\Projects\Aetheria\Assets\Scripts\ServerShared`:** - -- **Inheritance.** `ItemData.cs:272` `abstract class ItemData` (`Name` - `:275`, `Mass` `:284`), `:313` `abstract class CraftedItemData : ItemData` - (which re-declares `Name` at `:327`, hiding the base's), and only the - leaves carry `[CultDocument]`: `SimpleCommodityData` `:300`, - `CompoundCommodityData` `:330`, `GearData` `:449`, and more. The cache's - `DiscoverMembers` (`CultCache.cs:838-875`) walks `current.BaseType`, so a - leaf's descriptor carries every inherited member with its attributes - (`GetCustomAttribute` reads the member's own attribute wherever it is - declared). Inherited addressing therefore already resolves per leaf. - **Corrected on a second read:** duplicate *slots* are refused - (`:931-935`), and a **hiding member is already refused too** — `:937-943` - groups keyed members by `Member.Name` and rejects any name carried at two - different slots (`HiddenMemberMessage`), while a hiding member re-using - one slot is caught by the duplicate-slot rule. So `CraftedItemData`'s - `Name` at `ItemData.cs:327` hiding `:275` is **not registrable in - CultCache as it stands**, however the aliases fall; that is a finding for - the Aetheria campaign, not a hole here. What is *not* refused by name is - two members with **different names** carrying the **same index alias** - (base `[CultIndex("mass")] Mass`, leaf `[CultIndex("mass")] Weight`). - That does not collide silently either: the alias map is built by - `ToDictionary` at `:462-468`, which throws `ArgumentException("An item - with the same key has already been added")` — a real refusal, from the - wrong owner, naming neither schema nor member nor alias. D10 is therefore - a **renaming of an existing refusal**, not a new rule, and is smaller - than it first looked. -- **A reference to an abstract target.** `FactionProduct.cs:24` - `CultRecordRef Design`. `PersistedMember.FromMember` - (`CultCache.cs:1069-1071`) takes the target type and reads - `CultDocumentAttribute` from it — an abstract base has none, so - **`TargetSchemaName` is `null` today for exactly this reference**. The - registry can compute the leaf set by assignability (it already does for - index lookups, `:1506`, and store routing, `:1828`) but records nothing. -- **A dictionary reference with a value.** `ItemData.cs:333-334` - `[CultReference(typeof(PersonalityAttribute), many: true)] Dictionary, float> DemandProfile`. - The descriptor records `IsMany` (`:1080`) and nothing else: **no code in - the cache enumerates a many-reference's targets**, and the only accessor - is `Getter = document => getValue(document)?.ToString()` (`:1082`), which - on a dictionary yields the CLR type name. The edge and its payload are - declared and unreadable. -- **Numbers.** `PersistedMember.MemberType` (`:1053`) knows the member is a - `float`, and the catalog persists it as `TypeName` (`:38`, `:192`, - compared by `CompareSchemaShapes` `:605-629`), so **a member's numeric - type is already declared and persisted**; what is missing is a typed - accessor beside the string one. Adding an accessor is a runtime change - with no catalog-shape or hash change, because `TypeName` is already in - the canonical shape. - -**The cache's index maps are unique indexes, and the evaluator must not use -them.** `Index(stored)` (`CultCache.cs:1849-1862`) writes -`MapOf(_indexes, (type, alias))[value] = key` — **one key per value, last -writer wins**. Two rows of one type sharing an index value do not collide -loudly; the second silently displaces the first in the map. `GetByIndex` -(`:1502-1508`) reads that map and `Single` (`:1838-1848`) throws only -when *several types* assignable to `T` each contribute a key, never for two -rows of one type. So `GetByIndex` answers "the row that most recently -claimed this value", not "the rows with this value". - -The consequence for this cut is a rule, not a caveat: **the evaluator -evaluates every predicate per row, through the accessor, and never through -`_indexes` or `GetByIndex`.** The declared alias is an *addressing* name — -the cache's own lookup map is a different, lossier thing that happens to be -built from the same declaration. A "use the index, it is already there" -optimisation would turn `kind any_of [weapon]` into a one-row answer. S23 -pins it, `WatchByIndex` already has the bug (section 18), and the -unique-index behaviour itself is left exactly as it is: it is the cache's -own contract for a single-record lookup and this cut does not renegotiate -it. - -**A fourth selector engine, in Rust, that this map's first draft missed.** -`packages/cultnet-rs/src/snapshot_query.rs` (339 lines, `pub use` at -`lib.rs:29`) already serves and asks snapshot requests: - -- `serve_read_only_raw_snapshot` `:64-130` is an engine — it walks the - source's records, refuses a duplicate identity and an unregistered - schema, applies a `CultNetReadOnlySnapshotPolicy` allowlist of - `(schema_id, record_key)` pairs, then filters by the request's - `schema_ids` and `record_keys` (`:112-125`). The policy is - **authorization** and stays; `:112-125` is the fifth copy of the same two - allowlists and goes, as the C# three do. -- `CultNetRawSnapshotQuery` `:158-285` is the client side, and it exposes - the sharpest limit of the two-allowlist model: `request()` `:195-217` - turns a set of exact `(schema, key)` pairs into the **cross product** of - their schemas and their keys, and `accept_response` `:219-284` then - *errors* on any record the cross product dragged in - (`"snapshot response contains unexpected record"`). Asking for `(A,x)` - and `(B,y)` asks for four and refuses two. **v1 does not fix this**: - `schemas × keys` is still a cross product (section 13, Q-M). What v1 does - change is that `accept_response`'s check is a *declared expectation*, not - a re-filter of a page the server got right, and section 8's forbidden - writer is worded to keep that distinction honest. - -**The CultMesh audit** (Explore pass over `src/GameCult.Mesh` and -`CultNetDocumentRegistry.cs`, 92 sites classified) is in section 11 with its -numbers. - -## 2. The vocabulary, exactly - -One shape, `cultnet.selection.v1`, carried by two request messages at a new -version and answered by one page message. Field names are the wire -spelling; C# and Rust type names follow each runtime's convention. - -``` -Selection { - schemas: string[]? // kinds: schema ids or aliases, matched by CultNetSchemaAliasMatching; absent = every schema - keys: string[]? // record-key allowlist; absent = every key - fields: FieldPredicate[]? // conjunction; each is any-of over one declared index alias - cites: Citation? // rows whose declared reference `role` (or any reference) names `target` - cited: Incoming? // rows that some row does / does not name in `role` - projection: "header" | "document" // default "header" - descending: bool // default false; the one order, reversed - limit: uint32? // clamped 1..=200 - cursor: string? // opaque; minted by the answering server -} -FieldPredicate { // one operator per predicate, named by `op`; no union, no untagged - index: string // a declared index alias, reachable on some schema the selection can reach, else refused - op: "any_of" | "lt" | "le" | "ge" | "gt" - values: string[]? // present iff op = any_of; non-empty - number: string? // present iff op is a comparison; a canonical decimal (Q-J, below); the alias must be declared numeric on every reachable schema that declares it -} -Citation { target: RecordRef, role: string? } // target validated as a reference the row owner recognises -Incoming { role: string, exists: bool } -RecordRef { schemaId: string, recordKey: string } - -SelectionPage { // cultnet.snapshot_response_raw.v1 carries this - matched: uint32 - asOf: uint64 // the snapshot the page is exact for - next: string? // absent on the last page - headers: RawDocumentHeader[]? | documents: RawDocumentRecord[]? // exactly one present, by projection - edges: Edge[]? // present iff the selection has `cites` or `cited`: the edges the hop traversed - shardId?, shardEpoch?, shardLogSequence? // as v0 carries them -} -Edge { - from: RecordRef // the citing row - role: string - to: RecordRef // the cited row, its schema resolved to the leaf actually stored - payloadEncoding: "messagepack"?, payload: bytes? // the value attached to the edge (a dictionary's value), typed by the member's declared value type; present under `document`, absent under `header` -} -RawDocumentHeader = RawDocumentRecord minus payload, payloadEncoding -``` - -Messages: `cultnet.snapshot_request.v1 { messageId, selection, shardId?, shardEpoch? }`; -`cultnet.database_subscribe.v1 { messageId, subscriptionId, selection, includeSnapshot, consumerRuntimeId?, bodyIds?, supportedBodyTransports? }`; -`cultnet.snapshot_response_raw.v1` as above. `database_change_raw.v0` and -`database_unsubscribe.v0` are unchanged: a change is a change. - -**Rules, each one owner:** - -- **Order** is `(ordinal, schemaId, recordKey)` ascending, or the reverse - under `descending`. `ordinal` is the sequence of the commit that last - wrote the row (the shard-log sequence in the reference; the receipt - sequence in Huginn). The row owner supplies it; the vocabulary never reads - a clock. -- **Snapshot.** A page is exact as of `asOf`. A cursor carries `asOf`, the - last `(ordinal, schemaId, recordKey)`, and a digest of the selection with - `cursor` and `limit` cleared. A server that can present rows as of the - cursor's `asOf` answers the next page over that snapshot; **a server that - cannot refuses** `cultnet.error.v0 { code: "cursor_stale", details: { asOf, current } }`. - The reference refuses when the shard log has advanced (rows mutate, and a - replay is not this cut's); Huginn never refuses (append-only, one - writer). One contract, two honest servers. A cursor that does not decode, - or whose digest is not this selection's, is `cursor_invalid`. -- **Fields** are predicates over one declared index each, conjoined across - predicates: `any_of` over string values, or one of four comparisons - (`lt`, `le`, `ge`, `gt`) against one number over a declared numeric - member. A row of a schema that does not declare the index never matches - it. An `index` no reachable schema declares, an empty `values`, a - comparison on an alias any reachable schema declares non-numeric, an - empty `keys` or `schemas` list, and a `role` no schema declares are - refused typed at the door (`selection_invalid { field, value }`), never - answered as an empty page. `any_of` values are strings because the - cache's indexes are strings (`CultCache.cs:1084`). Comparison numbers are - **canonical decimal strings** under the operator's Q-J ruling. The rule is - specified in full in the **Numbers** item below. No range operator (two - predicates on one alias make a range), no `ne`, and no nesting. -- **Numbers (Q-J; Self's specification, 2026-09-22, which the ruling asked - for and this map had not yet written).** - - *Canonical form.* A number on the wire matches - `^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$`, and `-0` is excluded. That fixes - one spelling per value. It forbids leading zeros, trailing fractional - zeros, a bare trailing point, an explicit `+`, exponent notation, - negative zero, whitespace, and culture separators. - - *Non-canonical spellings are refused.* Any other spelling is refused at - the door with `selection_invalid { field: "number", value }`. It is never - normalised. The reasons: the cursor digest and the parity vectors need - one byte form per value, and a lenient parser differs between runtimes - at exactly these edges. The ruling says "a non-canonical spelling of an - equal value must not change an answer". That holds because such a - spelling never reaches the evaluator. It gets a typed refusal, not a - different page. - - *Row side.* The cache renders a numeric member as its exact canonical - decimal: - - An integer type renders as its exact decimal. There is no 2^53 limit. - - `decimal` renders with its trailing zeros stripped. - - `float` and `double` render in the runtime's established shortest - round-trip form (`"R"` in .NET, the standard shortest form in Rust), - rewritten from exponent notation to positional digits. Parity pins - this. - - A NaN or infinite member matches no comparison. - - *Comparison.* Both operands are canonical, so they are compared - numerically by sign, then by the integer part's length, then by the - integer digits, then by the fraction digits padded on the right. It is - one small pure function per runtime, with no numeric parse and no - precision limit. - - *Mutants that must die:* - - a lexicographic comparison ("10" < "9"); - - a comparison that ignores sign; - - a comparison that ignores the integer part's length; - - a door that accepts `+1`, `1.0`, `01`, `1e3` or `-0`; - - a row rendering through float64 (a `long` member of 2^53+1 must - compare greater than `"9007199254740992"`). - - Refusing a non-canonical number at the door, rather than normalising it, - was Self's reading of the ruling. **The operator accepted it on - 2026-09-22.** -- **Inheritance.** A schema is a leaf: only a type with `[CultDocument]` - has a schema name, and `schemas` names leaves. A predicate on an alias - applies to every reachable leaf whose descriptor carries a member with - that alias, inherited or own — the cache already discovers inherited - members per leaf, so "gear or commodities with `mass` over 10" is - `schemas: [geardata, simplecommoditydata], fields: [{index: mass, op: gt, number: 10}]` - with `mass` declared once on the abstract base. Two leaves that each - declare a member with the same alias independently are two members; the - predicate reads each leaf's own. One leaf that resolves the same alias to - two members (a base member and a hiding re-declaration both carrying the - alias) is refused at registration (D10). A numeric comparison on an alias - that is numeric on one reachable leaf and a string on another is refused - at the door, because the row owner cannot compare a string. -- **The hop** reads edges the row owner declares: in the reference, members - with `CultReferenceAttribute` (role = the member's index alias or name); - a one-reference yields one edge, a many-reference yields one edge per - element, and a dictionary keyed by references yields one edge per key - **with the value as the edge's payload**. In Rust, `Row::references()` - yields `(role, target, payload)`. **A reference's target is a set of - leaves**: the declared target type's own schema if it is a leaf, else - every registered leaf assignable to it (`CraftedItemData` → - `compoundcommoditydata`, `consumableitemdata`, …). The hop follows an - edge into any row whose schema is in that set. **A stored edge naming a - row whose schema is outside the set refuses the selection** - (`reference_outside_target { from, role, to }`), not skips it: an edge the - declaration forbids is corrupt data and a read does not paper over it. - The citer's status, fields and authorization are not consulted by the - hop; one hop, no second. -- **Edges in the answer.** A selection with `cites` or `cited` returns, beside - the rows, the edges the hop traversed, each with its payload under - `document` projection and without it under `header`. A weighted reference - and a bill of materials are the same shape: follow the edge, return the - payload, filter the rows. **Filtering on the payload as part of the - traversal is not expressible** (section 13): that is a join condition. -- **Projection.** `header` is the record without its payload; `document` is - the record. The consumer decides what a header *means* for typed rows - (Huginn's typed summary is its header; the substrate carries whichever - type the page was instantiated with). -- **Not on the wire:** the admission window (`admittedAfter/Before`) of - Huginn's Cut 9 — `descending` plus the cursor answer "the latest"; a date - window is a range, and ranges are out (R-3). - -**The bound, carried forward in force, with one widening the operator -ruled.** Closed key set (declared aliases), a closed operator set (any-of -and four comparisons, one per predicate, no nesting), no path language, no -expression terms, no OR, one negation, one hop with its edges returned, no -depth field, no sort field, no text, no predicate on an edge's payload in -the traversal. Section 13 lists what is deliberately impossible. The -capability set is now: **C1** typed predicates over declared members, -string any-of and numeric comparison; **C2** projection; **C3** order and -cursor; **C4** one hop, both directions, returning typed edge payloads; -**C5** existence and count without bodies. - -**Nothing on the wire is serde-untagged — rule, with evidence.** Probe -`scratchpad/untagged-probe/` (result in `untagged-probe-result.txt`), run -2026-09-17 against `rmp-serde 1.3.1` / `rmp 0.8.15` / `serde 1.0.229` (the -versions Huginn's lockfile resolves; `cultnet-rs` pins `rmp-serde 1.3`): -every `#[serde(untagged)]` enum with struct variants **fails to round-trip -under positional MessagePack** (`data did not match any variant of untagged -enum`) and passes only under named maps; externally tagged shapes pass under -both. The reference encodes named maps and so does Rust today, so an -untagged shape would work by accident of an undocumented encoding choice. -`grep untagged packages/cultnet-rs/src packages/cultcache-rs/src` is empty -at `51fb449`; this cut keeps it empty and pins it (section 10). - -## 3. Decisions, with the reasons - -**D1. Strings for field values, not typed enums.** In the Huginn-targeted -design a predicate was `Option>`, closed at compile -time. The substrate cannot know a consumer's enums, and the cache's own index -model is string-valued; so the wire carries strings and the row owner -refuses a value outside its enum at the door. The loss is compile-time -closure; what is kept is runtime closure with a typed refusal, which is the -same guarantee to a caller. - -**D2. Declared aliases, not a field-path language.** `index` and `role` are -names the document's owner declared with `CultIndexAttribute` and -`CultReferenceAttribute`; an undeclared name is refused. A dot-path over the -payload would be the string language the bound excludes, and the cache -already has the declaration mechanism. - -**D3. A new message version, not new fields on v0.** Section 1's fact: -Python reads only the fields it knows and would answer a v0 selector it -did not understand with the wrong rows. A v1 version string makes every -runtime not in this cut refuse or drop by its own dispatch rather than -mis-answer. The cost is that v0 stays answerable by the reference until the -follow-ups land (R-1, a scheduled deletion with a named trigger). - -**D4. One evaluator in the reference, three engines deleted.** `Select` -over `(descriptor, key, document)` rows from the cache replaces -`CreateRawSnapshotResponse`'s loop, `CultNetDatabaseServer.CreateChangeMessage`'s -`Matches`, and `CultNetDatabaseSubscriptionServer.CreateChangeCore`. -`CultNetSchemaAliasMatching` (`CultNetDatabase.cs:47-97`) becomes the one -alias matcher and goes `public`; the four Mesh copies and two Networking -copies go. - -**D5. Projection becomes a wire value; `_projectRecord` dies.** The -delegate ran after authorization and reached no caller. `projection` on the -selection is the same authority as a value; `_authorizeRequest` and -`_authorizeRecord` stay, because authorization is not selection. - -**D6. Subscriptions evaluate the selection on the snapshot and on every -change, and hop-bearing selections reconcile.** `cites`/`cited` are -set-dependent: a change to row B can change whether row A matches. The -subscription server already owns a diff-against-delivered reconcile -(`Reconcile` `:251-292`, `DeliveredBySourceRecordKey`); a hop-bearing -subscription runs it on every change instead of the per-change fast path. -That is the mechanism by which "watch a question" falls out, and this cut -does **not** add a local `CultNetDatabase.Watch(Selection)`; section 11 -records what that later cut would collapse; it is Cut 2, section 18 (R-5). - -**D7. The Rust runtime carries the evaluator, not only the types.** Huginn -evaluates selections over its own rows; if `select` lived only in C#, Rust -consumers would each write one. `packages/cultnet-rs/src/selection.rs` -holds the types, the cursor, and `select` over a `Row` trait, with no -subscription client in this cut (Huginn does not subscribe). - -**D8. Numbers are addressable through the type the cache already declares; -the cache gains accessors, not attributes.** A comparison needs a numeric -value the string getter cannot give. `CultCache.cs:1053` holds the CLR -`MemberType` and the catalog already persists `TypeName` in the canonical -shape, so numeric-ness is declared; what is added is runtime: on each -member, `ValueOf(document) -> object?` and `NumberOf(document) -> string?` (the exact canonical decimal, Q-J; was `double?` before 2026-09-22) -(non-null exactly for the closed set of CLR numeric types), and the public -member entry exposes `IsNumeric` derived from `TypeName`. No new attribute, -no catalog-shape change, no schema hash moves, no migration. Rust mirrors -it as `Row::number(index)` and `RowSet::is_numeric(schema, index)`; the -consumer decides from its own types. - -**Verified by source read, because everything downstream rests on it:** -`CultDocumentMemberDescriptor.TypeName` is set at `CultCache.cs:474` from -`CultSchemaTypeNames.FromType(member.MemberType)`, which renders a CLR full -name (`System.Single`, `System.Nullable`, -`System.Collections.Generic.Dictionary, -System.Single>`); `ToCatalogEntry()` `:168` copies it into -`CultSchemaMemberCatalogEntry.TypeName` `:42`; `CompareSchemaShapes` -compares it inside `CanonicalSchemaMember` `:605-629`. Numeric-ness is -declared and persisted today, and the claim holds. - -**Two corrections to the estimate.** First, `IsNumeric` has two possible -authorities — the CLR `MemberType` in hand and the `TypeName` string read -back from a stored catalog. They must agree, or a schema validates one way -locally and another way against a persisted catalog. **The CLR set is the -authority** (a closed set: the eight integer types, `float`, `double`, -`decimal`, and `Nullable` over any of them); the string set is generated -from it by `CultSchemaTypeNames.FromType`, never hand-written. S22 pins -that. - -Second, and larger: the accessor is not the only thing missing. Per section -1, `IndexAccessors` and `Members` are `internal` and `GameCult.Networking` -is not a friend assembly, so the evaluator cannot read *any* declared -value — string or number — without the reflection the map is deleting. The -cache add is therefore a **public read surface over declared members**, of -which the numeric accessor is one method: -`TryGetIndexValue(object document, string alias, out string? value)`, -`TryGetIndexNumber(object document, string alias, out string canonicalDecimal)` -(**revised 2026-09-22 under Q-J**: `NumberOf` returns the member's exact -canonical decimal string, never `double`; see §2 "Numbers"; commit 0 shipped -`double` and is corrected in the Q-J fix), `ReferencesOf` (D11), and a public member view carrying `IndexAlias`, -`IsReference`, `IsMany`, `IsNumeric` and the target **`Type`** (D9 needs -the type, not the name). Section 14 carries the revised cost. - -**D9. A reference's target set is resolved by the registry at evaluation, -not persisted.** Persisting `TargetSchemaNames` would change the canonical -member shape and every stored catalog's hash for such schemas. Resolving at -evaluation by assignability over registered leaves (`:1506`, `:1828` do -this today) costs nothing and is correct as of the registry in hand. The -catalog's `TargetSchemaName` stays what it is (`null` for an abstract -target) and is not read by the hop. - -The resolution needs a **`Type`**, and `CultDocumentMemberDescriptor` -carries only `TargetSchemaName`, a string that is `null` in exactly the -case that matters. So the public member view exposes the target `Type` -(held already on `PersistedMember.MemberType` / the attribute's -`TargetType`, `CultCache.cs:1069-1070`), and the leaf set is -`AllDescriptors` (public, `:254-255`) filtered by -`target.IsAssignableFrom(d.DocumentType)` — the same predicate -`GetByIndex` uses at `:1506` and store routing at `:1828`. It must be -computed **against the registry at evaluation, not at descriptor build**: -`BuildDescriptor` runs per type as it is registered, and a leaf registered -later would be missing from a set frozen early. A cached set keyed by -target type is legitimate only if it is invalidated by `Refresh()` -(`:257-265`) and by `RegisterDescriptor` (`:381-410`); simplest is not to -cache it. - -**D10. The duplicate-alias refusal is renamed, not invented.** The Aetheria -hiding case turned out to be refused already (section 1: `HiddenMemberMessage` -at `CultCache.cs:937-943`), and two differently-named members sharing one -alias already throw — from `ToDictionary` at `:462-468`, as an -`ArgumentException` that names neither the schema, nor the members, nor the -alias. D10 is to replace that with a rejection in `DiscoverMembers`' -`rejections` list, in the shape of its neighbours: one grouping over -`IndexAlias` beside the two at `:931-943`, one message beside -`DuplicateSlotMessage` / `HiddenMemberMessage`. The behaviour barely -changes; what changes is that the refusal can be read. Nothing in the -selection depends on it — a colliding catalog never registers either way — -so it is in this cut only because the cut is already in this file and the -message is four lines. - -**D11. Many-references are enumerated by the cache, with payloads.** On each -reference member, `ReferencesOf(document) -> IEnumerable<(CultRecordKey target, object? payload)>`: -a `CultRecordRef` yields one; an `IEnumerable>` yields -each with no payload; an `IDictionary, V>` yields each key -with its value. Anything else declared `many` is refused at registration -(`unsupported_reference_shape`), so a declaration the cache cannot walk is -never silently walked as nothing. - -The enumeration reads `ICultRecordRef.Key` (`CultDocumentContracts.cs:92-95`), -which every `CultRecordRef` implements (`:97-99`), so it needs no -generic dispatch. Two shape rules fall out of `ResolveReferenceTarget` -(`CultCache.cs:1100-1113`) and belong in the same refusal: a member -declared `many` whose element type is not a `CultRecordRef<>` and whose -attribute names no `TargetType` has **no resolvable target at all** -(`IsReference` is true only because the attribute exists, and -`TargetSchemaName` is `null` for a reason that is not inheritance); and a -`many` member that is neither `IEnumerable>` nor -`IDictionary, V>` cannot be walked. Both are -`unsupported_reference_shape` at registration. A `many: false` member whose -type is not `CultRecordRef` is the same fault and takes the same -refusal. - -**Agreement with our own abandoned prior art** (`ThreadEpiphanyGraphQuery`, -Epiphany `5c62a650`, deleted with its host `5f6f2441`): direction and edge -kind as data, schema derived — agreed; differs as before (predicate not -subgraph read; roles are declared names not free strings; one negation -added; no depth field). - -**Rejected, unchanged in reason:** Qdrant's `Filter` (open dot-path keys, -polymorphic values, OR/threshold/recursion advertised then refused, the -untagged hazard; Cut 11 lowers `FieldPredicate` to `must`/`match.any` in ~30 -lines on the daemon side); ReQL term trees; Firestore `CompositeFilter`; -Mongo/ES magic-key documents; every string-parsed evaluator surveyed. - -## 4. Deletes first - -| Path:lines (at `51fb449`) | Lines | What | -|---|---:|---| -| `src/GameCult.Networking/CultNetDatabaseServer.cs:347-468` | 122 | `CreateChangeMessage`, `CreateRawDocumentRecord`, `Matches`, `MatchesSchema`, `InferSchemaName` — the LiteNetLib selector engine; the handler calls the shared evaluator and the shared raw-record projection | -| `src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs:424-515` | 92 | `CreateAuthorizedChange`, `CreateChange`, `CreateChangeCore`, `ResolveWireSchemaId`, `MatchesRequestedSchema`, `CreateRawRecord` — the RUDP selector engine | -| `CultNetDatabaseSubscriptionServer.cs:27, :44, :50, :239-240, :314` | 6 | `_projectRecord`, its parameter and its two call sites | -| `CultNetDatabaseSubscriptionServer.cs:294-322` | 29 | `CreateProjectedSnapshot` body: becomes evaluate + authorize + project (~12 lines stay) | -| `src/GameCult.Networking/CultNetDocumentRegistry.cs:263-349` | 87 | `CreateRawSnapshotResponse`: the loop and its reflection over `cache.AllEntries` (`:325-342`); the raw-record construction (`:297-311`) survives as `ToRawRecord` | -| `CultNetDocumentRegistry.cs:505-524` | 20 | `MatchesRequestedSchema`, a copy of `CultNetSchemaAliasMatching.Matches(candidate, descriptor)` | -| `CultNetDocumentRegistry.cs:247-258` | 12 | `CreateSnapshotRequest` (v0 builder); v1 takes a `Selection` | -| **Networking total** | **368** | | -| `src/GameCult.Mesh/CultMeshSnapshots.cs:638-661, 750-773, 889-911` | 71 | three verbatim clones of the selector-carrying options; one `with` copy over a record type remains | -| `src/GameCult.Mesh/CultMesh.cs:2721-2748, 1941-1969` | 57 | the fourth and fifth copies (`ToSnapshotRequestOptions`, `WithPublicationBindingOptions`) | -| `CultMeshSnapshots.cs:973-983` | 11 | `CleanSnapshotFilter`: normalisation is the selection's own validation | -| `CultMeshSnapshots.cs:1000-1009, 1033-1047, 1078-1088` | 36 | the client-side re-filter of a snapshot by schema and `InferSchemaName`; the server's v1 answer is exact, decode stays | -| `CultMesh.cs:2750-2800` | 51 | `ReadDocumentFromSnapshotResponse`'s four-tier fallback (which returns a record with the wrong key when only the schema matches) and `TryDecodeSnapshotDocument`; replaced by one exact read (~10 stay) | -| `CultMesh.cs:3474-3497` | 24 | `IsSameCultDocumentSchema`: `CultNetSchemaAliasMatching`, now public | -| `CultMeshSnapshots.cs:625-636` and the rule at `:198`, `CultMesh.cs:2716` | ~15 | the "no schema filter when keys are given" rule, three times; one place | -| `CultMeshClient.cs:574-604, 830-860, 932-962` selector construction | ~25 net | `{recordKeys, schemaIds}` literals become `Selection` literals; the retry loops stay | -| `CultMeshBodyDemand.cs:177-221` | ~15 net | `CultMeshHotBodySubscription` builds two allowlists; builds one `Selection` | -| `CultMesh.cs:2701-2719, 2838-2858, 2875-2915` | ~20 net | selector construction for peer snapshots and body subscriptions | -| `CultMeshSnapshots.cs:139-201, 434-516, 611-623` overlay logic | ~40 net | overlaying caller `schemaIds`/`recordKeys` onto defaults, four times | -| **Mesh total** | **≈ 365 gross, ≈ 331 net** | see section 11 for what does not go | -| `packages/cultnet-rs/src/snapshot_query.rs:100-130` | ~31 | the fifth selector engine: the `requested_schemas` / `requested_keys` sets and the two `is_some_and` filters inside `serve_read_only_raw_snapshot` become one `select` over a lowered v0 selection. The duplicate-identity and unregistered-schema refusals (`:90-110`) and the whole `CultNetReadOnlySnapshotPolicy` (`:132-157`, authorization) stay | -| `src/GameCult.Caching/CultCache.cs` | **0** | the cache deletes nothing. The string `Getter`/`GetterNullable` pair (`:1081-1082`) stays: it is what `any_of` compares and what the index maps are built from (`:1858`). The numeric accessor is beside it, not instead of it | -| `tests/GameCult.Networking.Tests/NetworkingTests.cs`: `CultNetDatabaseServer_Creates_Filtered_SnapshotResponse` `:3124`, `…_ForCompatibleSchemaAlias` `:3158`, `CultNetDatabaseServer_Creates_Filtered_SubscriptionChange` `:5547`, `…_ForSchemaAlias` `:5601`, `DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding` `:2256`, `…ByRequestedSchemaAlias` `:2310`, and the `projectRecord` lambda in `…AuthorizesRequestAndFiltersSnapshotAndLiveRecords` `:1601-1605` | ~250 | rewritten over v1 selections against the one evaluator (section 10); the rules they pin survive | - -**Not deleted in this cut, by ruling (R-1), and scheduled for deletion on FU-v0's trigger:** the v0 message classes and -their three schema files, the v0 arms in `CultNetSchemaMessageSerialization`, -and the v0 handling in the two servers — kept as a **lowering**: a v0 -request lowers to `Selection { schemas: schemaIds, keys: recordKeys, projection: document }` -and is answered through the same evaluator, so v0 has no engine of its own. -They die with the last runtime follow-up. - -## 5. Keeps and moves - -- `CultNetSchemaAliasMatching` (`CultNetDatabase.cs:47-97`), `internal` → - `public`, the one alias matcher for every runtime-side match. -- `_authorizeRequest`, `_authorizeRecord`, `Reconcile`, `Watch`, - `ApplyProjectedChange`, `PublishDemand`, `DemandChanged` and the body-plane - fields of the subscribe message: authorization, reconciliation and demand - are not selection. -- `CultNetDocumentRegistry`'s raw-record construction (`:297-311`) as - `ToRawRecord(descriptor, binding, key, document, options)`; the payload - schema resolution (`:452-503`) untouched. -- The v0 messages, schemas and arms as a lowering (above). -- `CultMeshSnapshotRequestOptions` as a record `{ Selection, ShardId, ShardEpoch, …transport knobs }`. -- Every (B)-class site of the audit (section 11): transport racing, - authority, freshness, body routing, provider-session validation. -- The interop lanes and CI witness (`.github/workflows/cultnet-interop.yml`) - green throughout, because v0 still answers. - -## 6. Adds - -| Add | Owner | Live consumer | Protected invariant | What it replaces | -|---|---|---|---|---| -| `src/GameCult.Caching/CultCache.cs`: a **public read surface over declared members** — `CultDocumentDescriptor.DeclaredMembers` (a public view: `MemberName`, `Slot`, `IndexAlias`, `IsName`, `IsReference`, `IsMany`, `IsNumeric`, `TargetType`), `TryGetIndexValue(document, alias, out string?)`, `TryGetIndexNumber(document, alias, out string canonicalDecimal)` (Q-J, revised 2026-09-22), `ReferencesOf(document, member)` (D11), `ResolveTargetLeaves(targetType)` over `AllDescriptors` (D9) | `GameCult.Caching` | the evaluator; `CultNetDocumentRegistry`; later any runtime-side reader | **the cache owns addressing and its values**; a reader outside the assembly reads declarations and values through one surface instead of reflecting over documents | the reflection loop at `CultNetDocumentRegistry.cs:325-342`, and the absence that forced it | -| `CultCache.cs` registration refusals: one alias-collision rejection beside `:931-943` (D10), one `unsupported_reference_shape` rejection for a `many`/reference member the cache cannot walk (D11) | `GameCult.Caching` | every registering catalog | a declaration the cache cannot read is refused when it is declared, not answered as nothing when it is read | an `ArgumentException` from `ToDictionary` `:462-468` that names nothing; a silent unwalkable reference | -| `contracts/cultnet/cultnet.selection.schema.json` (`$id`, referenced by `$ref`), `cultnet.snapshot-request.v1.schema.json`, `cultnet.database-subscribe.v1.schema.json`, `cultnet.snapshot-response-raw.v1.schema.json`; registry entries in `CultNetSchemaRegistry.cs` | CultLib contracts | C#, Rust; TS/Py/Kotlin follow-ups | one published shape of a selection, hand-written like its siblings, pinned by vectors in both runtimes | `schemaIds`/`recordKeys`/`includeSnapshot` as the wire's only selection | -| `src/GameCult.Networking/CultNetSelection.cs`: `CultNetSelection`, `CultNetFieldPredicate`, `CultNetCitation`, `CultNetIncoming`, `CultNetSelectionProjection`, `CultNetRecordRef`, `CultNetRawDocumentHeader`, `CultNetSelectionPage`, **`CultNetEdge`** (`From`, `Role`, `To`, `PayloadEncoding?`, `Payload?`), **`CultNetSelectionOperator`** (`AnyOf`, `Lt`, `Le`, `Ge`, `Gt`, serialised as the `op` string, never as a union); `Validate(IReadOnlyList)` — declared aliases and roles, non-empty lists, exactly one of `values`/`number` per `op`, and every reachable leaf declaring a compared alias declares it numeric | `GameCult.Networking` | the three message classes, the evaluator, Mesh | a selection is typed data with one validation | `_projectRecord`, the allowlist pair | -| `src/GameCult.Networking/CultNetSelectionEvaluator.cs`: `Select(cache, descriptors, selection, ordinals, asOf)` → ordered, hopped, cursored, projected page; `Matches(selection, descriptor, key, document)` for a single change; the cursor mint/parse with `cursor_stale` / `cursor_invalid` | `GameCult.Networking` | `CultNetDocumentRegistry` (snapshot), both servers (changes), `Reconcile` | one evaluator; order, snapshot and hop have one owner | the three engines (section 4) | -| `CultNetDatabase.LastWriteSequence(schemaId, key)` (a per-key ordinal kept on apply, ~20 lines) | `CultNetDatabase` | the evaluator's order and cursor | a row's ordinal is its last commit's sequence | `storedAt` as the only time | -| v1 message classes beside the v0 ones; the v0→`Selection` lowering (one function) | `GameCult.Networking` | both servers | v0 has no engine of its own | — | -| `packages/cultnet-rs/src/selection.rs`: the types (`Serialize`, `Deserialize`, no `untagged`; `SelectionOperator` an **externally tagged / string** enum, `Edge { from, role, to, payload_encoding, payload }`), `Row` trait (`id`, `ordinal`, `values(index) -> &[String]`, **`number(index) -> Option`**, **`references() -> Vec<(role, RecordRef, Option>)>`**), `RowSet` trait (`declared_indexes`, `declared_roles`, **`is_numeric(schema, index)`**, **`target_leaves(role)`**), `Malformed`, `select`, cursor mint/parse, `LIMIT_MAX` | `cultnet-rs` | Huginn (section 12), later Rust services | the same evaluator semantics as the reference, pinned by vectors both ways | Huginn's own `select.rs` from the earlier design | -| `snapshot_query.rs`: `serve_read_only_raw_snapshot` calls `select` over a lowered v0 selection; the policy check stays where it is | `cultnet-rs` | its existing callers and tests | the fifth engine has no rule of its own | the filter at `:112-125` | -| `contracts.rs`: `SnapshotRequestV1`, `DatabaseSubscribeV1`, `SnapshotResponseRawV1` variants | `cultnet-rs` | Huginn's daemon | the wire spelling equals the reference's | — | -| `contracts/cultnet/selection-vectors.cs-written.json` and `selection-vectors.rs-written.json`: a fixture row set (`cultnet.interop-selection-row` document type; declared indexes `kind`, `severity`, `tags[]` and **`mass` (numeric, declared on the abstract middle type, not on the leaves)**; references `parent`, `related[]` and **`components` (a dictionary keyed by reference with a float payload)** and **`design` (declared target = the abstract middle, resolving to two leaves)**; **two leaf schemas under one abstract middle**, so inheritance, the abstract target, the dictionary edge and the comparison are all exercised by the fixture rather than argued about in prose), **twenty-eight** selections, and each selection's expected page as ordered ids + `matched` + `next` presence + **the expected `edges` with their payload bytes**. The fixture must also carry, because tests depend on it and a loosening that cannot fail is the fixture's defect: a row whose `mass` **equals** a compared number exactly (S16), **three** rows sharing one index value (S23), one edge pointing outside its declared target (S18), two dictionary entries with **different** payload bytes (S19), and a nullable numeric member and a `decimal` one (S22). **Expected to grow** (R-4): it is chosen for coverage now, not pinned as final, and adding rows or selections to it is a normal change | tests, both runtimes | `NetworkingTests`, `cultnet-rs/tests/selection.rs` | **vectors written by the reference decode and evaluate identically in Rust, and vectors written by Rust decode and evaluate identically in the reference** | a within-runtime round trip, which pins nothing about parity | -| `contracts/cultnet/interop/cultnet.interop-selection-row.schema.json` | contracts | the vectors | — | — | -| `docs/cultnet-selection-cut.md` (this file), a paragraph in `contracts/cultnet/cultnet-distributed-database.md` under "Implemented", and the runtime table row in `docs/runtime-parity-scope.md` | docs | — | describe the live system | — | - -No dependency in either runtime (`schemars` is **not** added to -`cultnet-rs`: the schema is hand-written JSON like every CultNet schema, and -Huginn's derived schema for its own request embeds `Selection` through a -`#[schemars(schema_with)]` field attribute in Huginn. The attribute returns a -`$ref` to this cut's published selection schema `$id`. It does not go through -the substrate. (Corrected 2026-09-22: a hand-written `impl JsonSchema` for a -foreign type fails to compile with `E0117`, the orphan rule. The read-side -Imagination probed it.) — -see section 12). No new package, binary, transport, or store format. -**`packages/cultcache-rs` is untouched**: the C# cache change buys the -evaluator a read surface it does not have, and in Rust the row owner -already supplies exactly that through `Row`/`RowSet`. The asymmetry is -real and intended — the reference reads documents the cache owns, Rust -reads rows the consumer owns — and it is the reason the parity obligation -is on the *vectors* and not on the two implementations' shapes. - -## 7. Per-file changes (code that exists, anchored; new code by name and rule) - -**`src/GameCult.Networking/CultNetSchemaMessages.cs`**: after `:826`, the -three v1 classes; `CultNetSchemaVersions` (`:77`, `:93` and the -`SnapshotResponseRaw` constant) gains `SnapshotRequestV1`, -`DatabaseSubscribeV1`, `SnapshotResponseRawV1`. v0 classes untouched. - -**`CultNetSchemaMessageSerialization.cs:9-85`**: three arms before the -`_ => throw`. - -**`CultNetSchemaRegistry.cs:360-385`**: four `SchemaResourceSpec` entries. - -**`CultNetDatabase.cs`**: `:47` `internal static class` → `public static -class`; a `Dictionary<(string, string), long>` maintained where a mutation -is appended to the shard log (Hands anchors on the append site; section 1's -assumption is confirmed there) and `LastWriteSequence`; `:1052-1075` untouched. - -**`CultNetDocumentRegistry.cs`**: `:247-258` → `CreateSnapshotRequest(messageId, CultNetSelection)`; -`:263-349` → `CreateRawSnapshotResponse(cache, messageId, selection, ordinals, asOf, options)` -calling the evaluator and `ToRawRecord`; `:505-524` deleted. - -**`CultNetDatabaseServer.cs`**: `:276-316` handles v0 (lowered) and v1; -`:332-345` `CreateSubscription` calls `evaluator.Matches(selection, change)` -then `ToRawRecord`; `:347-468` deleted. - -**`CultNetDatabaseSubscriptionServer.cs`**: `:27, :44, :50` `_projectRecord` -deleted; `:112-167` handles v0 (lowered) and v1, and answers a -`snapshot_response_raw.v1` page with `matched`/`asOf`/`next`; `:218-249` -`Watch`: a selection with `cites`/`cited` runs `Reconcile` on every change, -otherwise `Matches` on the changed row; `:294-322` evaluate + authorize + -project; `:424-515` deleted. Module doc `:14-18` rewritten: projection is a -value on the request. - -**`src/GameCult.Caching/CultCache.cs`** (the only cache file this cut -touches): - -- `:112-150` `CultDocumentDescriptor`'s constructor and fields: `Members` - `:149` and `IndexAccessors` `:148` stay `internal`; a **public** - `DeclaredMembers` view and the three value accessors are added beside - them. Do not widen `Members` itself — `CultDocumentMemberDescriptor` is a - mutable internal DTO whose shape `ToCatalogEntry` `:151-176` depends on, - and publishing a mutable descriptor list makes the catalog's shape a - public contract by accident. The public view is a readonly struct or - sealed record built from it. -- `:445-490` `BuildDescriptor`: the alias `ToDictionary` at `:462-468` is - the site of the D10 refusal's *symptom*; the refusal itself belongs in - `DiscoverMembers` so it lands in `rejections` with its neighbours. -- `:838-946` `DiscoverMembers`: two new groupings beside the two at - `:931-943` — one over `IndexAlias` (D10), one over reference shape - (D11) — and two message helpers beside `DuplicateSlotMessage` / - `HiddenMemberMessage`. Nothing in the existing walk changes. -- `:1050-1114` `PersistedMember`: `Getter`/`GetterNullable` `:1081-1082` - untouched; `NumberOf` (non-null exactly for the closed CLR numeric set, - `Nullable` unwrapped) and `ReferencesOf` added beside them; - `MemberType` `:1052` and the attribute's `TargetType` surface through the - public view as the target `Type`. -- `:254-255` `AllDescriptors` is the input to `ResolveTargetLeaves`; the - set is computed per call, not cached, because `Refresh()` `:257-265` and - `RegisterDescriptor` `:381-410` can both add a leaf after any given - descriptor was built. -- `CultSchemaTypeNames.FromType` (`CultSchemaTypeNames.cs:9-31`) is - `internal` and generates the `TypeName` strings; the `IsNumeric` string - set is generated from the CLR set through it, never written by hand - (S22). - -**`packages/cultnet-rs/src/snapshot_query.rs`**: `:100-130` the two -allowlist sets and their filters become one `select` over the lowered -selection; `:64-99` and `:132-157` untouched; `:158-285` -`CultNetRawSnapshotQuery` untouched in this cut and named in Q-M. - -**`CultNetSelection.cs`**, **`CultNetSelectionEvaluator.cs`** (new): as -section 6. Rules that must die under their own mutation are in section 10. - -**`src/GameCult.Mesh/CultMeshSnapshots.cs`**: `:16-62` the options record -carries `Selection`; `:139-201`, `:434-516`, `:611-636` overlay through one -`WithSelection`; `:638-661`, `:750-773`, `:889-911` deleted; `:973-983`, -`:1000-1009`, `:1033-1047`, `:1078-1088` deleted. -**`CultMesh.cs`**: `:1941-1969`, `:2721-2748`, `:2750-2800` (one exact read -remains), `:3474-3497` deleted; `:2701-2719`, `:2838-2858`, `:2875-2915` -build a `Selection`. -**`CultMeshClient.cs:574-604, 830-860, 932-962`**, **`CultMeshBodyDemand.cs:177-221`**: -`Selection` literals. - -**`packages/cultnet-rs/src/selection.rs`** (new), **`contracts.rs:314-331`** -region gains the three v1 variants, **`lib.rs`** re-exports. **`tests/selection.rs`** -(new): the vectors both directions, the evaluator, the cursor, the -refusals, the untagged grep. - -**`tests/GameCult.Networking.Tests/NetworkingTests.cs`**: the seven tests in -section 4 rewritten; new tests in section 10; the vector writer under -`CULTNET_WRITE_VECTORS=1` following `scripts/write-cultmesh-realtime-frame-vectors.mjs` -and the C#-written pattern the QUIC campaign established (`docs/typescript-quic-realtime-cut.md:15-21`). - -## 8. Authority map - -- **Owner.** CultNet's database organ, `src/GameCult.Networking`, as the - reference: `CultNetSelection` owns the shape and its validation; - `CultNetSelectionEvaluator` owns matching, the hop, the order, the - snapshot and the cursor; `CultNetSchemaAliasMatching` owns schema - identity; `CultNetDatabase` owns a row's ordinal. **The cache owns - addressing and every value of it**: which names are indexes, which - members are references, whether a member is numeric, what leaves a - reference may target, what a row's value at an alias is, and what edges - and payloads a row carries — through `CultIndexAttribute` and - `CultReferenceAttribute`, the persisted `TypeName`, and the public read - surface of section 6. The vocabulary reads those declarations and adds - none: no new attribute, no persisted field, no schema-hash movement, and - no opinion in the evaluator about what a CLR type means. **The evaluator - never reflects over a document**; if it needs something the cache does - not expose, the cache gains a method, not the evaluator a `GetProperty`. `packages/cultnet-rs` - is the Rust spelling of the same owner, at wire parity, with the row - owner supplying what the cache supplies in C# (`Row`, `RowSet`). -- **Inputs.** The cache's rows and descriptors, the shard log's sequences, - the selection, the peer's authorization answers. Not inputs: a clock, - a payload path, a transport. -- **Outputs.** Pages (`headers` or `documents`, `matched`, `asOf`, `next`), - per-change match decisions, typed refusals (`selection_invalid`, - `cursor_stale`, `cursor_invalid`) as `cultnet.error.v0` with a code, the - form the reference already uses for routing errors. -- **Derived state.** The page, the order, the incoming-edge index per - evaluation, the cursor. **Demotions:** `schemaIds`/`recordKeys` are no - longer a selection language; they are a lowering of v0 into one. - `_projectRecord` is no longer an authority; projection is a request - value. `CreateRawSnapshotResponse`, `CreateChangeMessage` and - `CreateChangeCore` are no longer engines; they call one. - `IsSameCultDocumentSchema`, the Mesh `InferSchemaName` and the registry's - `MatchesRequestedSchema` are no longer owners of schema identity. -- **Forbidden writers.** Neither server may match a record by a rule of its - own; **`serve_read_only_raw_snapshot` may not filter by schema or key** - (its policy is authorization and stays; its allowlist filter goes); Mesh - may not re-filter a page the server answered — a caller *may* verify a - page against expectations it declared before the request - (`CultNetRawSnapshotQuery::accept_response`, which errors rather than - quietly dropping, and so cannot hide a server that answered wrongly), but - no caller may narrow a page and call the narrowed result the answer; - nothing may mint a - cursor a caller could construct; nothing may answer a page over a - different `asOf` than its cursor's; the evaluator may not read a payload - by path, only declared indexes and references, **and may not reflect over - a document at all**; **the evaluator may not read the cache's `_indexes` - map or call `GetByIndex`** — those answer a unique-index lookup, not a - predicate (section 1, S23); no rule may consult an edge's payload while - traversing (section 13); nothing outside `GameCult.Caching` may decide - whether a member is numeric or what leaves a target resolves to; no type - on the wire is `untagged`; the Rust `select` may not diverge from the - reference on any vector. -- **Shared paths.** The evaluator under snapshot, change, and reconcile; - `CultNetSchemaAliasMatching` under every schema match in both assemblies; - `ToRawRecord` under snapshot and change; the v0 lowering under both - servers. -- **Deletion line.** Commit 0: the cache's public read surface and its two - registration refusals, with S17-S22 green and `GameCult.Networking` - untouched — the cache change is independently true and lands first, so - that if the rest of the cut is cut, the substrate is not left with half a - surface. Commit 1: the shape, the evaluator and the v1 - messages beside v0, with the three engines rewired and deleted and the - seven tests rewritten — green. Commit 2: `_projectRecord` and the - Mesh collapse. Commit 3: Rust. Commit 4: vectors both ways and entries. - Nothing lands with two engines alive. -- **Where the line to CultMesh falls.** Mesh is a client that builds - selections and reads pages; it evaluates nothing. Where Mesh watches a - cache locally by name or index (audit sites #2, #4-9, #16-21), it keeps - its own loops until `CultNetDatabase.Watch(Selection)` exists, which is Cut 2 (section 18). - -## 9. Parity: what this cut covers, and what the other runtimes do - -**In this cut:** the C# reference (both servers, the client, the registry) -and Rust (`cultnet-rs`: types, evaluator, cursor; no subscription client, -because no Rust caller subscribes). Parity is pinned by vectors in both -directions (section 10), not by a round trip inside one runtime. - -**Runtimes not in this cut, stated answer per runtime, by source read:** - -| Runtime | On v0 (unchanged) | On a v1 request it receives | On a v1 page it receives | -|---|---|---|---| -| TypeScript | unaffected; the reference still answers v0 | **refuses**: `parseCultNetMessage` finds no validator for the version and throws (`contracts.ts:728-744`) | refuses the same way | -| Kotlin | unaffected (client only) | **refuses**: `require(schemaVersion == …v0)` throws (`CultMesh.kt:2195`) | n/a as a server; a Kotlin client sends v0 and gets v0 | -| Python | unaffected | **silently drops, no reply**: `handle_server_message` returns `[]` for an unknown version (`interop_peer.py:387`), so a caller times out rather than reading a refusal; **it cannot answer with wrong rows**, because v1 is a version it does not dispatch, which is why D3 chose a version over new fields | as above | -| Rust | in this cut | in this cut | in this cut | - -The one runtime that mis-answers today is none: nothing sends v1 to a -runtime that does not speak it until its follow-up lands, and a stray v1 is -refused (TS, Kotlin, C#) or dropped (Python). **Python's drop is fixed in -this cut, not in its follow-up** (R-2): after this cut the table's Python -row reads "refuses with `unsupported_schema_version`", and every runtime on -the wire answers an unknown version with something a caller can read. The -build budget gains one Python test run for it. - -**Follow-ups, recorded with triggers:** - -- **FU-TS.** `cultnet-ts`: the three v1 contracts, Ajv registration, a - `Selection` type; `cultmesh-ts` builders. Trigger: the first TypeScript - caller that needs a filtered snapshot or subscription (the browser Eve - runtime, when it reads pipeline state). Until then TS refuses v1 loudly. -- **FU-Py.** Its first line is **not** a follow-up: by R-2, `cultnet-py` - and `cultmesh-py` reply `cultnet.error.v0 - { code: "unsupported_schema_version" }` for an unknown version **in this - cut** (five lines at `interop_peer.py:360-387` and the delegating path at - `cultmesh_py/server.py:341`, with one test per site that a v1 message - produces a refusal and not an empty list). The rest of FU-Py is the v1 - contracts, and the **three** copies of the selector match - (`interop_peer.py:110-125`, `cultmesh_py/server.py:449-470`, - `cultmesh_py/node.py:1322-1345`) collapsed into one evaluator. Trigger: - the first Python peer asked a v1 question, or the interop lane gaining a - v1 case. -- **FU-Kt.** `cultmesh-kotlin`: v1 snapshot request builder and page - reader. Trigger: an Android/JVM client reading filtered state. -- **FU-v0.** Retire v0's classes, schemas and lowering once FU-TS, FU-Py, - FU-Kt have landed and the interop lanes send v1. Trigger: the last of the - three. -- **Not a follow-up any more:** `CultNetDatabase.Watch(Selection)` is - **Cut 2**, mapped in section 18 with its own deletes, authority map, - verification and numbers. It lands after this cut. The audit's ≈ 240 - local-watch lines are counted there, and the real figure turned out - larger than the audit's estimate (≈ −378 in Mesh, section 18.3), because - the audit did not count the nine `Collection*` overloads as selection. - -`docs/runtime-parity-scope.md`'s table gains, per runtime, "typed selection -v1: C# and Rust claimed; TS, Python, Kotlin not claimed (refuse / refuse / -drop)". The claim is written where the parity claims live, not here alone. - -## 10. Verification - -**Builds.** `dotnet build` of `GameCult.Caching`, `GameCult.Networking`, -`GameCult.Mesh` and their test projects — the cache is now in the build -budget, and it is the assembly the rest of CultLib depends on, so -`dotnet test tests/GameCult.Caching.Tests` is the gate on commit 0 and runs -on every later commit; `cargo test -p cultnet-rs`; the TypeScript workspace -untouched but `npm run test --workspace packages/cultnet-ts` run once to -prove the interop lane still passes on v0. Host = target = workstation for -C# and Rust; no native code. Nothing cleaned. - -**Tests, each named for the rule it pins, with the mutation that kills it — -a loosening where one exists.** - -| # | Test (C# unless marked) | Pins | Revert kills | Loosening kills | -|---|---|---|---|---| -| S1 | `Selection_RefusesUndeclaredIndexRoleAndEmptyLists` | D2, door refusals typed | validation dropped (empty page) | `index` checked against *any* schema's aliases rather than the reachable ones (a predicate on an alias only an excluded schema declares matches nothing silently) | -| S2 | `Evaluator_ConjoinsAnyOfPredicatesOverDeclaredIndexes` | fields | an arm dropped | any-of treated as all-of | -| S3 | `Evaluator_OrdersByLastWriteSequenceThenIdentityAndReverses` | order, `descending` | sort by `storedAt` | `descending` ignored | -| S4 | `Evaluator_PagesExactlyOnceAndTheLastPageSaysSo` | cursor walk | `next` always present | position `>=` (last item repeats) | -| S5 | `Evaluator_RefusesAStaleCursorAfterTheShardLogAdvances` | `cursor_stale` typed | advance ignored (a page over a moved set) | digest check dropped (a cursor accepted under another selection) | -| S6 | `Evaluator_HopsOneEdgeByDeclaredReferenceAndRole` | hop, roles from `CultReferenceAttribute`, `Many` | role ignored | a second hop followed | -| S7 | `Evaluator_NegatedIncomingEdgeIsTheOnlyNegation` | `cited { exists: false }` | `exists` ignored | citer's status consulted (an unauthorized citer's edge dropped) | -| S8 | `Evaluator_HeaderProjectionCarriesNoPayload` | projection | payload carried | `payloadEncoding` carried without payload | -| S9 | `Subscription_EvaluatesSelectionOnSnapshotAndEveryChange_AndReconcilesHopBearingOnes` (rewrite of `:1587`) | D6 | a change matched by v0 allowlists | a hop-bearing subscription taking the per-change fast path (a change to the citer not reconciled) | -| S10 | `V0Request_LowersToASelectionAndHasNoEngineOfItsOwn` (rewrites of `:3124`, `:3158`, `:5547`, `:5601`, `:2256`, `:2310`) | D3/D4 | v0 matched by its own loop | alias matching inlined again in one server | -| S11 | `SchemaAliasMatching_IsTheOneMatcherInBothAssemblies` (grep-shaped, negative) | D4 | — | — | -| S12 | `SelectionVectors_WrittenByTheReference_DecodeAndEvaluateIdenticallyInRust` (Rust reads `selection-vectors.cs-written.json`) and `SelectionVectors_WrittenByRust_DecodeAndEvaluateIdenticallyInTheReference` (C# reads `selection-vectors.rs-written.json`) | **parity** | any semantic drift on either side | a vector whose expected ids are compared as a set (order lost) — the vectors carry ordered ids and `next` presence | -| S13 (Rust) | `selection_is_never_untagged` (grep over `packages/cultnet-rs/src` for `untagged`, empty) and `selection_round_trips_named_and_positional` (both encodings) | the encoding rule | an `untagged` shape added | — | -| S14 (Rust) | `select_over_a_toy_row_set_matches_orders_hops_pages_and_refuses` | D7, no CultCache knowledge in the evaluator | as S1-S8 | as S1-S8 | -| S15 | `Mesh_ReadsASnapshotPageWithoutReFilteringIt` (Mesh tests) | the client trusts the page | the four-tier fallback restored | schema re-filter restored | -| S16 | `Evaluator_ComparesNumbersAtTheBoundaryForEachOfTheFourOperators` | D8's operators; one operator per predicate; two predicates make a range | the comparison ignored (every row matches) | `le` evaluated as `lt` (and `ge` as `gt`) — **kills only if the fixture carries a row whose `mass` equals the compared number exactly**; the fixture must, and if it does not, that is the fixture's defect, not licence to mutate something easier | -| S17 | `Evaluator_AppliesAnInheritedAliasToEveryReachableLeaf` | the inheritance rule; the cache's `BaseType` walk reaching the evaluator | the walk stopped at the leaf (`CultCache.cs:847`) — kills S17 and S12 together | "reachable" narrowed from *every* leaf matching `schemas` to the first descriptor consulted, so a two-leaf selection on an alias declared only on the abstract middle answers with one leaf's rows and no refusal — **requires the fixture to declare `mass` only on the middle and to carry rows of both leaves** | -| S18 | `Evaluator_ResolvesAnAbstractTargetToItsLeavesAndRefusesAnEdgeOutsideThem` | D9; the typed `reference_outside_target` | the target set taken as `{TargetSchemaName}`, which is `null` for an abstract target, so the hop follows nothing | the out-of-target edge **skipped** instead of refused — the option section 3 rejected, and the mutant that proves the rejection is load-bearing; **requires the fixture to carry one edge pointing at a schema outside the declared target** | -| S19 | `Cache_EnumeratesADictionaryReferenceAsEdgesCarryingItsValues` (cache tests) | D11 | `ReferencesOf` yields nothing for a dictionary — today's behaviour, restored | the keys enumerated and the **values dropped** (`payload` null), which is the shape a careless `IEnumerable` implementation would have; **requires two dictionary entries with different payload bytes**, or the assertion passes on shape alone | -| S20 | `Evaluator_ReturnsTraversedEdgesOnlyForHoppingSelections_AndNoPayloadUnderHeader` | the `edges` rule | `edges` never emitted | payload carried under `header`; and, separately, `edges` emitted for a selection with neither `cites` nor `cited` — a widening rather than a loosening, and killed by the same test | -| S21 | `Cache_RefusesADuplicateIndexAliasAndAnUnwalkableReferenceByName` (cache tests) | D10, D11's shape rule | the rejections removed — **kills only if the test asserts the message names the schema, the two members and the alias**, since without them `ToDictionary` still throws an `ArgumentException` and a shallow `Assert.Throws` passes either way. That is the entire content of D10 | the alias grouping keyed by `MemberName` instead of `IndexAlias`, which refuses nothing the existing `HiddenMemberMessage` (`:937-943`) did not already refuse | -| S23 | `Evaluator_MatchesEveryRowSharingAnIndexValue_NotTheCacheIndexWinner` | the evaluator reads values per row, never `_indexes` / `GetByIndex` | the predicate answered from `GetByIndex`, so an `any_of` over a value **three** fixture rows share answers with one | the per-row read kept but short-circuited after the first match when `matched` is all the caller asked for — a plausible optimisation that makes `matched` a boolean in disguise; **requires three rows sharing one index value**, which the fixture must carry | -| S22 | `Cache_DerivesIsNumericFromTheClrSetAndTheTypeNameSetAgrees` (cache tests) | D8's two authorities agreeing | `IsNumeric` true for every member, so a comparison on a string alias validates and compares `ToString()` lexically | the `TypeName`-string set hand-written and missing `System.Nullable` or `System.Decimal`, so a member is numeric against the live descriptor and non-numeric against the same schema read back from a persisted catalog — **requires the fixture to declare a nullable numeric member and a `decimal` one** | - -**On the parity vectors and the four findings.** S12 is not a new test but -a widened one: the fixture of section 6 carries the abstract middle, the -two leaves, the inherited numeric alias, the dictionary reference with -float payloads and the abstract-target reference, and the vector set -includes at least one selection per finding — a comparison on the -inherited alias, a hop into the abstract target, a hop over the dictionary -returning payloads, and the same hop under `header`. Both directions: -**vectors written by the reference are evaluated in Rust, and vectors -written by Rust are evaluated by the reference**. A round trip inside one -runtime pins nothing and is not counted. The Rust side's fixture rows -implement `Row` over the same declared values, so the vectors test the -*semantics* both ways while each runtime keeps its own source of rows. - -**Negative greps:** `rg -n "GetProperty|GetField|GetCustomAttribute" src/GameCult.Networking` -empty (the evaluator reflects over nothing); -`rg -n "untagged" packages/cultnet-rs/src packages/cultcache-rs/src` -empty; `rg -n "InferSchemaName" src/GameCult.Mesh` empty and -`src/GameCult.Networking` only in `CultNetSchemaAliasMatching` and the -registry's payload-schema resolution; `rg -n "MatchesRequestedSchema|MatchesSchema\(" src` -empty; `rg -n "_projectRecord|projectRecord" src` empty; -`rg -n "SchemaIds = .*RecordKeys = " src/GameCult.Mesh` empty (no -allowlist pair assembled by hand); `rg -n "CloneSnapshot(Facade)?RequestOptions" src` at most one. - -**Mutations.** `tools`-style entries in `scripts/mutate-cultmesh.mjs`'s -format with three new targets, `caching` (killer `dotnet test -tests/GameCult.Caching.Tests`, carrying S19, S21 and S22), `networking` -(killer `dotnet test tests/GameCult.Networking.Tests`) and `rust` (killer -`cargo test -p cultnet-rs`), -one entry per rule above with revert and loosening; the runner's -byte-rewrite/verified-restore discipline applies unchanged. The runner is -the QUIC campaign's file and is dirty in the tree; **this campaign adds its -entries in its own file** (`scripts/mutate-cultnet-selection.mjs` or an -entries module the runner imports) and does not edit the QUIC campaign's -targets — Self decides the exact split at landing. - -## 11. The CultMesh subtraction audit, with real numbers - -Method: an Explore pass classified 92 sites in `src/GameCult.Mesh` and -`CultNetDocumentRegistry.cs` as (A) mechanical selection/projection over -schema ids, keys or fields, or (B) a domain rule (authority, freshness, -transport, session). (A) totalled **1,158 lines**, (B) **429**. **(A) is not -a reduction figure**; it is what *could* be expressed as a selection. What -collapses is what has a replacement in this cut. - -| Category | Sites (audit #) | Lines now | After | Net | -|---|---|---:|---:|---:| -| Selector-carrying options cloned five times | #11, #3, #74, #75, #77 | 128 | ~30 (`with` on a record) | **−98** | -| "No schema filter when keys are given" rule, three times | #65, #73, #10 | ~16 | ~4 | −12 | -| `CleanSnapshotFilter` | #79 | 11 | 0 | −11 | -| Client-side re-filter of a snapshot by schema, plus `InferSchemaName` | #80 (part), #81, #82 | ~51 | ~10 (decode only) | −41 | -| Four-tier read fallback + decode gate | #12, #13 | 50 | ~10 | −40 | -| `IsSameCultDocumentSchema` | #22 | 24 | 0 (public matcher) | −24 | -| Selector literals in client bindings | #32, #34, #35 | 93 | ~68 | −25 | -| Hot-body subscription allowlists | #25 | 45 | ~30 | −15 | -| Peer snapshot / body subscribe selector construction | #10, #14, #15 | 81 | ~61 | −20 | -| Snapshot facade overlay logic | #64, #66-70, #72 | ~110 | ~70 | −40 | -| **Collapses in this cut** | | | | **≈ −326** | -| Local cache/database watches by name and index and their predicates | #2, #4-9, #16-21 | ≈ 240 | ≈ 240 | 0 in this cut; **≈ −378 in Cut 2** (section 18.3 — larger than this audit estimated, because the nine `Collection*` overloads at `CultMesh.cs:2278-2480` were not classified as selection and are) | -| Handle catalogs by type/schema, surface catalog, operation payload | #41-49 | 233 | 233 | 0 — these index *handles*, not records | -| Verse discovery selection and reshaping | #36, #38 (part), #39, #40, #83, #85 | ~110 | ~110 | 0 — over Verse observations, not documents | -| Single-file catalog membership | #58-62 | 81 | 81 | 0 — file-format membership | -| Provider session wire decode gate | #50 | 12 | 12 | 0 | -| (B) sites | 24 sites | 429 | 429 | 0 by definition | - -**Plain statement.** `src/GameCult.Mesh` shrinks by about **326 lines of -21,720 (1.5%)** in this cut, and a further **≈ 378 in Cut 2** — the two -together are about 3.2% of the assembly, and Cut 2 is where the audit's -expectation of a real reduction is met. About 590 of the -audit's (A) lines stay because they select handles, Verse observations or -file-catalog entries, not database records. The operator's expectation of a -net reduction in CultMesh holds, modestly; it does not hold for CultLib as a -whole in this cut, because the Rust runtime is new capability (section 14). -If the expectation was a large reduction, this audit says it is not there -without the watch cut, and it is not there at all for the catalogs and -discovery. - -## 12. Huginn: the consumer cut, and what moved - -**Moves to CultLib (this map):** `Selection`, `Citation`, `Incoming`, -`Projection`, `Page`, the cursor, `select`, the `Row`/`RowSet` traits, the -untagged rule and its probe, the cap, the order rule and the snapshot -contract. Huginn's earlier `select.rs` is never written. - -**Stays with the organ (a smaller, later cut in the Epiphany campaign map, -against a `cultnet-rs` dependency Huginn already has):** - -- `impl Row for PipelineDocumentView`: `ordinal = admission.sequence` - (Q-B A, still recommended; under Q-B B Huginn refuses `cursor_stale` on - advance exactly as the reference does, which is now a legitimate second - option), `values(index)` for the declared aliases `campaign`, `repo`, - `cut`, `kinds`→(schema), `in_force`, `faculty`, `severity`, `confidence`, - `origin`, `authority`, `claim_outcome`, `outcome`; `references()` from - `docs::citations` with the fifteen roles; `RowSet::declared_*` as the - organ's closed lists, so an unknown alias or a value outside an enum - refuses typed. -- `docs::citations`, `CitationRole`, `ResolutionOutcomeKind`, the summary - as the organ's header, the `as_of` restriction of the image to receipts, - the semantic refusal, the ref validation of `cites.target` through the - leaf, and the deletion of `open_items`/`history` with the relation table - unchanged (`history(Subject)` = `kinds: [resolution], cites { target, role: subject }`; - the open lists = five selections). -- **Deleted from the organ's wire (R-3, ruled, not proposed):** - `PipelineQuery`; **`admitted_after` and `admitted_before`**, which existed - only to approximate an order and are replaced by `descending` plus the - cursor — they are a delete in the Huginn cut's own deletes-first table, - with their query-builder call sites, their schema fields and the tests - that pin them, not a deprecation; and `QUERY_LIMIT_MAX`. Huginn's `Query { instance, selection }` carries - the substrate's type; its published request schema embeds `Selection` - through a `#[schemars(schema_with)]` attribute that returns a `$ref` to the - published selection schema. A hand-written impl fails with `E0117` - (corrected 2026-09-22), and the substrate publishes JSON, not `schemars`. It - is pinned by the - existing byte-for-byte test. -- Cut 11's lowering to Qdrant and the after-index application of the hop, - as before. - -The Huginn cut is ordered **after** this cut's commit 3 (Rust) and before -Cut 11 and Cut 13, as 10b was. - -## 13. Deliberately not possible, and why - -OR across predicates (ask twice); negation beyond `cited { exists: false }`; -a predicate on the far end of the hop; a second hop; ranges, dates, text, -paths, sort fields, counts beyond `matched`; a page not tied to `asOf`; -selection across shards or minds; a cursor the caller constructs. Each for -the reason the earlier specification gave, unchanged. - -Named individually, because each has an obvious-looking one-line -implementation and a caller who will ask for it: - -| Not expressible | The obvious ask | Why it is the door | -|---|---|---| -| **A predicate on an edge's payload during traversal** — "components whose quantity is over 4", "demand profile entries weighted above 0.5" | one `if` inside the hop loop, over a payload the evaluator is already holding | **It is a join condition.** The moment a predicate can read a value that belongs to neither the row nor the selection, the evaluator is combining two sources under a condition, and everything that follows is a query planner: which side to drive from, whether to build the incoming-edge index before or after filtering, how `matched` counts a row whose only surviving edge was filtered, what the cursor means when the page is a join result, and whether the payload's type is comparable at all when its declared type differs per leaf. None of that is answerable in a bounded vocabulary. **The caller follows the edge, receives the payload in `edges`, and filters the result** — one extra step for the caller, an entire category of machinery this organ never grows | -| **A set of exact `(schema, key)` pairs** | `keys` already exists; let an entry name its schema | `schemas` × `keys` is a cross product and stays one. `CultNetRawSnapshotQuery` (section 1) shows the cost honestly: it over-requests and verifies. The fix is either a second list shape or a pair type on the wire, and both are the beginning of a term language. A caller who needs exact pairs asks once per schema, or accepts the cross product and verifies against what it declared. Recorded as Q-M, recommendation A: state the limit | -| **A predicate that depends on how many edges a row has** — "hulls with more than three components" | `cited` already counts to one | `exists: true/false` is a boolean, and a threshold is a range over a derived aggregate. Aggregation is a second evaluator with its own ordering and paging semantics | -| **`edges` without rows, or rows the hop excluded** | a `projection: "edges"` | the page's rows are the answer and the edges explain it; an edge-only page is a different question (a graph read) and would need its own order, cursor and bound | - -## 14. Subtraction ledger (estimate) - -| | Removed | Added | -|---|---:|---:| -| `src/GameCult.Caching` | 0 | **~150**: the public member view ~40, `TryGetIndexValue` / `TryGetIndexNumber` + the CLR numeric set ~35, `ReferencesOf` over the three shapes ~40, `ResolveTargetLeaves` ~10, two rejections with their messages ~20 | -| `src/GameCult.Networking` | 368 | ~400 (`CultNetSelection` + `CultNetEdge` + the operator 170, evaluator + cursor + the incoming-edge index 190, ordinals 20, v1 classes and lowering ~40 minus shared) | -| `src/GameCult.Mesh` | ≈ 365 gross | ≈ 40 (one `WithSelection`, one exact read) | -| `packages/cultnet-rs/src` | ~31 (`snapshot_query.rs`'s filter) | ~430 (`selection.rs` 390 with edges and numbers, `contracts.rs` 40) | -| `packages/cultnet-py`, `cultmesh-py` | 0 | ~5 (the `unsupported_schema_version` reply, ruled into this cut) | -| `contracts/cultnet` | 0 | 4 schema files (~200 lines JSON), 1 interop row schema, 2 vector files (generated) | -| tests, C# | ~250 rewritten | ~600 (S1-S12, S15-S18, S20 in Networking; S19, S21, S22 in Caching; the vector writer) | -| tests, Rust | 0 | ~340 (S12-S14 with the edge and number vectors) | -| entries | 0 | ~38 entries across three targets | -| docs | — | this map; two paragraphs | -| **net source outside tests** | | **≈ +32 in C# Networking, +150 in C# Caching, +399 in Rust; ≈ +585 across the repo** | - -**The estimate moved, and in the wrong direction.** The committed version of -this ledger said ≈ +370 and ≈ −18 in C#. Both were wrong, for one reason: -the map assumed the cache needed no change, and the cache needs about 150 -lines of new public surface before the evaluator can read a single declared -value from outside the assembly (section 1). The honest figure is ≈ +585. -What the repo buys for it is unchanged in kind and larger in size: one -evaluator where **five** stood (three in C#, one in Mesh's re-filter, one -in Rust), a read surface where the substrate's own consumers were -reflecting over documents, edges and their payloads where a declared -reference was unreadable, numeric comparison over a type the catalog has -persisted all along, and a Rust runtime with the capability at all. Nothing -here is a reason to shrink the cut; it is a reason not to claim the cut -shrinks the repo. - -The repo grows by about 370 source lines for: a typed selection with a -derived-by-vector schema where two allowlists were the only language; -projection as a value where it was a delegate no caller reached; an exact -snapshot cursor where paging did not exist; one evaluator where three -engines and six schema matchers stood; a Rust runtime that did not have the -capability at all. CultMesh proper shrinks ≈ 326. The liability retired is -not in the line count: five copies of one options record, four spellings -of schema identity, a client that re-filtered what the server answered and -a read that could return the wrong key. - -## 15. Build budget - -C#: **three** assemblies and three test projects (`GameCult.Caching` joins -the budget; it is a dependency of the other two, so touching it rebuilds -them), debug, workstation. Rust: -`cultnet-rs` lib + tests, warm target dir, +0 to +60 paths. Nothing cleaned. -No native, no TypeScript build beyond one interop-lane run. - -## 16. Operator questions, one batch - -**Ruled 2026-09-17 and no longer questions.** Four of the batch are -settled; they are recorded here as rulings and carried in the sections -they govern. - -- **R-1 (was Q-F). v0 retires on a named trigger, not "eventually."** The - reference keeps answering v0 by lowering it into a `Selection`, with no - engine of its own. That lowering is a **scheduled deletion**: it is - deleted when FU-TS, FU-Py and FU-Kt have all landed and the interop lanes - send v1, and that is FU-v0's trigger in section 9. A shim with no - deletion date is a permanent shim; this one has a date written as a - condition, and the condition is checkable by grep over the three - runtimes' contract files. Section 4's "not deleted in this cut" row and - section 5's keeps both point here. -- **R-2 (was the second half of Q-F). Python's silent drop is fixed in this - cut.** Every other runtime refuses a message it does not understand; - Python returns `[]` (`interop_peer.py:387`) and the caller reads a hang - rather than a rejection. The five lines that reply `cultnet.error.v0 - { code: "unsupported_schema_version" }` land here, ahead of FU-Py's own - trigger, because a silence is a worse failure than a missing feature. - Carried in section 9's FU-Py entry and section 14's ledger. -- **R-3 (was Q-G). `descending` is the order; the time windows go.** - `admittedAfter` / `admittedBefore` existed to approximate an order the - vocabulary now states outright, and two filters standing in for an order - are worse than the order. They are deleted from Huginn's wire in the - consumer cut (section 12), not kept as an organ-side alias any-of. "The - latest N" is `descending, limit: N`. -- **R-4 (was Q-I). The fixture is chosen for coverage and expected to - grow.** The vector row type is the dedicated - `cultnet.interop-selection-row` family of section 6, shaped to *exercise* - the four hostile findings rather than describe them: shared fields on an - abstract middle with two leaves under it, a reference whose declared - target is that middle, and a many-reference carrying a payload. It is - **not pinned as final** — the fixture is expected to gain rows and - selections as the vocabulary meets more catalogs, and a change to it is a - normal change, not a contract break. What is pinned is the parity - obligation over whatever the fixture holds: both directions, ordered ids, - `matched`, `next` presence, and the edges with their payloads. -- **R-5 (was Q-H). Watching a selection is its own cut, mapped in section - 18 of this document**, after this one and depending on it. Nothing of it - is smuggled into the selection cut and nothing here is designed around it - speculatively; the seam already drawn — one selector type serving both a - query and a subscription — is all this cut owes it. - -**Still open:** - -- **Q-A. Leaf `Short` titles for question and ruling** (Huginn/Epiphany). - Unchanged: **A, not now** (recommended); B, a leaf follow-up before Cut 13; - C, a text projection (Cut 11's question). Depends: the organ's header row - for those two kinds. -- **Q-B. Huginn's receipt ordinal.** **A, record it** (recommended; exact - order, no `cursor_stale` on an append-only mind); **B, do not**: Huginn - refuses `cursor_stale` on advance like the reference does — now a - legitimate option under one contract, costing a re-walk on a busy mind - and id-order ties within a second. Depends: Huginn's consumer cut only. -- **Q-C. Delete `open_items` and `history`.** **A, delete** (recommended); - B, thin presets in Huginn; C, wire operations (refused). Depends: Cut 13's - tool list. -- **Q-D. The representation.** **A, declared-alias any-of over strings in a - new v1 selection** (recommended; D1, D2); B, Qdrant's `Filter` (rejected, - section 3); C, typed enums on the wire (impossible in the substrate: it - cannot know a consumer's enums; the organ keeps the typed refusal at its - door). Depends: everything in sections 2-10. -- **Q-E.** Withdrawn: the seam question is answered by the placement ruling. -- **Q-J. Number encoding on the wire. RULED B, decimal strings, - 2026-09-17.** The operator: floats are fragile, and strings are the most - reliable way to store a number. **Self's recommendation of A was argued - backwards and the map said so wrongly.** B was framed as "a second value - encoding beside `any_of`'s strings"; it is the opposite. Every value in this - vocabulary is already a string, so decimal strings keep the wire to **one** - value encoding, and it is float64 that would add a second. What B really - costs is a comparison rule, and that is where the danger sits: a decimal - string compared lexicographically answers that ten is less than nine. So - the cut must specify a canonical decimal form and a numeric comparison, - and pin both with mutants — a lexicographic comparison must die, and a - non-canonical spelling of an equal value must not change an answer. Name - the edge cases the canonical form settles: leading and trailing zeros, an - explicit plus, exponent notation, and negative zero. No exactness limit - needs stating, because there is no longer one. - - *Superseded, kept as history:* **A, IEEE float64, with the 2^53 - limit stated in the schema and in this map** (recommended: one encoding, - no parse rule per runtime, and no catalog in hand declares an integer - member wide enough to lose). **B**, decimal strings — exact, but a second - value encoding beside `any_of`'s strings and a per-runtime parse rule, - and two ways to spell a number is how a vocabulary starts growing terms. - **C**, add `integer: int64` beside `number` now, sharing `op` — additive - and untagged-free, and can be added later without breaking A, which is - why adding it now is premature. Depends: the schema files and both - runtimes' predicate types. -- **Q-K. Family names for abstract bases on the wire. PARKED, 2026-09-17, - and Self's reason was wrong.** The operator: this was possible when - CultCache was a single file, so it is odd that it is hard now; not - load bearing either way. That is correct. In-process a family is one - assignability check, because the runtime holds the hierarchy. Over the - wire there is no type system, only schema names, and an abstract type - carries no `[CultDocument]` so it never gets one. **The hardness is not - modelling, it is that nothing publishes the hierarchy where a remote - caller can see it.** The honest cost is therefore a catalog addition, - each leaf's entry naming its base chain, after which a family is the - leaves whose chain contains the name asked for. Small work — but it moves - the catalog's shape, and this cut's whole claim to being cheap is that it - moves neither the catalog's shape nor its hashes. So it is **deferred to a - cut that is already touching the catalog**, not deferred because there is - nothing to name. - - **Trigger named, 2026-09-17: the source-generator campaign needs it first.** - The operator's position is that any project using CultNet wants the types it - touches native, and that where the hierarchy is not being declared in - parallel by hand, a generator should build it. That generator runs against - **published schemas**, with C# authoring and the schema as the contract, - which is what the reference-runtime rule already says. - - The obstacle is the same missing fact. `CultCache.cs:847,887,966` walks the - base types while collecting members and then discards the hierarchy, and - `ToCatalogEntry` (`:152-177`) publishes a schema id, the canonical schema, - the compatible ids and a flat member list carrying slot, name, type, - reference-ness, many-ness, target and alias. **Nothing records what a type - inherits from.** So a generator fed published schemas alone emits one flat - class per leaf with the inherited members copied into each — exactly the - hierarchy the operator wants built for them, and unbuildable from what is - published today. - - So the base chain in the catalog is a **prerequisite of the generator - campaign**, and family names fall out of it for free once it exists. Until - then a family expands at the call site into its leaf schema names, which the - generator can emit as a constant, costing the protocol nothing. The honest - limit of call-site expansion, to be stated wherever it lands: it is - closed-world per compilation, so a leaf declared where the caller does not - compile leaves the list silently short, and an open-world caller must ask the - catalog at run time instead. - - The generator is **its own campaign, mapped after this cut lands** (operator, - 2026-09-17: "definitely later"), because its value is emitting against a - shape that is settled and this cut is what settles it. It is also where the - query sugar comes from: typed per-kind builders make filtering a document by - another kind's field a compile error rather than an empty page. **No source - generator exists in CultLib today** — no Roslyn generator anywhere in the - repository, and `src/GameCult.Caching/emitted/` is empty. - - Superseded reasoning, kept as history: - an abstract type has no `[CultDocument]`, no schema name - and no catalog entry, so there is nothing to name; a caller who wants - "every crafted item" enumerates the leaves from the schema catalog it - already reads. **B**, a `[CultDocumentFamily]` declaration plus a catalog - concept plus a matching rule in every runtime, in a later cut. Note this - is only about the `schemas` list: the *hop* already resolves an abstract - target to its leaves (D9), so the hostile example works under A. -- **Q-L. Refusing an out-of-target reference at write time. RULED A, - 2026-09-17**, with the follow-up upgraded from optional to intended. The - operator: notice on read for now, but on write eventually; today this is - left to consumers to enforce, as Aetheria's Database Tools did and as - CultCache Studio is expected to, and it is easy to insert garbage at the - source level which a cheap check would prevent. - - **Two things make the follow-up worth more than "nice to have."** First, - enforcement living in consumers is duplicated authority by construction: - every tool that writes records re-implements the same check, and a tool - that forgets is indistinguishable from one that does not, until a read - refuses. Second, the check really is cheap — the declaration already names - the target (`TargetSchemaName` on the member catalog entry), and the write - path already has the record in hand. - - **What Self could establish about the consumer story, 2026-09-17:** it - rests on less than it sounds. There is no CultCache Studio repository - under `F:\Projects`, and no project file by that name anywhere; every - reference to it is forward-looking, in Aetheria's migration documents, - where it is described as absorbing that project's Database Tools. Aetheria's - actual data tooling today is the `tools/AetherDb` console project. So the - enforcement being relied on is one tool, in one project, for one catalog, - and there is no second implementation to be inconsistent with yet — which is - the cheapest moment to move the rule to its owner rather than the most - expensive. - - Original options: **A, follow-up (FU-Ref)** (recommended): this cut refuses it at read - (`reference_outside_target`, S18), which is where the corruption is - noticed; refusing at write touches the cache's put path and every - runtime's apply path and is a change of a different size. **B**, in this - cut. Under A, the read-side refusal is the only thing standing between a - corrupt edge and a wrong answer, which is why it refuses instead of - skipping. -- **Q-M. The `(schema, key)` cross product. RULED A, 2026-09-17**, in the - operator's words: once per schema. The cross product stays, the limit is - stated, and a caller needing exact pairs asks once per schema. No third - addressing list and no rule for combining three of them. - Original options: **A, state the limit** - (recommended): `schemas` × `keys` stays a cross product; a caller needing - exact pairs asks once per schema or verifies what it declared, as - `CultNetRawSnapshotQuery` already does. **B**, allow a `refs: - RecordRef[]` list beside `keys` — expressible without a term language and - genuinely useful, at the cost of a third addressing list and a rule for - how three lists combine. **C**, let a `keys` entry carry a schema — a - path language in miniature, refused. Depends: section 13's table and - `snapshot_query.rs`'s client. - -**Q-A, Q-B and Q-C are no longer open.** The operator ruled all three on -2026-09-17 and they are recorded in the Huginn campaign's map, -`F:\Projects\Epiphany\notes\eureka-pipeline-state-cut.md`: receipt ordinals -yes; `open_items` and `history` deleted rather than kept as presets; and the -leaf gains titles on `question` and `ruling`, riding the same store version -bump as the ordinal so there is one migration rather than two. They are named -below only because work here still depends on them. - -What depends on each: S1-S8 on Q-D A; S5 on the shard-log assumption -(section 1) and on Q-B for Huginn; S9 on D6; S16 and the schema files on -Q-J; S18 on Q-L A; the Huginn cut on Q-B and Q-C. Settled by R-1 to R-5: -S10, S12, the fixture, FU-v0's trigger, FU-Py's five lines, Huginn's -window deletion, and the watch cut's existence. - -## 17. What was probed, read, and not settled - -**Probed:** the untagged round trip (section 2). **Read:** every line -anchored above; the CultMesh audit's 92 sites by an Explore pass whose -classification I checked at the sites that carry the ledger (the five -clones, the four matchers, the fallback, the two engines). **Not run:** -`dotnet` or `cargo` on CultLib — the tree is dirty from the QUIC campaign -and no claim here depends on a build; Hands takes the baselines named in -Pins. **Not settled:** whether every committed write has a shard-log -sequence (section 1, Hands confirms at the append site); the exact split of -mutation entries against the QUIC campaign's runner (section 10, Self at -landing); whether MessagePack-CSharp needs a union attribute for the -page's `headers | documents` (it does not if both are optional arrays with -exactly one present, which is the spelling chosen so no union is needed — -Hands confirms the schema says "exactly one" with `oneOf` over `required`). - -**Read on the second pass, and what it changed.** Each of these was read at -the source, and three of them contradicted something this map or its -predecessor's notes asserted: - -| Read | Held | Changed | -|---|---|---| -| `CultCache.cs:474`, `:151-176`, `:598-629`, `CultSchemaTypeNames.cs:9-31` | **Yes.** A member's type name is derived from the CLR type, persisted in the catalog entry and compared inside the canonical shape. Numeric addressing needs no new declaration, no hash movement and no migration — the load-bearing claim of this whole widening | — | -| `CultCache.cs:112-150` | — | **No.** `Members` and `IndexAccessors` are `internal` with no friend assembly, so the evaluator cannot read a declared value from outside `GameCult.Caching` at all. "No cache change is needed for addressing" was false; the cache add is ~150 lines, not ~60, and the ledger moved from ≈ +370 to ≈ +585 | -| `CultCache.cs:931-943`, `:462-468` | — | **No.** A hiding member is already refused (`HiddenMemberMessage`), and a duplicate alias already throws from `ToDictionary`. D10 shrank from "a missing refusal" to "a refusal that names nothing", and its test only means anything if it asserts the message | -| `packages/cultnet-rs/src/snapshot_query.rs` | — | **No.** Rust has a fifth selector engine (`serve_read_only_raw_snapshot:112-125`) that "Rust: none" concealed, and its client (`:195-217`) shows the cross-product limit that v1 does not fix (Q-M) | -| `CultDocumentContracts.cs:92-99`, `CultCache.cs:1100-1113`, `:1495-1509`, `:1818-1838`, `:254-265` | **Yes.** `ICultRecordRef` is the enumerable handle D11 needs; assignability is already how the cache resolves a base type to concrete documents (D9); `AllDescriptors` is public | — | - -**Still not settled, and who settles it:** - -- Whether every committed write takes a shard-log sequence (unchanged; - Hands, at the append site). -- Whether `CultDocumentDescriptor`'s public member view can be a readonly - struct without forcing an allocation per row per predicate in the - evaluator's inner loop. Nothing here depends on the answer being fast, - but a `Select`-per-row over a fresh list would be a silly cost to build - in; Hands measures if a fixture of a few thousand rows is slow, and not - before. -- The exact split of mutation entries against the QUIC campaign's runner - (unchanged; Self at landing). -- Whether `serve_read_only_raw_snapshot`'s existing tests survive the - lowering unchanged; they should, since the behaviour is identical, but - they were not read line by line. -- Q-J, Q-K, Q-L, Q-M: the operator. - -**Not probed, and deliberately:** nothing was built or run against CultLib. -The tree is hosting a live QUIC campaign, and no claim in this map needs a -build to be true — every one of them is a source read, and the three that -were wrong were wrong because the first pass did not read far enough, not -because a build would have caught them. - ---- - -# Cut 2: watching a selection - -Ruled 2026-09-17 (R-5): the operator wants this and wants it soon, so it is -mapped here rather than left as a follow-up trigger. It is **its own cut**, -landing after Cut 1 and depending on it. Nothing below is smuggled into Cut -1, and Cut 1 is not shaped around it: the seam Cut 1 draws — one selector -type serving a query and a subscription alike — is the whole of what Cut 1 -owes this one. Cut 1 can go to Hands without waiting for a ruling on -anything here. - -Same discipline as Cut 1: facts by source read, deletes first, an authority -map, verification with a mutation per rule including a loosening, and a -subtraction estimate with real numbers. - -## 18.1 Body facts - -**The local watch surface today** (`src/GameCult.Networking/CultNetDatabase.cs`): - -- `Watch()` `:1051-1057` filters the change stream by CLR type; - `WatchAllChanges()` `:1063-1067`; `WatchRecord(key)` `:1072-1075`; - `WatchGlobal()` `:1080-1083`; `WatchByName(name)` `:1088-1094`; - `WatchByIndex(alias, value)` `:1100-1107`. -- **`WatchByName` and `WatchByIndex` do not do what they are named.** Both - read the cache's *current single winner* for the lookup and compare it to - the change's document by `ReferenceEquals`. Combined with section 1's - finding that the cache's index maps hold **one key per value**, this - means `WatchByIndex("kind", "cannon")` observes changes to - whichever single `Weapon` most recently claimed `cannon`, not to the - weapons whose kind is `cannon`. It also re-runs the lookup *at change - time*, so a change that clears the value is evaluated against the state - after the clear. The same two lines exist again in CultMesh as - `MatchesName` / `MatchesIndex` (`CultMesh.cs:3312-3333`). -- The rule is set-dependent even without a hop, and nothing tracks - membership: there is no "was in the set, now is not" anywhere in the - local path. `Reconcile` (`CultNetDatabaseSubscriptionServer.cs:251-292`) - is the only membership tracker in the reference and it belongs to a - server, over a delivered set, per peer. - -**The CultMesh collection surface** (`src/GameCult.Mesh/CultMesh.cs`): nine -`Collection*` overloads at `:2278-2480` — three shapes (all / by name / by -index) by three sources (`CultCache` `:2278`, `:2302`, `:2331`; -`CultNetDatabase` `:2362`, `:2386`, `:2413`; `CultMeshNode` `:2442`, -`:2456`, `:2471`, which delegate to the database trio in three lines each). -`CollectionByIndex` over a cache resolves its contents as -`Optional(cache.GetByIndex(alias, value))` — a "collection" of -**at most one row**, whose live feed then filters by `MatchesIndex`. Three -shapes exist because the shape *is* the query language; a selection -replaces the shape axis entirely and leaves the source axis alone. - -**What Cut 1 leaves in place for this cut:** `CultNetSelection` and its -validation, `CultNetSelectionEvaluator.Matches(selection, descriptor, key, -document)` for a single row, the cache's public read surface, and D6's rule -that a hop-bearing selection is set-dependent and must reconcile rather -than decide per change. - -## 18.2 The shape - -``` -CultNetDatabase.Watch(CultNetSelection selection) - -> Observable - -CultNetSelectionChange { - kind: Entered | Left | Updated // membership, not storage - schemaId, recordKey - document: object? // present on Entered/Updated under `document` projection - previous: object? // present on Left/Updated - cause: Self | Edge // this row changed, or a row it cites / is cited by did -} -``` - -**Rules, each one owner:** - -- **Membership, not mutation.** The stream reports a row entering the - selection, leaving it, or changing while inside it. A write to a row that - was outside and stays outside emits nothing. A write that changes a value - the selection filters on emits `Entered` or `Left`, **not** `Updated` — - which is precisely what `WatchByIndex` cannot express today. -- **`cause: Edge`** is how a hop-bearing selection stays honest: when a - change to row B moves row A in or out, the subscriber is told about A, - with the cause marked, and never about B unless B is itself in the set. -- **No order, no cursor, no `asOf`, no `limit`.** A live feed is not a - page. A selection carrying `limit`, `cursor` or `descending` is **refused - at the door** (`selection_invalid { field }`) rather than silently - ignored: "the latest ten, live" is a different question with a different - answer (it needs an eviction rule), and this cut does not answer it. - `projection` is honoured; `schemas`, `keys`, `fields`, `cites` and - `cited` are honoured. -- **The initial set is not a snapshot message.** `Watch` is local and - in-process; it emits `Entered` for every row already matching, at - subscribe time, under the cache's lock, before any live change. There is - no `asOf` because there is no wire and no second reader. -- **A hop-bearing selection recomputes; a plain one decides per row.** - Without `cites`/`cited`, membership is `Matches(selection, row)` and - costs one row's read. With a hop, a change anywhere can move anything, so - the watch re-evaluates the selection over the cache and diffs against the - membership set it holds. That is O(rows) per change and it is stated - plainly rather than hidden (Q-N). - -## 18.3 Deletes first - -| Path:lines | Lines | What | -|---|---:|---| -| `CultNetDatabase.cs:1080-1107` | 28 | `WatchGlobal`, `WatchByName`, `WatchByIndex` — the three that encode a query in a method name, two of them wrongly. `WatchGlobal` lowers to `keys: ["global:{schemaId}"]`, the other two to a `fields` predicate | -| `CultMesh.cs:3312-3333` | 22 | `MatchesName`, `MatchesIndex` — the same `ReferenceEquals`-against-the-winner rule, second copy | -| `CultMesh.cs:2278-2480` | 203 | the nine `Collection*` overloads; **after: three**, one per source (`CultCache`, `CultNetDatabase`, `CultMeshNode`), each taking a `CultNetSelection`, about 75 lines | -| `CultMeshSnapshots.cs` and `CultMesh.cs` local-watch predicates, audit sites #2, #4-9, #16-21 | ~240 | the remaining local watch loops and their inline predicates, about 40 after | -| **Total** | **~493** | **~115 after; net about −378** | - -`Watch()`, `WatchAllChanges()` and `WatchRecord(key)` **stay**, as -Cut 1's v0 lowering stays: a CLR-type filter and a single-key watch are -conveniences that lower to a selection and have no rule of their own. If -they grow a rule again, W6 catches it. - -## 18.4 Adds and authority - -| Add | Owner | Live consumer | Protected invariant | Replaces | -|---|---|---|---|---| -| `CultNetSelectionWatch` in `GameCult.Networking` (~130 lines): the membership set, the per-change decision, the hop recompute, `CultNetSelectionChange` | `CultNetDatabase` | Mesh's three `Collection` overloads; any local consumer that watches a question | membership is tracked in one place, and a row that leaves the set is reported leaving | `WatchByName` / `WatchByIndex` / `MatchesName` / `MatchesIndex`, four spellings of one wrong rule | -| `CultNetDatabase.Watch(CultNetSelection)` (~15 lines) | `CultNetDatabase` | the above | one entry point | three named-query methods | -| Rust: **nothing in this cut** | — | — | — | no Rust caller watches locally; when one does, it gets the same shape over `Row`/`RowSet` | - -- **Owner.** `CultNetSelectionWatch` owns membership and only membership. - `CultNetSelectionEvaluator.Matches` owns whether a row is in the set — - the watch never re-implements a predicate. `CultNetDatabase` owns the - change stream. The cache owns values, as in Cut 1. -- **Inputs.** The change stream, the cache's rows through the public read - surface, the validated selection. Not inputs: a clock, a transport, the - shard log's sequences (there is no order here), a peer's authorization. -- **Outputs.** `Entered` / `Left` / `Updated` with a cause, and one typed - refusal for a selection carrying page-shaped fields. -- **Derived state.** The membership set, and the incoming-edge index when - the selection hops. **Demotions:** `Watch` and `WatchRecord` are no - longer queries, they are lowerings; Mesh's collection *shape* is no - longer a language, it is a `Selection`; `MatchesIndex` is not an owner of - anything. -- **Forbidden writers.** Nothing outside `CultNetSelectionWatch` may decide - membership; no watch path may call `GetByIndex` or `GetByName` to decide - whether a change is interesting (that is the bug being deleted, in both - its copies); Mesh may not filter a feed the watch answered; the watch may - not emit `Updated` for a row that crossed the predicate boundary; the - watch may not accept `limit`, `cursor` or `descending`. -- **Shared paths.** `Matches` under both the query and the watch — a row - that a page would return and a row the watch reports entering are decided - by the same function, or the two truths drift. The subscription server's - `Reconcile` keeps its own per-peer delivered set (it answers a different - question: what this peer has been told) and **does not** become the local - membership tracker. -- **Deletion line.** Commit 1: `CultNetSelectionWatch` and - `Watch(Selection)`, with the three named-query methods deleted and their - tests rewritten. Commit 2: Mesh's nine overloads collapsed to three, both - matchers deleted. Nothing lands with `WatchByIndex` alive beside it. - -## 18.5 Verification - -| # | Test | Pins | Revert kills | Loosening kills | -|---|---|---|---|---| -| W1 | `Watch_ReportsARowLeavingWhenItsFilteredValueChanges` | membership, not mutation — the bug being deleted | `Left` never emitted (today's behaviour restored: the change is simply not delivered) | `Updated` emitted instead of `Left`, so a subscriber holding a list keeps a row that no longer matches | -| W2 | `Watch_ReportsEveryRowSharingAnIndexValue_NotTheCacheIndexWinner` | the deletion of the `ReferenceEquals`-against-the-winner rule | `MatchesIndex` restored | membership decided by the winner *plus* the changed row, which passes a two-row fixture and fails a three-row one — **the fixture must carry three rows sharing the value** | -| W3 | `Watch_EmitsTheInitialSetBeforeAnyLiveChange` | the subscribe-time set | no initial emission | the initial set emitted *after* the subscription goes live, so a write racing the subscribe is delivered twice or not at all — needs a test that writes between subscribe and first emission | -| W4 | `Watch_ReportsAnEdgeCausedEntryWhenTheCiterChanges` | `cause: Edge`, D6 locally | a hop-bearing watch decided per changed row (the citee never moves) | the citee reported with `cause: Self`, which loses the subscriber's ability to tell a row's own change from a set change | -| W5 | `Watch_RefusesAPageShapedSelection` | no order, cursor or limit on a feed | the refusal dropped and the fields ignored | `limit` refused but `descending` ignored — one field's worth of silent ignoring, which is how a "harmless" ignored field becomes a bug report | -| W6 | `Watch_AndSelect_AgreeOnMembershipForTheSameSelection` (property-shaped: for each fixture selection, the set the watch holds equals the ids a page returns) | the shared `Matches` | the watch given its own predicate | the watch's copy agreeing on the plain selections and differing on the hop-bearing ones — kills only if the fixture's selections include hops, which section 6's fixture does | -| W7 | `Mesh_CollectionTakesASelectionAndFiltersNothingItself` (Mesh tests) | the collapse | an overload restored | the three surviving overloads re-filtering the feed after the watch answered | - -**Negative greps:** `rg -n "WatchByIndex|WatchByName|MatchesIndex|MatchesName" src` -empty; `rg -n "GetByIndex|GetByName" src/GameCult.Mesh src/GameCult.Networking` -empty; `rg -n "CollectionByIndex|CollectionByName" src` empty. - -**Mutations.** Entries in this campaign's own file, target `networking` -(killer `dotnet test tests/GameCult.Networking.Tests`) plus the Mesh tests -for W7, one per rule above with its revert and its loosening. - -## 18.6 Subtraction estimate - -| | Removed | Added | -|---|---:|---:| -| `src/GameCult.Networking` | 28 | ~145 | -| `src/GameCult.Mesh` | ~465 | ~115 | -| tests | ~80 rewritten | ~260 (W1-W7) | -| **net source outside tests** | | **about −233** | - -This is the cut where CultMesh actually shrinks: about −350 in -`CultMesh.cs` and the snapshot facade against about +117 in the database -organ. It is also the cut that deletes a wrong answer rather than a -duplicate one — four spellings of "watch the row that currently wins a -unique-index lookup", replaced by one membership tracker over the selection -the caller actually asked about. Cut 1 pays about +585 for the vocabulary; -Cut 2 spends it. - -## 18.7 Operator questions for this cut - -- **Q-N. The hop-bearing watch's recompute.** **A, recompute and diff on - every change** (recommended: obviously correct, O(rows) per change, and - the local caches this runs over hold thousands of rows, not millions). - **B**, maintain an incremental incoming-edge index invalidated per write - — faster, and a second piece of derived state that can disagree with the - cache. Take B only with a measured reason. -- **Q-O. Does `WatchRecord` survive?** **A, yes, as a lowering** - (recommended; it is one key and no rule). **B**, delete it too and make - every caller say `keys: [k]` — cleaner, noisier at its call sites. -- **Q-P. Ordering on a feed, later.** Out of this cut by the rules above. - If "the latest N, live" is wanted, it is a third cut with an eviction - rule, not a field added here. - -**R-AA landed, 2026-09-22** (Sonnet), `b7d77b7..99e366a` on -`cultnet/selection-cut1`. One new test file, 27 tests, 707 lines. **No -production code touched.** Score **66% → 77.62%**: killed 345→371, survived -85→57, no-coverage 75→50, over the same 428-mutant set both passes. Suite -green on Yggdrasil, 271 passed / 2 skipped. - -Every one of the nine named survivor groups is killed, with two corrections to -the triage that matter more than the score. - -- **`EdgesFor`: three of five killed. STRUCK 2026-09-23 — what stood here was - wrong.** It claimed that within one hop direction `(From, Role)` determines - a unique declared edge value, so no legal selection could make two edges - share anchor, From and Role while `To` differs, and recorded `To.SchemaId` - and `To.Key` as equivalent "so nobody pays for them a third time". **A - selection carrying both hops mixes their edges into one bucket**, and Soul - built the counterexample: a row self-referencing through the role it uses to - cite a peer. `To.Key` is now killed in both runtimes; `To.SchemaId` is - settled under R-BA. The text is struck rather than edited because it was - read as a live instruction for two passes. -- **A sixth mutant in the same method that nobody had named**: - `OrderBy(pageRowIndex)`, which is R-B's *primary* key, ahead of - `(from, role, to)`. It survived the first pass and is now killed by a test - that forces page order and `(from, role, to)` order to disagree. The rule - R-B exists to protect had its first component unguarded, and the triage had - not caught that either. -- **The digest needed a golden value, not a comparison.** Beyond the named - branch mutants, about nineteen finer-grained ones survived — deleting - individual `AppendString` calls. A same-versus-different digest test - structurally cannot catch these: a mutant that swaps which case gets which - flag still produces two *different* digests, merely mislabelled. Hands - reconstructed a golden HMAC independently from the method's doc comments - rather than from its private helpers, which is the right way to build one, - and it killed all but three residual coincidences. - -**Line drift: every rule had moved 30–90 lines** from Soul's `52082ec` -numbers, none to a different method. Locate by name, not by number, and do not -trust the numbers in this section either. - -**Four things found along the way, flagged rather than silently fixed.** Two -are test gaps; two look like real defects: - -- `ReadLengthPrefixedFields`, `length <= 0`: a legitimately zero-length field - would be wrongly refused. -- `ReferenceMembers`'s `!member.IsReference` guard: a data member could be - treated as citeable. -- `FindCursorPosition`'s loop bound: an out-of-bounds read when paging past - the last row with a cursor exactly at the end. -- `ComputeDigest`'s `else`-branch appends survive only because the golden - test has both `Cites` and `Cited` non-null; a second golden with both absent - closes them. - -**Self's rulings, 2026-09-22:** - -- **R-AC. STRUCK 2026-09-23. It said the two `EdgesFor` `To` mutants were - equivalent and "recorded, not to be chased".** That instruction was false - and was obeyed for two passes. Soul's counterexample kills `To.Key` in both - runtimes; `To.SchemaId` is settled under R-BA. **The sentence it closed with - — a survivor correctly identified beats a test written to move a number — - is still true, and is exactly why a survivor must be identified by a - construction attempt rather than by an argument.** -- **R-AD. The three suspected defects above get behaviour tests first**, since - a test that fails today is the only thing that proves they are defects - rather than readings. The out-of-bounds read in `FindCursorPosition` is the - one to check first: it is reachable from an attacker-supplied cursor. -- **R-AE. A second golden digest with both hops absent**, closing the three - residual mutants rather than leaving them as folklore. - -**Fix batch 4, Rust half, landed** (Sonnet), `cultnet/selection-cut1` now at -`d7f73af`, rebased onto the C# side's `99e366a`. Six commits: `c91e937` -(R-V and Rust's half of R-W), `97627dd` (R-Y), `4bfd989` and `94c32ac` -(R-AB), `8005586` (S-12, prose), `d7f73af` (a vendored-schema resync). - -**217 Rust tests green** on Yggdrasil, and re-confirmed locally with -`contracts/` entirely hidden, which simulates cargo-mutants' isolated copy. -**Mutants scoped to the diff: 23 tested, 23 caught, 0 missed.** Nothing to -triage. - -**S-2 is C#-only** — Rust already checked the declared target before filtering -by key, so the divergence was one-sided and the C# commits close it. No Rust -change was needed and none was made. S-1 and S-3 are closed by R-V and R-W. - -**R-AB worked, and it took two fixes rather than one.** The named escape was -`schema_discovery.rs`'s `include_str!("../../../contracts/…")`. Vendoring -byte-identical copies under `packages/cultnet-rs/contracts/` fixed that and -left the crate still unmutable: `tests/selection.rs`'s fixture loader read the -same canonical tree three levels up, and 37 of 45 tests panicked under the -isolated copy. The first full-crate run failed its unmutated baseline for -exactly that reason. Vendoring the fixture too, plus a skip-when-absent guard -on the test that reads C#'s own `cs-written.json` output — which cannot be -vendored, being the other runtime's product rather than a checked-in input — -made the crate mutable for the first time. - -**The vendoring is duplicate authority, and it drifted within hours.** The -rebase onto the C# side's landed R-X changed `cultnet.error.schema.json`, and -the vendored copy did not see it. **Hands' own drift test caught it** and the -copy was re-synced at `d7f73af`. - -That is worth stating plainly rather than filing as a win. Twenty-two schemas -now exist twice, and the guard against the copies diverging is a test rather -than a structure. The cleaner shape — a build script staging the canonical -contracts into `OUT_DIR` — **fails for the same reason the `include_str!` -did**: cargo-mutants copies the package tree, so a `build.rs` reaching three -levels up is just as unreachable. Publishing the contracts as their own crate -would fix it properly and is out of this cut's scope. **So the duplicate is -accepted for now, on the record, with the drift test as its named guard.** It -goes in the follow-ups, not the win column. - -**Hands burned an unscoped full-crate mutants run** — 2,329 mutants and a -timeout cascade across files the cut never touched — before rescoping to the -diff. It reported that unprompted and discarded the output. The ruling did say -"the cut's diff"; the run cost most of a night on a shared host. - -## Soul, the merge gate, 2026-09-23 - -Opus, against `d7f73af`. Counts re-run by Soul itself: Networking 269 passed / -2 skipped, Mesh 255, cultnet-rs **224** across 12 targets. (R-AA reported -"271 passed" — that is 269 + 2 skipped. The Rust half reported 217; the -measured figure is 224. Both green, one overstated.) - -**Verdict: Cut 1 may not merge.** Three reasons, any one sufficient. - -### SM-1, high. S-1 is not closed. One ruling became two different rules. - -`CultNetSelectionEvaluator.cs:358-386` resolves a reference's target by -**record key plus the declared leaf set** — its own comment says "a -reference's stored target is a bare record key, the wire carries no schema for -it". `packages/cultnet-rs/src/selection.rs:1765` and `:1817` resolve it by an -exact `by_key.get(&(target.schema_id, target.record_key))` on the -`RecordRef`'s own schema id. R-V said both runtimes index rows **and incoming -edges** by the pair. C# indexes rows by the pair and edges by the key; Rust -indexes both by the pair. - -Probed at the evaluator in both runtimes, same logical rows: - -| Shape | C# | Rust | -|---|---|---| -| Only row at key `k` is out-of-target; narrow ref declared to leaf_a points at `k` | **REFUSED** `reference_outside_target` | **ACCEPTED**, empty page | -| Two rows at key `k`; ref declared to leaf_a, stored target names leaf_b | **ACCEPTED**, resolves the leaf_a row, emits an edge | **REFUSED** `reference_outside_target` | - -**They give opposite answers on the exact shared-key shape R-V was written -for.** A consumer paging the same data through the two runtimes gets a -refusal from one and rows from the other. Parity is this cut's invariant. - -**No vector covers it:** the shared fixture has zero duplicate record keys -across its ten rows, exactly one reference, and that reference's role targets -both leaves — so `reference_outside_target` is never reached cross-runtime at -all. R-V's narrower promise does hold: within each runtime, both row orders -agree. - -### SM-2, high. Both runtimes take a hostile cursor into an unhandled fault, on mirror-image cases. - -- **C#.** `ReadLengthPrefixedFields` (`CultNetSelectionEvaluator.cs:750-751`) - tests `pos + length > bytes.Length` in `int`. A prefix of `2147483647` - overflows, the guard passes, and `Encoding.UTF8.GetString` throws - `ArgumentOutOfRangeException`. `HandleSnapshotRequestV1Async` catches only - the three typed selection exceptions and `CultNetRudpSchemaServer.DispatchAsync` - has no catch at all, so **an untyped exception reaches the server's poll - loop**. The peer gets no answer. -- **Rust.** `read_length_prefixed_fields` (`selection.rs:2044-2046`) parses the - prefix as `usize` and computes `pos + len`; `18446744073709551615` - overflows. Probed: **panic.** - -Rust refuses the `int::MAX` case typed; C# refuses the `usize::MAX` case -typed. **Each runtime has exactly the hole the other closed** — which is what -happens when a rule is hardened twice independently and neither run fed it -garbage. R-N's whole point was that every cursor refusal reaches the wire as -`cursor_invalid`. - -### SM-3, high. R-Z did not land, and nobody said so — including me. - -R-Z required the vectors to compare **page bytes**, plus `asOf`, `next` and a -refusal's **code**, with projection vectors on both sides. -`git diff --stat 52082ec..d7f73af` touches neither -`SelectionParityVectorTests.cs` nor either vector file's vectors. The reader -still compares row ids, `matched`, `hasNext` and edge fields, and -`expectedRefusalField` — **never the code**. There is no page-bytes field in -either JSON. `rs-written.json` still carries no projection vector, which is -S-7's original wording verbatim. The C#-side projection vectors run through -`Select`, which ignores `Projection`, so the comment claiming "page bytes -under each projection" describes something the test does not do. - -**R-Z is absent from the batch's work and from its open list.** Self recorded -the batch as closing its rulings without checking that one, which is the -tracking failure that let it pass silently. - -### SM-4, medium. Two ordering rules are pinned by their mutants, not their behaviour. - -Twelve non-revert mutations, each changing a rule a different way from the -Stryker mutant its R-AA test was written against. Nine died. Three survived: - -- **`EdgesFor`'s secondary key.** Permuting `Role` ahead of `From.Key` - (`:307-311`), keeping every component, passes 269/269. R-AA's test kills the - *primary* key's deletion; the **composition** of the rest is unpinned, and - the fixture's single reference pins nothing either. -- **The row tiebreak.** Swapping `SchemaId` and `Key.Value` in the ascending - tiebreak (`:214`) passes 269/269. The fixture does have one tied ordinal, - but both rows are leaf_a, so **the schemaId component of - `(ordinal, schemaId, recordKey)` is exercised by nothing** — not in unit - tests, not in vectors. Rust's `order_rows` uses the same three components, - so a silent permutation on either side goes unnoticed. -- **`!member.IsReference`** (`:456`) is a redundant second copy of the guard - in `CultCache.ResolveReferences`/`ReferencesOf`, which already returns empty - for a data member. **Equivalent mutant, and duplicate authority** — not a - defect, but not innocent either. - -### SM-5, medium. The Rust half of the vocabulary has 59 live mutants and nobody has looked. - -The diff-scoped run ("23 tested, 23 caught, 0 missed") measures only the lines -batch 4 changed. Soul ran cargo-mutants over the whole of `selection.rs`: -**351 mutants, 274 caught, 59 missed, 14 unviable, 4 timeouts, 36 minutes.** - -- **`canonical_number`: about 35 missed plus all four timeouts.** `is_canonical` - — the Q-J door — has **every branch mutation surviving**. `decompose`, - `pad_right`, `canonicalize_decimal_digits`, `big_mul_small` and - `big_to_most_significant_first` can each be replaced with `String::new()` or - `"xyzzy"` and the suite stays green. **This is R-D, a byte-for-byte wire - parity rule.** -- `validate`: 4 missed, including a deleted `!` and `||` → `&&` — the door R-F - put inside `select`. -- `matches_schema_keys_fields`: 3 missed, including two deleted `!` and an - `==` → `!=` — which rows match. -- `schema_alias::matches`: `==` → `!=` — R-E's one identity rule. - -**S-5 blocked this cut at 66% on the C# side and produced R-AA. The Rust side -of the same rules has never been measured. The asymmetry is the finding.** - -### Settled, not defects - -- **`FindCursorPosition`'s loop bound is not a defect.** It bounds at - `i < ordered.Count`. Probed in both runtimes: paging to the exact end and - replaying the last cursor gives an empty page, `next = null`, no exception, - no panic. The Stryker survivor was a coverage gap. **R-AD's headline - suspicion was wrong**, and settling it was worth the pass. -- **`length <= 0` does not exist** in either runtime, and a zero-length field - parses fine in both. The real bug in that function is SM-2's overflow. -- **S-2 held, and Hands' "C#-only" claim is true** — Soul checked rather than - accepted it. Both Rust call sites run `ensure_within_declared_target` before - any role or key filtering, with a test pinning it. -- **S-3 held.** A both-hops selection returns all its `cites` edges in both - runtimes, each anchored by its own hop, identically. -- **S-12 is half done.** The fixture's prose was fixed; - `SelectionParityVectorTests.cs:200-208` still calls the name-alias case a - "Confirmed cross-runtime defect" that the alias port already fixed. Low, but - it is a falsehood in a file a future reader will trust. - -### Promises that held - -S-4 (real `CultNetErrorMessage` bytes validated against the schema for all -four codes), S-6 (a spliced digest and a reused cursor under a changed -selection both refused), S-9, S-10, S-11 (the hop refusal is gone and the hop -is served; still no dedicated regression test). **R-AB's guard is real**: all -23 vendored files are byte-identical right now, the hand list matches every -`include_str!` 22/22, and flipping one byte makes the drift test fail by name. -**R-AA's tests are mostly behavioural**: nine of twelve hand mutations died, -including the digest under a double-append rather than a deletion, the -comparator forced back to UTF-16 units, both role-naming sites under inverted -precedence, and cursor positioning made inclusive. - -**Caching's three failures remain unproven, not passing** — the stopgap's -container runs as root and the tests need a write to a read-only directory to -fail. Soul declined to call them environmental on its own authority. - -### What Soul could not run - -- **No page-byte parity comparison exists to run.** C#'s page is a - `CultNetSnapshotResponseRawV1Message` envelope; Rust's is a bare - `SelectionPage`. **There is no common wire type**, so R-Z as written needs a - shape decision before it can be implemented at all. -- No fresh Stryker run on the C# side; twelve hand mutations instead, which is - what the brief asked for. The Rust figure is Soul's own measurement. -- Only `selection.rs` was mutated in Rust; `contracts.rs`, `schema_discovery.rs` - and the rest of the crate are unmeasured. - -### The rig, handed over - -`C:\Users\Meta\eureka-soul-probes\cultnet-selection-cut1\` — deliberately -outside the repo and outside the session scratchpad, **so it does not -evaporate the way the Idunn rig did**. `README.md` says where each file drops -in and what it proves: `SoulProbeTests.cs`, `soul_probe.rs`, -`soul-mutants.sh` and twelve precomputed mutated files (the dotnet image has -neither python3 nor perl to patch with). - -## Self's rulings for fix batch 5, 2026-09-23 - -- **R-AF (SM-1). One rule, and the wire decides which.** Hands establishes - empirically what a stored reference actually carries — a bare record key, or - a key and a schema id. **If the wire carries no schema, C#'s rule is right - and Rust adopts it. If it carries both, Rust's is right and C# adopts it.** - I am not choosing by taste between two implementations when the Body already - knows the answer; what I am ruling is that the answer is one rule, derived - from the wire, in both runtimes. **A parity vector must cover the shared-key - shape in both directions** — its absence is why this survived four passes. -- **R-AG (SM-2). Every malformed cursor becomes `cursor_invalid` on the - wire, in both runtimes, and nothing else escapes.** Use checked arithmetic - on both sides. C#'s dispatch path gains a catch so an untyped exception - cannot reach the poll loop. **Feed each runtime the other's proven - overflow**, plus a small set of deliberately hostile cursors, and pin them. - The shape of this bug — each runtime holding exactly the hole the other - closed — means neither side's tests are evidence about the other. -- **R-AH (SM-3). R-Z needs its shape settled before it can be built.** The - envelope types differ, so **the vectors compare the canonical MessagePack - bytes of the page payload** — rows, edges, `asOf`, `next` — which both - runtimes can produce, rather than the transport envelope, which only one - has. Refusals compare the **code**. Projection vectors exist on both sides, - and `Select` must actually honour `Projection` or the vector is a lie. - **Flag for operator review**: this is my ruling on a wire-shape question, - and if the payload's canonical form is meant to be something else, it should - be corrected before the vectors harden around it. -- **R-AI (SM-5). Measure the Rust vocabulary and triage it.** Run - cargo-mutants across `selection.rs` and the rest of the crate's selection - path, and close survivors by behaviour. **`canonical_number` first**: R-D is - a byte-for-byte parity rule whose door currently survives every branch - mutation, which means the canonical decimal form is pinned by nothing in - Rust. Equivalents get a one-line reason. -- **R-AJ (SM-4). Pin the ordering compositions, in both runtimes and in the - vectors**: `EdgesFor`'s secondary key ordering, and the schemaId component - of the row tiebreak, which needs a fixture with a tied ordinal across two - schemas — the current fixture's tie is within one leaf. -- **R-AK. Delete the evaluator's redundant `!member.IsReference` guard.** - `CultCache` owns that decision and already enforces it. Duplicate authority - is the defect even when the duplicate is harmless today. -- **S-12's remainder:** correct the comment that still calls a fixed defect - confirmed. - -### Fix batch 5 landed, 2026-09-23 - -Sonnet, five commits on `sel-fix5`. C# Networking **274 passed / 2 skipped** -(was 269/2), Mesh 255, Rust 50/50 with `cargo-mutants --in-diff` reporting 26 -tested, 21 caught, 5 unviable, **0 missed**. - -**R-AF: the wire carries no schema, so C#'s rule was the right one.** -Established by probe, not by reading either comment: a real `Design` reference -field serialized through `MessagePackSerializer` dumps as `A4-61-2D-65-71` — a -bare five-byte msgpack string, `"a-eq"`. The whole document is -`["citer-1","a-eq"]`. No schema id anywhere. `CultRecordRefFormatter.Serialize` -confirms it: `writer.Write(value.Key.Value)`, unconditionally, for every -`CultRecordRef` in the codebase. - -**Rust adopted C#'s rule.** `Row::references()` returns a bare -`target_record_key`; `by_key` is grouped by record key alone; a new -`resolve_reference_target` mirrors `TryResolveReferenceTarget` exactly — -search every row sharing the key, return the first inside the declared leaf -set, refuse deterministically by schema id if none is. `RecordRef` survives -only where the wire genuinely carries both, which is the `Citation.target` -query filter. - -**The vector that was missing now exists.** The fixture gained a -`citer_narrow` schema with a real SHA-256 id and rows for **both** shapes from -Soul's table: a lone out-of-target row, which refuses, and a shared key with -one in-target and one out-of-target row, which accepts and resolves the -in-target one **regardless of row order**. Plus a new `expectedEvaluationRefusal` -vector field — narrower than R-AH's job, just which typed refusal fires. - -**A second real defect, found while landing R-AF.** Rust's `matches_citation` -resolved and target-checked **every** declared reference on a row before -filtering by the citation's requested role; C# filters by role first. Rust's -role filter moved ahead of resolution to match. One existing Rust test was -therefore pinning behaviour C# does not have, and was rewritten to mirror C#'s -real shape. **This was a live divergence nobody had named** — neither Soul nor -any ruling. - -**R-AG.** C#'s `pos + length` overflowed in unchecked `int`; fixed by -promoting to `long`. Rust's `pos + len` overflowed in `usize`; fixed with -`checked_add`. `CultNetRudpSchemaServer.DispatchAsync` gained a backstop catch -so no untyped exception reaches the poll loop. **Each runtime was fed the -other's proven overflow**, plus three further hostile shapes, four new tests a -side, each confirmed to fail without the fix. - -**R-AJ, and the finding inside it: the existing row-tiebreak test was -degenerate.** Hands hand-reverted the tiebreak and swapped it, and the test -**still passed 100%** — purely by accident of which of `SelLeafA`/`SelLeafB`'s -real hash ids happened to sort first. It was rewritten to assign keys relative -to the schema ids discovered at runtime, then re-probed and now fails -correctly. The same shape was added in Rust, with a shared-fixture vector -carrying a tied ordinal **across two schemas** — the original fixture only -ever tied rows of the same schema. `EdgesFor`'s secondary key got the same -treatment. - -**R-AK.** Verified before deleting: `CultCache.ReferencesOf` already searches -only its own reference members for a role match, and index aliases are unique -per descriptor, so a data member's role string cannot collide with a reference -member's. The redundant guard is gone. - -**S-12** is closed; the stale "confirmed cross-runtime defect" comment is -corrected against source. - -**Not done, and named rather than buried:** - -- **No diff-scoped Stryker run.** `--since` crashed on `GitInfoProvider` in - the detached-clone container, so Hands fell back to whole-file globs: 312 - tested, 262 killed, **47 survived**, 3 timeouts, untriaged between the - changed lines and the large pre-existing bulk of both files. Every rule - Hands touched was separately confirmed by hand. **The tooling failure was - worked around, not root-caused**, which means the next pass inherits it. -- **The RUDP dispatch backstop has no dedicated integration test.** It would - need a fuller server harness. Flagged, not built. - -### R-AI landed, 2026-09-23 — `canonical_number` measured and closed - -Sonnet, four commits rebased cleanly onto `sel-fix5`, pushed as -`sel-fix5-rsmut` at `fc51e9a`; 50/50 Rust tests pass against the rebased tree. -**None of it touches the edge-resolution path** — the whole diff is insertions -inside `canonical_number`'s own nested `mod tests`, with zero hits for -`references`, `by_key`, `matches_citation`, `resolve_reference_target` or -`RecordRef`, so nothing was measured against code batch 5 has since rewritten. - -**126 mutants: 89 caught / 29 missed / 7 timeout / 1 unviable → 114 caught / -4 missed / 6 timeout / 2 unviable.** - -`is_canonical` — the Q-J door, where **every branch mutation had been -surviving** — is closed by direct grammar tests that had never been called -before; the function was only ever reachable indirectly through a handful of -`validate_field` spellings. `compare`, `decompose`, `pad_right`, -`canonicalize_decimal_digits`, `big_mul_small`, `big_to_most_significant_first`, -`big_from_u64` and `exact_decimal_from_mantissa` each got direct tests, every -one confirmed red under its own mutation by a real re-run rather than by -inference. - -**Another test that passed by luck.** `render_f32`'s sign-bit mutant survived -Hands' first negative-value tests because **1.0 and 2.0 happen to have bit 0 -matching their real sign bit**. Adding `-1.0` and `-2.0` killed it. Same shape -as the degenerate row-tiebreak test in batch 5: a fixture whose values -coincidentally satisfy the assertion. - -**Four survivors, all judged equivalent by hand-tracing rather than assumed:** -two in `is_canonical` (a leading-zero guard that is a pure early exit, since -anything it would catch is caught later by the dot check; and an increment -skipping a digit the caller already validated, which the following loop -re-consumes either way), and `|` → `^` in both `render_f64` and `render_f32`, -where the fraction is masked to exactly 52 or 23 bits so the implicit leading -bit is never set and the two operators are bit-identical. - -**Six timeouts are inherent infinite loops under mutation, not test gaps** — a -`+=` → `*=` on a loop's own increment, two on `big_from_u64`'s loop bound, and -two exponent-sign corruptions that wrap a negative `i32` into a near-`usize::MAX` -bound. Any test reaching those lines hangs regardless of what it asserts; the -timeout is cargo-mutants' signal for "behaviour changed", it simply cannot -classify a hang as caught. - -### **`-j4` is not reliable for verdicts on this image, and that is retroactive** - -The identical commit gave **different missed and timeout sets across three -separate `-j4` runs** — `render_f64`'s L284 alone flipped between missed and -caught with no code change — while every `-j1` run reproduced the same 4 -missed / 6 timeout. The likely cause is the shared `CARGO_TARGET_DIR` under -concurrent mutant builds, which is the same class as this pipeline's existing -shared-target scar. - -**This makes every concurrent mutation figure in this campaign provisional**, -including Stryker runs made with `--concurrency 4` and any cargo-mutants -result above `-j1`. It does not invalidate them — a killed mutant that was -genuinely killed stays killed — but a *survivor* list from a concurrent run -cannot be trusted as complete or as accurate. Where a survivor list drove a -ruling, the ruling stands on the hand-confirmed evidence beside it, not on the -count. - -### Honest scheduling account - -Six real runs launched, **two duplicated and killed** — one caught by Hands, -one Self had to flag. Effective slot time on real measurement runs was about -**47 minutes**, spread across **just over five hours of wall-clock**, most of -it back-and-forth: duplicate cleanup, the `-j4` flakiness detour, and the -rebase. Hands reported this unprompted and in detail, which is the only reason -the `-j4` finding exists at all. - -### Unmeasured, and named - -`contracts.rs` (about 100 mutants) and `schema_discovery.rs` (about 50) were -**not run against `sel-fix5` at all**. One partial run, killed mid-flight, -showed several `MISSED` across `contracts.rs`'s `validate_*` family — -`decode_cultnet_message_from_slice`, `validate_message`, -`validate_mutation_contract`, `validate_schema_descriptor`, -`validate_document_record`, `validate_raw_document_record`, -`validate_shard_descriptor`. **That is real signal on the door family, not a -count.** At `-j1`, 150 mutants is a several-hour commitment. - -**Self's decision: queue it, do not run it now.** The merge gate needs a Soul -pass on batch 5 first, and the selection cut cannot merge on a mutation sweep -of an adjacent file. Recorded as a follow-up with the partial signal attached -so the next pass starts from it rather than rediscovering it. - -## Soul, the merge gate again, 2026-09-23 - -Opus, against `fc51e9a`. Counts re-measured: C# Networking 274 / 2 skipped, -Mesh 255. **Rust is 254 tests across 11 targets**, not the 50 reported — -that figure was `tests/selection.rs` alone, one target of eleven. - -**Verdict: Cut 1 may not merge.** Two reasons, either sufficient. - -### SM-6, CONFIRMED, high. R-AK deleted a load-bearing guard, and wrote a false premise into the source to justify it. - -This one is mine. I ruled the `!member.IsReference` guard was duplicate -authority and ordered it deleted. Hands verified and got it wrong, then left -a comment claiming "index aliases are unique per descriptor, so a data -member's role string can never coincide with a reference member's." - -**Source contradicts that.** D10 (`CultCache.cs:1085-1097`) groups only -members whose `ResolveIndexAlias` is non-null — alias against alias. A plain -data member with no alias contributes its `MemberName` to the same role -namespace and is checked against nobody. - -Probed: a document with a data member `Owner` and a reference member aliased -`Owner` **registers fine**, and then every hop path throws -`ArgumentNullException: Value cannot be null (Parameter 'targetType')`. -`ReferenceMembers` now yields the data member, `ReferencesOf` matches it to -the *reference* member's edges by role string, `member.TargetType!` is null, -and `ResolveTargetLeaves` throws. Before the deletion the guard skipped that -member and the selection returned one edge. - -**Restoring the guard leaves 274/274 green — the suite has no opinion either -way.** Any schema shaped that way makes every `cites`/`cited` selection over -it fault, the peer gets no typed refusal, and **the batch's own new dispatch -backstop swallows it with no log.** - -### CONFIRMED, medium. The dispatch backstop is untested and answers nothing. - -`catch (Exception) when (false)` — so the backstop never matches — survives -274/274. `HandleSnapshotRequestV1Async` catches only the three typed selection -exceptions, unlike its siblings `HandleShardLogRequestAsync` and -`HandlePutAsync`, which catch `Exception` and answer a `CultNetErrorMessage`. -**The snapshot path is the one handler where an untyped fault becomes -silence.** R-AG's invariant was that malformed input reaches the wire typed -and nothing else escapes; a silently dropped dispatch is not that. It is a -compensator, and it is already hiding SM-6. - -### SM-4, CONFIRMED, medium. R-AJ closed about half of it, and Rust got the thin half. - -| mutation | C# | Rust | -|---|---|---| -| drop edge-sort `To.SchemaId` | survives | survives | -| drop `To.Key` | survives | survives | -| drop `From.SchemaId` | killed | **survives** | -| drop `Role` | killed | **survives** | -| drop row-order `recordKey` | killed | **survives** | -| swap row-order schema/key *(control)* | killed | killed | - -**The controls dying means the rewritten tests are not degenerate in the old -way** — R-AJ's headline fix is real. But the new Rust edge test puts both -edges on the same `from.schema_id` and never reads `to`, so it pins exactly -one thing: `from.record_key` before `role`. **Dropping `role` entirely — the -component it was written for — survives.** - -### CONFIRMED, medium. Shared-key resolution is order-dependent and unpinned in both runtimes. - -Iterating candidates reversed (C#) and preferring the last in-target candidate -(Rust) both survive. Nothing pins "the first row in the caller's order wins" -when **two in-target** rows share a key; R-AF's new vector covers one-in and -one-out only. Both runtimes agree in source today, and that agreement assumes -both are handed rows in the same order, which nothing between a C# cache and a -Rust row owner guarantees. - -### PLAUSIBLE. `RowSet::target_leaves(role)` takes no schema - -(`selection.rs:1333`), where C# resolves the target set from the declaring -member's own `TargetType`. Two schemas legitimately declaring one role name -with different targets **cannot be expressed in Rust at all**. Low-medium. - -### PLAUSIBLE, low. A `+` prefix in a cursor length field - -parses in Rust (`str::parse::`) and is refused in C# -(`NumberStyles.None`). Gated behind the HMAC, so unreachable without forging -it. Read from source, not probed. - -### Promises that held - -**R-AF's wire fact is confirmed, and harder than the probe claimed.** It is -not one serialization — **the type has nowhere to put a schema id.** -`CultRecordRef` has exactly one field, `ICultRecordRef` exposes only `Key`, -the formatter writes only the key, and all three declared reference shapes -yield `CultRecordKey`. A document carrying scalar, many-list and -many-dictionary references at once encodes with no schema id anywhere. **No -other formatter, version or nesting can introduce one without changing the -type.** C#'s rule was right, Rust adopting it is correct, and it is not wrong -in the other direction. - -**Both runtimes now agree at the evaluator on every shape the previous verdict -split them on** — seven matched probes built independently in each runtime, -identical answers, including the shared-key matrix in either row order. **The -role-filter reordering, which no ruling covered and only Hands had checked, -agrees with C#.** - -**R-AG is behaviourally pinned**: moving the cast inside the parens — still -wrapping, still looking like a fix — kills a test. Dropping the redundant -`length < 0` survives and is genuinely equivalent. - -### R-AH: right scope, under-specified, still unbuilt - -**Comparing the payload rather than the envelope is the right call** — the C# -message carries a non-deterministic `messageId` and three shard fields Rust -has no concept of, while the shared six are in the same declaration order on -both sides with camelCase keys already. - -**But "the canonical MessagePack bytes" is not a specification.** The same -page encodes as 285 bytes compact and 402 named, and **the compact form emits -a 4-element array for a 6-field struct** because `skip_serializing_if` drops -absent optionals positionally — it is not decodable. The ruling must also fix -map encoding with camelCase keys, declared field order, and whether an absent -optional is **omitted** (Rust today) or **nil** (C# today). Those differ right -now, and the same rule has to reach `Edge`, `RawDocumentHeader` and -`SelectionDocumentRecord`. - -**The structural reason it keeps slipping: C# has no page type at the -evaluator layer.** `Select` returns rows, and `Projection` appears once in the -evaluator, inside the cursor digest. Either `Select` gains projection, or the -harness builds the page outside it and the vectors start testing -`CultNetDocumentRegistry` too. **Nobody has chosen, and my ruling did not.** - -At `fc51e9a`: `pageBytes|payloadBytes|canonicalBytes` is **0 hits** in both -vector files, `rs-written.json` still carries no projection vector, and door -refusals still compare a field and never a code. - -**Can parity be claimed without it? No — not byte parity.** What exists is -semantic parity over an agreed field subset. **Even the selection bytes differ -between the two written files for the same vector** — C# writes nine fields -with nils, Rust omits absent ones — so there is no byte-level agreement -anywhere in this cut. - -### What Soul could not run - -No Stryker run; the 47 survivors stay untriaged, and **two of Soul's own -confirmed survivors are on lines batch 5 changed**, so that list is not empty -of real signal. The `--since`/`GitInfoProvider` crash is still not -root-caused. `contracts.rs` and `schema_discovery.rs` unmeasured. The R-AH -harness unbuilt, pending the shape decision. Caching's three failures still -unproven. **One run was lost to CRLF and still exited 0** — fixed in the -stopgap at Eureka `b12016a`, with a verdict sentinel so a job that never ran -cannot report success. - -## Self's rulings for fix batch 6, 2026-09-23 - -- **R-AL (SM-6). Restore the guard, and fix the owner.** The guard is not - duplicate authority — filtering members by kind is the evaluator's own job, - and I was wrong to call it a duplicate. Restore it, delete the false comment, - and add the probed shape as a test that fails without it. **Separately, D10 - is the owner of role-namespace uniqueness and currently admits the - collision**: make registration refuse a data member whose name collides with - a reference member's alias. Both, with tests. **Standing: a "redundant" - deletion must be probed at the shape that would break it, not argued from a - property nobody checked.** This is the second time a redundancy ruling of - mine was wrong; the first was caught by Hands, this one was not. -- **R-AM. The snapshot handler answers like its siblings** — catch - `Exception`, return a `CultNetErrorMessage`. **The dispatch backstop logs**, - always. A backstop that swallows silently is a compensator, and this one hid - a high-severity regression for a whole batch. -- **R-AN. Finish R-AJ in Rust.** Its edge test pins one component of a - four-component rule. Every component of both orderings dies under its own - mutation, in both runtimes. -- **R-AO. Pin shared-key resolution order** — two **in-target** rows sharing a - key — in both runtimes and in a vector. "The first row in the caller's order - wins" is currently a coincidence of source, not a rule. -- **R-AP (R-AH). Decide the shape, then build it.** The payload, as a - **map with camelCase keys**, fields in declared order, and **absent - optionals written as nil** rather than omitted — C#'s current behaviour, - because a decodable fixed-shape encoding is worth more than brevity, and - because omission is what makes the compact form undecodable. The same rule - reaches `Edge`, `RawDocumentHeader` and `SelectionDocumentRecord`. - **`Select` gains projection**, so the thing the vectors test is the thing - that decides; the harness must not assemble a page the evaluator never - produces. -- **R-AQ. Until R-AP lands, this cut claims semantic parity over a named - field subset, not byte parity** — and the map says so in those words. - Soul is right that byte parity cannot be claimed today, and it is better to - name the smaller claim than to let the larger one stand unearned. - **Flagged for the operator**: wire parity is this campaign's stated - invariant, so if semantic parity is not enough for Cut 1 to merge, R-AP - becomes a blocker rather than a follow-up. - -### Fix batch 6 landed, 2026-09-23, verified locally and not on Yggdrasil - -Sonnet, six commits on `sel-fix6` (pushed by Self after review). C# Networking -**279 passed / 2 skipped** (was 274), Mesh 255, Caching 193 unchanged. Rust -**259 across 12 targets** (was 254/11). +650 / −47 across 13 files, no new -targets, packages or dependencies. - -**The verification gap comes first, because it is Self's.** The brief said -"use `ygg-verify.sh`" without its path, where every earlier brief gave the -full path. Hands searched, could not find it, and **verified the whole batch -locally on the workstation** — the exact work the load budget forbids there. -It reported the gap plainly rather than claiming the bar was met, and **left -the branch unpushed on purpose** so the work would not look merge-ready. Both -were the right calls. Recorded as a scar at Eureka `301df32`; **this batch -still owes its Yggdrasil run**, and Soul's pass will provide it. - -- **R-AL.** The guard is restored and the false comment deleted. **The real - fix went to the owner**: D10b in `CultCache.DiscoverMembers` now groups - **every** member by its reference-role namespace (`IndexAlias ?? MemberName`), - not only aliased ones, and refuses registration when a data member's bare - name collides with a reference member's alias. Registration of the probed - shape now throws. Since D10b closes the only production door, the guard - itself is pinned by a test that **builds the colliding descriptor through - reflection to bypass the registry deliberately** — and removing the guard - makes it yield both members and fail. -- **R-AM.** `HandleSnapshotRequestV1Async` now catches `Exception` and answers - a `CultNetErrorMessage`, like its two siblings. The dispatch backstop logs, - always. **Writing the test found a second defect**: the backstop logged - `DynamicInvoke`'s `TargetInvocationException` wrapper — "Exception has been - thrown by the target of an invocation" — rather than the real fault, so it - now unwraps. The first run failed on the wrapper text, which is precisely - the bug that half of R-AM exists to fix. -- **R-AN.** Two isolating Rust tests added; the previous one tied `From` and - `To` across both edges, so dropping `role` survived. - **The `To.SchemaId`/`To.Key` equivalence argument recorded here was FALSE, - and is deleted rather than amended.** It claimed that within a page-position - bucket no construction gives two edges with tied `From` and `Role` and - differing `To`. Soul built the counterexample and ran it in both runtimes: - **a selection carrying both hops mixes their edges into one bucket**, so a - row that self-references through the same role it uses to cite a peer yields - a `cites` edge (`to` = the queried peer) and a `cited` edge (`to` = itself), - tied on `From` and `Role`, differing on `To`. A self-referencing role is a - parent or peer link — ordinary, not exotic. **This is the second - justification-from-an-unchecked-property in three batches**, and R-AL's - standing rule applies to it. - -- **R-AO.** Shared-key order pinned in both runtimes, both row orders, plus a - vector case regenerated into both written files. -- **R-AP, half built and half stopped, correctly.** The encoding is decided - and **C# already did it** — `[MessagePackObject]`/`[Key("name")]` writes a - camelCase map with nil for absent optionals by default. The gap was entirely - Rust-side: `skip_serializing_if = "Option::is_none"` on four types omitted - keys under `to_vec_named`. Removed from all four, `#[serde(default)]` kept - for decode. **Byte-identical now, against real C# MessagePack 3.1.7 hex**: - `RawDocumentHeader` and `Edge`, each broken once by hand to confirm the - assertions are not vacuous. **The byte counts recorded here were wrong** — - 86 and 99 — and are corrected to **228 and 246**, measured on Yggdrasil - against MessagePack C# 3.1.7. The committed hex constants matched the real - bytes exactly all along; only this prose was wrong. - - **"`Select` gains projection" was not built**, and Hands stopped and said - why rather than reporting the ruling closed. Header and document projection - is owned by `CultNetDocumentRegistry.SelectPage`/`SelectAll`, a different - class from the evaluator, which knows only generic rows and descriptors and - has no legitimate route to a schema's wire-binding override. Moving that - ownership is buildable — thread a caller-supplied serializer, as - `ordinalOf`/`shardIdOf` already are — but changes both signatures, every - caller, and the vector harness itself. - -**R-AQ, restated honestly.** The claim has moved from "semantic parity, byte -parity absent" to **"nested wire-type byte parity confirmed and vector-tested; -whole-message byte parity still blocked on unbuilt page-assembly ownership."** -No vector yet exercises the real message output byte for byte. - -### Self's rulings - -- **R-AR. The page-assembly ownership question is its own cut, not a - follow-up.** It changes two public signatures, every caller and the harness, - which is a cut's worth of work and exactly the kind of thing that has - slipped five passes by being carried as a loose end. **Cut 1 does not wait - for it.** -- **R-AS. Cut 1's honest claim is nested wire-type byte parity plus semantic - parity over the named field subset.** The map says that in those words, and - the target document is reconciled to it. **Flagged for the operator:** if - whole-message byte parity is required before this cut merges, R-AR becomes a - blocker and Cut 1 stays open; if it can be its own cut, Cut 1 can close on - the claim above once Soul clears it. - -## Soul, the merge gate, third pass, 2026-09-23 - -Opus. **Every run went to Yggdrasil**, supplying the run batch 6 owed. -Reproduced: C# Networking 279 / 2 skipped, Mesh 255, Rust 259 across 12 -binaries. **Caching is 190 passed / 3 failed** — the three assert a write is -*refused*, which root in a container cannot produce. **Unproven, not -passing**, and Soul declined to call them environmental on its own authority. - -**Verdict: Cut 1 may not merge.** - -### SM-7, CONFIRMED, high. R-AN did not land, and its justification is false. - -Three named survivors are still alive: - -| mutation | Rust | C# | -|---|---|---| -| drop both `to.schemaId` and `to.recordKey` from the edge comparator | **survives** | **survives** | -| drop `recordKey` from the **row** comparator | **survives** | killed | - -The row survivor is the fourth line of SM-4's own table from the previous -gate, and **batch 6 added no Rust row-order test at all**. - -**Soul built the counterexample and ran it in both runtimes.** The false -argument reasoned about each hop separately; a selection carrying **both** -hops mixes their edges into one `Vec`. A row that self-references through the -same role it uses to cite a peer produces a `cites` edge (`to` = the queried -peer) and a `cited` edge (`to` = itself) in the **same page-position bucket**, -tied on `From` and `Role`, differing on `To`. Rust's fixture `peer` role and a -C# `[CultReference(typeof(Self), many: true)]` both express it. Reversing the -`to.recordKey` comparison is killed **only** by Soul's probe, so the branch is -live and decides real output. **A self-referencing role is a parent or peer -link — ordinary.** - -Both runtimes agree today only because the comparator text is duplicated — -the "coincidence of source, not a rule" that SM-4 raised and R-AN was written -to close. - -### SM-8, CONFIRMED, medium. The cut's parity claim has no home. - -Section 9 says the claim is written into `docs/runtime-parity-scope.md`, "not -here alone". **That file contains zero mentions of typed selection**, and -never did — not even the earlier overstated wording. R-AS said the target -document "is reconciled to it". It is not. - -### SM-9, CONFIRMED, low. The map's byte counts are wrong; the constants are right. - -Serialized on Yggdrasil against MessagePack C# 3.1.7: `CultNetRawDocumentHeader` -is **228 bytes**, `CultNetEdge` is **246 bytes**. The committed hex in -`selection_wire_parity.rs` matches **exactly**. The landing note recorded 86 -and 99. **The thing Self asked Soul to distrust was sound; the prose beside it -was not.** - -### Promises that held - -**R-AL is complete, including inheritance.** Seven shapes built through -`Reflection.Emit` and pushed at the real registry, all refused: data name -against reference alias, reference name against reference alias, alias against -alias, data *alias* against reference *name*, implicit many-reference through -`List>`, and **across inheritance**, where the message names -both declaring types. The one shape that still registers is two *data* members -colliding with no reference present — **not a hole**, because the index -namespace is looked up by exact `IndexAlias` and never by the name fallback; -only the reference namespace uses `IndexAlias ?? MemberName`. -`PersistedMember.IsReferenceCandidate` is a character-exact mirror of -`FromMember`'s `isReference`, so D10b cannot disagree with the descriptor it -guards. Narrowing D10b dies; weakening the restored guard dies. - -**R-AM reaches the wire, not just the log.** Real loopback UDP through -LiteNetLib with the production transport: the null-`Selection` fault arrives at -the client and decodes as `CultNetErrorMessage` with the real message. -Mutating the handler to log but answer nothing dies. - -**R-AO's vector genuinely discriminates.** Under last-wins the resolved schema -stops matching the citation's declared target and the page empties, so the -vector has real power rather than merely existing. - -**R-AP's bytes are right, and more of them than claimed.** -`SelectionDocumentRecord` is byte-identical at 269 bytes, and — the one that -matters — **the whole `cultnet.snapshot_response_raw.v1` envelope is -byte-identical at 376 bytes**, Rust's encoder against C#'s message. Nothing -broke on decode. - -### Observations, not defects - -`SelectionPage` has **no C# counterpart to be byte-identical to**; byte parity -is undefined for it. The new generic catch sends raw .NET exception text to an -unauthenticated peer — **exactly what R-AM ordered, and what the siblings do**, -so consistent rather than wrong, but worth its own ruling one day. D10b is -stricter than the restored guard needs: a data member aliased `Owner` beside a -reference member named `Owner` used to register and worked, and is now -refused — defensible, but a breaking registration change with no note. - -### Soul on the wording, and Self's correction - -Soul agrees **R-AR is its own cut**. It does not agree with how I described -the residual gap, and it is right on both counts: - -- **"Nested wire-type byte parity" overstates it.** The branch proves two - types, each at exactly one value with **every optional absent**. Nothing in - the batch touches a header with `tags` populated or an edge with a - `payload` — precisely where `string[]` versus `Vec` and `byte[]` - versus `serde_bytes` could diverge. The correct words are **"byte parity for - `RawDocumentHeader` and `Edge` at an all-optionals-absent value"**. -- **"Blocked on unbuilt page-assembly ownership" is the wrong word, and the - expensive one.** It ran two independent things together: the two *encoders* - producing identical bytes for the same page value, and the two *evaluators* - producing the same page value from the same rows. The second is what the - vectors already compare. **The first needs no ownership move at all — Soul - wrote it this afternoon and it passes at 376 bytes.** What R-AR buys is a - single test driving rows to bytes in one call rather than two halves meeting - on paper. **Calling it blocked made the cheapest available evidence look - expensive, which is how it slipped six passes while sitting there for the - taking.** - -### The rig - -`C:\Users\Meta\eureka-soul-probes\cultnet-selection-cut1-fix6\`, beside batch -5's: three C# probe classes, a Rust wire probe, the counterexample patch, -three mutation drivers, seven precomputed C# mutants, and a README carrying -the full mutation table and the two `ygg-verify.sh` rakes (now in the -script's own header, Eureka `db1c84d`). - -### What Soul could not run - -No Stryker; the C# survivor list stays untriaged. No cargo-mutants sweep — -thirteen hand-built non-revert mutations instead, all at `-j1`. -`contracts.rs` and `schema_discovery.rs` unmeasured, with batch 5's partial -`validate_*` signal still hanging. The `--since`/`GitInfoProvider` crash still -not root-caused. **Soul proved the `to` comparator branch is reachable; it did -not prove the two runtimes would disagree on it, only that nothing stops -them.** - -## Self's rulings for fix batch 7, 2026-09-23 - -- **R-AT (SM-7). Every component of both orderings dies under its own - mutation, in both runtimes** — including `to.schemaId` and `to.recordKey`, - which now have **a known construction to test against**, and Rust's row - comparator `record_key`, which SM-4 named a pass ago and batch 6 never - touched. Take Soul's counterexample from the rig rather than rebuilding it. -- **R-AU. The false equivalence argument is deleted from the map**, done - above, not amended. Standing rule reaffirmed: **an argument that a branch is - unreachable is a claim to falsify, not a reason to skip a test.** -- **R-AV (SM-8). `docs/runtime-parity-scope.md` carries the typed-selection - row**, in the corrected words. A claim whose named home does not mention it - is not recorded. -- **R-AW (SM-9). Correct the byte counts to 228 and 246.** -- **R-AX. Land the whole-message parity vector.** Soul already has it green at - 376 bytes. **This is not blocked and never was** — my wording made it look - so. Land it as a vector on both sides, and add a header with `tags` and an - edge with a `payload`, since those are the shapes where the collection and - byte-array mappings could diverge and the all-absent value proves nothing - about them. -- **R-AY. Cut 1's claim, in these words**: 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. **R-AR remains its own cut** and buys a single rows-to-bytes - path, not the claim itself. -- **D10b's strictness gets a note** in the registration error and the map: a - shape that used to register and work is now refused. - -### Fix batch 7 landed, 2026-09-23 - -Sonnet, seven commits on `sel-fix7`, **all verified on Yggdrasil**, one slot -at a time across thirteen sequential jobs with no concurrent verdicts. C# -Networking **280 passed** (was 279 / 2 skipped), Mesh 255 unchanged, Rust -**264 across 12 binaries** (was 259). Caching stays 190 / 3, the three still -unprovable under a root container and deliberately not "fixed". - -**No production logic changed** except one error message and its comment. -Everything else is tests and docs. - -- **R-AT.** All three surviving comparator components now die, in both - runtimes, at `-j1`: dropping both `to` components, reversing - `to.record_key`, and dropping `record_key` from Rust's row comparator. - - **And Hands found that Soul's own construction did not actually kill the - "drop both" mutant.** Taken verbatim from the rig, it survives in *both* - runtimes — not because the branch is unreachable, but because `select`'s - push order happens to match alphabetical sort for that naming, and **the two - runtimes push in opposite orders** (Rust emits `cited` before `cites`, C# the - reverse). Rust needed the self-citer's key to sort *after* the target's, - C# needed the reverse. **A third fixture passing by coincidence**, found - while executing rather than by a later pass. Each runtime now carries its - own naming with the reasoning written into the test. -- **R-AV.** `docs/runtime-parity-scope.md` gains the typed-selection row in - R-AY's words, naming the four files that carry the byte and semantic - vectors and calling out R-AR as separate and still open. **The claim now has - the home the map said it had.** -- **R-AW.** Verified, not assumed: the committed hex constants are **228 and - 246 bytes**, matching a real MessagePack C# 3.1.7 run. The map's prose is - corrected above; the constants were right all along. -- **R-AX. The whole-message vector landed, byte-identical at 376 bytes**, and - the thin evidence is no longer thin: - - `RawDocumentHeader` with `tags = ["alpha","beta"]`: **239 bytes**, - identical — `string[]` and `Vec` agree. - - `Edge` with `payload = [1,2,3]` and `payload_encoding = "messagepack"`: - **261 bytes**, identical — `byte[]` and `serde_bytes` agree. - - **No divergence.** That was a real risk named in the ruling and it did not - materialise. -- **D10b's breaking change is noted** in the error message and its owning - comment: a data member aliased `Owner` beside a reference member named - `Owner` used to register silently and fault at resolution; it is now refused - at the door with no compatibility path. - -**Every condition Soul set for merging is now met.** A final gate pass -follows. - -## Soul, the merge gate, fourth pass, 2026-09-23 - -Opus. Four sequential Yggdrasil jobs, one slot, Rust at `-j1`. Merge to -`origin/main` is conflict-free; main's 42 divergent commits touch no file this -cut touches. - -### **Verdict: Cut 1 may merge.** - -**Baselines, with one correction.** Rust 264 across 12 binaries, Mesh 255, -Caching 190 / 3 — all as reported. **C# Networking is 283 passed / 2 skipped, -not the 280 reported.** Under-reported this time rather than over; the count -still went into the record wrong. - -### The five conditions - -1. **Met for everything that decides output, not met on the letter.** Killed - in both runtimes: dropping both `to` components, dropping `to.record_key` - alone, reversing it, dropping `record_key` from the row comparator, plus - `from.record_key` and `role` in Rust. **The batch's new fixtures are the - killers and they are not coincidental** — Soul attacked them with - single-component variants and they held. - - **But `to.schemaId` survives both dropping and reversing, in both - runtimes.** Hands never claimed it dies alone; **R-AT did, and R-AT was - mine.** Soul could not construct a selection where it decides anything — - under D10b a role resolves to one declared target type and reference - resolution yields at most one row per (role, key) — but says plainly that - is a claim, not a proof, **and the same shape of argument that has been - wrong twice here.** Hence the follow-up is "construct a killer **or delete - the component**", not "record it as equivalent". -2. **Partially met, and the miss is mine.** The false argument is gone where - batch 6 restated it and **still stands, unstruck, at its original site** — - the `EdgesFor` paragraph and ruling **R-AC** ("Recorded, not to be chased"). - Those read as live instructions. I deleted the copy and left the original, - which is the same sweep failure this map has recorded before. -3. **Met.** `runtime-parity-scope.md` carries the row in R-AY's words, naming - four files that all exist. -4. **Met**, and the constants were right all along. -5. **Met, and genuinely two-sided.** The three constants are byte-identical - strings in both files; each runtime serializes and asserts independently, - so **neither reads the other's answer**. All three C# tests pass and **all - three fail when Soul flipped one hex digit** of the expected constant. 239, - 261 and 376 are real MessagePack C# 3.1.7 output. - -### SM-10, CONFIRMED. The 246-byte all-absent `Edge` vector is Rust-only. - -The 228-byte all-absent header is nested inside the 376-byte envelope, so C# -regenerates it. **The edge has no such cover.** A C# edge encoder that resumed -omitting a null `payload`/`payloadEncoding` would still pass the 261-byte -populated vector, and **nothing on the C# side would fail** — which is exactly -what R-AP existed to kill. It leaves `runtime-parity-scope.md`'s "verified at -populated and absent optional values" true for three cells of four. - -**Five lines of C# closes it.** - -### Self's decision: close four items, then merge - -Soul named this the follow-up it "would put a name on". A known hole that a -five-line test closes is not a follow-up — merging over it would be shipping -the exact asymmetry the cut exists to prevent, with a note promising to come -back. **Batch 8 closes items 1 to 4, then the cut merges.** - -- **R-AZ (SM-10).** Mirror the 246-byte all-absent `Edge` constant into - `SelectionWireParityTests.cs`, so both runtimes assert all four cells. -- **R-BA.** Settle `to.schemaId`: **construct a killer, or delete the - component from both comparators.** A component no input can distinguish is - not free — it is a line the next reader must reason about, and the reasoning - has been wrong twice. -- **R-BB.** Correct the landing note to 283 passed / 2 skipped. -- **R-BC.** Strike R-AC and the `EdgesFor` equivalence paragraph **at their - original site**, not with a note beside them. - -**Not blocking, recorded as genuine follow-ups**, none a known defect in the -cut's behaviour and all outside the selection surface: the C# Stryker survivor -list untriaged; `contracts.rs` and `schema_discovery.rs` unmeasured with batch -5's partial `validate_*` signal hanging; the `--since`/`GitInfoProvider` crash -unrooted; Caching's three, which **need a non-root container to prove and must -not be "fixed"**. - -Rig: `C:\Users\Meta\eureka-soul-probes\cultnet-selection-cut1-fix7\`. - -### Fix batch 8 landed, 2026-09-23 — the pre-merge batch - -Sonnet, two commits on `sel-fix8` (`cda28fa`, `492dd0e`), verified on -Yggdrasil at `-j1`. C# Networking **284 passed / 2 skipped** (283 baseline -plus the one new test), Rust **264 across 12 binaries**, unchanged — the -deletion is behaviour-neutral. - -- **R-AZ.** The 246-byte all-absent `Edge` vector is mirrored into - `SelectionWireParityTests.cs`, so both runtimes now assert all four cells. - **Proven non-vacuous**: flipping byte 0 of the constant fails exactly that - test and nothing else, on a throwaway commit that was never pushed. -- **R-BA. Deleted, not constructed — and the argument is stronger than the one - the ruling carried.** Hands traced both resolvers: each resolves a reference - purely from `(citer's schema, role, recordKey)` against the evaluation's own - fixed candidate index, with no edge- or caller-specific input steering which - candidate wins — Rust's `target_leaves(role)` does not even take a schema. - So once `From`, `Role` and `To.RecordKey` tie in the comparator, **the - resolved row including its schema is forced identical by construction**. - - That does not depend on schema-authoring discipline the way my D10b argument - did; it depends only on the resolution function being a pure function of - already-tied inputs. **It is a structural proof rather than a semantic - claim**, which is the distinction that has bitten this cut twice. Hands - checked it against the live counterexample too — the self-citing row breaks - `to.record_key`, not `to.schema_id`. - - The tiebreak is gone from both comparators, with its stale documentation in - a third file. No test relied on it. - -**All four of Soul's pre-merge items are closed**, three by this batch and -R-BC by Self, which struck the false equivalence argument at both original -sites rather than annotating it. diff --git a/docs/cultnet-transport-parity.md b/docs/cultnet-transport-parity.md index 17fc6964..d0779196 100644 --- a/docs/cultnet-transport-parity.md +++ b/docs/cultnet-transport-parity.md @@ -271,15 +271,6 @@ Current progress: flush where the runtime supports one, so callers that require confirmed transport delivery do not substitute a fixed sleep. TCP-framed profiles do not publish the field because they do not own the RUDP pending-reliable queue. -- The receive window is a contract, not a discovery. A receiver must hold 1,023 - sequences and 4 MiB of payload beyond its contiguous received watermark, and - a conforming sender never exceeds either. The sender's flow window enforces - it without a wire field: a reliable packet is admitted only while its - sequence is at most 1,023 above the lowest unacked sequence and the payload - above that sequence stays within 4 MiB. The first packet is always - admissible, so a payload larger than 4 MiB goes out alone, and packets wait - in the FIFO in order, so a small packet never overtakes a large one. C#, - Rust, TypeScript, and Python enforce it; Kotlin does not yet. - TypeScript, C#, Rust, Python, and Kotlin now share a portable `cultnet.reconnect_policy.v0` document and deterministic exponential delay helper, and RUDP transport profiles advertise that policy under diff --git a/docs/document-variants-cut.md b/docs/document-variants-cut.md deleted file mode 100644 index bd340c87..00000000 --- a/docs/document-variants-cut.md +++ /dev/null @@ -1,548 +0,0 @@ -# CultCache document variants: cut map (draft) - -Date 2026-09-29. Imagination output for `docs/document-variants-target.md` (branch -`codex/document-variants`). Body read at `origin/main` `268e0ef`. This map owns means only; -the target owns ends and rulings 1-7. Nothing here is decided that the batch in section 4 -asks the operator to decide. - -## 1. Probe facts (run, not inferred) - -Probe: scratch clone `scratchpad/variant-probe` (commit `7b6de8e`, probe files only), run on -Yggdrasil via `ygg-verify.sh` with images `dotnet`, `rust`, `python:3.12-slim`, `node:22`. -Each runtime's current reader wrote a normal store, the bytes were altered four ways, and -the store was reopened. Output in `scratchpad/probe-out/*.txt`. - -| Altered store | C# | Python | TypeScript | Rust | -| --- | --- | --- | --- | --- | -| A. v1 header, record gains slot 4, payload empty | refuses, "read past end of stream" | refuses, "incomplete input" | refuses, "Offset outside DataView" | refuses (strict tuple), misleading legacy-envelope message | -| B. v1, slot 4 plus a full payload | **loads the payload silently** | **loads silently** | **loads silently** | refuses | -| C. header `cultcache.store.v2` | **ignores the header**; refused only by the empty payload | refuses, misleading "legacy envelopes" message | refuses | refuses | -| D. payload holds the delta as a sparse array | **loads `Name=''`, `Max=99`** | **loads `name=None`** | refuses | loads; `get` of that key fails | - -Consequences: -- The **empty payload is the only tripwire every shipped reader trips**, pinned builds - included (Aetheria pins `caching-unity-v1.4.0`). A variant record's payload must be empty, - and the delta must live outside it. -- A payload-as-delta encoding is ruled out: C# and Python misread it silently. -- C# never checks `formatVersion` (`CultDocumentMessagePackSerialization.DeserializeSnapshot` - defaults a missing one to v1). C#, Python and TypeScript skip extra record slots silently. - These are latent defects today and become misreads when the format grows. -- Every refusal is loud but none of them names the record. The target requires named refusals, - so current readers need a small fix (C0). - -Other Body facts that shape the map: -- **CultNet has no member-path grammar.** `docs/cultnet-selection-cut.md` D2: "Declared - aliases, not a field-path language". It reaches top-level declared `index`/`role` aliases - only (`CultDocumentMemberView`). Nothing exists to reuse. See section 3. -- **The schema catalog describes top-level members only.** A nested type (`BehaviorData`, - `PerformanceStat`, `HardpointData`) appears only as a CLR type name. Soft drift and hard - rejection cannot see below slot level. -- **Resolution needs a codec, and the core has none.** `GameCult.Caching` holds objects. The - MessagePack codec lives in `GameCult.Caching.MessagePack`. `CultInspectorModel` already - takes the codec as two injected functions (`CultCacheMessagePack.CreateInspectorModel`). -- **Same concrete type implies same home store.** `Home(type)` routes by type, so ruling 3 - ("same store") falls out of ruling 6. No separate check is needed. -- **The name index shadows silently.** `Index()` does `MapOf(_names, type)[name] = key`, so a - second record with the same name overwrites the entry. A variant that inherits its base's - name would make `GetByName` return one of the two, depending on apply order. -- **Studio edits by whole-document upsert.** `CultInspectorEdit.Commit` calls - `UpsertAsync(type, copy, key)`, and `Duplicate` clones through the codec. -- **Raw readers bypass the cache.** In CultLib: `CultMesh.ReadSingleFileDocument` - (`CultMeshSingleFileDocuments.cs:112`), plus its legacy snapshot decoder. In consumers: - AetheriaEve `AetheriaRuntimeCatalogStore` (directory-store pages). -- **Kotlin reads no store files.** It sees CultNet raw snapshots only. - -## 2. Model page (step 0b) - -| Kind | Named by | Over time | Decided by | -| --- | --- | --- | --- | -| Variant record | its own `CultRecordKey`, minted like any record; same `schemaId` as its type | created, re-overridden, rebased, removed; never converted to or from a plain record implicitly (Q1) | author through Studio or the variant write API; the cache validates | -| Base reference | the base's key, in the record's variant slot | changed only by an explicit rebase; a base cannot be deleted while it has variants, unless the same batch rebases or removes them | author; the cache refuses a cycle, a missing base, a different concrete type, a global type | -| Override entry | `(op, path, id, value)`; op is Set, Insert or Remove | added by editing; removed by clearing; survives base edits while its path resolves | author; the cache refuses entries a base edit strands (Q2); an entry whose member left the type soft-drifts with a warning (Q4 c) | -| Member path | a sequence of `(slot, elementId)` steps; `slot` is the MessagePack `[Key]`; `elementId` is `""` unless the step enters an object-list element | a type change can invalidate it | the type's owner (slot authority); ids by the element's creator | -| Element id | a `[CultElementId]` string member on an object-list element type; `""` means unset | minted by the cache on write when unset; copied unchanged into variants; a moved element is a new element | cache mints it; it is never retargeted | -| Resolved view | `CultStoredDocument.Document` for a variant key | recomputed inside the hold that admits the variant, or any base on its chain | the cache only; never persisted, never written back (Q1) | -| Index and name entries | as today, from the resolved view | a dependent's entries are dropped and re-added in the same `Apply` as its base change | the cache; a variant must override the `[CultName]` member (rule R6) | -| Watches | as today; each re-resolved dependent emits `Updated` with its own `Sequence` | published in the same hold as the base change | the cache | -| Compare-exchange | `(schemaId, storedAt)` of the stored delta (ruling 2) | a base change does not stamp its variants | the cache; the resolved view is not an identity | -| Persisted format version | store header `formatVersion` | `cultcache.store.v2` when the store holds at least one variant; `v1` otherwise (Q5) | the writing store, from content | -| Variant slot on the wire (CultNet) | **empty cell**: no CultNet message carries a delta today | C5 | ruling 4 decides deltas; the message shape is C5's design | -| Dictionary entries, scalar lists, arrays | **empty cell**: no stable identity exists | whole-member Set only (Q7) | Q7 | -| Template (non-item) bases | **empty cell**: parked by ruling 6 | none | not in these cuts | - -## 3. Where the Body contradicts the target - -1. **"A member path in CultNet's grammar"** (ruling 1 and fork 1 text) has nothing to reuse. - CultNet selection deliberately has no path language. This map specifies a minimal slot - path (section 2) owned by `GameCult.Caching`. If CultNet ever needs paths, it can adopt - this one. Nothing is shared today, and nothing is invented twice. -2. **"Overrides are subject to the same soft drift rules as full records"** conflicts with - the ends list: "Loading refuses an override naming a member that does not exist on the - type". Under soft drift, a persisted slot missing locally is ignored with a warning, so - the two rules disagree for a dropped member. Below top level, the catalog has no shape to - drift against at all. See Q4. -3. **"Deletes first"** has little to delete: the feature is additive. The honest deletions - are the silent extra-slot skips and C#'s ignored header. C0 puts them first. - -## 4. Operator questions (one batch) - -**Operator rulings, 2026-09-29, each asked on its own:** - -- **Q1: (a)**, a plain `Upsert` at a variant's key is refused loudly. -- **Q2: (a)**, a base edit removing an overridden element is refused unless the same batch - resolves it. Operator's reading: "the very existence of that element in the child collection - must become the new override state... you can't change the parent without also changing the - child... that's the resolve part of (a)." -- **Q3: ids everywhere, not opt-in.** Operator: "Opt-in sounds nice but consider the UX. We're - planting a footgun if we don't require ids everywhere." Rollout: **required, minted on load**. - The C# registry requires an id member on object-list element types. A store written before ids - existed loads, and ids are minted once on its first write (C2's rewrite). Other runtimes carry - the id as an ordinary member and enforce the rule when they gain variants. Each consumer's type - edit is a sweep in its own repo after C2. -- **Q4: (c) soft drift everywhere. This reverses the recommendation and supersedes the target's - end "loading refuses ... an override naming a member that does not exist on the type".** - Operator: "Soft drift everywhere aligns with the vision... the rationale for adopting - migrationless changes is still valid for variants. Any member dropped in a type change is a - design change, the reason for it to apply to the parent also applies to the variant." A - dangling override, at any depth, is ignored with a warning naming the variant and the member, - and the variant inherits the base value. The other broken-variant refusals (a base cycle, a - missing base, an incompatible base type) stand. -- **Q5: (a)**, the v2 header is written only when the store holds a variant. -- **Q6: yes**, the directory store and CultNet refuse variants loudly until their own cuts. -- **Q7: yes**, object-list elements are addressable by id, and dictionaries, scalar lists and - arrays are replaced whole. The operator asked first whether design roles and quality levels - use dictionaries. They do not: `CraftedItemData.Roles` is a `List` and - `FactionProduct.Roles` is a `List` (Aetheria `ItemData.cs:316`, - `FactionProduct.cs:30`). A market-segment product becomes a variant overriding one - `ProductRole` by id. - -*The questions as asked:* - -**Q1. A plain `Upsert` of a whole document at a variant's key.** It happens when Studio's -current edit commits, when AetherDb migrations do `batch.Upsert(type, doc, key)`, and on a -CultNet remote write. -- (a) Refuse it loudly. A variant changes only through the variant API. Flattening is an - explicit, separate operation. -- (b) Diff the document against the base and store the difference. Ids make the diff - deterministic, but a value set equal to the base stops being an override without anyone - saying so. -- (c) Flatten silently (today's behaviour by default). -- **Recommend (a).** Affects C1's write path, C3's edit commit, and every AetherDb migration - (each skips variants or uses the variant API). - -**Q2. A base edit that removes an element some variant overrides.** -- (a) Refuse the commit unless the same batch clears or retargets those overrides. Studio - offers keep, retarget or clear at edit time, and "keep" means the base edit is not - committed. The store never holds a conflict, and loading refuses one. -- (b) Persist the conflict. The override goes inert, readers get the resolved document - without it, and Studio lists it. -- **Recommend (a).** Option (b) makes a reader's resolved document silently differ from what - the author wrote. Affects C2's validation, C3's rebase UI, and migrations that remove - behaviours. - -**Q3. Scope of "ids everywhere".** -- (a) Every object-list element in every registered document type, in all 28 consumers. The - registry refuses a type whose element types lack an id. -- (b) Only document types that opt in with `[CultVariants]`. For those, the registry requires - `[CultElementId]` on every object element type reachable through their lists, including - union bases. -- **Recommend (b).** Option (a) breaks 28 registries for a feature that one consumer uses. - Affects C2 and Aetheria's adoption cut. A `BehaviorData` id needs a base slot above the - subclasses' highest key (32), which adds about 30 nil bytes per behaviour unless Aetheria - renumbers. - -**Q4. Schema drift under overrides** (contradiction 2). -- (a) Refuse loading when an override names a member missing at any depth, or holds a value - that will not decode into the member's current type. Record this as a stated exception to - soft drift in `cultcache-schema-compatibility.md`. -- (b) Top-level overrides follow catalog soft drift (ignore with a warning); nested ones are - refused. -- **Recommend (a).** It follows the target's ends and is one rule. The cost: after a type - change drops an overridden member, the catalog must be rewritten before the new build opens - it. - -**Q5. Format version.** -- (a) Write `cultcache.store.v2` only when the store holds a variant. Stores without variants - stay byte-identical `v1` for every consumer. -- (b) Always write v2 after the upgrade. -- **Recommend (a).** The empty payload remains the tripwire for pinned old C#, which ignores - the header. Affects C0 and C1. - -**Q6. Slice scope.** Should the directory store and CultNet refuse variants loudly until -their own cuts? The directory store's only consumer is AetheriaEve's raw reader. CultNet -must carry deltas (ruling 4), and no message can yet. -- **Recommend yes.** It is two refusals. Otherwise C1 must design CultNet messages first. - Affects C1's size and the order of C5. - -**Q7. What is addressable below a member.** The proposal: object-list elements by id only. -Dictionary entries, scalar lists and arrays are replaced whole. -- **Recommend yes.** Aetheria's cases (`Hardpoints` whole; `Behaviors[id]...Max`) fit. Keyed - dictionary addressing can be added later without a format change. Affects C2. - -## 5. Cuts, in order - -All cuts are in `F:\Projects\CultLib`. Rebase `codex/document-variants` (docs only) onto -`origin/main`. Each cut is one Hands pass on that branch and merges to main after its Soul -pass. Probes show that a C1 writer can merge safely before other runtimes change, because -every reader refuses it. Verification uses `ygg-verify.sh /f/Projects/CultLib ''`. -The command strings below are the ``. Keep the job's exit status last. - -Overlap: BP-1 (`cultmesh/rust-content-plane`) edits `docs/runtime-parity-scope.md`, which -C0 also edits. Rebase after BP-1 merges. `codex/fix-node24-ajv-esm` has no diff against main. - -### C0. Tripwires: every reader refuses what C1 will write, and names the record - -Deletes: -- The silent extra-slot skip in the C# record decoder - (`CultDocumentMessagePackSerialization.ReadPersistedRecord`'s trailing `Skip` loop) and - its copy in `CultMeshSingleFileDocuments.ReadLegacyPersistedRecord`. -- The `?? "cultcache.store.v1"` defaulting of the header. -- Python `raw_record[:4]` slicing. -- The TypeScript and Python fallthrough to the legacy envelope decoder when the header is a - `cultcache.store.*` string they do not know. - -Adds, one rule in each runtime: a snapshot whose header is not `v1`, or whose record has more -than 4 slots, is refused with a message that names the version or the record key, and says -the store needs a runtime that resolves variants. C# accepts `v1` only, until C1 adds `v2`. - -Keeps: every v1 byte and every v1 test. - -Verification (rule: "unknown header or extra record slot is refused by name"). Each runtime -gets one test, and each test's negative is fixture B from section 1 (full payload plus slot -4) loading: -- `dotnet`: `dotnet test tests/GameCult.Caching.Tests --filter FullyQualifiedName~PreCut2StoreFormat` -- `node:22`: `npm install --no-audit --no-fund && cd packages/cultcache-ts && npm test` -- `python:3.12-slim`: `pip install -q msgpack -e packages/cultcache-py && python -m unittest discover packages/cultcache-py/tests` -- `rust`: `cd packages/cultcache-rs && cargo test` (message only; Rust already refuses) - -Subtraction: about −20 lines of skip and fallthrough; +4 small refusals and 4 tests. Build: -the 4 cache packages only. - -### C1. C# core: variant records, top-level Set overrides, resolution in the admitting hold - -Adds: -- **`CultPersistedRecord.Variant`**, record slot 4: `[baseKey, overrides[]]`. Payload slot 3 - is empty for a variant. An override is `[op, path[], id, value]`. - - Owner: `GameCult.Caching`. - - Consumer: both file stores and Studio. - - Invariant: an old reader refuses rather than misreads (section 1). - - Why no existing owner serves: the payload cannot hold the delta (probe D). -- **`CultStoredDocument.Variant`** (base key and overrides; null for plain records). - `Document` stays the resolved view. -- **A codec port on `CultCache`**: the `(serialize, deserialize)` pair `CultInspectorModel` - already takes, lifted. - - Owner: `GameCult.Caching.MessagePack`, supplied by `CultCacheMessagePack.Create`. - - Consumers: resolution and the inspector model. - - Invariant: one codec per cache; clones and override values use the store's encoding. - - `CultInspectorModel`'s constructor takes the port instead of its own pair, so there is one - pair, not two. - - A cache without a codec refuses variant writes. -- **Write API** on `CultCacheBatch`, with single-call equivalents: `UpsertVariant(key, baseKey, - overrides)`, `Flatten(key)` (Q1a), `Remove` unchanged. Rebase is `UpsertVariant` with a new - base. -- **`Validate` resolves** every admitted variant and every dependent of an admitted or - evicted base, in base-first order, against the post-batch set. It refuses, naming keys: - a cycle; a missing base; a different concrete type; a global type; an evicted base with - surviving variants; a variant whose resolved `[CultName]` equals its base's (R6); a plain - `Upsert` at a variant key (Q1a). **Not refused (Q4 c, operator 2026-09-29):** an override - naming a slot the type no longer has, or holding a value that no longer decodes into the - member's type, soft-drifts exactly as the same member would on a plain record under - `cultcache-schema-compatibility.md`. It is ignored with a warning naming the variant and - the member, and the variant inherits the base value. One rule, the existing soft-drift - path, never a variant-specific one. -- **`Apply` re-resolves dependents** in the same hold. Each emits `Updated` and is re-indexed. - A base change stamps no dependent `storedAt`. -- **Header**: the store writes `v2` iff it holds a variant (Q5a) and reads `v1` and `v2`. -- **Refusals** (Q6): `DirectoryMessagePackBackingStore` push and commit of a variant; - `CultNetDocumentRegistry` row building for a variant; `CultMesh.ReadSingleFileDocument` of - a variant key. -- **Contracts**: the variant record section in `cultcache-persistence-format.md`; one - sentence in `cultcache-schema-compatibility.md` saying overrides soft-drift like members - (Q4 c, no exception); a "variants: C# resolves; others refuse" row in - `runtime-parity-scope.md`. - -Authority map: -- Owner: `CultCache.Validate`/`Apply` owns the resolved view. -- Inputs: stored deltas, base records, the codec. -- Outputs: the resolved `Document`, index entries, changes. -- Demoted: the resolved `Document` is no longer a writable owner for a variant key (Q1a). - `storedAt` is the delta's identity only. -- Forbidden writers: store `Loaded` callbacks never resolve; Studio never writes a resolved - document to a variant key; CultNet never emits a variant row. -- Shared paths: a load (`Admit` with a source), a single write, a batch and a remove all pass - the same `Validate` and `Apply`. There is no second resolution path. - -Verification. New `VariantTests.cs`; each rule gets a positive and a named negative: -- a load with a variant resolves; -- a base edit updates the variant's `Get`, `GetByName` and `GetByIndex`, and `Watch` sees both - changes inside one publication, with no gap in `Sequence` between them. Negative: a - subscriber observing the base change must already read the new variant. -- `Expect` on the variant survives a base change; -- each refusal case, and the refusal message names both keys; -- the header is `v1` without variants and `v2` with them; -- a read-only store resolves; -- a variant round-trips byte-identically through flush. - -Cross-runtime vectors: the Caching interop peer gains `write-variant --file`. A test in -`packages/cultcache-ts/test/cult-cache.test.ts` asserts that TypeScript refuses that file by -name; Python and Rust tests read the same fixture from `contracts/cultcache/variant-store.cc`. - -Command, `dotnet`: -`dotnet test tests/GameCult.Caching.Tests && dotnet test tests/GameCult.Networking.Tests --filter FullyQualifiedName~Registry`, -then the C0 commands for `node:22`, `python` and `rust` against the new fixture. - -Subtraction: the inspector's own codec pair collapses into the port; otherwise net additive, -estimated +350 to 450 lines of core and +400 of tests. Build: the Caching and MessagePack -projects, Caching.Tests, the Networking test filter, the interop peer. - -**C1 status, 2026-09-29 (Self).** C1 landed on `hands/variants-c1` (`136adce`). Soul (Opus) found it -not closeable, and a fix batch is in Hands: -- **F1:** CultNet's live-change paths leak variants as plain records, which breaks Q6. -- **F3:** an undecodable override soft-drifted where a plain record refuses to load. -- **F4:** invalid overrides were accepted at write. -- **F2:** an inherited unique index value shadows the base, and the winner depends on order. - -**Self's readings and defaults, not new operator rulings:** -- **Q4 (c) read literally:** an override follows the plain-record outcome. A missing slot - soft-drifts; a value that no longer decodes refuses loudly, as a plain record does. -- **Invalid overrides are refused at write.** Drift covers type changes after the fact, not - bad writes. -- **F2 extends R6 to indexes.** A commit that leaves a variant sharing an indexed value - with another record is refused. -- **One override per slot.** - -**Open for the operator:** plain records with duplicate unique-index values are silently -overwritten today. That predates variants. Fixing it could refuse existing stores. - -**C2a status, 2026-09-30.** Hands pushed `hands/variants-c2a` at `640ad9a`. -- Element ids are 12 hex characters. Writes mint them at random. Load mints them deterministically. - Derived ids use `[CultElementId(nameof(Member))]`. -- The CDN chunk ref's id is its offset. The witness types carry ids, and the ids cross C#, TS, Python and - Rust. - -Soul held it. Fixes are in Hands: -- ids are never minted inside variant overrides; -- interface unions are never minted; -- a null derived source leaves an empty id; -- load minting is culture-dependent; -- the minted-on-load list goes stale; -- a refused batch leaves minted ids behind; -- the id format is unchecked; -- late union-slot reuse passes registration. - -**C2a rulings (operator, 2026-09-30):** -- **Old readers are tripwired.** A store that holds element ids carries a marker that every pre-C2a reader - refuses under C0's unknown-header rule. An old C# reader can therefore never silently strip nested ids. - MessagePack's generated formatters skip unknown slots inside elements. -- **Duplicate element ids refuse at load,** naming the record, the list and the id. This matches the - duplicate-index ruling. -- **C2b precondition (Soul F7).** Whether a pre-id element keeps its load-time id depends on the write path. - C2b must refuse an override that targets a record whose ids exist only in memory, or mint on open. - -**C2a Soul pass 2 (`e85b201b`), 2026-09-30: hold.** The tripwire only works against unreleased readers. -Every published release (`cultlib-unity-v1.0.60`, `caching-unity-v1.4.0`, `cultcache-ts-v0.14.0`, -`cultcache-py-v0.3.0`) predates C0. The 1.0.60 C# reader loads a v3 store, and its next write is v1 with the ids -stripped (probe). Other findings: -- F2: the same element object twice in one list commits, then the store refuses to load. -- F3: CultMesh single-file writes bypass the marker. -- F4: TS `pushAll` writes the header without reading it first. - -**C2a rulings, 2026-09-30 (operator):** -- **Rollout order closes the released-reader hole.** Ship a CultLib release that refuses v3, move every C# - reader of a store onto it, then let a C2a writer touch that store. There is no format change for this. - This supersedes the claim above that old readers are tripwired: that claim holds only for post-C0 readers. -- **Mark by content.** A store is written as v3 (v5 for directory stores) only when a record actually holds an - element id. An existing v3 header on disk stays. -- Fix batch 3 in Hands: F2, marking by content in every runtime, F3 routed through the cache's header - decision and load checks, F4. - -**C2a Soul pass 3 (`9ed61dd2`), 2026-09-30: fix first.** -- A flattened record's `HoldsIds` goes stale when its base changes in the same batch. -- A conditional commit onto disk ignores content. -- Rust `push_all` clobbers headers it cannot read. -- TS keeps a stale header after the file goes away. -- Self's claim that "rollout order makes the raw-writer hole benign" was wrong. The reader that strips ids - from a v1 store is a post-C0, pre-C2a build. That is exactly the reader the v3 marker targets, and rollout - order does not keep it away. - -**Ruling, 2026-09-30 (operator): C0 is released only together with C2a.** The first CultLib release that -refuses v3 also contains C2a, so no released reader strips ids. C0 and C1 are never released on their own. -Raw and cross-runtime writers that cannot see ids stay sticky-only. Fix batch 4 is in Hands. - -**C2a F2 ruled (operator, 2026-09-30, accepting Self's recommendation B): records nobody claims are carried -untouched.** A store may hold records under a schema id that no local type owns or lists as compatible, for -example after a rename that declared nothing. -- Both store kinds carry those records byte for byte under their own id. The catalog lists them as foreign - records. -- Writes to other records proceed. -- A write that would overwrite or relabel an unclaimed record is refused with a typed schema conflict. -- The single-file store's whole-view write stops relabelling such records onto local types. The directory - store stops refusing every commit while it holds one. -- Why: batches 8-9 showed that the danger is rewriting a record under a new identity (F1, F6), not an - undeclared old id. Batch 10's owner-beats-lister rule makes declaring an old id cheap anyway. -- Self's first lean, "refuse loudly" (A), is superseded. - -**C2a batches 9-10 (Soul pass on `fdb8a60a`, 2026-09-30): hold.** -- **F6, A-B-A lost update on the single-file store.** Ruled by Self: any write that changes a record's bytes or - schema id mints a later `storedAt`. -- **F8, batch 9 over-refused.** An owner beats a lister, so v1 and v2 coexist. -- **F9.** Compatible ids compare as a set. -- **F10.** Typed refusal errors in TS and Python. -- Batch 10 is in Hands. -- **F7 ruled (operator, 2026-09-30): retire `AsSchemaAlias`.** "Schema aliasing sounds like a really bad idea, I - support retiring it." - - C# refuses a second type for one schema, as TS, Python and Rust already do. There is one type per schema in - every runtime. - - The API, its registry path (`IsExactWireAlias`) and the tests that exercised it (7 Mesh, 2 Caching) are - deleted or rewritten. - - No consumer called it. - -**Operator rulings, 2026-09-30:** -- **Duplicate unique-index values do not load** ("Duplicate indices should not load"). This covers - plain records too, so R6-for-indexes becomes one general rule: - - a commit that would leave two records holding one indexed value is refused; - - a store holding duplicates refuses to load, and the refusal names both keys and the index. - - The variant-specific rule `RefuseVariantIndexSharing` collapses into this rule. - - Before it ships, every real store is scanned, so no store stops loading by surprise. - - **Scan result (Eyes, 2026-09-30):** `[CultIndex]` had been recycled as a grouping and - selection-filter declaration: `Category`, `Kind`, `HardpointType`, `ShardId`, and others in - CultMesh, Geometry and docs. AetheriaEve's `aetheria-world.cc` (taxidermy) is the only store - holding duplicates. The only single-result lookups in real code are unique: - `PlayerData.PlayerId` and `Email` in `Server.cs`. The operator: "Pretty sure indexes are - supposed to be unique, it was not my decision to recycle that mechanism for grouping - purposes" and "Grouping was a desirable feature from my old DatabaseListView that didn't - quite make it into the initial CultCache Studio port". - - **Resolution (Self, from those answers):** `[CultIndex]` means unique. Grouping belongs to - the Studio record-grouping campaign's own attribute (`docs/studio-grouping-cut.md` on - `codex/studio-grouping`, attribute-based per the 2026-09-17 ruling). CultNet selection - filters any declared member by its catalog name, so no index is needed for filtering. The - grouping-only `[CultIndex]` declarations are deleted. This is one CultLib cut, queued - behind C2a because both edit `CultCache.cs`. - - The TS/Python last-writer-wins parity gap closes by the same rule when those runtimes - adopt it. -- **CultNet invisibility is a defect** ("looks like a defect to me"): `cache.Commit` and - `cache.UpsertAsync` on a database's cache must publish to subscribers and to the mutation - log (`CultCache.Publish`'s `if (!loaded) continue;`). -- **Hop subscriptions keep refusing variants** until CultNet carries deltas (C5): "keep refusing, - I guess, CultNet will have deltas by the time we're done here anyway". - -### C2. C# element identity and nested paths (Set, Insert, Remove) - -Adds: -- **`[CultElementId]`** (a string member). **Ids everywhere, not opt-in (Q3, operator - 2026-09-29): there is no `[CultVariants]` attribute.** The C# registry requires an id member - on every object element type reachable through a list in any registered document type, - union bases included, and refuses a type that lacks one. -- **Minting**: on write, the cache mints ids for unset ids in every type, and refuses a - duplicate id within one list. A store written before ids existed loads, and its ids are - minted on its first write (minted on load, per the operator's rollout ruling). -- **Path steps below top level.** -- **Insert** anchored after an id, where `""` means the head. An inserted run sits - immediately after its anchor and before base elements added there later. -- **Remove** by id. -- **Canonical order**: removes, then inserts in list order, then sets by path. -- **Q2a refusal**: a base commit that orphans an override is refused. -- **Q7**: whole-member Set for dictionaries, scalar lists and arrays. -- **`CultCache.MintElementIds()`**: the one-shot rewrite for existing stores (ruling 1). It - upserts every record that has an unset id through the normal path. -- **The consumer sweep (Q3 rollout).** About 28 consumers carry list-element types, and each - needs an id member before it can take the CultLib that requires one. C2 ships the - requirement. Self schedules the per-repo sweep, one small type edit per consumer, before - those consumers bump. C2 lists the element types it finds per consumer, as data for that - sweep. - -Keeps: the C1 format unchanged. C2 fills path shapes that C1 refused. - -Verification: -- Two look-alike elements (two of one union subtype) get different overrides, and a base - insert before them retargets nothing. -- Rebase re-applies overrides by id. A missing id is refused, and the message names the - variant, path and id. -- A Set on a removed element is refused. -- Minting is idempotent. -- A registered type with an id-less list element type is refused, naming the type and member. -- A store written before ids loads, and its ids are minted exactly once on first write - (idempotent). -- A C#-written nested-override fixture joins `contracts/cultcache/`. - -Command: `dotnet test tests/GameCult.Caching.Tests`. - -Estimate +300 lines and +350 of tests. Build: as C1, without Networking. - -### C3. Studio: edit the delta - -Adds, in `CultInspectorModel` (engine-free; the lowering stays a renderer): -- For a variant edit, each member path is marked inherited or overridden. -- `Commit` writes `UpsertVariant` with overrides equal to the prior overrides plus a - structural diff by id. It is deterministic because ids exist. -- An explicit "override" pins a value equal to its base's. -- `ClearOverride(path)`. -- `CreateVariant(record)` next to `Duplicate`; it prompts for a name (R6). -- `Rebase(record, base)` returns a conflict list for Q2a before commit. - -Lowering, in `CultCacheStudioWindow`/`Drawers`: an overridden marker and a revert control per -member, a base link, and variant creation and rebase controls. - -Verification: -- `CultInspectorModelTests`: the diff produces exactly the touched paths; clear removes one - entry; the pin survives an equal value; rebase conflicts are listed. -- Negative: a Studio commit never calls a plain `Upsert` at a variant key. - -Only the operator can check these in Unity against a copy of `Aetheria.cc`: -- the markers read correctly; -- clearing reverts the value live; -- a hull variant replaces `Hardpoints`; -- a laser variant changes one behaviour's `PerformanceStat.Max`; -- a base edit shows in the open variant. - -Then tag `caching-unity-v1.5.0`. **This is the point where Aetheria is unblocked.** - -Estimate +250 model, +150 lowering, +250 tests. Build: Caching.Tests. The Unity package is -checked by the operator in the editor. - -### C4. Aetheria adoption (Aetheria repo, its own cut, after the release) - -- Bump the package to `v1.5.0`. -- Add `[CultElementId]` to every list-element type Aetheria's documents carry (ids everywhere, Q3). There is no per-type opt-in. -- Add `[CultElementId]` to `BehaviorData` (base) and `HardpointData`. -- Run `MintElementIds` once over `GameData/Aetheria.cc`. -- AetherDb migrations skip variants or use the variant API (Q1a). -- Re-author the families listed in section 6. - -### C5. CultNet deltas (C# reference) - -Adds a new raw-record message version that carries the variant slot, following selection D3: -a v1 message string, so peers not in this cut refuse through their own dispatch. The C1 -refusal in `CultNetDocumentRegistry` is deleted. Scheduled when a CultNet consumer needs -variants; none does today. - -### Parked: TypeScript, Python and Rust resolution - -Each runtime gets its own cut when a consumer that reads a variant-bearing store exists -(ruling 5). None does. `Aetheria.cc` is read by C# only. - -## 6. First payoff in `Aetheria.cc` - -Scan of the live file (206 records, `v1`): records of one schema whose leaves differ by at -most 20 percent, name excluded. This is a heuristic; the operator confirms the real families. - -| Type | Records | Families that collapse | Records becoming variants | -| --- | --- | --- | --- | -| geardata | 32 | Refinery / Assembly Line / Deep Ore / Surface Ore / Shipyard (1 to 3 leaves differ); Small / Medium / Large Drive; RevvITup 2.0 / deep space burnout / Talaria; Skiron / Iapyx; Arctica / cold like my heart | 10 | -| weaponitemdata | 18 | ChargeBlast SG / ChargeBlast+- (14 of 253 leaves differ); pswarm / scorched void policy; Earp / 6k Shooter; Spectra / ColdFire | 4 | -| hulldata | 5 | Longinus / LonginusX (only `Behaviors`, `Hardpoints` and two cosmetic members differ) | 1 | - -That is 15 of 55 equipment records. In most of these families the difference lies inside -`Behaviors`, so element-level overrides (C2) are what pay. Top-level overrides alone would -still copy each behaviour list. - -## 7. Consumers - -There are 28 projects: C# 13, Rust 9, TypeScript 10, Python 1, Kotlin 0 (Explore survey; -dependency-level for most, read in detail for Aetheria and AetheriaEve). -- **See variants:** Aetheria through C# `Get`, `GetByName` and `CultRecordRef` (resolved - documents only), CultCache Studio (deltas), and AetherDb tools (must honour Q1). -- **Must stay oblivious or refuse:** every other consumer. No other project reads - `Aetheria.cc`. Raw readers refuse through the empty payload plus C0/C1: `CultMesh` - single-file reads, AetheriaEve's directory-page reader (the directory store refuses - variants, Q6), and Mimir (writer only). diff --git a/docs/document-variants-target.md b/docs/document-variants-target.md deleted file mode 100644 index 0e62ece1..00000000 --- a/docs/document-variants-target.md +++ /dev/null @@ -1,153 +0,0 @@ -# CultCache Document Variants: Target - -Date: 2026-09-17 - -Status: target, before any substrate map or cut map. This document owns the ends. -A cut map, written after an Imagination pass over the Body, will own the means. -Branch: `codex/document-variants`. - -## Why - -A CultCache record today is always complete. To make a bigger laser from a -laser, an author copies the record and re-specifies every stat. For a ship hull -with several hardpoint layouts, every layout is a full copy of the hull. When -the base changes, each copy has to be found and edited by hand, and copies drift -apart silently. - -The first consumer is Aetheria's catalog (`F:\Projects\Aetheria\GameData\Aetheria.cc`), -edited in CultCache Studio: - -- **Hull variants:** one hull with several hardpoint layout options. A variant - replaces the whole `HullData.Hardpoints` member. -- **Item variants:** a bigger laser. A variant changes stats nested inside the - design's behavior list, for example one behavior's `PerformanceStat.Max`. - -## Rulings (operator, 2026-09-17) - -- **This is a CultCache feature, not an Aetheria mechanism.** Operator: "Yes, this - is absolutely a CultCache feature, write it up in CultLib". -- **A variant is stored as a delta from its base and resolved by the cache.** A - record stores a reference to its base plus a sparse set of overrides. The - cache resolves it; readers see complete documents. The operator chose this - over two alternatives. Authoring-only variants in Studio over flattened stored - copies leave a derived cache pretending to be data. Per-type variant records - in each consumer rebuild the feature bespoke for every type. - -## Ends - -- **Readers see complete documents.** `Get`, `GetAll`, lookups by name and by - index, watches and snapshots return resolved documents. No consumer outside - the cache and its authoring tools handles a delta. -- **A base change reaches its variants.** Editing a base changes every variant - that does not override the edited value. Watches, indexes and name lookups - observe that change on the variants too, in the same commit that changed the - base. -- **A variant is a record.** It has its own key and name, and references to it - behave like references to any record. In Aetheria a lot's design and a - product's design can name a variant directly. -- **Studio edits the delta.** It shows which values a variant inherits and which - it overrides, and it can clear an override so the base value applies again. -- **Loading refuses a broken variant loudly and names the records.** Cases: - - a cycle of bases; - - a base that does not exist; - - a base whose type the variant's type cannot inherit from; - - ~~an override naming a member that does not exist on the type~~ **superseded 2026-09-29 - (operator, variants Q4 c): a dangling override soft-drifts like any dropped member, ignored - with a warning; see `document-variants-cut.md` section 4.** - - Deleting a base that still has variants is refused. - -## Invariants that must survive - -- **Wire parity** for the persisted format (`src/GameCult.Caching/Contracts/cultcache-persistence-format.md`, - `docs/persisted-record-encoding.md`). Every runtime that reads a store holding - variants resolves them identically, or refuses the store loudly. Which - runtimes resolve is decided in the map, against - `docs/runtime-parity-scope.md`; none may misread silently. -- **One home store per type**, and atomic single-store commit - (`cultcache-store-composition.md`). A base change and the resulting variant - changes are one commit, not a repair that runs afterwards. -- **Schema compatibility** (`cultcache-schema-compatibility.md`): overrides are - subject to the same semantic identity, soft drift and hard rejection rules as - full records. -- **Compare-exchange** (`Expect`, `ExpectUnchanged`) keeps a single meaning for - variants. The map settles whether it tests the stored delta or the resolved - document; it must not mean both. - -## Fork rulings (operator, 2026-09-17) - -- **1, granularity: stable element identity, ids everywhere** (operator, 2026-09-17: "ids - everywhere"). - - Every object element in a list carries a stable id, minted when the element is created. - This is the approach of sequence CRDTs (RGA, YATA/Yjs, Automerge, Fugue). - - Matching algorithms (Myers, diff3, GumTree) are heuristics. They fail on look-alike - elements such as two `EnergyDraw` behaviors, which is why identity is minted, not - inferred. - - An override addresses an element id plus a member path in CultNet's grammar. An - insertion is a new element anchored after an existing id; a removal names the - removed id. Index addressing is out. - - Rebasing is deterministic. An override whose element id exists in the new base - re-applies. One whose element is missing is an explicit conflict that Studio shows, - to keep, retarget or clear; it is never silently retargeted. - - Existing stores get ids minted once, by a rewrite. -- **2, where resolution lives: at load, kept in memory** (operator: "agreed"). Resolution - computes a variant's complete document from its base chain and its overrides. - - The cache holds both the stored delta and the resolved document. - - A base edit re-resolves its variants inside the same commit, so watches and indexes - observe the change atomically. - - Compare-exchange (`Expect`) tests the stored delta. The resolved document is derived. -- **3, cross-store bases: no.** A variant and its base live in the same store. - *Future direction (operator, 2026-09-30, from Aetheria moddable ships):* "we'll want cross-store variant - support eventually; modders should be able to add variants of existing items without modifying the - original". Ruling 3 holds for this campaign. A mod store layering variants over a shipped catalog is the - named future consumer that would reopen it. Until then, no consumer builds its own cross-store resolver: - Aetheria's compose step stays out of resolution. -- **4, CultNet: deltas.** -- **5, runtimes: C# first.** TypeScript, Python, Rust and Kotlin refuse a store - or snapshot containing variants, loudly, until each implements resolution. -- **6, type change: the same concrete type only** (operator: "agreed"). - - An override can only name members the base type has, and rebasing requires the same - type. - - A variant whose type differs from its base's is refused loudly. - - Subtype variants are parked until real content needs a template base. A record - flagged as a template, not a real item, may be the smaller feature. -- **7, clearing and rebasing.** - - Clearing an override removes that member from the variant's stored delta. A member - that is not overridden is not stored. - - Rebasing (changing a variant's base) is supported. Operator: "rebasing is a nice - tool to have, things can get annoying otherwise". - -## Open forks, for the Imagination pass to map and the operator to rule - -1. **Override granularity.** - - Top-level members are enough for hardpoint layouts. - - A bigger laser needs overrides inside list elements. Addressing list - elements by index is fragile: an insertion into the base silently retargets - every variant's overrides. - - Candidates: stable element identity (for example a behavior's type within its - design); replacing whole list elements only; or addressing paths. - - CultNet's selection work already resolves member paths per leaf type, - inherited members included (`docs/cultnet-selection-cut.md`). A variant's - delta addressing should reuse that grammar, not invent a second one. -2. **Where resolution lives:** at load in the cache core, lazily on read, or as a - materialized view the cache maintains. This affects memory, watch cost, and - the compare-exchange meaning. -3. **Base and variant across stores.** Can a variant in one store name a base in - another (a run-store variant of a catalog record)? This interacts with - one-home-store routing and read-only stores. -4. **CultNet.** Do variants travel as deltas, or resolved? A subscriber that - receives only resolved documents needs no resolver; one that edits needs - deltas. -5. **Runtime scope.** Which of C#, TypeScript, Python, Rust and Kotlin resolve, - and which refuse a store containing variants. -6. **Type change.** May a variant's type be a subtype of its base's type, - adding members, or must the two types match? -7. **Clearing and reparenting.** What clearing an override writes, and whether - a variant can change its base. - -## Not in scope - -- Scaling or formula variants ("every stat times 1.5"). An override states a - value. Generated authoring can compute values. -- Any Aetheria catalog content. Aetheria adopts the feature after a release, as - its own cut. diff --git a/docs/mutation-testing.md b/docs/mutation-testing.md deleted file mode 100644 index 8db67be5..00000000 --- a/docs/mutation-testing.md +++ /dev/null @@ -1,159 +0,0 @@ -# Mutation Testing - -CultLib measures its C# test suites with Stryker.NET, pinned as a repo-local -tool in `dotnet-tools.json`. Coverage is added project by project; a project -with no `stryker-config.json` next to its test project has not been scoped -yet (see "Follow-ups" below for what's outstanding). - -## Running it - -Restore the tool once per checkout: - -```powershell -dotnet tool restore -``` - -Run a full mutation pass from a covered test project's directory, for -example CultMath: - -```powershell -cd packages/cultmath/tests/CultMath.Tests -dotnet stryker -t mtp -``` - -`-t mtp` selects Stryker's Microsoft.Testing.Platform runner. It is required -for test projects on `xunit.v3` (CultMath.Tests is one): Stryker's default -`vstest` runner's coverage capture does not work against xunit.v3's native -test host and the run stalls retrying test sessions indefinitely. Projects -still on `xunit` v2 with `xunit.runner.visualstudio` can use the default -`vstest` runner. - -For a single cut, scope the run to what changed instead of the whole -project: - -```powershell -dotnet stryker -t mtp --since: -``` - -Use a full commit SHA for `--since`, not an abbreviation: `git rev-parse -` first, then pass the full result. Stryker resolves `--since` -through the same git plumbing a short SHA can resolve ambiguously (or fail to -resolve at all) depending on the checkout's object database, and a -misresolved base silently changes what "since" means instead of erroring. - -In a container, also set `git config --global --add safe.directory '*'` -before running Stryker (or any other command that shells out to `git` -against this checkout) - git refuses to operate on a repository owned by a -different user by default, which is the common case for a bind-mounted -checkout in a container. - -Reports land in `StrykerOutput/` next to the test project (git-ignored). -Open the `reports/mutation-report.html` file for a browsable view, or read -`reports/mutation-report.json` for a scriptable one. - -## Triage rules - -The mutation score is not a gate (`break` threshold is 0). Every surviving -mutant is a finding about the tests, not a build failure, and gets triaged -into one of: - -- **missing behavioural test** — name the test that should exist and what - branch, edge case, or component it would exercise. -- **degenerate fixture** — the code path is exercised, but the fixture's - values make two different mutants produce the same observable result (a - zero component that erases a `+`/`-` distinction, an already-normalized - input that erases a `*`/`/` distinction, all-equal or all-same-sign inputs - that erase a boundary or `&&`/`||` distinction). Fix the fixture, don't - invent a new one that only kills that one mutant. -- **equivalent** — the mutant cannot change behaviour. Float-threshold - boundary flips (`>` to `>=`) are equivalent by default. A reasoned - equivalence claim (for example, `>>` and `>>>` on an already-unsigned - operand) needs its one-line reason recorded, not assumed. - -Do not write a hand-rolled mutation suite or anchor test as a substitute. -Where the tool can't reach (native/shader/editor code), the defence is a -behavioural test at the layer the rule is decided, not a committed mutation -harness. - -## Known tool caveat - -Stryker 5.0.0's `mtp` runner is documented as preview. Its per-test coverage -capture was found to report false "Survived" verdicts specifically for -mutations inside `static readonly` field initializers that a test does -cover (confirmed by hand-mutating `bool3.@false` and `bool4.@true` and -running the full suite directly with `dotnet test`, both of which fail -against the mutation that Stryker reported as surviving). Treat a survivor -inside a `static readonly` field initializer as unproven until confirmed -with a direct `dotnet test` run against the same mutation; do not triage it -as a missing test without that check. - -A survivor that looks like it must be covered is unproven until confirmed -with a direct `dotnet test` run against the same hand-applied mutation - but -confirm what the mutant actually is before drawing a tooling conclusion from -it. An earlier pass here reported three survivors in -`CultGeometrySurfaceNets.TryGetOtherEndpoint`'s per-axis `if (x + 1 >= -sizeX) return false;` guards and initially triaged them as a `vstest` -coverage false-survivor (the same shape as the `mtp`/`static readonly` -caveat above). That triage was wrong: the actual mutant Stryker reports -there is `return false` -> `return true` inside the guard, which is -equivalent, not a coverage miss. Returning `true` from -`TryGetOtherEndpoint` still leaves the out `other` coordinate equal to the -current coordinate (see the method: `other` is cloned from `coord` and only -one component is incremented when the method returns `true`), so the caller -compares a sample against itself, the inside/outside flags always match, -and the crossing is skipped exactly as it would have been had the method -correctly returned `false`. No test can distinguish the two branches because -there is nothing to distinguish - it is a legitimate equivalent mutant, not -evidence of a `vstest` false survivor. - -`CultGeometrySurfaceNets.Extract`'s `if (inside == (OrientationSign[axis] < -0))` (the sole authority for quad winding after the geometric cross-product -fallback was deleted) has three mutants on that line: `==` -> `!=`, and `< 0` -negated to `> 0` or widened to `<= 0`. The first two, `inside != (OrientationSign[axis] -< 0)` and `OrientationSign[axis] > 0`, are both reported `Killed`. The third, -`OrientationSign[axis] <= 0`, survives, but it is an equivalent mutant, not a -coverage gap: `OrientationSign` is the fixed table `{ 1, -1, 1 }` and never -holds `0`, so `< 0` and `<= 0` agree on every value the table can actually -produce. There is nothing for a test to distinguish, the same shape as the -`TryGetOtherEndpoint` equivalent mutant above. - -No `vstest` coverage-based false survivor has actually been confirmed for -GameCult.Geometry. Every survivor triaged here so far has turned out to be a -genuine equivalent mutant once hand-mutated and checked directly against -`dotnet test`; treat any survivor that looks like it must obviously be -covered as unproven until confirmed the same way, but do not cite this -project as precedent for the `vstest` caveat itself - that caveat is -`mtp`-runner-specific and documented above for `static readonly` field -initializers, and GameCult.Geometry has never actually hit it. - -## Follow-ups - -CultMath.Tests and GameCult.Geometry.Tests are the only projects currently -scoped. GameCult.Geometry's `stryker-config.json` has no `test-runner` -entry: the project is NUnit 4 with `NUnit3TestAdapter` and -`Microsoft.NET.Test.Sdk`, so Stryker's default `vstest` runner applies -directly (no `mtp` workaround needed, unlike CultMath.Tests's xunit.v3 -suite). - -GameCult.Geometry has only ever been run `--since:`-scoped to the cut that -added surface nets; a full-project baseline (running mutation against all of -`CultGeometryIsoSurface`, `CultGeometryDocuments`, and -`CultGeometryPrimitives`, not just the diff) is a recorded follow-up, not -something either cut did. - -That first `--since` pass did not actually stay diff-scoped: adding -`stryker-config.json` itself in the same cut that first used it made Stryker -log "391 mutants will be tested because: Non-CSharp files in test project -were changed" and mutate the whole `mutate` glob (all four existing -GameCult.Geometry source files), not just the new surface-nets files. A cut -that changes a non-C# file inside the test project (a fixture, this config -file, anything under `Fixtures/`) loses `--since` line-level scoping for that -run; a future cut whose diff is C#-only, against a base commit that already -has `stryker-config.json`, should scope correctly. - -`GameCult.Caching`, `GameCult.Mesh` (+ `.Quic`, `.Quic.Native`), -`GameCult.Networking` (+ `.WebSockets`), and the non-.NET runtimes -(`cultcache-rs/py/ts`, `cultmesh-*`, `cultnet-*`) each still need their own -`stryker-config.json` (or ecosystem-equivalent tool: `cargo-mutants` for -Rust, `StrykerJS` for TypeScript, `mutmut` for Python) and a baseline pass. -None of that is wired into CI yet. diff --git a/docs/nuget-packaging.md b/docs/nuget-packaging.md index 1da3ba63..afc94570 100644 --- a/docs/nuget-packaging.md +++ b/docs/nuget-packaging.md @@ -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 ``` diff --git a/docs/runtime-parity-scope.md b/docs/runtime-parity-scope.md index 2c6f2211..44715c7c 100644 --- a/docs/runtime-parity-scope.md +++ b/docs/runtime-parity-scope.md @@ -13,11 +13,6 @@ Cross-runtime parity has three layers: - Wire parity: shared CultCache file/state contracts, schema-v0 MessagePack documents, transport profiles, and RUDP packets must cross runtime boundaries without translation folklore. - Every store reader refuses, by name, a header or record slot it does not - understand (vectors: `tests/vectors/document-variants-c0`). - Document variants: C# resolves them (`cultcache.store.v2`); every other runtime, - CultMesh single-file reads, the directory store and CultNet refuse a variant by name - (vector written by C#: `tests/vectors/document-variants-c1`). - Feature parity: each runtime must expose the CultCache/CultNet/CultMesh surfaces needed for its intended role, with unsupported production-server ownership called out plainly. @@ -67,34 +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. -- CultMesh content plane (`cultmesh.content_chunk_request.v1` and `.response.v1` - plus the CDN manifest, C#/Rust only): byte parity in both directions (manifest metadata keeps its wire key order and a - duplicate key is refused, as the reference refuses it), and the reference's answer - to a chunk request, including its failure spellings: every bad request is - answered `found: false`, never refused at decode. - `packages/cultnet-rs/tests/content.rs` and - `tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs` judge - `contracts/cultmesh/content-vectors.cs-written.json` and - `content-vectors.rs-written.json`. Rust carries these chunks only on the - session the client already holds (see `src/GameCult.Mesh/docs/transport-planes.md`); - it has no authenticated content connector, so it claims no TCP+TLS or QUIC - content path. - Recorded limits: a caller that passes `max_bytes = u64::MAX` to `fetch_content` gets an unbounded - body, because the reference refuses artifacts over `int.MaxValue` bytes and Rust bounds them only by the - caller's cap. - `normalize_hash` matches the `sha256:` prefix ASCII-case-insensitively while C# `OrdinalIgnoreCase` may - also match non-ASCII forms (for example `ſha256:`); unprobed, and it affects hostile input only. ## Expansion Work Outside The Current Parity Claim diff --git a/docs/semver-policy.md b/docs/semver-policy.md deleted file mode 100644 index e1af0365..00000000 --- a/docs/semver-policy.md +++ /dev/null @@ -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 `-v` 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 `## []` 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 - `-v*` tag older than the version being released (or treats the - release as a package's first if none exists); -2. requires a `## []` 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 ---changelog --version --tag-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. diff --git a/docs/studio-grouping-cut.md b/docs/studio-grouping-cut.md deleted file mode 100644 index 770d0698..00000000 --- a/docs/studio-grouping-cut.md +++ /dev/null @@ -1,432 +0,0 @@ -# Studio Record Grouping And Reference Drag-Drop Cut - -First mapped 2026-09-17 (Imagination) against CultLib `122c217`. **Refreshed 2026-09-29 -(Imagination) against CultLib `main` `feeced8` and Aetheria `codex/fire-control-12` `4b594e11`.** -Every `file:line` below is against those revisions unless marked. Hands re-reads an anchor -before editing it; `main` keeps moving. - -**Rulings.** Q1 **B** (a class attribute on the model; argument order is nesting order; -2026-09-17). Q2 **A** (occupied values only). Q3 **A** (list only, not the ref picker). -Q4 **A** (no record search). Q5 **overtaken**: one release from `main`, together with -document-variants C3 (`docs/document-variants-cut.md`), taking whatever native-plugin drift -`main` carries. Q6 **A** (flat row by resolved value, C3's variant marker; 2026-09-30). Q7 **B** (legacy parity -minus manufacturer, plus `FactionProductData` by `Manufacturer` then `Design`, plus -`WeaponItemData` by `WeaponType`; 2026-09-30). - -## Target - -### Ends - -1. **Declared record grouping.** `[CultInspectorGroupBy(nameof(A), nameof(B))]` on a document - class makes Studio's record list for that type a foldout tree, nested in argument order. A - declaration on a base type groups every derived type listed under it. -2. **Create in a group.** Every group node offers `Create`. The new record gets every grouped - member on the path to that node set to that node's value, so it lands in that node. -3. **Reference drag and drop.** A record row dragged from Studio's list onto any - `CultRecordRef` value the inspector draws (member, list element, dictionary key or value) - is accepted exactly when the ref's picker would offer that record. - -Recovered from the legacy "Database Tools" window (Aetheria `d3db1730`, -`Assets/Scripts/CultCache/Editor/DatabaseView.cs`, `AetheriaDatabaseView.cs`, -`Inspectors/DatabaseLinkInspector.cs`). Selector lambdas are rejected (operator, 2026-09-17). - -**The attribute is not `[CultIndex]`.** `[CultIndex]` means unique (operator, 2026-09-30; -`CultDocumentContracts.cs:34`). Grouping is `CultInspectorGroupByAttribute`, in -`CultInspectorAttributes.cs`, read only by `CultInspectorModel`. It changes no schema id, -catalog entry, index, or store byte. The index cut's deletion of grouping-only `[CultIndex]` -declarations (CultMesh, Geometry) adds no `[CultInspectorGroupBy]` in their place: those are -service stores, not Studio-authored catalogs. - -### Invariants - -- **I1. The model owns grouping.** `CultInspectorModel` alone decides which members group a - listed type and in what order, which declarations are invalid (and the notice), the tree's - partition, node identity, order and labels, and the object create-in-group makes. Studio - lowers the tree; it never reads the attribute, calls `SetValue` for a preset, or computes a - group label. -- **I2. The leaves partition the candidates.** Across all leaves the tree holds exactly - `RecordCandidates(CultRecordRef, records)`, each record once, in candidate order - inside a leaf. Grouping never hides a record, including one whose grouped value is unset, - dangling, or an undefined enum value. -- **I3. The listed type owns the tree's shape.** Grouping comes from the type selected in the - type pane, never from each record's runtime type. -- **I4. Node identity is value identity, not the label.** A ref groups by key, an enum by its - underlying value, other kinds by the value's invariant string. Foldout state keys on node id. -- **I5. Create-in-group round-trips.** Upsert what `CreateInGroup(listed, node)` returns, - regroup: the new record lies in a node with the same id. -- **I6. One candidate rule.** `IsRecordCandidate(refType, record)` is the only test for "this - record may be this ref's value". `RecordCandidates` filters through it; drop acceptance calls it. -- **I7. One ref label.** The model owns `None` and `Missing `; the picker and group labels - both use it. -- **I8. Invalid declarations never break the window.** They give a flat list plus the model's - notice, never a throw in `OnGUI`. -- **I9. A drop honours the picker's gates.** Refused in a disabled (read-only) scope, when the - payload came from another model, and when the key equals the current key. A dictionary-key - drop still passes `ReplaceKey` (`CultCacheStudioDrawers.cs:303-309`). -- **I10 (new). Grouping reads what readers read.** A variant record groups, labels, and is a - drop candidate by its resolved `Document` (`CultCache.cs:369-371`), never its delta. - -### Non-goals - -No sibling-runtime port (TS `cult-cache-inspector.ts` is a byte dumper; Rust and Python have -no inspector). No runtime CultUI panel. No search (Q4), no picker grouping (Q3), no -drop-to-append on list headers, no legacy `New ` naming. - -## Body findings - -Kept from 2026-09-17, re-anchored: - -- **F3.** Legacy grouping matched the exact type; grouping derived records under a base - declaration is new. -- **F4.** Legacy create-in-group was dead (`DatabaseView.cs:70-75` throws); legacy foldouts - keyed on the label hash. -- **F5.** `member.GetCustomAttributes(true)` ignores `inherit` for a `PropertyInfo`, so a base - `[M] abstract P` is not seen on the override. Sites: `CultInspectorModel.cs:36` (now the only - metadata site: `MetadataOf` caches it per member, `:341-351`) and `:209` - (`CultInspectorDrawerClaims.Resolve`). Fix: `Attribute.GetCustomAttributes(member, true)`. -- **F6.** Studio `Add` creates only the listed type (`CultCacheStudioWindow.cs:340-346`, - `CreateElement(type, type)`), disabled when the model cannot create it (`:141`). So - create-in-group never makes a derived type, and an abstract listed type offers no `Create`. -- **F7.** Studio search filters the type pane only (`:112-120`). -- **F8.** Foldouts are in-memory (`CultCacheStudioDrawers.cs:33`); `OnDisable` closes the store - (`:48-51`), domain reload included. The only `EditorPrefs` key is `LastPathKey` (`:13`). -- **F9.** No drawer claims `CultRecordRef<>`, so every ref reaches `DrawRecordRef` - (`CultCacheStudioDrawers.cs:254-280`). -- **F10.** The Studio editor sources compile outside Unity (netstandard2.1, C# 9, against - Unity 6000.3.24f1 `Editor\Data\Managed\UnityEngine\*.dll` without the `UnityEditor.dll` - facade, plus the built `GameCult.Caching*` and tracked `MessagePack*.dll`). The 09-17 - scratch project is gone; Hands rebuilds it in its own scratch from this recipe. -- **F11.** The record row reads its click in `GUILayout.Toggle` (`:184`); a drag start must be - handled on a reserved rect before the toggle draws (IMGUI convention, not probed). - -New since the map: - -- **F12. Variants (C1, `6ffe17a`).** `CultInspectorModel(registry, CultCodec)` - (`CultInspectorModel.cs:302-306`); Studio still calls `CultCacheMessagePack.CreateInspectorModel(registry)` - (`CultCacheMessagePack.cs:33-34`, unchanged). `CultStoredDocument.Variant` is the delta - (`CultCache.cs:374`); `Document` is the resolved view. A top-level override is a - `Set` whose path is one step at the member's slot (`CultVariants.cs:72-73`), so "is this - grouped member overridden on this variant" is computable from the member's `Slot`. -- **F13. Every Studio commit to a variant key is refused until C3.** `CultInspectorEdit.Commit` - is a plain `UpsertAsync` (`CultInspectorModel.cs:266-282`); Q1a refuses a plain write at a - variant key (`CultCache.cs:2494-2496`). A drop into a variant's inspector therefore reports - that refusal until C3 lands. The release waits for C3, so no shipped Studio shows it, but - the operator click-through must run after C3. -- **F14. Studio `Duplicate` of a variant makes a flattened plain copy** - (`CultCacheStudioWindow.cs:349-353` clones the resolved `Document`). Not this campaign's - defect; C3's `CreateVariant` sits beside it. -- **F15. Element ids (C2a, `hands/variants-c2a`, WIP `df74f8c`).** No grouping interaction: - grouped members are top-level scalars and refs; ids live on list elements. Drops write a ref - into the edit document; ids are untouched and minted on write for new elements; C3's diff - keys overrides on them. Two consequences: - - After C2a, registration refuses a document type whose object-list element lacks a - `[CultElementId]` (`CultCache.cs` descriptor build, WIP). Cut 1's new fixtures carry no - object lists. - - C2a does not hide or lock the id member, so Studio shows it as an editable string. - Hand-editing one retargets overrides. That belongs to C3 (make `[CultElementId]` members - read-only in the model), not here; flagged for Self. -- **F16. The 1.0.60/1.4.0 release shipped** (`0155ba8`, tags on `45c2f40`) and - `codex/asset-guid-attribute` is merged. It already carried the 302,080-byte native bridge, - so the 09-17 Q5 drift is spent. Native source has moved since (`git diff --stat 45c2f40 main - -- native/`: 5 files, +840/-118), so this release rebuilds the bridge again. The v1 exports - the managed side imports (`src/GameCult.Mesh.Quic.Native/*.cs:263-276`) remain at - `cultmesh_quic_native.cpp:1405-1465`. -- **F17. Aetheria's schema moved.** `ItemData.Manufacturer` is gone (slot 3 retired, - `ItemData.cs:281-282`). The manufacturer relation now lives on `FactionProductData` - (`FactionProduct.cs:27`, `CultRecordRef Manufacturer`; `:24`, - `CultRecordRef Design`). Aetheria pins `cultlib-unity-v1.0.60` / - `caching-unity-v1.4.0` / `cultmath-unity-v0.2.4` (`Packages/manifest.json:54-56`), - `CultLibRevision` `45c2f40` and a separate `CultMathRevision` `6d5e209` - (`Directory.Build.props:6,10`). -- **F18. After C2a, Aetheria cannot take the new CultLib without its element-id sweep** - (`List`, `List`, `List`, `List`, - `List`, `List`, among others). So the Aetheria re-pin belongs to - variants C4, and this campaign's Cut 4 shrinks to annotations and tests on top of it. - -## Identity and authority - -| Thing | Owner | Lifecycle | Persisted | -|---|---|---|---| -| `[CultInspectorGroupBy]` | consumer source | compile time; nearest declaration wins; no arguments opts out | source only | -| `CultInspectorGrouping` (resolved) | model | cached per listed type like `_shapes` (`CultInspectorModel.cs:296`) | no | -| `CultInspectorRecordGroup` tree, node ids, labels | model | rebuilt each `OnGUI`, as `RecordCandidates` is (`CultCacheStudioWindow.cs:167`) | no | -| Group foldouts (node id → bool) | window | window memory; cleared by `CloseStore` (`:318-327`) | no | -| Drag payload `{ Model, Key }` under `"GameCult.CultCacheStudio.Record"` | Studio list (source) | one drag | no | -| Drop acceptance | `IsRecordCandidate` plus I9 gates | per `DragUpdated`/`DragPerform` | no | -| Created-in-group object | model makes; `CultCache` admits via `UpsertAsync`, as `Add` | new plain record | store, on `Save` | - -## Forks - -Q1-Q5 are ruled (header). Two remain. - -### Q6. RULED A (2026-09-30). How does the grouped list show a variant? (blocks nothing in Cuts 1-2; shapes C3's row lowering) - -Mechanical defaults, not asked: a variant groups by its resolved values (I10), so a variant -that inherits its base's `HullType` sits in the base's node and moves when the base changes; -create-in-group always makes a plain record; C3's `CreateVariant` needs no preset because the -variant inherits its base's group; a variant is a valid drag payload. - -- **A. Flat by resolved value.** A variant is an ordinary row in its resolved group. C3 adds a - row marker ("variant of Longinus"). Which of its grouped values are overridden shows in the - inspector through C3's per-member marker, not in the tree. -- **B. Nested under the base.** Inside a leaf, a variant row sits indented under its base's row - when both share the leaf; otherwise it stands alone with the marker. -- **C. A's rows plus an inherited/overridden glyph on the row** for the grouped members. -- **Recommendation: A.** I2 stays one rule, the tree has one shape, and the base link already - lives in C3's inspector. B makes the tree's shape depend on two relations, and splits - whenever a variant overrides a grouped value. C duplicates C3's inspector marker. - -### Q7. RULED B (2026-09-30). Which Aetheria types group, and by what? - -Candidates on `codex/fire-control-12` (enums unless noted; `[Flags]` enums excluded): - -| Type | Member | Anchor | Note | -|---|---|---|---| -| `SimpleCommodityData` | `Category` (`SimpleCommodityCategory`) | `ItemData.cs:310` | legacy parity | -| `CompoundCommodityData` | `Category` (`CompoundCommodityCategory`) | `ItemData.cs:338` | legacy parity | -| `GearData` | `Hardpoint` (`HardpointType`) | `ItemData.cs:454` | legacy parity; `WeaponItemData` inherits when listed under `GearData` | -| `WeaponItemData` | `WeaponType`; or `WeaponCaliber`, `WeaponRange` | `ItemData.cs:485`, `:482`, `:479` | re-declaration, applies when listing weapons | -| `HullData` | `HullType` | `ItemData.cs:519` | legacy parity | -| `FactionProductData` | `Manufacturer` (ref `Faction`), then `Design` (ref `CraftedItemData`) | `FactionProduct.cs:27`, `:24` | where "by manufacturer" now lives | -| `Loadout` | `Hull` (ref `HullData`) | `Loadout.cs:17` | optional | - -`ConsumableItemData`, `CargoBayData`, `DockingBayData`, `Faction`, `PersonalityAttribute` -have no groupable member worth a level. - -- **A. Legacy parity minus manufacturer:** commodities by `Category`, `GearData` by - `Hardpoint`, `HullData` by `HullType`. -- **B. A, plus `FactionProductData` by `Manufacturer` then `Design`, plus `WeaponItemData` by - `WeaponType`.** -- **C. B with `FactionProductData` by `Design` then `Manufacturer`** (the market-segment view: - who makes this design). -- **D. B plus `Loadout` by `Hull`.** -- **Recommendation: B.** It recovers everything the legacy window grouped, puts the - manufacturer view where the manufacturer now is, and gives the weapon list its own axis. - C is the better view if the operator authors products per design rather than per faction. - Product record counts are unprobed; Cut 4's Hands reports them. - -## Defaults (recorded, not asked) - -- **Labels:** enum `Enum.GetName`, else the number; ref `RecordLabel` of the candidate with that - key (for `Faction`, `Name`, not legacy `ShortName`); unset ref `None`; dangling `Missing `; - string its value or `(empty)`; integer invariant string; bool `False`/`True`. -- **Order within a level:** enums by underlying value; refs `None` first, then label - (`OrdinalIgnoreCase`, then key), `Missing` last; strings `OrdinalIgnoreCase` then ordinal; - integers numeric; `false` before `true`. -- **Groupable kinds:** String, Integer, Bool, Enum (not `[Flags]`), RecordRef. Refused with a - notice and a flat list: any other kind (Float included), a `[Flags]` enum, `Hidden`, - `IsReadOnly`, a name that is not an inspector member of the listed type, a name given twice. -- **Global types are never grouped.** -- **Foldouts:** window memory, collapsed by default, cleared by `CloseStore`. Create-in-group, - `Add` and `Duplicate` expand the path to the new selection. No `EditorPrefs`/`SessionState`. -- **Create into a `Missing ` node** presets the dangling key (keeps I5). -- **A drop of the current key is rejected** (no no-op commit). -- **Variants:** see Q6's defaults and I10. - -## Sequence with document variants - -| Cut | Files | Overlaps grouping? | -|---|---|---| -| Variants C2a (element ids, in flight) | `CultCache.cs`, new `CultElementIds.cs`, Mesh/Geometry documents, `ElementIdTests.cs` | no | -| Index cut (`[CultIndex]` means unique; queued behind C2a) | `CultCache.cs`, Mesh/Geometry | no | -| Variants C2b (nested paths; inferred from the brief, not yet written in the variants map) | `CultCache.cs`, `CultVariants.cs` | no | -| **Grouping Cuts 1-2** | `CultInspectorModel.cs`, `CultInspectorAttributes.cs`, `CultInspectorModelTests.cs`, `CultCacheStudioWindow.cs`, `CultCacheStudioDrawers.cs`, Studio README | — | -| Variants C3 (Studio edits the delta) | `CultInspectorModel.cs` (edit, commit, diff, `CreateVariant`, `Rebase`), window (variant controls beside `Duplicate`, row marker), drawers (per-member marker and revert), `CultInspectorModelTests.cs` | **yes: all five grouping files** | - -**Order:** grouping Cuts 1-2 now, in parallel with C2a, the index cut and C2b (no shared -files). Grouping merges to `main` before C3's Hands starts; C3 needs C2b anyway. C3 then -rebases onto grouping and lowers its row marker inside the grouped row drawing that Cut 2 -writes. Then **one release (Cut 3)**, then one Aetheria pass (variants C4 plus Cut 4). - -Where they touch: `DrawRecords` rows (`CultCacheStudioWindow.cs:180-185`; grouping rewrites, -C3 marks); the toolbar (`:142-157`; C3 adds variant buttons beside `Duplicate`, grouping -leaves it alone); `DrawRecordRef` (grouping adds the drop target; C3 marks at the member -level in `DrawValue`, not inside it); `CultInspectorModel.cs` (disjoint methods); -`CultInspectorModelTests.cs` (disjoint fixtures; name grouping fixtures `Group*`). - -**One release commit:** Cut 3 below is the single release for both campaigns. The variants -map's C3 line "Then tag `caching-unity-v1.5.0`" should point here instead of owning a second -release step (proposal for Self). - -## Cuts - -Hands for Cuts 1-2: branch `hands/studio-grouping` in a fresh worktree off `origin/main` -(Self picks the path, e.g. `C:/wsNN-grouping`). `F:\Projects\CultLib-studio-grouping` holds the -superseded docs-only `codex/studio-grouping`; do not reuse it. Do not touch the main checkout. -Mutation testing is Stryker.NET on the cut's diff; there are no hand-applied mutations. - -### Cut 1. CultLib model: declaration, tree, candidate and label authority (~150k tokens) - -- **Delete and collapse first:** - - `CultInspectorModel.cs:519`: the inline `target.IsInstanceOfType(record.Document) && - record.Key.Value.Length > 0` in `RecordCandidates` (`:514-522`) moves into - `IsRecordCandidate`; `RecordCandidates` filters through it (I6). - - `:36` and `:209`: `member.GetCustomAttributes(true)` → `Attribute.GetCustomAttributes(member, true)` (F5). -- **Add in `CultInspectorAttributes.cs`**, before `CultInspectorDrawerAttribute` (`:81-86`): - `CultInspectorGroupByAttribute(params string[] members)`, `AttributeUsage(Class, - Inherited = true, AllowMultiple = false)`, property `Members`, with a comment in the file's - style stating nesting, nearest-wins and opt-out. -- **Add in `CultInspectorModel.cs`** (or a sibling `CultInspectorGrouping.cs`, same namespace; - one file while it stays under ~800 lines; it is 645 now): - - `CultInspectorGrouping { Type ListedType; IReadOnlyList Members; string? Notice }` - (members empty when `Notice` is set). - - `CultInspectorRecordGroup { string Id; string Label; int Depth; IReadOnlyList Values; - IReadOnlyList Children; IReadOnlyList Records; int Count }`. - - `GroupingOf(Type listed)`, cached like `ShapeOf` (`:324-333`): names resolve against - `MembersOf(listed)` (`:335-339`); validation per Defaults. - - `GroupRecords(Type listed, IEnumerable records)`: partitions - `RecordCandidates(CultRecordRef)` by value identity, reading each record's - `Document` (resolved, I10). - - `CreateInGroup(Type listed, CultInspectorRecordGroup group, out string? notice)`: - `CreateElement(listed, listed, out notice)` (`:387-398`), then `SetValue` each level; refs - via `CreateRecordRef` (`:524-529`). `ArgumentException` for a node not of `listed`'s current - grouping. - - `IsRecordCandidate(Type refType, CultStoredDocument record)`. - - `RecordRefLabel(Type refType, object? value, IEnumerable records)`: `None`, - `RecordLabel` (`:315-319`), or `Missing `. -- **Tests** in `tests/GameCult.Caching.Tests/CultInspectorModelTests.cs` (476 lines; fixtures - `:299-476`). New fixtures `GroupBase` (abstract) / `GroupLeaf : GroupBase` `[CultDocument]`, - a re-declaring type and an opting-out type; members: enum declared out of alphabetical - order, `CultRecordRef`, string, float, `[Flags]` enum, hidden int, read-only int. - No object lists (F15). Behaviours, one test each: - 1. levels nest in declared order, not slot order; - 2. a `GroupLeaf` listed under `GroupBase` groups by the base declaration (I3); - 3. nearest declaration wins; an empty declaration opts out; - 4. leaves partition the candidates over unset refs, dangling refs and undefined enum values (I2); - 5. two refs sharing one `[CultName]` give two nodes; `None` first; `Missing k` last (I4, I7); - 6. enum nodes order by value; - 7. create at depth 2, upsert into a temp single-file `CultCache`, regroup: same node id (I5); - 8. each invalid declaration (unknown, float, `[Flags]`, hidden, read-only, duplicate) gives a - notice and a flat tree, no throw (I8); - 9. `IsRecordCandidate` accepts a subtype, refuses a sibling and an empty key, and - `RecordCandidates` equals the records it accepts (I6); - 10. a base abstract property's `[CultInspectorLabel]` reaches the override's metadata (F5); - 11. **a variant groups by its resolved value**: base with `Enum=A`, variant overriding it to - `B` via `UpsertVariantAsync` and `Override`, regroup: the variant is in `B`; change the - base to `C` with the variant not overriding it, regroup: it follows to `C` (I10). -- **Verify (Yggdrasil):** - `ygg-verify.sh /f/Projects/CultLib dotnet 'dotnet test tests/GameCult.Caching.Tests -c Release'`, - then - `ygg-verify.sh /f/Projects/CultLib dotnet 'dotnet tool install -g dotnet-stryker && export PATH="$PATH:$HOME/.dotnet/tools" && cd tests/GameCult.Caching.Tests && dotnet stryker --project GameCult.Caching.csproj --since: > /tmp/s.log 2>&1; rc=$?; tail -60 /tmp/s.log; exit $rc'`. - Survivors triaged by name in the report. -- **Ledger:** model ~+170, attributes ~+15, tests ~+230; one predicate collapsed. - -### Cut 2. CultLib Studio: lower the tree, create in group, drag source, drop target (~150k tokens) - -- **Delete first:** - - `CultCacheStudioDrawers.cs:266` inline `"Missing " + key : "None"` → - `Model.RecordRefLabel(type, value, Records)` for `names[0]` when `index == 0`. - - `CultCacheStudioWindow.cs:180-185` flat `foreach` → tree drawing. -- **Window** (`CultCacheStudioWindow.cs`): - - fields (`:13-30`): `Dictionary _groupFoldouts` (ordinal), `const string RecordDragKey`; - `CloseStore` (`:318-327`) clears them. - - `DrawRecords` (`:135-189`): `GroupingOf(_selectedType)`; a `Notice` shows a warning - `HelpBox`. Draw `GroupRecords(_selectedType, _records)` recursively: foldout `Label (Count)` - indented by `Depth`; an expanded node draws children, leaf rows, then a `Create` mini - button under `Add`'s gate (`:141-146`). The root is not a foldout; `Add` stays root create. - - row: reserve the rect with `GUILayoutUtility.GetRect`, handle `MouseDrag` there first - (`PrepareStartDrag`, `SetGenericData(RecordDragKey, new CultCacheStudioRecordDrag(_model, key))`, - empty `objectReferences`, `StartDrag(label)`, `Use()`), then `GUI.Toggle(rect, ...)` (F11). - Keep the existing `` suffix logic (`:182-183`) in one place for C3 to extend. - - `AddInGroup(node)` mirrors `Add` (`:340-346`) through `Run` (`:369`); selects the key and - expands the node's id prefixes. `Add`/`Duplicate` reveal the new record's path (a - `_revealKey` resolved next frame is acceptable). -- **Drawers** (`CultCacheStudioDrawers.cs`): `internal sealed class CultCacheStudioRecordDrag - { Model; Key }`. In `DrawRecordRef` (`:254-280`), capture the `HorizontalScope` rect; on - `DragUpdated`/`DragPerform` inside it with our payload: `Copy` when `GUI.enabled`, same - `Model`, key differs, record in `Records`, and `IsRecordCandidate`; else `Rejected`. Leave - foreign drags alone. On perform: `AcceptDrag`, `next = key`, `GUI.changed = true`, `Use()`; - return through the existing `:279`. -- **README** (`src/GameCult.Unity/Assets/Caching/README.md`): grouping, create in group and - drag-to-reference after the paragraph at `:30-34`; the grouping API, `IsRecordCandidate`, - `RecordRefLabel` under `## Inspection Model` (`:39`). -- **Verify:** - - Cut 1's Yggdrasil test command still passes. - - Negative greps (each empty): - `rg -n "IsInstanceOfType" src/GameCult.Unity/Assets/Caching/Editor | rg -v "assetType.IsInstanceOfType"` (I6; `:215` is the asset-GUID sub-asset check, not candidacy); - `rg -n "\"Missing \"" src/GameCult.Unity/Assets/Caching/Editor` (I7; the union drawer's - `"None"` at `:393` is not a ref label, so grep `Missing` only); - `rg -n "SetValue|GetCustomAttribute|CultInspectorGroupBy" src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioWindow.cs` (I1); - `rg -n "EditorPrefs" src/GameCult.Unity/Assets/Caching/Editor` shows only `LastPathKey`. - - **Starfire, one job, editor closed:** the F10 compile gate, `dotnet build` of a scratch - netstandard2.1 project over the worktree's `Assets/Caching/Editor/*.cs`. 0 errors. -- **Ledger:** window ~+80, drawers ~+30, README ~+20; two inline decisions deleted. - -### Cut 3. Joint release: cultlib Unity 1.0.61, Studio 1.5.0 (after grouping and variants C3 merge) - -- **Base:** `main` with grouping Cuts 1-2 and variants C1-C3 merged; a clean worktree. -- **Files:** `unity/org.gamecult.cultlib/package.json:4` `1.0.60` → `1.0.61`; - `src/GameCult.Unity/Assets/Caching/package.json:4` `1.4.0` → `1.5.0` and `:13` dependency - `1.0.61`; `CHANGELOG.md` entries in both packages (grouping, create in group, drag to - reference; variant editing from C3); plugins via - `powershell -File scripts\build-unity-package.ps1 -UpdateTemplate` (Windows: MSVC and the - pinned MsQuic digests; its version check is at `:168-170`). -- **Tags:** `cultlib-unity-v1.0.61`, `caching-unity-v1.5.0`; no `cultmath-unity` tag. -- **Verify (Starfire, one job at a time):** - - byte check: rerun the script; `git status --porcelain unity/` is empty; - - expected diff: `GameCult.Caching.dll/.pdb` changes, and `gamecult_mesh_quic_native.dll` - changes (F16); `msquic.dll` should not (same pinned digest). Anything else stops the release; - - API present: reflect over the tracked `GameCult.Caching.dll` for - `CultInspectorGroupByAttribute`, `GroupRecords`, `CreateInGroup`, `IsRecordCandidate`, - `RecordRefLabel`, and C3's `CreateVariant`; - - native ABI: `dumpbin /exports` lists `cultmesh_quic_open`, `_state`, `_poll`, `_error`, `_close`; - - `git ls-remote --tags origin` lists both tags. - -### Cut 4. Aetheria: grouping annotations (inside variants C4's pass; blocked on Q7) - -- **Where:** the Aetheria branch and worktree Self opens for variants C4. Never - `F:\Projects\Aetheria`'s working tree. C4 owns the pin bump (`Directory.Build.props:6`, and - `Packages/manifest.json:54-55` to `#caching-unity-v1.5.0` / `#cultlib-unity-v1.0.61`; - `:56` cultmath unchanged; `packages-lock.json` follows) and the element-id sweep (F18). This - cut adds only: - - Q7's `[CultInspectorGroupBy(...)]` class attributes (recommendation B: five declarations); - - `tests/Aetheria.Shared.Tests/StudioGroupingTests.cs` (xUnit): `GroupingOf` over - `CultCacheMessagePack.CreateInspectorModel(registry)` names exactly the declared members - per annotated type with no notice; `GroupingOf(typeof(WeaponItemData))` follows Q7; every - registered document type's `GroupingOf` has a null notice. -- **Verify:** - - headless (Starfire, one job; the revision check needs `CultLibRoot` clean at the release - SHA and `CultMathRoot` clean at `6d5e209`): - `dotnet test tests\Aetheria.Shared.Tests -p:CultLibRoot= -p:CultMathRoot=`; - - Unity 6000.3.24f1 batchmode compile, editor closed: - `-batchmode -nographics -quit -projectPath -logFile `; 0 errors, - both new tags resolved. -- **Operator only, in Aetheria's Studio against a copy of `GameData/Aetheria.cc`:** - - each annotated type shows the declared levels; `GearData` includes `` rows; - - create in a node: the record lands there and is selected; - - drag a `Faction` onto a `FactionProductData.Manufacturer`: accepted; drag a gear record onto - it: rejected; - - drag a `PersonalityAttribute` onto a `DemandProfile` key that already exists: refused - through the notice; - - read-only open: every drop rejected; - - a variant (once C4 authors one, e.g. `LonginusX`) sits in its resolved `HullType` node, - and a drop into its inspector writes an override (C3), not a refusal; - - close without edits: store bytes unchanged. - -## Subtraction ledger - -Added: one attribute, two model types, five model methods, ~11 CultLib and ~3 Aetheria tests, -~110 Studio lines, five annotations. Collapsed: the candidate predicate (now one method, reused -by drop), the ref label strings, the property-inheritance miss (F5). Not added: selectors, -registries, `EditorPrefs` keys, runtime ports, packages, targets, daemons, a second release. -Build footprint: `GameCult.Caching` and its tests (Yggdrasil); the F10 compile gate, the -package script (all tracked plugins including the native bridge) and Aetheria's headless and -batchmode checks (Starfire; Windows is the target for the editor plugin and win-x64 bridge). - -## What the 2026-09-17 map had wrong as of `feeced8` - -- Cut 4 grouped `GearData` by `Manufacturer`; `ItemData.Manufacturer` no longer exists (F17). -- Q5's native-plugin drift was spent by the 1.0.60 release (F16); the Cut 3 "rebase onto - `codex/asset-guid-attribute`" note is obsolete (merged). -- Cut 4 re-pinned Aetheria on its own; after C2a it cannot, without the id sweep (F18). -- Aetheria branch `codex/cultcache-cutover`, props `:5`, manifest `:53-55`, cultmath `v0.2.3`: now - `codex/fire-control-12`, `:6`, `:54-56`, `v0.2.4` with a separate CultMath pin. -- Negative greps for `IsInstanceOfType` and `"None"` would fail on unrelated lines (`:215`, `:393`). -- Hand-applied mutations per test are retired; Stryker.NET on the diff replaces them. -- Moved anchors: `RecordCandidates` `:512-520`→`:514-522`; `_shapes` `:298`→`:296`; `RecordLabel` - `:313-317`→`:315-319`; `CreateElement` `:385-396`→`:387-398`; drawer attribute `:80`→`:81-86`; - `DrawRecordRef` `:195-221`→`:254-280`, label `:207`→`:266`; `ReplaceKey` routing - `:243-251`→`:303-309`; test fixtures `:380-459`→`:299-476`; README `:36-39`→`:30-39`; build-script - version check `:150-155`→`:168-170`. -- Worktree `F:\Projects\CultLib-studio-grouping` is now the superseded docs branch; Hands uses a - fresh one. diff --git a/docs/typescript-quic-realtime-cut.md b/docs/typescript-quic-realtime-cut.md deleted file mode 100644 index ebef2893..00000000 --- a/docs/typescript-quic-realtime-cut.md +++ /dev/null @@ -1,2771 +0,0 @@ -# TypeScript QUIC Realtime Plane Cut Map - -Date: 2026-09-16 - -Status: Imagination output for the target in -`docs/typescript-quic-realtime-target.md` (operator-accepted 2026-09-16). No -code in this document has been written. Nothing here is committed by -Imagination; the root agent commits. Q5, Q6 and Q7 were ruled the same day -(section 13, Rulings); Q5 was then re-ruled from A to C: no registry in this -campaign, registry publication parked for a later pass by choice. Cut 6, -section 5.4, the binary-resolution and dependency lines of Cut 4, and Cut 7's -ledger are remapped under C (landed 2026-09-16, same day). Q8 is ruled: -the built binaries are committed to the tree (section 11.2). No operator -question is open. **Cut 1 landed** at `e234a64` (shared verifier, tests, -C#-written vectors), `66286b9` (browser copy deleted, −143 net) and -`062a5c6` (mutation runner; twelve killed, one equivalent), then three -Soul passes and three fix batches; **closed 2026-09-16**, three residuals -landed at `c64a2da`, `6c933d0`, `fe2555d`. - -**Cut 2 landed** at `6cbc73f` (the codec, 178 lines; C#-written and -TypeScript-written frame vectors, each decoded by the other side, the C# -test reading both with the reference alone) and `ae29910` (37 entries as a -third runner target; 72 of 72 across the three targets killed, one -equivalent named). Closed 2026-09-16 after Soul's pass and one fix batch. -Hands' discrepancies, kept: the -65,535-byte identity and 64 MiB payload ceilings recorded as a build rule -plus the digest of the whole encoding rather than half a megabyte of -literal text; the Body has five refusal messages where the spec listed six -(negative length and length-sum mismatch share one); three refusals added -that C#'s type system gives free, among them **a real divergence found by -the test: `TextDecoder` deletes a leading U+FEFF where -`Encoding.UTF8.GetString` keeps it, and U+FEFF is not .NET whitespace, so -such an identity passes `Validate` and reaches the wire**, closed with -`ignoreBOM`; C# sums lengths under `checked` and TS covers the overflow -with its length refusal, unreachable from either encoder. Residual 1's -exact-equality verification could not hold as written: applying the -pre-check leaves eleven stricter-than-C# spellings, all the safe direction, -pinned by name, because exact agreement needs .NET's registered-scheme -table. The no-control-byte test caught a literal U+001F the Write tool put -into its own introducing comment. - -Two items for Self's ruling, both taken: **`npm run test:ts` and `build:ts` -have been broken since `8cb3b72`** (the scoped rename; Hands first -attributed it to `8dd5a45`, which only bumped a version, and Soul -corrected it) because `scripts/run-typescript-workspaces.mjs:9` names the -old unscoped `cultcache-ts`; one-word fix, lands in Cut 2's fix batch. -**The runner is `scripts/mutate-cultmesh.mjs`** since `65f2b66`'s batch -(renamed from `mutate-cultmesh-authority.mjs` once it carried a codec -target); every reference below that names the old path is history. - -**Soul's pass on Cut 2** (Fable; probes `probe-cs/`, `parity.mjs`, -`symmetric.mjs` and others in the session scratchpad) held byte parity in -both directions over its own thirty-nine encode specs and thirty -hand-built byte strings: identical digests, identical refusal messages, -identical U+FFFD counts on invalid UTF-8, the byte-order mark round trip, -all four digest ceilings regenerated from their build rules on both sides, -the five decode refusals in the same order, and every symmetric two-site -mutant dead. **Cut 2 does not close as promised**, on three small findings -in the fix batch: - -- **The mutant Hands called equivalent is not.** A 37-byte frame whose - identity lengths cancel a negative payload length sums to exactly 37 and - decodes to blank identities where C# refuses. The clause is right; nothing - defended it. Medium. -- **The decoded payload aliases the frame when the input is a Node - `Buffer`**, because `Buffer.prototype.slice` is a view; the test used only - a `Uint8Array`. The same trap the repo recorded for `verifyP256`. Medium - for the bridge, nil today. -- **Neither half of the frame parity runs in CI**; a C# codec change leaves - everything green. The Cut 1 scar, repeated. Medium. - -Recorded, not defects: the reference encoder writes delivery values its -own decoder refuses (a cast on the enum), where TypeScript refuses at -encode, the stricter side; an overflow near `int.MaxValue` is reachable -from hostile bytes and refused on both sides with different words; the -runner's sentinel detects only the last mutant by design, and the sidecar -repair names the mutation a dead run was inside. - -**The Cut 2 fix batch landed** (Opus) at `65f2b66` (the aggregate scripts -reach every workspace again; breakage confirmed at `8cb3b72`), `9a4c939` -(the payload copy is `new Uint8Array(bytes.subarray(…))`, because Soul's -prescribed `Uint8Array.prototype.slice.call` returns a `Buffer` from a -`Buffer` through `@@species` and fails Soul's own assertion; three crafted -negative-length frames refused; the false equivalence note gone), -`6ed0ff7` (both halves of the frame parity in CI on both OSes) and -`7a0749b` (the eleven stricter spellings named for what they are: three -backslash, two missing-authority, four host repairs, two `URL` throws; the -same slip in `isProtectedEndpoint`'s own comment fixed). 75 of 75 -mutations killed across three targets. Gap, not a workaround: `npm run -test:ts` still cannot go green on the workstation because one -`cultcache-ts` test spawns `python`, the Store alias stub here; `py -3` -has msgpack; pre-existing since `8cb3b72` and outside every cut. - -**Cut 3 landed** (Opus) at `6a091ef` (the bridge: 440 → 1,168 lines of -C++, a two-role event-queue C ABI v2 with twelve exports, the five v1 -exports kept as a Windows-only Unity shim) and `23dc093` (CMake for both -platforms, `scripts/build-quic-native.sh`, README). Host and target: -win32-x64 built and smoked on the workstation (MSVC 14.44, CMake 4.3.2); -linux-x64 built and smoked in the workstation's Docker `debian:13`, which -is the development loop and **not release-grade**; its release build is -Cut 6's Actions job. Fifteen-check FFI smoke 15 of 15 on both; the v1 gate -3 passed, 1 skipped by design; ten native mutations killed under two green -controls; exports 17 on Windows and exactly 12 on Linux with hidden -visibility; the deb digest and header sizes match the probe ledger; -manifests with SHA-256s beside every binary. Two bugs the old shape never -exercised, found by hanging tests and now mutation-pinned: the v1 -certificate pin must be attached before `ConnectionStart` (the handshake -reaches the certificate event on a worker thread at once on loopback), and -teardown must close connection handles itself because `RegistrationClose` -blocks until every child is closed. Spec discrepancies kept: `ntdll` on the -Windows link list; the deb carries no licence text, so the script copies -the committed MIT text; the deb ships no unversioned `.so` symlink, so the -script creates one; four Linux runtime libraries are load-bearing and now -in the README. Soul in flight. - -**Q9, the one fork Cut 3 hit, ruled A on 2026-09-16 (detail below).** The -pinned Windows MsQuic is the Schannel flavour, and Schannel does not -implement `QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12`: `ConfigurationLoadCredential` -returns `QUIC_STATUS_NOT_SUPPORTED`, so the provider role cannot open a -listener on Windows under it. The identical bridge DLL passes the full -smoke beside the OpenSSL 2.5.9 `msquic.dll` (SHA-256 `FEE9A664…`, -4,181,856 bytes). Linux is unaffected: Microsoft ships only the OpenSSL -build for Debian. -- **A. Pin OpenSSL on Windows.** One credential type on both platforms, - provider role works, ABI unchanged. Costs: the package URL and digest in - `build-quic-native.ps1`; the committed Windows `msquic.dll` grows from - 537 KB to 4.18 MB, so Q8's tree price becomes about 11.6 MB; the Unity - plugin's `msquic.dll` diverges until the operator's unscheduled rebuild. - The v1 client path works under either flavour. -- **B. Keep Schannel and convert PKCS12 to a certificate context via - `PFXImportCertStore`.** Keeps the pin and the Unity DLL; puts about - forty lines of Windows crypto in the shared provider path, breaking - "runtime-neutral" and the cut's own negative grep. -- **C. Providers Linux-only.** Cheapest; kills the Windows FFI smoke the - cut requires and the Windows provider lanes Cuts 4 and 5 need. -**Recommended: A. Ruled A, 2026-09-16** ("A"). The Windows pin moves to -`Microsoft.Native.Quic.MsQuic.OpenSSL` 2.5.9 in `build-quic-native.ps1` -with its digest; Q8's committed tree price is restated at about 11.6 MB; -the Unity plugin's `msquic.dll` stays Schannel until the operator's -rebuild, recorded in section 15, and the v1 client path is verified under -both flavours. The spec's "one credential type for Schannel and OpenSSL" -sentence in section 8 is withdrawn: the header declares the type and only -the OpenSSL provider implements it. *(Digest correction, Soul: `FEE9A664…` -is the NuGet zip, 19,179,817 bytes; the DLL is `c17c6581…`, 4,181,856 -bytes. Hands' report and this map's first record had the zip's digest on -the DLL.)* - -**Soul's pass on Cut 3** (Fable; `soul3-*` in the session scratchpad, -including a C consumer of the ABI that is not koffi and an ASan build of -the bridge) held the Cut 2 fixes entire, the export counts, the greps, the -clean-container Linux rebuild byte-identical to the manifest, the four -load-bearing Linux runtime libraries, the stress at 16,384 frames with -zero lost or reordered on both targets, both hang mutations dying by -timeout, and both of Q9's load-bearing claims (Schannel refuses the PKCS12 -credential with not-supported; the identical bridge passes 15 of 15 beside -the OpenSSL DLL; the v1 gate passes beside either). **Cut 3 does not -close.** Found, in a fix batch (Opus): - -- **A use-after-free between the host thread and MsQuic's worker** (high): - the id lookups drop the lock and return a raw pointer, the worker frees - the object on shutdown-complete, and the host's next send dereferences - it. One in three release runs crashes on each target under an ordinary - peer-driven stream teardown; three of three under ASan. The fix is an - object kept alive across the call, not a guard. -- **Event 7 is not one per frame send**: the kind-byte send completes as - its own event with nothing to tell it apart, six events for five frames. -- **The initiator of a shutdown never learns its connection is gone**: - MsQuic gives it only shutdown-complete, which the bridge swallows. -- **Return codes invert by platform**: `QUIC_STATUS` is a negative HRESULT - on Windows and a positive errno on POSIX, so a Linux host checking for a - negative code misses every failure; the README promised negative. -- **`runtime_close` wakes a blocked poller into freed memory**; the wake - makes the pattern look supported. Close must quiesce. -- **No host-facing header**: the struct layout and the twelve signatures - live in the `.cpp` and the spec; Soul wrote its C consumer from the spec. -- Smaller: an unbounded per-connection trace string from the v1 - diagnostics; a listener config failure leaving a dead handle in the map; - a stream emplaced after start; a partial listener open leaving an entry; - a null-payload poll consuming the event; the DLL importing the VC - runtime with the README silent. - -The Q9 pin change and the manifest's stale commit line ride in the same -batch. - -**The Cut 3 fix batch landed** (Opus; its commits are signed Opus 5, which -is what ran) at `9a01321` (`include/cultmesh_quic_native.h`, 305 lines: -the event struct with offsets static-asserted, every export, the id and -return-code conventions, the lifetime and threading rules; the koffi smoke -and Soul's C consumer are checked against it), `47aa7b6` (objects kept -alive across the call that names them: one `shared_ptr` per map entry, a -lookup returns a reference the caller holds for the whole MsQuic call, the -worker drops only the map's reference, the handle closes exactly once in -the destructor under the exchange guard, a stream owns its connection so -close order is structural; `runtime_close` marks closing, wakes pollers, -waits for `active_calls == 0`, and refuses any later call), `8c41896` (the -ABI gaps: one event 7 per frame send with `Canceled` distinct; event 4 -uniform at completion for every path, the initiator included, published -after the id stops resolving; return codes mapped negative on every -platform with `cultmesh_quic_last_status` for the raw status, thirteen v2 -exports now; a null payload with capacity refused; a stream in the map -before start; failed listeners leaving nothing) and `c729dff` (the OpenSSL -pin with both digests, `/MT` so the DLL imports no CRT, manifests with a -real commit line). Host and target: win32-x64 on the workstation, 20 of 20 -smoke checks, the C consumer's seq, cancel, stress (16,384 frames, zero -lost or reordered), race 400×20 with zero crashes, closerace 20×512, the v1 -gate 3 passed 1 skipped, 18 exports; linux-x64 in the workstation's Docker -`debian:13`, development loop only, the same set plus ASan clean on every -mode, 13 exports. 22 of 23 mutations killed under two green controls. -**L3 survived**: deleting the quiesce wait cannot be caught, because the -wake happens under the lock and every poller drains before teardown; right -by construction and by reading, unfalsifiable by this harness. Soul rules -whether that stands or a dev-only probe asserts the invariant. Open after -the batch: the Unity plugin's `msquic.dll` is still Schannel until the -package build runs; the Schannel dependency cache is orphaned; the v1 -return-code values changed while the zero-versus-nonzero contract held. -Bridge 302,080 bytes / `f4c5f644…` (win32-x64, static CRT), 108,128 / -`61d5b96e…` (linux-x64). - -**Soul's second pass on the fix batch** (Fable; `soul3b-*` in the session -scratchpad, its C consumer extended with close, re-entry and completion-race -scenarios, ThreadSanitizer and AddressSanitizer on linux-x64 in the -workstation's Docker `debian:13`). Held: the lifetime model, race 400×20 with -zero crashes on both targets and ASan clean on every mode; 16,384 of 16,384 -frames under stress with none lost or reordered; one completion event per -frame with cancellation distinct; the completion event uniform at the end of -every connection, with its mutant killed on both targets; negative return -codes everywhere; the null-payload refusal; the header compiling clean as -both C and C++ under maximum warnings on two compilers with no padding and -no implicit conversion; no C runtime import; the OpenSSL pin re-downloaded -and both digests equal; the version-one gate green beside either flavour of -the library. **The cut does not close at `c729dff`.** Findings: - -- **The quiesce is one line short, and it is a use-after-free.** `~CallScope` - decrements the call count under the lock, releases it, and only then - notifies. The closer's predicate is already true, so if that last poller is - preempted in between, the runtime is destroyed and the notify lands on a - freed condition variable. ThreadSanitizer caught it on the **unmutated** - bridge, so the previous batch's model was right in shape and wrong by one - line. Confirmed, medium, and the blocker. -- **The header oversells the return code.** It is portably negative, not - portably meaningful: the same failure is −12032 on Windows and −1126 on - Linux, and the Windows mask collides two distinct statuses onto −1002. The - sign is the contract; the magnitude is diagnostic. Low, wording. -- **The previous batch's own README claims a rebuild reproduces the committed - Unity plugin.** It does not: that plugin is the older Schannel build at - 40,448 bytes with a C runtime import, against 302,080 bytes without one - today. Low, and exactly the stale-artifact trap. The current build does - reproduce itself. -- **A Windows status sign-extends in diagnostics**, printing sixteen hex - digits. Trivial. - -**L3 is ruled: it becomes killable, it is not accepted as unfalsifiable.** -Soul established the survival was a harness limit rather than a proof of -correctness. Under AddressSanitizer with pollers blocked mid-copy of large -frames, deleting the quiesce wait hit use-after-free four times in -forty-eight attempts, while the release scenario never observed it. So a -dev-only assertion now aborts when the call count is non-zero after the -quiesce block, which fires deterministically with enough blocked pollers and -makes the mutation die by the existing scenario; the sanitizer run stays as -the second check. That assertion does not cover the defect above, where the -count has already reached zero. **Two mutations are honest only on the -sanitizer target:** the pop-then-copy one and the closed-handle one survive -on Windows release and kill three times of three under AddressSanitizer, so -the entries table must say which target each is honest on. A mutation whose -kill depends on the target is not killed until it runs there. - -**The second fix batch landed** (Opus) at `a4753f4` (the notify moves inside -the gate), `dc9e12c` (a Windows status stops sign-extending in diagnostics), -`53d5929` (the header says the return code is portably negative, not portably -meaningful), `19d8d5c` (the readme says the committed Unity plugin is the -older build), `f4186e7` (a committed scenario runner for the runtime -lifetime) and `180917d` (four native entries, each naming the target it is -honest on). - -**The discovery that outweighed the fix: there was no committed native -harness at all.** Every native mutation kill this cut had reported came from -consumers and scenarios that lived in agent scratchpads and no longer exist. -The runner at the previous head carried three package targets and nothing -touching the bridge, and two entries could not even be re-marked because -their definitions were gone. Hands built what the brief had assumed already -existed. - -**Soul's third pass, 2026-09-17** (Fable). Held: the lifetime fix on both -targets, the reverted notify killed three of three by the thread sanitizer -and invisible to the address sanitizer, so its target marking is right; the -assertion killing the deleted wait deterministically on both targets and -absent from both shipped libraries and a fresh release build; two fresh -release builds byte-identical to the committed artifact; the return-code -numbers recomputed from the pinned headers; byte-exact restore on both hosts. -**Cut 3 still does not close**, and the reason is that the harness defending -the fix is weaker than the fix: - -- **The committed Linux path does not run as committed.** The sanitizer - configuration dies before the scenario starts, on the kernel's - address-space randomisation, and the runner swallows the child's standard - error, so a future agent sees a red control and no diagnosis. Soul reached - four of four kills only by disabling randomisation and relaxing the - container's seccomp profile, neither of which any committed file mentions. - The image came from a definition that is not in the repository, and the - documented development recipe cannot run the runner at all, since it - installs the C++ toolchain but not the JavaScript runtime. High for the - claim that the Linux kills are rerunnable. -- **The scenario does not test the rule it exists for.** Deleting the call - guard from the polling entry point, so host calls are not counted at all, - survived nine runs of nine across every configuration. The harness pins the - order of the count and the notify, not that anything is counted. -- **The scenario passes vacuously when nothing parks.** Inverting the timeout - comparison makes every poller return before the close begins, and the - scenario is green on all six configurations. -- **The bounded-wait loosening is pinned by a constant, not by the rule.** At - one millisecond it dies with as few as two calls outstanding; at fifty - milliseconds it survives on both targets. A bounded wait is the wrong - contract at any bound. -- **A skipped entry exits zero** with no closing summary, so a gate reads a - host that killed nothing as green. -- **The header contradicts itself**, forbidding a call that begins after - close starts and separately promising such a poll is refused, when that - refusal path reads freed memory after teardown. - -**Two rules are defended by nothing committed**: popping before copying, and -a handle closed under a live call. Both were previously reported killed on -the sanitizer target only, and neither has a definition left. The third fix -batch is told to give each a real entry or to say plainly that the rule is -undefended, because an honest gap beats a fabricated kill. - -**The third fix batch landed** at `62e53df` (the development seam and a -hold-then-close scenario), `e4e09a0` (four entries, the bounded wait at 50 ms, -an exit status on skips, the child's stderr on a red control, re-execution -with randomisation disabled), `12c8bfc` (the header's self-contradiction) and -`4407098` (the committed container, the Ninja generator, the README recipe -with the seccomp requirement). - -**Soul's fourth pass, 2026-09-17** (Fable, banked mid-pass when that model ran -out, resumed on Opus from the bank; the handoff paid for itself by carrying a -live harness process, a dirty tree and two leads the successor opened with). -**The verdict splits, so it is given twice.** - -*The infrastructure question is now yes.* The committed container built with no -cache, then the literal README run line, gives eight killed, none surviving, -none skipped, exit zero on Linux; Windows gives six killed, two honestly -skipped, and a real exit code. A stranger can rerun both targets today. The -seccomp requirement is genuinely load-bearing and the randomisation -re-execution genuinely runs. The development seam is absent from a release -build: thirteen production symbols, no debug symbols, no assertion strings, -and every executable section byte-identical between pre- and post-change -release builds. The predecessor's harness had already exited, restoring the -tree byte-exactly, so the restore path holds. - -*The rules question is still no.* Three of the four rules in the native table -are pinned. The fourth is not, and the cause is structural rather than a bad -constant: - -- **F1, confirmed, high: the hold sits after the wait whose duration is the - rule**, so every timeout mutant lands in a blind spot by construction. - A hard-coded 100 ms survives Windows and Linux with assertions, and dies - under the thread sanitizer only because its slowdown trips an unrelated - condition. **A hard-coded 2000 ms survives the entire matrix on both - targets.** A host asking for five seconds silently gets two and every - consumer's poll loop spins at two and a half times the rate it asked for, - forever. The committed one-millisecond entry dies only because one is less - than the scenario's fifty-millisecond settle: constant pinning constant. -- **F2, confirmed, high: the pop-before-copy gap was wrong about itself.** - A client opening to a closed loopback port reaches that path in about a - millisecond with no listener, credential or connection, because the failure - path always publishes a non-empty reason; fifteen lines. Moving the pop - before the copy then crashes an ordinary optimised build twice out of two, - while both committed scenarios stay green on that same binary. -- **F3, confirmed, medium: the refusal during a close has no entry** and - survives being neutered, though the header promises it. A call entering - behind the closer increments the in-flight count behind the wait, which can - then never reach zero. -- F4, low: the seam misdescribes itself, claiming the held call touches - nothing afterwards when its scope destructor takes the gate, decrements and - notifies. The safety comes from the assertion, not the hold. -- F5, low: the bounded wait at 600 ms survives, the same shape as F1 and - honestly labelled as a limit by Hands. -- Also: "the close wakes every blocked poll" **is** defended by committed code - but has no entry, so the table understates coverage in one place while - overstating it in another. - -**Two scars from the pass itself.** An export made with `git archive` carried -1,459 inserted carriage returns, poisoning any byte comparison built on it; -raw blobs verified against the worktree hash are the only safe source. And the -51 bytes that differ between pre- and post-change release builds are accounted -for: 48 are four copies of the reproducibility content hash, and the last -three sit in exception metadata outside every executable section and **could -not be fully accounted for**, which Soul said rather than rounding away. The -same source rebuilt at the same path twice differs by zero bytes; built -elsewhere it differs by 69, so the figure is only meaningful at a shared path. - -**One undocumented step remains** in an otherwise clean path: the README -hardcodes the container mount, so a newcomer on another checkout must edit the -line. - -**The fourth fix batch landed** at `25d7de2` (the container mount taken from -the running checkout rather than one machine's path) and `a937a0b` (the -scenarios, the seam correction and the new entries). - -**Hands rejected Self's diagnosis and gave a better one.** Self had said the -blind spot was that the hold sat after the wait whose duration was the rule. -The real cause is that **no committed scenario ever let a timeout finish a -poll**: both ended their waits with a close, so the timeout governed nothing -either could observe. The fix is therefore a scenario rather than a seam -recording. `polltimeout` polls an idle runtime and measures elapsed against -what was asked, **at two values with non-overlapping bands** — 200 ms in -[180,500] and 900 ms in [880,1200] — because one value cannot tell a bridge -honouring the host's timeout from one waiting on a constant that happens to -equal it, and no constant lies in both bands. No new export, no header change, -and it works in the shipped shape. - -All three constants now die by that scenario: 100 ms, 600 ms and 2000 ms. The -600 ms case survives both older scenarios and dies to the new one, which is -the demonstration of what the old ones could not see; Hands kept it as a hand -check rather than carrying a third constant in the table. - -Also landed: Soul's closed-port probe committed as `payloadfit`, reaching the -two-phase poll in about a millisecond, where the pop-before-copy mutant -segfaults an optimised Windows build with no sanitizer, five runs of five; an -entry for the refusal of a call racing a close; an entry for the wake, which -the table had been silently failing to claim; and the seam's self-description -corrected, since a held call does leave its scope, which takes the gate, -decrements and notifies. Release shape unchanged on both targets, with no -debug exports and no assertion strings. - -**Tables now: Linux 18 killed, none surviving, none skipped, exit 0. Windows -16 killed, none surviving, two honestly skipped, exit 2.** Controls green on -both. - -**A declared equivalence, handed to Soul rather than substituted.** Changing -the close's wake-all to a wake-one survives, and Hands argues it is equivalent -rather than uncovered, because the first poller out calls wake-all under the -gate from its own scope destructor. It recorded the equivalence beside the -entry and shipped the queue-conditional wake as the loosening instead. This -campaign's fourth encounter with that defect shape, and the first where the -answer was an argued equivalence rather than a swap. - -Named limits: the 600 ms mutant lives only in the report; `payloadfit` and -`polltimeout` run only in the assertion configuration, so the pop-before-copy -kill rests on an unsanitized crash and an exit code rather than an -address-sanitizer configuration the harness still lacks. **Soul's fifth pass, 2026-09-17** (Opus). Both targets reproduced end to end -from a fresh clone at a different path. **Cut 3 still does not close, and for -the same underlying reason in a third costume: the scenario proves the wait is -not a constant, not that it is the host's.** - -- **F1, confirmed, high: an expression defeats the two-band argument.** No - constant sits in both bands, which is true and is not the rule. Any mapping - that is identity at 200 and 900 passes for free, and the late tolerance is a - flat 300 ms. Three mutants, each run through all five Windows scenarios, all - **survived all five**: clamping the wait to at most a second, adding 150 ms, - and scaling by six fifths. **The clamp is the one that matters**, because its - failure is verbatim the bug this batch claims to fix — a host asking for five - seconds gets one, forever, with nothing reporting it — and "cap the wait so - shutdown gets noticed" is the most ordinary spelling that line will ever be - given. The close is cheap: raise the long probe above any plausible ceiling, - which kills the clamp and the scale, and make the tolerance proportional - rather than flat, which kills the offset. -- **F2, confirmed, medium: a Windows clone cannot run the documented Linux - path at all.** `.gitattributes:2` is `* text=auto` with no rule for shell - scripts, so a default Windows clone writes the build script with carriage - returns and the container dies in under a second complaining about the - interpreter line. Soul hit it from a clean clone before anything else ran. - The mount fix works; this is a second, independent blocker on the same - documented path, and it fires for the likeliest newcomer. One line repairs - it. -- **F3, confirmed, low: the only non-comment source change in the range is - pinned by nothing.** Reverting the hold's new guard survived all five - scenarios, because the timeout scenario never arms the hold and the other two - never let a timeout expire, so nothing is ever in both states at once. The - seam folds away in release, so nothing shipped is at risk; it is the - recurring shape again. - -**The declared equivalence holds, and its stated reason was the weaker half.** -Narrowing the close's wake to one waiter is genuinely equivalent, reproduced. -But the equivalence rests on two premises together: the closing flag is set -permanently under the gate before the notify and appears in every waiter's -predicate, so after a close begins no poller can block at all; and each exiting -call's own destructor takes the gate, decrements and wakes everyone, cascading. -Soul then narrowed **both** wakes, which also survived and **is not equivalent -by any argument**, since a notify consumed by the closer stalls the chain. It -passes on wait-queue ordering alone. The premises belong beside the entry so -the claim is checkable rather than assertable. - -**Held:** Linux 18 killed and Windows 16 with two honest skips, both -reproduced; 600 ms dying to the new scenario and surviving the older two; -the closed-port probe reaching the two-phase poll and both pop-before-copy -entries dying on both targets; the release shape clean on both, with the new -local folding away. **The red-control path proved itself unasked** — a run -from a deep temporary path failed MSBuild's file tracker, and the harness -reported a red control with the child's output rather than banking eighteen -kills it had not earned. - -**Two record corrections.** The source digest quoted in the previous report is -checkout-dependent, because line-ending conversion changes it; Soul's clone -restored to a different one. It is not a portable identity. And the Windows -harness needs a short checkout path or the control fails on the file tracker, -which belongs where a newcomer meets it. **Fifth fix batch in Hands.** - -**The fifth fix batch landed** (Opus), `ca6c0f5..244154e`. F2: `*.sh text -eol=lf` in `.gitattributes` (`ca6c0f5`); the container line no longer picks a -shell's continuation, the Dockerfile's recipe mounts the running checkout, and -the win32-x64 short-path requirement is stated where a newcomer meets it -(`c177533`). F1: the long probe is 5000 ms and the late tolerance is -`60 + timeout/8` rather than a flat 300, which kills the clamp and the scale on -both checks and the offset on the short one; the clamp is its own entry. Stated -limit, in source beside the tolerance: **a scaling smaller than an eighth -survives** (`c682619`). F3: `holdtimeout` puts a call in both states; the -revert and the reordered-conditions loosening both die there at about 800 ms -and on nothing else (`5ab4c62`). The single-waiter equivalence now states both -premises beside the entry, and narrowing both wakes is recorded as **not yet -reached**, with why a scenario cannot force it while the first premise holds -(`b985f07`). The restore digest is printed as a restore check, not an identity -(`244154e`). Hands' numbers: win32-x64 19 killed, 2 honest skips; linux-x64 21 -killed. **Soul's sixth pass dispatched 2026-09-22** (Opus). - -**Soul's sixth pass, 2026-09-22** (Opus). Hands' table reproduced exactly from -fresh `autocrlf=true` clones: win32-x64 19 killed, 2 skips; linux-x64 21 -killed; controls green. **Cut 3 still does not close.** Every scenario so far -has a single-threaded host. - -- **N1, confirmed, medium: the wait's predicate is pinned by nothing.** Dropping - it (`wait_for(lock, ms(t)) == no_timeout`, `cultmesh_quic_native.cpp:1179-1180`) - survived the whole matrix on both targets. At the bridge, a second host thread - calling `cultmesh_quic_last_error` every 20 ms made an idle `poll(1000)` return - after 0 and 20 ms, where the real bridge stays 1000. Every `CallScope` - destructor wakes all waiters (`:286`), so a host with a poll thread and a send - thread spins at the rate of its other calls. The rule "a poll with nothing to - deliver stays for its timeout" is pinned only for a host with one thread. -- **N2, confirmed, low-medium: the stated limit understates what survives.** - Survived on both targets: `min(t,5000)`, `min(t,4990)`, `t*11/10`, `t+70`. - `max(t,220)` survived on Linux and died on Windows. `max(t,250)` and `t+80` - die only on `holdtimeout`'s 150 ms probe, by margins of 22 and 2 ms, so those - kills depend on timing. "5000 is above any plausible ceiling" is wrong, - because 5000 is itself a round ceiling, and no probe can see a floor under - about 60 ms. Either add a small-timeout probe and a probe above 5000, or state - the full limit: offsets to about 75 ms, floors to about 220 ms, and clamps at - or above about 4980 ms. -- **N3, confirmed, low-medium: the LF fix does not reach an existing clone.** - A clone made at `367c9bc` and moved to `244154e` keeps the CRLF script, since - the blob did not change. Its status is clean and it still dies on `bash\r`. - Nothing tells an existing clone to check its `*.sh` out again. -- **N4, confirmed, low (development seam): no hold mutant is a function of the - input.** `(woken || timeout_ms > 1000) && HELD` survived on both targets. - Both of Hands' hold entries only rearrange the guard's syntax. - -**Held:** F2 from a fresh clone, covering every file the container runs or -parses. F3's revert and its loosening, with only `holdtimeout` failing under the -revert. The clamp entry. Both equivalence premises, checked against source, with -"narrow both wakes" recorded as not yet reached. Restores verified by hash -against `checkout-index`. Sidecar repair after a mid-mutation `taskkill`. -`polltimeout` and `holdtimeout` stayed green for five rounds under 16 burners on -8 CPUs. **Not run:** a forced red control, and Windows under a CPU burner. -**All four go to Hands as the sixth fix batch.** - -**The sixth fix batch landed** (Opus), `b3d9cf7..ff72f16`. Details: - -- **N3 (`b3d9cf7`):** the README gives an existing clone a one-line repair beside - the container path. It is `git rm --cached -q scripts/build-quic-native.sh; - git checkout HEAD -- scripts/build-quic-native.sh`. A plain checkout keeps the - CR. The line was verified on clones made at `367c9bc` from Git Bash and from - PowerShell 5.1. -- **N2 (`37967e8`):** the timeout probes are 15, 200 and 7300 ms. The 15 ms - probe takes the fastest of ten tries, and every try is still checked for an - early return. There are new entries for a round clamp, a floor and an - offset. The limits measured on both targets are now written in source: - - `t+25` survives and `t+40` dies; - - `max(t,40)` survives and `max(t,50)` dies; - - `t*106/100` survives and `t*108/100` dies; - - `min(t,7285)` survives and `min(t,7275)` dies; - - any shortening up to 20 ms survives. - - The "above any plausible ceiling" claim is deleted. -- **N1 (`4ffa4c9`):** `pollbusy` runs a second host thread that calls into the - bridge while a poll is idle. Three entries sit on it: the revert, "reads - what woke it", and "each wake restarts the whole timeout". All three die on - `pollbusy` on both targets. -- **N4 (`ff72f16`):** `holdtimeout` makes a second held poll at 1300 ms. The - input-derived hold mutant dies there and nowhere else. -- **Recorded as not yet reached:** the loosening that checks only `closing`. - It breaks "an arriving event ends the wait early", a promise no scenario - measures. - -Hands' numbers: - -| Target | Killed | Survived | Skipped | Control | -|---|---|---|---|---| -| win32-x64 | 26 | 0 | 2 | green | -| linux-x64 | 28 | 0 | 0 | green | - -Under 16 burners on 8 CPUs, 15 of 15 rounds passed on each target. The worst -single poll was 15 ms late on Windows and 41 ms on Linux. - -**The batch added one mechanism nobody specified. Self accepted it, 2026-09-22.** -On Windows the timed scenarios raise their own process priority. Without that, -Windows under load put polls up to 127 ms late. The unmodified bridge then -failed even the pre-batch tolerance: the 150 ms hold probe came back 100–115 ms -late against 78 ms allowed. The only alternative was a flat allowance of about -150 ms, which would readmit the offsets and floors this batch exists to kill. -The priority change is test-fixture only, needs no admin rights, and changes -nothing in the bridge. Accepted, and handed to Soul to attack. -**Soul's seventh pass dispatched** (Opus). - -**Soul's seventh pass, 2026-09-22** (Opus). Reproduced from a fresh clone: - -| Target | Killed | Survived | Skipped | Control | -|---|---|---|---|---| -| win32-x64 | 26 | 0 | 2 | green | -| linux-x64 | 28 | 0 | 0 | green | - -Exit 2 on win32 is by design: it means entries were skipped -(`mutate-cultmesh.mjs:1192`). A forced red control was reported red, and the -harness ran nothing after it. Restores matched `checkout-index` by hash. N3 -holds from both shells. **The priority raise is sound.** It is scoped to the -process, which exits after each scenario, and it cannot hide a lock held -across a sleep, because every thread in the process is raised equally. -**Cut 3 still does not close.** - -- **S1, medium: N1 is not closed.** Two predicate mutants survive the full - matrix on both targets: - - **K1**, `|| ++wakes > 64`. Under a thread calling in a yield loop, an idle - `poll(1000)` returns after 0–5 ms. - - **K2**, `|| !runtime->error.empty()`. After one refused call, every later - idle poll returns at 0 ms. - - `pollbusy` covers one call kind, at one pace, on a runtime with no error - recorded. -- **S2, medium: the 15 ms probe has no early check.** The check is - `elapsed < 15 - 20`, which cannot fail. Both `t<50 ? 0 : t` and `t/16*16` - survive. -- **S3, low-medium: more derived waits survive.** Rounding up to a 40 ms - quantum survives. So does any fault that only shows after a runtime's 12th - poll, because no scenario polls a runtime more than 12 times. -- **S4, low: two stated boundaries depend on timing on Windows.** `t+25` died - in one of two repetitions. `min(t,7275)` survived in one of two. -- **S5, low:** the hold guard `timeout_ms > 1500` survives. The stated limit - should put the ceiling at 1300. - -**Self's ruling, 2026-09-22: change the method, not the probes.** This rule has -now taken seven passes. Each fix batch pinned the functions the previous Soul -named, and the next Soul found another function that equals the identity at -every probe point. A rule about the arithmetic of a derived wait cannot -converge under wall-clock observation from outside, because there is always -another function that matches at the probes. The skill already says it: put -the observation where the rule is decided. - -- **The wait.** A dev-only seam, of the same kind as the existing hold seam - and folded away in release, records the timeout the bridge actually hands to - its condition wait on every poll. A scenario asserts that it equals the - host's argument **exactly**: - - over a spread of values: 0, 1, 15, 16, 39, 40, 999, 1000, 1001, 7300, - `INT32_MAX`, and the bridge's documented maximum if it has one; - - on polls well past the 12th on a single runtime. - - Every clamp, floor, round, scale, offset and later-poll mutant then dies by - equality, deterministically, on every host, with no timing tolerance. The - wall-clock scenarios stay as proof that the recorded wait is actually waited, - with **generous** margins. The wide boundary table goes: those limits existed - only because the observation was indirect. Stated limits that are no longer - true are deleted, not kept as history in source. -- **The predicate.** A `pollhammer` scenario runs one host thread that calls - every entry point which touches the gate, in a yield loop, while another - thread holds an idle poll. It runs once on a clean runtime and once after a - refused call has recorded an error. The poll must stay for its timeout, - within a generous margin. K1, K2 and a "returns on any wake" revert must die - there. -- **The seam itself** gets a revert entry and a loosening entry: a seam that - records the argument instead of the computed wait must die. The release shape - must still fold it away. - -To Hands (Sonnet) as the seventh fix batch. - -**The seventh fix batch landed** (Sonnet), `66830e9` and `b8fad13`. - -What was added: - -- **`waitseam`** checks the wait by equality. A dev-only seam, gated like the - hold seam and folded away in release, records the timeout the bridge hands - its condition wait. The scenario asserts it equals the host's argument for - 0 to `INT32_MAX` and over 40 polls on a single runtime. For - `timeout_ms <= 0` the bridge never reaches the wait, so the scenario asserts - the seam was left untouched. That is also what kills the loosening "records - unconditionally". The bridge documents no maximum below `INT32_MAX`. -- **`pollhammer`** covers the predicate. It runs every gate-touching entry - point in a yield loop, both on a clean runtime and after a recorded error. - -What was removed: - -- the fastest-of-ten mechanism; -- the tight tolerances, replaced by one generous 800 ms margin; -- the stated-limits table; -- **the Windows priority raise.** - -Which scenario kills which mutant: - -- K1, K2 and the "returns on any wake" revert die on `pollhammer`. -- Every derived wait from passes 5 to 7 dies on `waitseam`. That includes the - quantum rounds, the small-timeout zero, doubling after the 12th poll, - `t+25` and `min(t,7275)`. All of them are now deterministic. -- S5's `>1500` hold guard dies on `holdtimeout`, with a probe at 1600 ms. -- The seam's revert and loosening die on `waitseam`. - -Harness results, with restores matching `checkout-index`: - -| Target | Killed | Survived | Skipped | Control | -|---|---|---|---|---| -| win32-x64 | 39 | 0 | 2 | green | -| linux-x64 | 41 | 0 | 0 | green | - -Stress: 5 rounds with 16 burners on each target, all green. Worst overshoot -was 325 ms on Windows and 26 ms on Linux. - -**Scar:** the stress cleanup used `taskkill /IM node.exe /T`. That killed every -Node process on the shared host. The rule now in the Eureka skill is to kill -by PID. **Soul's eighth pass dispatched** (Opus). - -**Soul's eighth pass, 2026-09-22** (Opus). Hands' numbers reproduced: 39 killed -on Windows and 41 on Linux. Restores matched `checkout-index`. A forced red -control was reported red. The seam records the same evaluation that the wait -receives, and it folds out of release: there are no debug exports, and the -release macro is plain `(timeout_ms)`. The wall-clock layer is not vacuous. It -kills every shortening, and on `pollhammer` it kills K1, K2 and Soul's new P2 -and P3. **Cut 3 still does not close.** The method moved the gaps outward. - -- **F1, medium: nothing runs the timeout rule against the release build.** - The only configuration without asserts runs `closerace` alone, and no CI - builds the bridge. The seam macro creates a release-only site at `:108`. - Mutant R1 changes the `#else` to `((timeout_ms)/10)` and survives by - construction. Run against the release build, `polltimeout`, `pollbusy` and - `pollhammer` each kill it. -- **F2, medium: the seam records the integer, not the duration handed to - `wait_for`** (`:1201-1202`). Each of these survives on both targets: - - `ms(RECORD(t)) + 600ms`; - - `max(ms(RECORD(t)), 500ms)`, which turns a 15 ms realtime poll into - 500 ms; - - a 1/909 duration ratio. - - Only lengthening survives, since the timed layer's early tolerance kills - any shortening. -- **F3, medium: the predicate is covered only on a runtime with no live - objects.** `|| !runtime->connections.empty()` survives on both targets. With - one open connection, an idle `poll(1000)` returns in 0 ms, so any host with a - connection busy-spins. -- **F4, low-medium: a zero-timeout poll is never timed.** `sleep_for(500ms)` - on `timeout_ms == 0` survives, as does a 300 ms spin. A game loop that polls - with 0 every frame would stall. No negative timeout is probed, and -1 would - collide with the seam's sentinel value. -- **F5, plausible, low:** `closerace` goes red under load on Windows in 4 of 5 - rounds. It is starved of CPU, not the bridge. It fails loud, but it makes - the control flaky. -- **F6, low:** stale prose: the 1300 ms history at `mutate-cultmesh.mjs:763`, - a scenario list missing `pollhammer`, and a header §7 intro that still says - "quiesce only". - -**Self's rulings, 2026-09-22:** - -1. **F1.** The harness gains a plain **release** configuration on both - targets. It runs `polltimeout`, `pollbusy`, `pollhammer` and the new - zero-timeout check against the release library, and R1 goes in as an - entry. The release build is the thing that ships, so a rule the release - build never runs is not pinned. -2. **F2.** The seam wraps **the whole argument to `wait_for`**, meaning the - duration expression. It records that duration's count, and `waitseam` - asserts it equals `milliseconds(host argument)`. W1, W2 and W3 go in as - entries. -3. **F3.** `pollhammer` also runs against a runtime that holds live objects: - an open connection to an unroutable address, plus a listener and a stream - where the fixture can make them. P1 goes in as an entry. -4. **F4.** Any `timeout_ms <= 0` returns within a small bound, timed with a - generous margin. A negative timeout is probed. The seam's sentinel moves to - a value that cannot collide, or becomes a separate "recorded" flag. Z1 and - Z2 go in as entries. -5. **F5.** `closerace`'s fixture gets whatever it needs to survive - starvation, such as barriers or longer windows, without weakening what it - checks. Prove it with 5 rounds under 16 burners on Windows. -6. **F6.** Fix the prose. - -**Superseded the same day, 2026-09-22, by the operator's retirement of the -mutation harness.** The operator's words: *"The manual mutation testing turned -out to be a terrible idea, lots of guarding the shape of the code instead of -the behavior. Better to have nothing than a harness that punishes -refactoring."* `scripts/mutate-cultmesh.mjs` and every committed suite are -deleted, with no fallback. Four of the six rulings above named harness entries -(R1, W1–W3, P1, Z1, Z2); those clauses are dead text. What each ruling -protected is not: - -1. **F1 stands as behaviour.** The scenarios gain a **release** configuration - on both targets, run against the release library. The release build is the - thing that ships, so a rule the release build never runs is not pinned. - Without the harness there is no R1 entry; the release path is proven by the - scenarios themselves going red when the seam's `#else` shortens the wait, - which Soul checks by hand on a scratch copy. -2. **F2 stands.** The seam wraps the whole argument to `wait_for` — the - duration expression — and `waitseam` asserts it equals - `milliseconds(host argument)`. The assertion is the pin; W1–W3 are Soul's - hand probes, not committed entries. -3. **F3 stands.** `pollhammer` also runs against a runtime holding live - objects: an open connection to an unroutable address, plus a listener and a - stream where the fixture can make them. -4. **F4 stands.** Any `timeout_ms <= 0` returns within a small bound, timed - with a generous margin. A negative timeout is probed. The seam's sentinel - moves to a value that cannot collide, or becomes a separate recorded flag. -5. **F5 stands**, and its 5 rounds under 16 burners run **on Yggdrasil**. The - burners that motivated this ruling are what froze the operator's - workstation on 2026-09-22; no CPU burner runs on Starfire again. -6. **F6 widens.** The prose fix now includes removing every reference to the - deleted harness, not only the stale 1300 ms history and the scenario list. - -**There is no mutation evidence for the bridge and there will not be one -committed.** It is C++, and no ecosystem mutation tool is wired here. The -bridge's rules are pinned by behavioural scenarios at the layer that decides -them; a mutant is a thing Soul tries by hand on a scratch copy and throws away. - -**The eighth fix batch landed** (Sonnet) at `5646ff7`, before the retirement, -and the harness deletion followed at `796433f`. Both are on -`hands/cultmath-erf`, not yet on `main`. What landed: - -- the seam wraps the whole duration handed to `wait_for`, and its "unset" - sentinel became a separate `debug_wait_recorded` flag, so a recorded `-1` - cannot read as nothing recorded; -- `pollhammer` runs against a runtime holding a live connection to an - unroutable address (RFC 5737 TEST-NET-1), a stream on it, and a real - never-connected listener; -- a `zerotimeout` scenario pins that `timeout_ms <= 0` returns within 50 ms, - probing 0, -1 and `INT32_MIN`; -- `closerace`'s settle waits for `debug_peak_calls` to reach the poller count - instead of sleeping a guessed duration; -- `kGenerousLateToleranceMs` narrows from 800 ms to 400 ms, so the wall-clock - layer catches arithmetic applied outside the seam's own argument — - `waitseam`'s equality check structurally cannot see that. - -The release configuration and the seven mutation entries (R1, W1–W3, P1, -Z1–Z2) landed **inside the harness**, and went out with it two commits later. -The scenario runner and every seam survived untouched: the README and the dev -Dockerfile now document building with `-DCULTMESH_QUIC_BUILD_TESTS=ON --DCULTMESH_QUIC_DEBUG_ASSERTS=ON` and running each scenario as a direct -argument to the binary. **The release configuration is therefore owed again as -a plain build, not as a harness target**, and R1's substance — that the -release macro is unexercised — is a Soul hand probe now. - -**The Linux leg ran on Yggdrasil, 2026-09-22** (Sonnet), against -`hands/cultmath-erf` HEAD, in an image built from the committed -`scripts/quic-native-linux-dev.Dockerfile` (pinned `debian:13`, tagged by -content hash `cultlib-quic-native-dev:13d7fbd109fc`). No repo change was -needed and none was made. - -- **Asserts build, all ten scenarios pass**, 20 rounds each: `closerace`, - `holdclose`, `holdtimeout`, `polltimeout`, `pollbusy`, `pollhammer`, - `zerotimeout`, `payloadfit`, `latecall`, `waitseam` (11 probes, 40 repeated - polls on one runtime). -- **Release build, asserts off, all four required scenarios pass**: - `polltimeout`, `pollbusy`, `pollhammer`, `zerotimeout`. -- Worst overshoot anywhere was 4 ms, against `kGenerousLateToleranceMs` of - 400 ms. **F1 is closed on Linux**: the build that ships now runs the timeout - rule. - -**A defect in the README's own worked example, found by running it.** The -example builds the test binary and runs it directly, and every scenario dies -with `libmsquic.so.2: cannot open shared object file`. Both the bridge and the -test executable carry `INSTALL_RPATH "$ORIGIN"` and expect the MsQuic runtime -beside them in `bin/`, but nothing puts it there for a test build — only -`build-quic-native.sh`'s packaged output directory gets that copy. Hands -worked around it with a `cp` after each build and correctly did not treat it -as a bridge defect. **This is the documented path, written in `796433f` when -the harness was deleted, and it has never worked.** A documented path that -fails on first use is worse than no documentation: it was published in the -same commit that removed the working one. - -**The README's worked example was broken twice, and fixed at `5533c34`** -(Sonnet, on `hands/cultmath-erf`). - -- The loader failure above, fixed in its owner: a `POST_BUILD` step on the - test target copies `libmsquic.so.2` beside the built binaries. CMake already - receives `MSQUIC_LIB_DIR` as a required argument on Linux, so no new input, - option or script was invented — the knowledge it already had now acts - instead of sitting in prose. The `$ORIGIN` rpath is untouched. -- **A second, independent break that fires first.** The example passed - `-DMSQUIC_INCLUDE_DIR` and `-DMSQUIC_LIB_DIR` as relative paths. CMake - resolves a relative cache path against the `CMakeLists.txt` that consumes - it, not the directory `cmake` ran in, so the build failed on a missing - `msquic.h` **before the loader problem was ever reachable**. Fixed to - absolute paths with a paragraph saying why. - -So the documented path failed at its first command, not its last. It was -published in `796433f` — the commit that deleted the working path — and -nobody ran it. **The check that would have caught this is running the -documentation, which is what found it now.** Verified on Yggdrasil by -executing the README's command block literally, with no extra step: -`polltimeout` and `pollbusy` both pass, 20 rounds each. - -Structural delta: one `add_custom_command`, four lines, in the test target's -build graph. No new files, scripts, options or targets. - -**The Windows leg ran, 2026-09-22** (Sonnet), and found the same class of -defect as Linux in a far worse form. Committed at `f3aae70`. - -**The scenarios were passing against the wrong MsQuic.** The bridge imports -`msquic.dll` by name. Neither `build-quic-native.ps1` nor CMake ever copied -the pinned library beside the test binary in the build tree — only into the -packaged output directory, which a test build never produces. Linux failed -loudly here (`cannot open shared object file`). **Windows did not fail at -all**: DLL search fell through to `PATH` and loaded an unrelated `msquic.dll` -from the Windows Performance Toolkit — 529,480 bytes, SHA `B2B7FD2D…` — -instead of the pinned `Microsoft.Native.Quic.MsQuic.OpenSSL` 2.5.9 build at -4,181,856 bytes, SHA `C17C6581…`. Every asserts scenario passed on the first -run against that binary with nothing indicating anything was wrong. - -This is the doctrine's own trap: a stale asset impersonating logic, and the -green result was the thing hiding it. Fixed by mirroring `5533c34`'s Linux -`POST_BUILD` copy on the Windows side, taking `msquic.dll` from -`MSQUIC_ROOT/bin/x64`. Both test build directories now carry the pinned -library at the right size and SHA, and every result below is from a rerun -after the fix. - -**Open question, not yet answered: how far back does this reach?** Every -earlier Windows scenario and mutation number in this campaign was produced on -this machine, and the copy has never existed. If the harness did not place the -pinned DLL itself, then Windows figures recorded in earlier passes — including -the 39-killed count — were measured against whatever `msquic.dll` happened to -be on `PATH`. **Nothing here re-proves those numbers.** They should be treated -as unconfirmed until someone checks whether the deleted harness copied the -library, and re-runs them if it did not. - -**Asserts build** (`Debug`, tests on, asserts on), nine scenarios, all pass: -`polltimeout` (worst overshoot 31 ms), `pollbusy` (17 ms), `pollhammer`, -`zerotimeout` (3 probes), `payloadfit`, `holdclose` (20×256 pollers), -`latecall`, `holdtimeout` (63 ms), `waitseam` (11 probes, 40 repeated polls). -Every overshoot is far inside the 400 ms tolerance and nothing was weakened. - -**Release build** (asserts off): `polltimeout` (20 ms), `pollbusy` (24 ms), -`pollhammer`, `zerotimeout`, and `payloadfit` as a bonus — all pass. **F1 is -now closed on both platforms.** `holdclose` refuses to run there with -*"needs the development seam"*, which is useful evidence in itself: it proves -the release build's asserts are genuinely compiled out rather than merely -untested. - -`closerace` was **not run**. It is the sixteen-burner stress, which stays an -operator fork. - -**A documentation gap, not a wrong instruction.** The README's Windows section -documents only the plain library build and carries no worked test-build -recipe, where the Linux section has one. Hands followed the brief's flags -because nothing better existed. That gap is why the missing DLL copy went -unnoticed for so long: there was no documented Windows path to run, and so -nobody ran one. - -### Cut 3 closed, 2026-09-23 - -StreamPixels is the priority now, and this is the first cut on its path. -The operator named it the week's goal: *"We needed to get to the StreamPixels -cut this week. We need that TO EAT."* - -- **Windows release configuration** (Sonnet, `quic-cut3-winrel`, no repo - change). This is a plain release build, asserts off, of the four required - scenarios, 20 rounds each: all pass, worst overshoot 16 ms against 400 ms. - The `msquic.dll` beside the binary is 4,181,856 bytes, SHA-256 `C17C6581…`, - which matches `build-quic-native.ps1`'s pin for the OpenSSL 2.5.9 runtime, - so the silent wrong-DLL load did not happen again. **F1 is closed on both - platforms.** Documentation gap: the README has no Windows test-build - CMake line; Hands mirrored the Linux one. -- **F5, operator fork ruled:** *"Feel free to set Starfire on fire, I'll step - away. If I know it's coming, it's not so bad."* Load on Starfire is allowed - when the operator is told first and nothing else runs alongside it. - - **Linux** (Yggdrasil, 16 burners on a 4-CPU cap, asserts on, `cc71f17`): - `closerace` 5 rounds of `20x256`, all pass. Earlier text in this map - records `20×512`; these rounds ran the binary's default size. - - **Windows, first run** (Starfire, 16 burners on 8 logical CPUs, asserts - on): **5 of 5 fail**, every one with *"256 of 256 pollers returned before - the close began, so they were never in the race"*. **This was not a - bridge defect. The fixture contradicted itself.** Under the debug seam, - the settle waited up to 30 s for every poller to be counted inside, while - each poller's wait timed out after `kPollTimeoutMs` = 5 s. Under - starvation, the early pollers left before the late ones were counted. The - bridge neither crashed nor asserted. - - **Fixture fix** `44ab376` (Sonnet): the settle budget is named, and - `kPollTimeoutMs` is derived as that budget plus 5 s in both - configurations. A deadline that expires now reports *"fixture could not - settle"* instead of blaming the pollers. The new message was reached only - by forcing the branch; under load on either platform, the counted - deadline never expired naturally. `HoldCloseOnce`, `LateCallOnce` and - `HoldTimeoutOnce` share the settle idiom but not the defect, because their - pollers are held at a barrier and cannot time out during it. - - **Windows rerun at `44ab376`:** `closerace 20x256: ok` in all 5 rounds - under 16 burners. A direct run confirmed exit code 0, because the runner - script lost the exit codes (`Start-Process -NoNewWindow` with redirected - output). The Linux rerun at the same commit: all ten scenarios pass, and - `closerace` passes 5 of 5 under 16 burners. **F5 is closed on both - platforms.** -- **Recorded, not blocking:** whether Windows numbers from before today were - measured against the pinned MsQuic stays unconfirmed. Today's were. - -Cut 3's work lives on `hands/cultmath-erf` plus `quic-cut3-winrel`, and -neither is merged to `main`. Cut 4 branches from `cc71f17`. - -### Cut 4, 2026-09-23: landed, gated, fix batch 1, confirming pass running - -**Landed** (Sonnet, 12 commits, `cc71f17..c57b613` on `quic-cut4`): the koffi -binding, consumer, session manager and trust. It reported 109/109 on Yggdrasil. -The interop lane was reported as an operator fork (EHOSTUNREACH). **Scars from -this landing:** -- A test helper leaked a runtime handle. It left a verify job idle at 0% CPU - for 25 minutes, and the agent waiting on it never woke. Self found the hang - with a SIGUSR2 diagnostic report: the event loop was idle with only - unreferenced handles. The fix at the owner: `ygg-verify.sh` gained a watchdog - (Eureka `9359e9c`). The first watchdog leaked the slot lock to its own - `sleep` child and held two slots for an hour; fixed at `dbe594e`. -- The agent ran past 550k tokens of context and was retired for a fresh one. - Its cut came in at +2,061 lines against an estimate of about 1,300. - -**Soul gate (Opus): Cut 5 may not build on this.** Confirmed by executed probes: -- A malformed frame from a peer killed the whole process (an unhandled - rejection in the pump). C# faults only that connection. -- `release()` was a use-after-free: it closed the runtime while a - `nextEvent.async` call was queued but not started. Exit 134 under - thread-pool saturation with `MALLOC_PERTURB_`. -- A peer shutdown leaked a runtime reference, and pending receives hung - forever. -- The session manager never went offline on transport death (C# - `InvalidateRealtimeSession`), and had no disposed guard or single-flight. -- The race waited on `allSettled`, so one dead route cost about 10 s. C# - returns the first success. -- `streamKinds` and the receive queue grew without bound, with no latest-only - coalescing as in C#'s inbox. -- The types said `bigint` while koffi struct members returned `number`, so - the canceled-send check was dead code. -- Eight spec rules had no killing test (M2–M4, M6–M10), and M1 was killed - only by a hang. - -**The interop "fork" was not a network problem.** Self's IPv6 hypothesis was -refuted: IPv6 was enabled and `--network host` failed identically. The probe's -first explanation, that an AF_INET socket ignores v4-mapped traffic, was also -wrong: ECONNREFUSED meant nothing was listening. The executed cause: -- the harness spawns peers with `stdio: ["ignore", ...]`, and the C# serve loop - stops on stdin EOF, so the listener tore itself down at once; -- the serve mode broadcast without waiting for a client; -- .NET in the image could not find `libmsquic`. - -**Fix batch 1** (fresh Sonnet, `c57b613..8e32bff`): -- 125/125 pass. **The C# managed provider to TS consumer lane passes**, - separate processes, on Linux. -- Kills are confirmed for reverted fixes 1 and 3–6. **Not proven by - execution:** the `release()` ordering fix (checked by reading only) and - kills for Soul's M2–M10. -- **The cut grew by about +906 net instead of shrinking.** Hands declined the - subtraction item on its own authority, arguing that `NativeBindings` is the - only static typing over koffi. `realtime-quic.ts` is 849 lines, against an - estimate of about 650 across Cuts 4 and 5 combined. -- Fix 7 coerces u64 struct members from `number`, which is lossy above 2^53. - -A confirming Soul pass (Opus) is rerunning the crash reproductions and M1–M10, -checking the 2^53 exposure, and judging the subtraction. **Cut 5 started in -parallel** on `quic-cut5` from `8e32bff`, to save wall-clock time. If Soul -finds defects, Cut 5 rebases onto their fixes. - -### Cut 4 confirmed, fix batch 2, Cut 5 landed, retention (StreamPixels Cut C), 2026-09-23 - -- **Cut 4 confirming Soul pass (Opus):** the core fixes hold under abuse. - - The `release()` use-after-free survives 300 cycles with the thread pool - saturated and `MALLOC_PERTURB_`. The old order dumps core. - - Malformed frames fault only their own connection. - - Racing and coalescing match C#. - - koffi returns `bigint` above 2^53, so there is no precision loss. - - It found that a throwing `onDisposed` handler killed the process or - leaked a ref, and that several fixes had no killing test. -- **Cut 5 landed** (Sonnet, `8e32bff..fd2a701`): the TS provider. **All four - lanes pass on Windows, including TS provider to the C# native connector, - the Unity overlay's path.** Linux: 3 lanes pass and 1 skips (Windows-only). -- **Fix batch 2** (Sonnet, `fd2a701..275010a`): - - Handler isolation, a use-after-free regression test (reverted order - crashes), 18 mutations killed, two further leaks fixed, and the root - lockfile fixed (`npm ci` works; this unblocked the StreamPixels deploy). - 146/146 on both hosts. - - **Scar:** this agent ran to about 800k tokens over 2.6 h because Self - packed ten items, subtraction, two hosts and a late addition into one - brief. Fix batches are split per file from here on. - - Subtraction missed: net +39 against Soul's −130..−150 estimate. The - `NativeBindings` collapse moved code rather than deleting it. -- **Retention** (StreamPixels Cut C, operator OQ1 = A; Sonnet, - `275010a..8cf89a7` on `quic-retention`): the provider retains the newest - latest-only frame per `(channel, body)` and seeds each new peer before it - joins, in TS (+12 lines) and C# (+36, seeding under the same lock as the - add). A late-joining C# native connector receives the retained frame on - Windows. Both runtimes' mutations are killed. - -**Soul gate on Cut 5 plus fix batch 2 (Opus): do not merge toward -StreamPixels.** Executed probes found: -1. **Critical: every provider peer is evicted at `handshakeTimeoutMs`.** A - handler swap meant CONNECTED was never recorded. No test lived past 10 s. -2. **Critical: `koffi.alloc` is never freed.** About 53 B per native call, - about 36 MB a day idle, and gigabytes a day under load. -3. **High for StreamPixels:** no chain-validated no-pin connect path exists in - the C# native connector or the bridge. The operator therefore ruled a - **pinned, long-lived self-signed certificate** (StreamPixels map, section - 7), so no bridge change is needed. -4. **High: client frames pile up unboundedly** in the provider's - `readyFrames`. C#'s inbox coalesces. -5. **Medium:** one invalid frame evicts every peer while `broadcast` reports - success. -6. **Medium:** `advertisedEndpoint` cannot describe a public host. -7. **Low:** `receive(abortedSignal)` never settles. -8. **Plausible:** the consumer-side native event queue is unbounded when - Unity stops polling. -9. **Hygiene:** under M2 the provider test file hangs for 234 s. - -Held: stalled peers never block latest-only `broadcast` (about 2 µs per -call), per-peer memory is bounded by the key count, 20k churn cycles return to -baseline apart from the koffi leak, eviction racing with dispose is clean, and -wire parity at maximum sizes is byte-identical. Lane 2's golden check is not -tautological. - -In the mutation matrix, Hands' 18 are killed. Surviving provider mutants: P3 -(send-failure eviction), P4 and P8 (two claimed fixes with no killing test), -P10b (`onFault` isolation), and the dispose and resurrection guards. - -**Fixes in flight, split per file:** -- **3a** (`realtime-quic.ts`, on `quic-retention`): synchronous accept via - `retain()`, which deletes the async-accept window and its guards and fixes - finding 1 structurally; encode once in `broadcast`; an `advertisedHost` - option; `receive(aborted)`; tests for P3, P8 and P10b; hygiene. -- **3c** (`realtime-quic-native.ts`, on `quic-koffi-leak`): allocate once, free - on close, and a steady-state RSS test. -- **3b** follows 3a: one inbox shaped like C#'s `CultMeshRealtimeInbox` across - the transport, provider fan-in and outbox, fixing finding 4. - -**Cut 1 Soul findings, 2026-09-16.** Held: every test count, both negative -greps, all twelve mutations rerun and killed, the control catching a -truncated write, the equivalent mutant confirmed (Node 24 WebCrypto refuses -every non-64-byte P1363 signature tried, across forty keys), the separator -byte matching C#, and the TypeScript-signs-C#-verifies direction proven by -a probe over adversarial inputs, which nothing in the repo yet pins. Found: - -- **The mutation runner leaves the last mutant compiled in `dist/`**, which - is git-ignored, and the browser tests then pass against it. A stale asset - impersonating logic, the doctrine's own trap. Medium. -- **The C# half of the vector check never runs in CI.** The only `dotnet - test` of that project is filtered to one lease test; a C# transcript - change would leave the committed vector self-consistent and nothing red. - Medium. -- **`verifyP256` fails closed on Node `Buffer` inputs**: `.slice().buffer` - on a pooled buffer is the whole pool. Internal callers allocate their own - bytes, so it is safe today and wrong for the Cut 4 consumer. Medium. -- **The `protocolIds` sort is untested**: the vector's ids were written - sorted, so removing the sort survives. Low-medium. -- **Four edge divergences from the C# rules**: an empty signature is - "unsigned" in C# and "invalid" in TypeScript; C#'s loopback set is wider - (`127.0.0.2`, and the TypeScript test pinned the narrower answer); root - lookup and validity window check in opposite orders; duplicate root key - ids throw in C# and take-first in TypeScript. All aligned to C# in the fix - batch. -- **The negative grep pins names, not structure**: a local shim under - another name passed every browser test and both greps. A guard-strength - note, recorded. -- **A killed runner leaves the mutant in the tree**; a sidecar restore is - added, as Epiphany's harness has. -- **`cultmesh-ts` resolves modules with `moduleResolution: "Node"`**, which - ignores `exports`, so `cultnet-ts/authority` is unresolvable from that - package until Cut 4 changes it; the root re-export covers it today. - -**The Cut 1 fix batch landed** at `c2ffc32` (`verifyP256` copies its bytes; -empty or whitespace signature is unsigned; root lookup before the validity -window; duplicate root key ids refuse at first use; the loopback set is -C#'s, probed through the public policy: `127.0.0.0/8` in any spelling -`URL` normalises, `::1`, the IPv4-mapped `::ffff:7f00:1`, `localhost`, -`loopback`; `localhost.`, `0.0.0.0`, `::` and `::ffff:127.0.0.2` are not), -`1590145` (`scripts/sign-cultmesh-authority-vectors.mjs` writes -`contracts/cultmesh/authority-route-vectors.ts-signed.json` over adversarial -inputs and a C# test verifies it; a one-character change to the committed -signature fails that test), `cf1203a` (`cultmesh-portability.yml` runs -`dotnet test --filter AuthorityProof` on both OSes on every push; the -interop workflow was Windows-only and main-only) and `0e4f479` (the runner -rebuilds `dist/` after the final restore and asserts a sentinel from the -restored source; a sidecar of the original bytes is written first and -repaired from at the next start). Twenty mutations, twenty killed. Tests -74, 17, 10; closure smoke four tarballs. - -Scars from the batch: **the runner's two multi-line anchors matched nothing -on a CRLF checkout** (the tree is `autocrlf=true`, HEAD is LF, the anchors -were `\n`), so the runner would have thrown at those entries; the earlier -"killed" claims for the transcript-order and nonce mutations came from an -LF-on-disk state. Anchors are now normalised to the file's EOL. Fixes 1 and -2 share one commit because Hands had made both before committing. -**Still open after the batch:** the browser client's local-development -short-circuit at `packages/cultmesh-browser/src/index.ts:915` tests -`!route.certificate` where the shared verifier now treats an empty -signature as unsigned, so such a route passes the verifier and is then -asked for a provider proof, where C# would not ask. **Landed** at `a801e0a`: -`isUnsignedCertificate` is exported from the shared module and used by the -verifier and the browser's session check, with a browser test for the -blank-signature loopback route, the signed loopback route that still needs -a proof, and the blank-signature remote refused in both modes. Twenty-one -mutations, twenty-one killed. Tests 74, 18, 10. - -**Second Soul pass, 2026-09-16.** Held: byte copy, root before window, duplicate -root ids refusing on the unsigned path in either insertion order, the sort -pinned, the U+001F separator on both sides, 33 of 39 field tampers failing the -C# test with the survivors explained, the runner repairing itself after a -SIGKILL and leaving `dist/` free of every mutant string, all 21 anchors -matching once on both LF and CRLF copies, and every test and grep. Cut 1 -closes on its stated invariants. Four edge divergences from the C# rules -remain, with one guard gap, to be fixed before Cut 4 makes a non-C# signer -real (in Hands): - -- **`isLoopbackEndpoint` is not `Uri.IsLoopback`**: 15 of 90 probed - endpoints disagree. Seven are reachable and TS-wider (a trailing dot on - the host, `ws:` without `//`, a backslash separator, fullwidth characters, - a tab), because `URL` normalises what C# refuses; eight are C#-wider and - unreachable in the browser (zone ids the `URL` parser throws on, - `file:`/`mailto:` schemes the filter drops). The doc comment claimed - equivalence. Low: local-development only. -- **`trim()` is not `char.IsWhiteSpace`** at U+0085 (C# whitespace, TS not) - and U+FEFF (TS trims, C# does not), so a signature of either is - "unsigned" on one side and "signed" on the other; both are carriable on - the rendezvous wire. Low, fail-open only where an absent certificate is - already accepted. -- **C# cleans the route before the transcript and TS transcribes raw**: - duplicate or padded protocol ids, padded generation and padded key ids - verify in C# and refuse in TS, and an absent protocol list gets a default - in TS and `""` in C#. Latent while every signer is C#; live at the first - TS signer. Low-medium. -- **Two C# refusals collapse into one TS message** (not base64; not P1363). - Low. -- **A local `=== ""` shim beside the shared call in the browser survives - every test**, because no browser test uses a whitespace signature; the - name grep is silent by construction. Low-medium. -- **No workflow runs the `cultnet-ts` tests on Linux**, and the interop - workflow is Windows-only and main-only; on a `codex/**` push only the C# - half of the vector check runs. Low. -- Recorded, not a defect: the TS-signed vector is not reproducible - byte-for-byte (random `k`); the C# test pins that the committed bytes - verify under current rules, and the TS test pins that the committed file - verifies under the current module. - -**The edge-alignment batch landed** (Opus) at `e67e695` (C#'s whitespace -set through three named helpers; the two signature refusals split to C#'s -texts), `c4c2330` (route fields cleaned before the transcript as the C# -constructors clean them: trim, drop blanks, distinct, sort; generation and -key ids trimmed; a null or empty protocol list transcribes as `""`, and the -TS default that wrote bytes the reference never writes is gone; `verseId` -deliberately raw, because `CanonicalRoute` passes it raw), `803ee12` (a -raw-endpoint pre-check refusing the spellings WHATWG `URL` repairs and -`System.Uri` does not; loopback disagreements 15 → 6, all six documented -unreachable), `dfe582e` (the browser's local reading of "unsigned" killed -by a whitespace-signature session test; the runner mutates the browser -target too, with per-target sidecar, control, sentinel and post-restore -assertion) and `0dfef0e` (the TypeScript authority tests in CI on both -operating systems). Soul's probes rerun: 88 endpoints, zero reachable -disagreements; 34 code points, zero; five signature shapes, byte-identical -messages; fifteen field tampers, zero mismatches against C#'s answers. -Tests 77, 18, 79, 10; closure smoke four tarballs; 33 shared and one -browser mutation killed. - -Two scars from the batch, both recorded because they are the kind a -postmortem should carry: **the runner's sidecar bit twice**, a throw inside -the mutation loop leaving it behind so the next run "repaired" from stale -bytes and silently reverted two fixes Hands had just made (the sidecar is -now removed in the `finally` immediately after the verified restore); and -**a literal NUL byte was committed inside a regex** at `803ee12`, which made -git classify the file as binary, stop normalising its line endings, and -diff the whole file as a rewrite, escaped at `dfe582e`. Residuals named, -not guessed at: `isProtectedEndpoint` did not get the pre-check and no -probe covers protected-scheme spellings; a negative priority throws in C# -and transcribes in TS, unreachable while only C# signs; two browser -mutations survive because the verifier refuses those routes first. - -**Third Soul pass, closing.** Held: all 34 whitespace code points in the -browser path too, with C#'s `VerifySessionProof` answer as the -expectation; the three signature refusals byte-identical; cleaning parity -in both directions with fresh keys (nine of nine each way, a padded -`verseId` refused cross-wise, C# confirmed not to trim it); 88 loopback -spellings with only the documented unreachable disagreements; the runner's -control writing through the same path as its mutations; CI's job, matrix, -triggers and setup order as promised; every suite, the closure smoke, both -greps. **Cut 1 is closed; Cut 2 may start.** Three residuals go with Cut 2's -Hands pass: - -- **`isProtectedEndpoint` lacks the raw-shape pre-check** that - `isLoopbackEndpoint` got, so eleven spellings C# refuses as unparseable - are accepted and dialled after repair by `URL`. A trusted signer would - have to emit a malformed endpoint, so security holds and the parity - invariant does not. Five lines. Low-medium. -- **A literal U+001F byte sits in the separator string** in source and - twice in the tests, where C# spells it ``; git does not treat it as - binary, but any editor that strips C0 controls changes every signature - with no visible diff. The NUL scar one rank lower. -- **The sidecar footgun is narrowed, not removed**: a SIGKILL still leaves - it, and an edit made before the next run is overwritten by the repair. - Repair must refuse when the file matches neither the sidecar nor the - mutant. - -Two corrections to this map's own record of the batch: **the NUL scar was -overstated**, since the byte sat past git's binary sniff and no whole-file -rewrite exists in history, only the normalisation path saw it and the blob -was LF-only anyway; and **CI was not green throughout the batch**, two of -its commits failing an unrelated Eve checkpoint job before the authority -steps existed, green from `0dfef0e`. - -Seen by Hands, recorded for section 15: `packages/cultnet-ts/src/generated/swarm-contracts.generated.ts` -is rewritten with LF by every build while the tree is `autocrlf=true`, so -it shows modified with zero content diff after any build; the generator's -line endings and the repo's normalisation disagree, and every pass discards -the churn by hand. - -Anchor: repo `F:\Projects\CultLib`, branch `main`, HEAD -`8d8ad568aa280bac34bf123503a9e38c175eb558`. The three commits since the -original anchor `f2cd2eb4` (`bf9f6f7`, `3c29fc9`, `8d8ad56`) touch only this -document `(probe: git log --stat)`, so every `file:line` below, read against -`f2cd2eb4`, is still exact at `8d8ad568`. If HEAD moves, re-anchor before -cutting. - -How to read this document: section 1 prices the largest liability first, as -the target requires, because if that price had turned out to dominate the -campaign, the cut order below would have been wrong. Section 2 is the probe -ledger; every mechanism claim in this map carries `(probe)` when it was -established by running something, or `(source read)` when it was established -by reading a file at a named line. Nothing is claimed from a package name or a -README. Section 3 is the consumer audit. Section 4 names rejected options and -why. Section 5 is the authority map for every ownership change. Sections 6-12 -are the cuts. Section 13 holds the operator questions; section 14 the -subtraction estimate; section 15 what could not be assigned. - -Two corrections to the target, recorded here rather than silently absorbed: - -- The target cites `src/GameCult.Mesh.Quic/CultMeshRealtimeTransports.cs`. - That file is `src/GameCult.Mesh.Quic/CultMeshQuicRealtimeTransport.cs` - (674 lines). `CultMeshRealtimeTransports.cs` exists, but in - `src/GameCult.Mesh/`, and holds the transport-neutral contracts - (`CultMeshRealtimeDelivery` at :8-13, `CultMeshRealtimeFrame` at :19-44, - `ICultMeshRealtimeTransport` at :46-52, - `ICultMeshRealtimeTransportConnector` at :55-63) `(source read)`. -- This map's first issue attributed the phrase "shipped through a registry" - to the target. The target contains no such phrase and no word "registry" - `(probe: rg)`; the reading was the map's, and it is withdrawn. What is - true: today only `@gamecult/cultcache-ts` has a registry publish job - (`.github/workflows/publish-packages.yml:20-24`, `:40-76`); `cultnet-ts`, - `cultmesh-ts` and `cultmesh-browser` have none, and every external consumer - reaches them by `file:` path (section 3). Q5 was ruled C on that Body: no - registry in this campaign, publication parked. - -## 1. The largest liability, priced first - -The target's biggest new surface is CultLib owning native MsQuic bindings for -Node on two platforms. The price depends on three things: what Node itself -offers, what the repo already owns, and how a native artifact reaches a -consumer host without a toolchain. Each was probed. - -### 1.1 What Node offers - -- Node `v24.15.0` on Starfire has no `node:quic`, with or without - `--experimental-quic` (`ERR_UNKNOWN_BUILTIN_MODULE`), and its internal - binding table has no `quic` entry (`No such binding: quic` under - `--expose-internals`). `process.versions` reports `openssl 3.5.5`, no - `ngtcp2`, no `nghttp3`, `napi 10` `(probe)`. Yggdrasil runs Node `24.14.1` - from NodeSource (`F:\Projects\gamecult-ops\inventory.md:660`) `(source read)`, - so the same absence holds on the deploy host. -- Conclusion: there is no first-party QUIC in this Node line. Anything that - works is native. - -### 1.2 What the repo already owns - -- `native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp` (440 lines) is a - CultLib-owned C ABI over MsQuic: `cultmesh_quic_open/state/poll/error/close` - (`:309-440`). It negotiates ALPN `cultmesh-state-v1` (`:32`, `:341-344`), - uses deferred certificate validation and answers the pin decision through - `ConnectionCertificateValidationComplete` (`:239-263`), reassembles the - 1-byte-kind plus 4-byte-LE-length QUIC stream framing (`:123-157`), and - queues whole encoded frames for a polling host (`:400-418`). It is Windows - desktop only by construction: ``, ``, `` - (`:1-3`), `CryptHashCertificate2` for the pin (`:184-200`), - `__declspec` exports (`:18-22`), and the CMake build refuses non-Windows - (`CMakeLists.txt:4-6`) `(source read)`. -- The bridge is consumed by the Unity connector through `DllImport` of library - name `gamecult_mesh_quic_native` - (`src/GameCult.Mesh.Quic.Native/CultMeshNativeQuicRealtimeTransport.cs:259-277`), - which polls every 2 ms by default (`:14`, `:167-203`) `(source read)`. The - built DLL and `msquic.dll` 2.5.9 are committed at - `unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/` (last rebuilt in - `a0813c6`, 2026-09-14) `(probe: git log)`. -- The Windows build is scripted and pinned: `scripts/build-quic-native.ps1` - downloads `Microsoft.Native.Quic.MsQuic.Schannel` `2.5.9` by URL, checks its - SHA-256 (`:13-14`, `:27-41`), and builds with CMake `-A x64` (`:48-52`). - The last build on this machine used Visual Studio 17 2022 Build Tools, MSVC - 14.44 (`artifacts/quic-native-build/x64/Release/CMakeCache.txt`) `(probe)`. - `cl.exe` is not on PATH but Build Tools are installed, so the existing - script works here as-is. -- `tests/GameCult.Mesh.Quic.Native.Tests/GameCult.Mesh.Quic.Native.Tests.csproj:20-29` - builds that bridge before the test build and copies it beside the test - output `(source read)`. `scripts/build-unity-package.ps1:80-82`, `:143-147` - builds it again for the Unity package `(source read)`. - -Conclusion: CultLib already owns the MsQuic body for one platform, one role -(client), and one host (Unity). The cost of the target is extending that body -to a second role (provider), a second platform (Linux x64), and a second host -(Node), not creating a body from nothing. - -### 1.3 How Node loads it - -The decisive probe: from Node 24.15.0, with `koffi@3.3.0` installed into the -scratchpad, `koffi.load` loaded the committed `msquic.dll` then -`gamecult_mesh_quic_native.dll`, bound the five v1 exports by C prototype, -called `cultmesh_quic_open("127.0.0.1", 9, <64 zero hex>)` which returned `0` -with a non-null handle, observed `cultmesh_quic_state` move from `Connecting` -to `Failed` after 34 ms, read the bridge's structured error string through -`cultmesh_quic_error` ("shut down by the transport (status=0xFFFFFFFF800704D0, -error=0x1, msquic=F:\...\msquic.dll, events=event=1)"), got `-2` from -`cultmesh_quic_poll` on a failed client, and closed cleanly `(probe: -scratchpad/koffi-probe/probe.js)`. MsQuic ran its worker threads inside the -Node process and the poll model crossed the boundary without any callback -into JS. - -What that proves and what it does not: - -- Proves: the existing CultLib C ABI is directly loadable and drivable from - the Node line in question with no addon build, no Node headers, no MSVC at - install time, and no per-Node-version artifact. The `.async` call form - exists on bound functions (`typeof state.async === "function"`), which is - the mechanism for a blocking wait off the JS thread `(probe)`. -- Does not prove: a QUIC handshake succeeded (port 9 was intentionally dead); - that the provider role works (the bridge has none); that Linux works (the - bridge is Windows-only). Those are cut deliverables, not probe results. - -koffi facts used by the design, from its shipped documentation, not from -memory: JS callbacks always run on the main thread and calls from other -threads are queued to it, with an explicit deadlock warning if the main thread -blocks on a secondary thread (`node_modules/koffi/doc/callbacks.md:166-176`); -asynchronous calls run on a worker pool sized to the core count with a default -cap of 256 queued calls (`doc/misc.md:26`, `:37`); prebuilt binaries ship for -Windows x64 and Linux/glibc x64 among others, Node >= 16, no compiler needed -(`doc/index.md:17-38`) `(source read)`. Package weight: 1,713,562 bytes -unpacked, 87 files, MIT `(probe: npm view)`. The design below uses no JS -callbacks at all; the only crossing is one blocking `next_event` wait per -runtime issued through `.async`, so the deadlock class in `callbacks.md:176` -cannot occur. - -### 1.4 How it reaches Linux - -- `libmsquic` is published by Microsoft for Debian 13 (trixie) at - `packages.microsoft.com/debian/13/prod`, versions including `2.5.9` - (`pool/main/libm/libmsquic/libmsquic_2.5.9_amd64.deb`, 2,906,420 bytes, - SHA-256 `1baa61ade0b7b4a99f6dcb6b00d9aedb12b5566d00918a325be7425e878e51ba`, - `Depends: libssl3t64, libnuma1, libxdp1, libnl-route-3-200`) `(probe)`. - Yggdrasil is Debian 13 (`gamecult-ops/inventory.md:356`) `(source read)`. - The same `2.5.9` pinned for Windows exists for the deploy host. -- The NuGet packages carry Windows binaries only: `Microsoft.Native.Quic.MsQuic.OpenSSL` - 2.5.9 contains `bin/{x64,x86,arm64}/msquic.dll`, `lib/*/msquic.lib`, - and headers `msquic.h`, `msquicp.h`, `msquic_winuser.h`; no `.so`, no - `msquic_posix.h` `(probe: nupkg listing)`. The Linux build needs - `msquic.h`, `msquic_posix.h`, `quic_sal_stub.h` from the msquic source - tree at tag `v2.5.9`; all three are fetchable at - `raw.githubusercontent.com/microsoft/msquic/v2.5.9/src/inc/` (HTTP 200, - 79,280 / 17,909 / 4,731 bytes) `(probe)`. GitHub releases `v2.5.9` carry only - `*_test.zip` bundles; `v2.5.11`, `v2.6.1` carry no assets `(probe)`, so the - deb is the only distribution-grade Linux runtime for this version. -- Build host for Linux: Docker Desktop on Starfire runs a `linux/amd64` engine - `(probe: docker version)`, so a `debian:13` container is a Linux build host - for the development loop. The release artifact must come from a Linux host - the deploy path trusts: Yggdrasil itself, or a CI runner. Q6. -- Windows toolchain: MSVC Build Tools 2022 present; CMake 4.3.2; also a MinGW - gcc from StrawberryPerl on PATH `(probe)`. The map keeps MSVC for Windows - because the committed Unity DLL is built with MSVC `/Brepro` for - reproducibility (`CMakeLists.txt:17-19`) `(source read)`; mixing toolchains - for one DLL is a liability with no capability behind it. - -### 1.5 Price verdict - -The binding does not dominate the campaign. The C body exists; the Node load -path is proven with one 1.7 MB MIT dependency and zero addon tooling; the -Linux runtime is a pinned distro package on the exact target OS; the Windows -build is already scripted. The real cost is the C++ rewrite from a -client-only, Windows-only, per-client poll bridge to a runtime-neutral, -two-role event bridge (section 8), estimated at roughly 1,000 lines of C++ -replacing 440, plus a Linux build script and CI job. That is one cut, not the -campaign. - -What would have changed this verdict: if the bridge had needed JS callbacks -from MsQuic worker threads (it does not; the wait/event model avoids them), or -if `libmsquic` had not been available for Debian 13 at the pinned version (it -is), or if the FFI load had failed on Node 24 (it did not). - -## 2. Probe ledger - -| # | Claim | Method | Evidence | -| --- | --- | --- | --- | -| P1 | Node 24.15.0 has no `node:quic` and no internal quic binding | probe | `require('node:quic')` -> `ERR_UNKNOWN_BUILTIN_MODULE` with and without `--experimental-quic`; `--expose-internals` -> `No such binding: quic`; `process.versions.ngtcp2 === undefined` | -| P2 | The committed CultLib MsQuic bridge loads and runs from Node 24 via koffi | probe | `scratchpad/koffi-probe/probe.js`: open rc 0, state 2 after 34 ms, structured error string, poll -2, clean close | -| P3 | koffi ships Windows x64 and Linux glibc x64 prebuilds, needs no compiler, Node >= 16 | source read | `koffi/doc/index.md:17-38` | -| P4 | koffi async calls run on a worker pool; JS callbacks only on the main thread | source read | `koffi/doc/misc.md:26,37`; `koffi/doc/callbacks.md:166-176` | -| P5 | koffi is 1.7 MB unpacked, MIT | probe | `npm view koffi dist.unpackedSize dist.fileCount`; `package.json:27` | -| P6 | MSVC Build Tools 2022 (14.44) and CMake 4.3.2 are present; the last bridge build used them | probe | `artifacts/quic-native-build/x64/Release/CMakeCache.txt`; `cmake --version` | -| P7 | `libmsquic 2.5.9` exists for Debian 13 amd64 with its SHA-256 and dependency set | probe | `packages.microsoft.com/debian/13/prod/dists/trixie/main/binary-amd64/Packages` | -| P8 | Yggdrasil is Debian 13 with Node 24.14.1 from NodeSource | source read | `gamecult-ops/inventory.md:356`, `:660` | -| P9 | MsQuic NuGet OpenSSL 2.5.9 carries no Linux binary or posix header | probe | nupkg listing | -| P10 | MsQuic POSIX headers are fetchable at tag v2.5.9 | probe | HTTP 200 for `msquic.h`, `msquic_posix.h`, `quic_sal_stub.h`, `msquic_winuser.h` | -| P11 | The pinned `msquic.h` has `QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12`, deferred/indicated certificate flags, `QUIC_SEND_FLAG_FIN`, `QUIC_STREAM_EVENT_SEND_COMPLETE`, API version 2 | source read | `artifacts/dependencies/msquic-schannel-2.5.9/package/build/native/include/msquic.h:123,132-133,142,246,394-398,1290,1530,1850` | -| P12 | Docker Desktop on Starfire runs a linux/amd64 engine | probe | `docker version --format '{{.Server.Os}}/{{.Server.Arch}}'` -> `linux/amd64` | -| P13 | `openssl` 3.5.5 CLI is available on Starfire (mingw) | probe | `which openssl` | -| P14 | npm names `cultmesh-ts`, `cultnet-ts`, `cultmesh-browser`, `@gamecult/cultmesh-ts`, `@gamecult/cultnet-ts`, `@gamecult/cultmesh-quic-native` are all unclaimed | probe | `npm view version` returned nothing for each | -| P15 | Only `@gamecult/cultcache-ts` and the three Python packages have publish jobs | source read | `.github/workflows/publish-packages.yml:20-24,40-76` | -| P16 | Every external TS consumer uses `file:` paths; none uses a registry version | probe (Explore agent over `F:\Projects`) | section 3 | -| P17 | The StreamPixels deploy path extracts a CultLib tarball beside the app and runs `pnpm install --frozen-lockfile` and `pnpm build` on Yggdrasil | source read | `gamecult-ops/scripts/deploy-streampixels-preview.sh:21,95-103` | -| P18 | The Unity native connector refuses any endpoint without a 64-hex `cert-sha256` pin | source read | `CultMeshNativeQuicRealtimeTransport.cs:103-105` | -| P19 | C# `IsProtected` accepts `wss`, `https`, or any scheme containing `quic`; the browser verifier accepts only `wss` | source read | `CultMeshAuthorityProof.cs:156-162`; `cultmesh-browser/src/index.ts:965` | -| P20 | C# and browser canonical route transcripts are field-for-field identical | source read | `CultMeshAuthorityProof.cs:275-292` vs `cultmesh-browser/src/index.ts:979-996` | - -## 3. Consumer audit - -Method: every `package.json` under `F:\Projects` (excluding `node_modules`, -`dist`, `dist-test`, `.git`, `bin`, `obj`, `Library`, `Temp`, and CultLib -itself and its worktree copies) checked for the four package names, plus a -grep of every `*.ts/*.tsx/*.mts/*.cts/*.js/*.mjs/*.cjs` import or require of -them, with symbols read from the import lines. Vendored copies inside -`Heimdall/vendor/CultLib`, `VoidBot/vendor/cultcache-ts`, and -`gamecult-ops/.artifacts/*` were classified as library-internal, not -consumers. - -Result: - -| Package | Repos | Source files | Symbols actually imported (union) | -| --- | ---: | ---: | --- | -| `cultnet-ts` | 11 (StreamPixels, Heimdall, AetheriaEve, AetheriaEve-cultlib-admission, VoidBot, Stonks, Vili, weksa, Sai, EvePlugins, Bifrost) | 28 | `CultNetRudpSession`, `decodeRudpPacket`, `encodeRudpPacket`, `encodeCultNetMessageForWire`, `parseCultNetMessage`, `invokeCultNetOperation`, `startCultNetOperationServer`, `defineCultNetDocumentBinding`, `CultNetDocumentRegistry`, runtime-presence helpers, and message/packet types | -| `cultnet-ts/contracts` | 0 | 0 | one esbuild alias only (`AetheriaEve-cultlib-admission/scripts/verify-aetheria-browser-provider.mjs:48`); `cultmesh-browser` is its only importer and is inside CultLib | -| `cultmesh-ts` | 7 (StreamPixels, Heimdall, AetheriaEve and its worktree, Stonks, weksa, Odin and its worktree) | 10 | `CultMesh`, `cultMeshRectFromBounds`, `cultMeshViewportRequest`, and types (`CultMeshRudpEndpoint`, `CultMeshDocumentCatalog`, `CultMeshViewportRequest`, diagnostics types) | -| `@gamecult/cultcache-ts` | 1 (Heimdall) | 5 | `SingleFileMessagePackBackingStore`, `defineDocumentType`, `CultCacheEnvelope` | -| `cultcache-ts` (old unscoped name) | 8 | 16 | `CultCache`, `SingleFileMessagePackBackingStore`, `defineDocumentRegistry`, `defineDocumentType`, `/inspection` | -| `cultmesh-browser` | 1 (AetheriaEve and its worktree) | 1 | `CultMeshBrowserClient`, `CultMeshBrowserOdinRendezvous`, `decodeCultNetPayload` | - -StreamPixels specifically: `apps/service/package.json:25-27` declares -`cultcache-ts`, `cultmesh-ts`, `cultnet-ts` as `file:../../../CultLib/packages/`; -`apps/service/src/verse-state.ts:4-6` imports `defineDocumentType`, -`CultMesh`, `defineCultNetDocumentBinding`; `apps/service/src/idunn-rudp-health.ts:2-7` -imports `encodeCultNetMessageForWire`, `encodeRudpPacket`, and two types. -Nothing else in StreamPixels imports these packages. No TS/JS file anywhere -under `F:\Projects` references `cultmesh-state+quic` or `CultMeshRealtime`. - -Sizing consequences: - -- Nothing this migration adds has a consumer yet. The QUIC surface is new - API; no existing export is reshaped, so no external repo changes are - required by the CultLib cuts. StreamPixels adoption is consumer work after - release (target, "Not in this migration"). -- The `verifyAuthorityRoute` family in `cultmesh-browser` is module-private - (not exported at `cultmesh-browser/src/index.ts:955`, `:979`, `:998`, - `:1012`, `:1028`), so moving it (Q2) changes no external import. The three - symbols AetheriaEve imports stay. -- `cultnet-ts/contracts` has no external consumer; adding a sibling subpath - export for the shared verifier follows an existing, internally consumed - pattern rather than inventing a new one. -- The `cultcache-ts` naming split (8 repos on the old unscoped name, Heimdall - on `@gamecult/`) is pre-existing and not touched here, but it is the - precedent Q5 has to reckon with. - -## 4. Rejected options - -- **Node built-in QUIC.** Absent in Node 24 (P1). Not a choice. -- **`@matrixai/quic` 2.0.9.** A Rust/quiche-based QUIC stack with its own - stream, certificate and event semantics `(probe: npm view description)`. - Rejected because the target requires the bytes on the wire, the ALPN, the - stream-kind byte, the deferred certificate pin decision, and the close/abort - codes to match `GameCult.Mesh.Quic` and the Unity MsQuic connector; two QUIC - stacks would have to be proven equivalent at every one of those points, and - CultLib would own neither. Semantically wrong for this campaign, not merely - unfashionable. -- **`@fails-components/webtransport` 1.6.8.** libquiche behind a WebTransport - (HTTP/3) session model `(probe)`. Wrong layer: the realtime plane is raw - QUIC with ALPN `cultmesh-state-v1`, not HTTP/3. -- **`node-quic` 0.1.3.** A wrapper around an abandoned pure-JS QUIC `(probe)`. - Not TLS 1.3 MsQuic; rejected on the same grounds. -- **A Node-API addon (node-addon-api + node-gyp/cmake-js) wrapping MsQuic - directly.** Viable, but it creates a second C++ owner of MsQuic callback - semantics beside the Unity bridge, requires Node headers and MSVC on every - Windows build host and a Node-ABI artifact per platform, and buys nothing the - C ABI plus FFI does not (P2). Rejected as duplicate authority. If koffi were - ever removed, the cheapest replacement is a thin N-API shim over the same C - ABI, not a second MsQuic body. -- **A separate .NET provider process or a WebSocket shortcut.** Already ruled - out by the operator (target, line 15-17). -- **JS callbacks from MsQuic worker threads.** koffi supports them by queuing - to the main thread (P4), but it turns every MsQuic event into a - cross-thread hop and opens the documented deadlock class. Rejected in favour - of the event-queue/wait model the existing bridge already uses. -- **Building the Windows DLL with the MinGW gcc on PATH.** Present (P6) but the - committed Unity artifact is MSVC `/Brepro`; two toolchains for one DLL is a - reproducibility liability. Rejected. -- **Building the Linux artifact on Starfire and shipping it.** Cross-building - is explicitly forbidden by the target; a Docker `debian:13` container on - Starfire is a Linux host and is fine for the development loop, but the - release artifact comes from the builder the operator names in Q6. -- **Keeping `cert-sha256` verification in C.** The v2 ABI hands the DER - certificate to the host and waits for the decision, which is the "caller - hook" the target demands and removes the Windows-only - `CryptHashCertificate2` from the portable path. The v1 entrypoint keeps its - in-C pin check only as the Unity compatibility shim (section 8). - -## 5. Authority maps - -### 5.1 Native MsQuic bridge (`native/GameCult.Mesh.Quic.Native`) - -- Owner: `cultmesh_quic_native.cpp` owns MsQuic registration, configuration, - listener, connection and stream handles, TLS credential loading, the - 1-byte-kind + 4-byte-LE-length stream framing, and a single event queue per - runtime. -- Inputs: host/port, PKCS12 bytes and password for providers, the host's - certificate decision for clients, encoded frame bytes to send. -- Outputs: events (`listener_new_connection`, `connection_connected`, - `connection_certificate_received` with DER payload, `connection_shutdown` - with code/status/text, `stream_started` with kind, `stream_frame` with the - whole encoded frame, `stream_send_complete`, `stream_shutdown`); the bound - listener port; structured error text. -- Derived state: none that the host reads as truth. Connection counts, remote - addresses and MsQuic status codes are diagnostics. -- Forbidden writers: the bridge never decodes a CultMesh frame, never decides - delivery semantics, never coalesces, never accepts a certificate on its own - in the v2 path, never reconnects. The v1 shim is the only place a pin is - compared in C, for the Unity contract only. -- Shared paths: the Unity managed connector (v1 exports) and the Node binding - (v2 exports) use the same library, the same MsQuic runtime, the same - framing code. -- Deletion line: the per-client `Client` struct with its own registration - (`:51-63`, `:316-393`), Windows-only includes and export macros (`:1-3`, - `:18-22`), `CertificateMatchesPin` as the sole trust path (`:184-200`). - -### 5.2 TypeScript QUIC realtime plane (`packages/cultmesh-ts`) - -- Owner: `src/realtime-quic.ts` owns provider and consumer semantics: which - stream kind carries which delivery mode, the per-peer keyed coalescing - outbox for `LatestOnly`, the single ordered stream for `ReliableOrdered`, - `Unreliable` failing closed, the generation filter on receive, and the - certificate pin decision on the consumer. -- Inputs: bridge events; `CultMeshRealtimeFrame` objects from application - code; an advertised `CultMeshTransportCandidate`-shaped route from the - session manager; the consumer's trust policy. -- Outputs: encoded frames to the bridge; decoded frames to the application; - the advertised endpoint string for a provider (with `cert-sha256`). -- Derived state: connection health, connected peer count, stream ids. -- Forbidden writers: application code never opens a stream, picks an - endpoint, or writes a reconnect loop (target). `src/realtime-wire.ts` never - touches a socket. `src/realtime-quic-native.ts` (the koffi binding) never - interprets frame bytes. -- Shared paths: provider broadcast and consumer send both go through - `sendFrame` on the same transport object; the coalescing outbox is the only - path for `LatestOnly` on a provider, whether the caller is a test, a tick, or - a reconnect. -- Deletion line: none inside `cultmesh-ts` (there is no prior QUIC code); the - RUDP provider transport at `src/provider-rudp-transport.ts` is untouched. - -### 5.3 Shared TypeScript route verification (Q2) - -- Owner after the cut: `packages/cultnet-ts/src/cultmesh-authority.ts`, - published as subpath `cultnet-ts/authority`. It is the only TypeScript - implementation of: Odin root lookup, validity window, canonical route - transcript, canonical session transcript, P-256/P1363 verification, - loopback detection, and the protected-scheme rule. -- Inputs: a route view (Verse, authority runtime, endpoint, protocol ids, - priority, generation, certificate), a trust policy (`mode`, `odinRoots`, - `now`), a session-open request and an accepted message. -- Outputs: resolution or a thrown `Error` whose message is the C# reference's - for the same refusal. *(First written as "the same message texts the - browser package emits today (its tests pin them)"; Soul found the browser - tests pin two messages only, and the channel-protection text was false for - QUIC. Corrected 2026-09-16.)* -- Derived state: none. -- Forbidden writers: `cultmesh-browser` must not retain or regrow - `verifyAuthorityRoute`, `canonicalRoute`, `canonicalSession`, - `canonicalFields`, `verifyP256`, or `isLoopbackEndpoint`; `cultmesh-ts` must - not implement any of them; neither transport package may depend on the - other. Negative greps in section 6 pin this. -- Shared paths: the browser WebSocket client and the Node QUIC consumer verify - the same route bytes through the same function. -- Deletion line: `cultmesh-browser/src/index.ts:955-1072` and `:797-808`. -- Behavioural note, not a silent change: the protected-scheme rule becomes the - C# rule (`wss`, `https`, scheme containing `quic`; - `CultMeshAuthorityProof.cs:156-162`) instead of `wss`-only - (`cultmesh-browser/src/index.ts:965`). The browser client still refuses - non-`ws(s)` route endpoints at `:537-540`, so browser behaviour is - unchanged; the shared module simply stops being wrong for QUIC routes. - -### 5.4 Build and packaging - -- Owner: `scripts/build-quic-native.ps1` (Windows x64) and the new - `scripts/build-quic-native.sh` (Linux x64) own producing the bridge - artifact; the new `.github/workflows/quic-native.yml` owns producing the - release-grade artifact for both platforms (Q6 A) and attaching it to a - GitHub Release; the committed tree `packages/cultmesh-ts/native/` owns - delivery to consumers (Q5 C): whatever carries the package directory (a - `git archive`, an `npm pack` tarball, a pnpm `file:` copy) carries the - binaries with it, and `packages/cultmesh-ts/native/MANIFEST.txt` plus - `SHA256SUMS` own provenance (MsQuic version, build host, digest per file). -- Inputs: pinned MsQuic 2.5.9 (Schannel NuGet on Windows; `libmsquic` deb on - Linux) with SHA-256 checks; the bridge source; the pinned `debian:13` image - digest for the Linux builder. -- Outputs: `gamecult_mesh_quic_native.dll` + `msquic.dll` (win32-x64), - `libgamecult_mesh_quic_native.so` + `libmsquic.so.2` (linux-x64), the MsQuic - license, and the two provenance files. -- Derived state: `artifacts/` (git-ignored, `.gitignore:66`) is the - development-loop build output and never delivery; the Unity plugin copies at - `unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/` remain the Unity - package's own body (section 8, operator-only rebuild), not a source for - Node. -- Forbidden writers: no `postinstall` compile or download step in any - package; a consumer host never needs CMake, a compiler, or registry access - for the binding; no workflow commits binaries to `main` (the operator - commits from a Release asset whose digest the manifest records, Q8); - `realtime-quic-native.ts` never searches `node_modules` for a platform - package. -- Deletion line: the platform packages - `packages/cultmesh-quic-native/{win32-x64,linux-x64}` and - `optionalDependencies` from the ruling-A map are never created; the Windows - script is kept and the Unity package script keeps calling it. -- Parked seam (registry publication, later pass): the same `quic-native.yml` - artifacts are the payload a publish job would wrap; the reattachment point - is a `cultmesh-ts` job shaped like `publish-packages.yml:39-78` that depends - on the two build jobs, plus `optionalDependencies` in - `packages/cultmesh-ts/package.json` and a `node_modules` branch in the - resolver. None of that exists under C. - -## 6. Cut 1 (subtraction): one TypeScript route verifier - -Purpose: land Q2 before any QUIC code exists, so the Node consumer has one -owner to call and the browser package has nothing left to grow. - -Deletes (exact): - -- `packages/cultmesh-browser/src/index.ts:955-977` `verifyAuthorityRoute` - (23 lines), `:979-996` `canonicalRoute` (18), `:998-1010` `canonicalSession` - (13), `:1012-1026` `canonicalFields` (15), `:1028-1058` `verifyP256` (31), - `:1065-1072` `isLoopbackEndpoint` (8), `:797-803` `bytesToBase64` (7), - `:805-808` `base64ToBytes` (4). Total 119 lines deleted from the browser - package. `randomNonce` (`:1060-1063`) stays: it is browser-client behaviour, - not verification. - -Keeps and moves: - -- The deleted bodies move verbatim into - `packages/cultnet-ts/src/cultmesh-authority.ts` as exported functions, with - one edit: the protected-scheme test in `verifyAuthorityRoute` becomes - `isProtectedEndpoint(endpoint)` implementing the C# rule (P19). Types - `CultMeshBrowserIdentity`, `CultMeshBrowserRoute`, - `CultMeshBrowserP256PublicKey`, `CultMeshBrowserRouteCertificate`, - `CultMeshBrowserAuthorityTrustMode`, `CultMeshBrowserAuthorityTrustPolicy` - (`cultmesh-browser/src/index.ts:19-53`) move too, renamed without the - `Browser` infix (`CultMeshAuthorityIdentity`, `CultMeshAuthorityRouteView`, - `CultMeshP256PublicKey`, `CultMeshAuthorityRouteCertificate`, - `CultMeshAuthorityTrustMode`, `CultMeshAuthorityTrustPolicy`). - `cultmesh-browser` re-exports them under the old names as type aliases. -- `validateSessionAcceptance` (`:926-953`) stays in the browser package; it - compares WebSocket handshake fields. Its crypto lines (`:944-951`) are - replaced by a call to the new `verifyProviderSessionProof(request, endpoint, - providerKey, signature)` exported from the shared module. - -Adds: - -- `packages/cultnet-ts/src/cultmesh-authority.ts` (about 190 lines: the moved - 119 plus types and `isProtectedEndpoint`). Browser-safe by construction: it - may use only `globalThis.crypto.subtle`, `TextEncoder`, `atob`, `btoa`, - `URL`, `DataView`, `Uint8Array`. No `node:` import. -- `packages/cultnet-ts/package.json:7-18` gains an `"./authority"` export - beside `"./contracts"` (`:12-16`), same three-key shape. -- `packages/cultnet-ts/src/index.ts` re-exports the module's public names. -- `packages/cultnet-ts/test/cultmesh-authority.test.ts` (about 150 lines): - signs a route with `node:crypto` `generateKeyPairSync("ec", { namedCurve: - "P-256" })` and verifies through the shared module; one negative per rule - (expired, unsigned remote, wrong Odin root, mutated endpoint, wrong signature - length); and asserts `isProtectedEndpoint` accepts `wss://`, `https://`, - `cultmesh-state+quic://` and rejects `ws://`, `cultnet+tcp://`. -- A cross-runtime vector: `tests/GameCult.Mesh.Tests/CultMeshAuthorityProofTests.cs` - gains a test that calls `CultMeshAuthorityProof.CreateSignedRoute` - (`src/GameCult.Mesh/CultMeshAuthorityProof.cs:221`) with a fixed test key - and writes `contracts/cultmesh/authority-route-vectors.json` (route fields, - Odin public key, signature) when `CULTMESH_WRITE_VECTORS=1`, and otherwise - asserts the committed file still verifies. The TS test verifies the same - file through the shared module. This is a shared-vector check, not - cross-process interop; it proves the transcript bytes, which is what Q2 - needs. - -Per-file changes: - -- `packages/cultmesh-browser/src/index.ts:1-17` add - `import { verifyAuthorityRoute, verifyProviderSessionProof, isLoopbackEndpoint, type ... } from "cultnet-ts/authority";` - (the package already imports `cultnet-ts/contracts` at `:2-17`, so - resolution is proven). `:536` and `:914` call the imported function - unchanged. `:944-951` call `verifyProviderSessionProof`. `:961` and `:965` - are gone with the function. -- `packages/cultmesh-browser/tsconfig.json` unchanged (`moduleResolution: - "Bundler"` already resolves the `exports` map). - -Verification: - -- `npm run test --workspace packages/cultnet-ts` (build + unit; pins the - shared verifier and the vector file). -- `npm run test --workspace packages/cultmesh-browser` (all 17 existing tests - at `test/cultmesh-browser.test.ts:29-472` must pass unchanged; they pin the - error strings and the refusal order, including "rejects a mutated or expired - signed route before opening a provider socket" at `:239-258`). -- `dotnet test tests/GameCult.Mesh.Tests --filter FullyQualifiedName~AuthorityProof` - (vector generation/verification; focused, one project). -- `node scripts/test-typescript-package-closure.mjs` (the packed - `cultmesh-browser` tarball must resolve `cultnet-ts/authority` from the - registry-shaped install at `scripts/test-typescript-package-closure.mjs:63-72`). -- Negative greps (must return nothing): - `rg -n "crypto\.subtle\.(verify|importKey)|function (verifyAuthorityRoute|canonicalRoute|canonicalSession|canonicalFields|verifyP256|isLoopbackEndpoint|isProtectedEndpoint|isUnsignedCertificate)" packages/cultmesh-browser/src packages/cultmesh-ts/src` - and `rg -n "cultmesh-browser" packages/cultmesh-ts/package.json packages/cultnet-ts/package.json`. - *(The name list grew by two after the fix batch: `isProtectedEndpoint`, - written fresh as the C# rule, and `isUnsignedCertificate`, the shared - reading of "unsigned" the browser's session check now uses. Soul noted - the grep pins names, not structure; a shim under another name passes it.)* -- Operator-only: none. - -## 7. Cut 2 (behaviour, pure TypeScript): the realtime frame codec - -Purpose: byte parity with `CultMeshRealtimeWireProtocol` before any transport -exists, proven in both directions with shared vectors. - -Deletes: none. - -Adds: - -- `packages/cultmesh-ts/src/realtime-wire.ts` (about 130 lines). Exports - `CULTMESH_REALTIME_ALPN = "cultmesh-state-v1"`, - `CULTMESH_REALTIME_CONNECTION_CLOSE_CODE = 0x43554c54n`, - `CULTMESH_REALTIME_STREAM_ABORT_CODE = 0x53544154n`, - `CULTMESH_REALTIME_RELIABLE_STREAM = 1`, `CULTMESH_REALTIME_LATEST_ONLY_STREAM = 2`, - `CULTMESH_REALTIME_MAX_PAYLOAD_BYTES = 64 * 1024 * 1024`, - `CULTMESH_REALTIME_MAX_ENCODED_FRAME_BYTES` (= max payload + 37 + 3 * 65535, - matching `CultMeshRealtimeWireProtocol.cs:20`), type - `CultMeshRealtimeDelivery = "reliable-ordered" | "latest-only" | "unreliable"` - with wire bytes 0/1/2 in that order (`CultMeshRealtimeTransports.cs:8-13`), - interface `CultMeshRealtimeFrame { channelId; schemaId; bodyId; - producerEpoch: bigint; sequence: bigint; delivery; payload: Uint8Array }`, - `encodeRealtimeFrame(frame): Uint8Array`, `decodeRealtimeFrame(bytes): - CultMeshRealtimeFrame`. Layout exactly `CultMeshRealtimeWireProtocol.cs:37-54` - (magic `0x31545343` LE u32, delivery u8, epoch i64 LE, sequence i64 LE, - three u16 LE lengths, payload i32 LE, header size i32 LE = 37, version u16 - LE = 1, then UTF-8 identities, then payload). Decode rejections match - `:60-76` one for one: truncated header, wrong magic, unsupported version or - header size, delivery out of range, negative payload length, length sum - mismatch. Epoch and sequence are `bigint` because i64 exceeds the safe - integer range; validation mirrors `CultMeshRealtimeTransports.cs:29-39` - (non-empty identities, non-negative epoch/sequence). -- `contracts/cultmesh/realtime-frame-vectors.json`: written by a new C# test - `tests/GameCult.Mesh.Tests/CultMeshRealtimeWireProtocolTests.cs` under - `CULTMESH_WRITE_VECTORS=1` and otherwise asserted against. Cases: one frame - per delivery mode with small payload; empty payload; identities at exactly - 65,535 UTF-8 bytes (multi-byte characters included so byte length and - character length differ); epoch and sequence at `long.MaxValue`; and a - 64 MiB-payload case recorded as `{ sha256OfEncoding, encodedLength }` rather - than bytes. Malformed cases: wrong magic, version 2, header size 36, - delivery 3, payload length -1, length sum off by one, each with the C# - exception message. There is no existing C# test for this codec - (`rg CultMeshRealtimeWireProtocol tests` returns nothing `(probe)`), so this - is also the first direct C# codec test. -- `packages/cultmesh-ts/test/realtime-wire.test.ts` (about 120 lines): every - vector decodes to the recorded fields and re-encodes to the recorded bytes; - every malformed vector throws; the 64 MiB case is built in-process and its - SHA-256 compared; TypeScript-produced bytes for the same fields equal the - C#-produced bytes (this direction is what "TypeScript encodings decode in - C#" means when the vector file is the medium; the cross-process direction is - cut 5). -- `packages/cultmesh-ts/src/index.ts:37-40` gains `export * from "./realtime-wire";`. - -Verification: - -- `dotnet test tests/GameCult.Mesh.Tests --filter FullyQualifiedName~RealtimeWireProtocol`. -- `npm run test --workspace packages/cultmesh-ts`. -- Negative grep: `rg -n "0x31545343|cultmesh-state-v1" packages --glob '!**/dist*/**'` - must hit only `realtime-wire.ts` and its test in TypeScript (one codec). -- Operator-only: none. - -## 8. Cut 3 (native, subtraction then behaviour): the runtime-neutral bridge - -Purpose: one MsQuic body for Unity and Node, two roles, two platforms, no -callbacks across the host boundary. - -Deletes first (in `native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp`): - -- `:1-3` Windows includes; `:18-22` `__declspec` macros; `:51-63` per-client - `Client` with its own `api/registration/configuration`; `:184-200` - `CertificateMatchesPin` as the trust path; `:288-305` `CloseHandles`; - `:309-393` `cultmesh_quic_open` as the primary API. About 200 of 440 lines - are removed or rewritten; the framing consumer (`:123-157`), stream callback - (`:159-182`), trace/error helpers (`:65-86`, `:202-221`) are kept in spirit - and re-homed on the runtime. -- `CMakeLists.txt:4-6` (Windows-only fatal) and `:7-9` (`MSQUIC_ROOT` - mandatory) are replaced by a platform branch. - -Adds (C ABI v2, all `extern "C"`, exported with a portable macro -`CULTMESH_API` that is `__declspec(dllexport)` on MSVC and -`__attribute__((visibility("default")))` elsewhere): - -``` -int32_t cultmesh_quic_runtime_open(const char* app_name, void** out_runtime); -void cultmesh_quic_runtime_close(void* runtime); -int32_t cultmesh_quic_listener_open(void* runtime, const char* host, uint16_t port, - const uint8_t* pkcs12, int32_t pkcs12_len, const char* password, - uint64_t* out_listener_id, uint16_t* out_bound_port); -void cultmesh_quic_listener_close(void* runtime, uint64_t listener_id); -int32_t cultmesh_quic_connection_open(void* runtime, const char* host, uint16_t port, - uint64_t* out_connection_id); -int32_t cultmesh_quic_connection_certificate_complete(void* runtime, uint64_t connection_id, - int32_t accept); -void cultmesh_quic_connection_shutdown(void* runtime, uint64_t connection_id, uint64_t code); -int32_t cultmesh_quic_stream_open(void* runtime, uint64_t connection_id, uint8_t kind, - uint64_t* out_stream_id); -int32_t cultmesh_quic_stream_send_frame(void* runtime, uint64_t stream_id, - const uint8_t* encoded_frame, int32_t length, int32_t fin); -void cultmesh_quic_stream_shutdown(void* runtime, uint64_t stream_id, uint64_t code); -int32_t cultmesh_quic_next_event(void* runtime, int32_t timeout_ms, - cultmesh_quic_event* out_event, uint8_t* payload, int32_t payload_capacity, - int32_t* out_required); -int32_t cultmesh_quic_last_error(void* runtime, char* destination, int32_t capacity); -``` - -- `cultmesh_quic_event` is a fixed 64-byte struct: `uint32 type; uint32 - stream_kind; uint64 listener_id; uint64 connection_id; uint64 stream_id; - uint64 code; int32 status; int32 payload_length; uint8 reserved[16]`. - Types: 1 `listener_new_connection`, 2 `connection_connected`, 3 - `connection_certificate_received` (payload = DER), 4 `connection_shutdown` - (payload = UTF-8 reason), 5 `stream_started` (inbound, `stream_kind` set - from the first byte), 6 `stream_frame` (payload = one whole encoded frame, - length prefix stripped), 7 `stream_send_complete`, 8 `stream_shutdown`, 9 - `listener_stopped`. `next_event` returns 0 on timeout, 1 with the event - filled and payload copied, 2 when `payload_capacity` is too small (event - stays at the head; `out_required` set), negative on error. This is the v1 - two-phase poll convention (`:400-418`) applied to a queue of typed events. -- Client connections use `QUIC_CREDENTIAL_TYPE_NONE` with - `INDICATE_CERTIFICATE_RECEIVED | DEFER_CERTIFICATE_VALIDATION | - USE_PORTABLE_CERTIFICATES` (already at `:358-364`), return - `QUIC_STATUS_PENDING` from the certificate event, enqueue event 3, and call - `ConnectionCertificateValidationComplete` only when the host calls - `certificate_complete` (P11: `msquic.h:132-133,142,1290`). Timeout: the - bridge sets a 10 s handshake idle; if the host never answers, MsQuic's - handshake timeout closes the connection and event 4 is emitted. -- Listeners load `QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12` - (`msquic.h:123,394-398`), one credential type for Schannel and OpenSSL; the - provider's advertised pin is the SHA-256 of the DER certificate computed on - the TypeScript side from the same PKCS12 with `node:crypto` (no hashing in - C). -- Settings mirror the C# server/connector: `PeerUnidiStreamCount 1024` - (`CultMeshQuicRealtimeTransport.cs:77`, `:202`), idle timeout 30 s and - keep-alive 5 s as in the existing bridge (`:335-338`). -- `stream_send_frame` copies the bytes, prepends the 4-byte LE length, calls - `StreamSend` (with `QUIC_SEND_FLAG_FIN` when `fin`), and emits event 7 on - `QUIC_STREAM_EVENT_SEND_COMPLETE`. `stream_open` writes the kind byte as the - first send. Framing therefore lives in C on both directions, exactly where - the v1 bridge already keeps it (`:123-157`), and `CultMeshQuicRealtimeProtocol` - keeps it in C# (`CultMeshQuicRealtimeTransport.cs:635-655`). -- The v1 exports `cultmesh_quic_open/state/poll/error/close` are kept, Windows - only (`#ifdef _WIN32`), implemented on top of a private runtime with the pin - check retained from `:184-200`. External contract protected: the Unity - package `DllImport` at `CultMeshNativeQuicRealtimeTransport.cs:259-277` and - `CanConnect`'s Windows gate at `:39-41`. They delegate; they decide nothing - new. -- `CMakeLists.txt`: Windows branch unchanged in effect (MSVC, `/Brepro`, - `MSQUIC_ROOT` from the NuGet layout, links `msquic crypt32 bcrypt`); Linux - branch links `msquic` from `MSQUIC_LIB_DIR` (the extracted deb's - `usr/lib/x86_64-linux-gnu`) with headers from `MSQUIC_INCLUDE_DIR`, `-fvisibility=hidden`, - output `libgamecult_mesh_quic_native.so`, `RPATH $ORIGIN` so the sibling - `libmsquic.so.2` resolves. -- `scripts/build-quic-native.sh` (about 70 lines): fetches - `libmsquic_2.5.9_amd64.deb` from the packages.microsoft.com pool URL in P7, - verifies its SHA-256, extracts with `dpkg-deb -x`, fetches the three headers - at tag `v2.5.9` (P10) and verifies their SHA-256 (record the hashes at first - run into the script, the same way `build-quic-native.ps1:14` pins the - NuGet), configures CMake with Ninja, and copies `libgamecult_mesh_quic_native.so`, - `libmsquic.so.2`, and the MsQuic LICENSE into `artifacts/quic-native/linux-x64`. -- `native/GameCult.Mesh.Quic.Native/README.md` rewritten to describe both - roles, both platforms, the v1/v2 split, and the "no host callbacks" rule. - -Per-file changes outside `native/`: - -- `scripts/build-quic-native.ps1:54-58` output path becomes - `artifacts/quic-native/win32-x64` by default (parameter default at `:4`); - callers `build-unity-package.ps1:80-82` and the Native.Tests csproj `:20-22` - pass explicit `-OutputDirectory` today, so they are unaffected. -- No change to `src/GameCult.Mesh.Quic.Native/*.cs`. The Unity managed - connector keeps polling v1. - -Verification: - -- Windows: `powershell -File scripts/build-quic-native.ps1` then - `dotnet test tests/GameCult.Mesh.Quic.Native.Tests` (the csproj rebuilds the - bridge itself at `:20-22`; all four tests at - `CultMeshNativeQuicRealtimeTransportTests.cs:15-141` must pass, including the - wrong-pin refusal at `:79-99` and the departed-client test at `:101-141`). - This is the regression gate for the v1 shim. -- Windows FFI smoke, before any TypeScript transport exists: - `scratchpad`-style script (to be committed as - `packages/cultmesh-ts/test/realtime-quic-native.test.ts` in cut 4) that - opens a runtime, a listener with a PKCS12 generated by `openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 ... | openssl pkcs12 -export` - (P13), a client connection to it, answers event 3 with accept, sees event 2 - on both sides, opens a latest-only stream, sends one frame with `fin`, and - reads event 6 on the server side with the identical bytes. -- Linux, development loop: build the committed image - `scripts/quic-native-linux-dev.Dockerfile`, pinned to the same `debian:13` - digest as the release workflow, and follow the recipe in the bridge's own - README. The image carries the compiler, Ninja, Node, `setarch` and the - library's runtime dependencies, and the sanitizer configurations need the - container's default seccomp profile relaxed so randomisation can be - disabled. The README and the Dockerfile header own that recipe; this map - does not restate it. This is a Linux build on a Linux host; it is not the - release artifact (Q6). *(The earlier one-line `docker run` recipe here - installed no JavaScript runtime and could not run the mutation runner at - all, and said nothing about randomisation or seccomp, which is how the - Linux path came to be red for everyone but the agent that had found the - flags by hand.)* -- Build economy: the only builds are the native CMake target and - `tests/GameCult.Mesh.Quic.Native.Tests` (which pulls `GameCult.Mesh.Quic`, - `GameCult.Mesh.Quic.Native`, `GameCult.Mesh`, `GameCult.Networking`, - `GameCult.Caching` by project reference). Before the first `dotnet build`, - take `Get-ChildItem -Recurse F:\Projects\CultLib\bin,F:\Projects\CultLib\obj,F:\Projects\CultLib\artifacts | Select FullName,Length` - to a scratch file and diff afterwards; restore or delete anything outside - those five projects' outputs. -- Negative greps: `rg -n "CryptHashCertificate2|wincrypt" native/` must hit - only inside the `#ifdef _WIN32` v1 block; `rg -n "windows.h" native/` only - inside `#ifdef _WIN32`. -- Operator-only: rebuilding and re-committing the Unity plugin DLL - (`unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/gamecult_mesh_quic_native.dll`) - and cutting a Unity package version through `scripts/build-unity-package.ps1` - is a release action; the map does not schedule it. Until it happens, Unity - keeps the old DLL, which is fine because v1 exports are unchanged. - -## 9. Cut 4 (behaviour): Node binding, consumer, session manager, trust - -Purpose: a TypeScript consumer that connects by advertised candidate, verifies -the Odin route through the cut-1 module, pins the TLS certificate, and -receives frames from the C# managed provider in a separate process. - -Deletes: none. - -Adds: - -- `packages/cultmesh-ts/src/realtime-quic-native.ts` (about 260 lines): the - koffi binding. Resolves the binary directory as `CULTMESH_QUIC_NATIVE_DIR` - when set (the development loop and CI point it at - `artifacts/quic-native/` straight from a build), otherwise - `path.join(__dirname, "..", "native", `${process.platform}-${process.arch}`)`, - the committed tree cut 6 lands (Q5 C; `__dirname` is `dist/` at runtime, so - the path holds in the repo, in an `npm pack` tarball, and in a pnpm `file:` - copy alike). No `node_modules` lookup, no platform package, no registry. - When neither directory holds `msquic` and the bridge, throw one error naming - both paths tried and the env var; until cut 6 lands, that is the error every - caller outside the test suite gets. Loads `msquic` first, then the bridge, by absolute path - (the probe showed the dependent library must be resolvable before the - bridge loads). Declares the twelve v2 prototypes. Exposes - `CultMeshQuicNativeRuntime` with a single event pump: a loop that awaits - `nextEvent.async(runtime, 250, ...)` and dispatches typed events to - registered listeners/connections by id. One pump per process-wide runtime; - reference-counted open/close. No koffi callbacks anywhere. -- `packages/cultmesh-ts/src/realtime-quic.ts` (about 650 lines across cuts 4 - and 5). Cut 4 lands: `CultMeshQuicRealtimeTransport` (implements - `sendFrame`, `receiveFrame`, `dispose`, `transportId = "msquic-realtime"`, - `endpoint`; the `isVerifiedFor(verseId, authorityRuntimeId, protocolId, - routeGeneration)` check mirroring `CultMeshQuicRealtimeTransport.cs:352-361`), - `CultMeshQuicRealtimeConnector` (`connectorId`, `priority`, `canConnect(candidate)`, - `connect(candidate, target, signal)`), and the receive path: stream kind - vs frame delivery consistency (`:519-524`), and the per-`(channel, body)` - generation filter that drops older `(producerEpoch, sequence)` (`:538-560`). - Certificate decision on event 3: caller `validateProviderCertificate(target, - der)` if supplied, else SHA-256 of DER equals the endpoint's `cert-sha256` - (`:111-119`, case-insensitive hex); otherwise reject. Endpoint parsing: - scheme `cultmesh-state+quic`, host, port, optional pin (`:98-109`). -- `CultMeshQuicRealtimeSessionManager` in the same file: takes an - `ICultMeshRealtimeLookupSource`-shaped port `{ resolve(target): Promise }`, - a trust policy from `cultnet-ts/authority`, and connectors. Binds - candidates from `authorityRoutes` whose `protocolIds` include - `cultmesh.realtime_state.v1` (`CultMeshSessions.cs:77`) and whose - `authorityRuntimeId` matches; verifies each with `verifyAuthorityRoute` - before any connector is asked; orders by connector priority then route - priority; races at most `maxRacedCandidates` per tier (`:833-880`); requires - `isVerifiedFor` after connect (`:885-896`); keys sessions by - `verseId + "\u001f" + authorityRuntimeId` (`:46`); marks offline on transport - failure. Ships with `CultMeshStaticRealtimeLookupSource` (an array of - `CultMeshVerseDescriptorMessage` from `cultnet-ts/contracts:350-369`). No - Odin WebSocket rendezvous in Node in this migration (Q7). -- `CultMesh` facade (`packages/cultmesh-ts/src/index.ts:4700`) gains statics - beside the RUDP ones at `:6008-6186`: `parseQuicRealtimeEndpoint`, - `createQuicRealtimeConnector(options)`, `createQuicRealtimeSessionManager(options)`, - and in cut 5 `createQuicRealtimeProvider(options)`. -- `packages/cultmesh-ts/package.json:44-48` gains `"koffi": "3.3.0"` (exact). - No `optionalDependencies`: under Q5 C the binaries are files of this - package, not packages of their own. `koffi` is required lazily inside - `realtime-quic-native.ts` so consumers that never touch QUIC (AetheriaEve - Electron, Odin, Stonks, weksa) load nothing native. `koffi` itself is a - registry dependency like `@msgpack/msgpack` (`:45`); every consumer host - already installs from the registry (`deploy-streampixels-preview.sh:102`), - so this adds no new kind of fetch. -- `packages/cultmesh-ts/test/realtime-quic-native.test.ts` (about 150 lines): - the cut-3 FFI smoke, now against the runtime class. -- `packages/cultmesh-ts/test/realtime-quic-consumer.test.ts` (about 250 - lines): TypeScript consumer against a TypeScript listener opened through - the raw runtime (no provider semantics yet): connect with correct pin; - refuse wrong pin (`cert-sha256` of 64 zeros, mirroring - `CultMeshNativeQuicRealtimeTransportTests.cs:79-99`); refuse missing - certificate when no validator; caller validator observed with the target - identity; generation filter drops a late older generation (mirrors - `CultMeshQuicRealtimeTransportTests.cs:111-140`). -- Trust negatives, each a separate test with its own mutation, in the same - file, through the session manager with a static lookup source and a signed - route built with `node:crypto`: (a) expired certificate (`now` past - `expiresAt`); (b) unsigned remote route (certificate absent, mode - `authenticated-remote`); (c) wrong Odin root (trust has a different - `keyId`); (d) `cert-sha256` pin mismatch (route verifies, TLS pin fails, so - the connector rejects before `isVerifiedFor`); (e) replayed nonce. On the - QUIC plane there is no `session_open` nonce exchange; the route's - freshness is its generation plus the TLS handshake. The replay test - therefore mutates `generation` on the connected transport's target and - asserts `isVerifiedFor` fails and the session manager disposes the - transport (`CultMeshSessions.cs:885-896`). If the operator wants a nonce on - QUIC, that is a protocol addition on both runtimes and belongs to a later - pipeline; this map does not invent it. -- Interop lane, C# managed provider to TypeScript consumer, separate - processes: `tests/GameCult.Networking.InteropPeer/Program.cs:27-49` gains - mode `quic-realtime-serve` (`--port`, `--frames N`, `--delivery - latest-only|reliable-ordered`, `--interval-ms`): creates a self-signed - certificate exactly as `CultMeshQuicRealtimeTransportTests.cs:203-228`, - starts `CultMeshQuicRealtimeServer`, prints one JSON line - `{ endpoint: "cultmesh-state+quic://127.0.0.1:?cert-sha256=" }` - to stdout, then broadcasts `N` frames with increasing sequence and exits - after the last `send_complete` or on stdin close. - `GameCult.Networking.InteropPeer.csproj:9-11` gains a `ProjectReference` to - `src/GameCult.Mesh.Quic/GameCult.Mesh.Quic.csproj`. - `packages/cultnet-ts/test/interop/cultnet-interop.test.ts` gains - `test("CultMesh QUIC realtime: C# managed provider and TypeScript consumer", ...)` - using `spawnServeProcess` (`:2491`) for the C# peer and `runJsonCommand` - (`:2557`) for a new TypeScript peer script - `packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts` (`dial --endpoint - --expect N`) that prints the received frames as JSON (fields plus payload - hex). The harness drives processes; it does not import `cultmesh-ts` - (dependency direction: `cultmesh-ts` depends on `cultnet-ts`, so the reverse - import is forbidden). The peer script path follows the `tsPeerScript` - pattern at `:41`. - -Verification: - -- `npm run test --workspace packages/cultmesh-ts` with - `CULTMESH_QUIC_NATIVE_DIR=artifacts/quic-native/win32-x64`. -- `npm run test:interop --workspace packages/cultnet-ts` filtered with - `--test-name-pattern "QUIC realtime"`; it builds the C# interop peer through - the existing `csharpInteropPeerBuild` path (`:45-58`, `:71`). -- Negative greps: `rg -n "new Function|koffi\.register|\.async\(" packages/cultmesh-ts/src` - must show `.async(` only on `nextEvent` in `realtime-quic-native.ts` and no - `koffi.register` (no callbacks); - `rg -n "verifyP256|canonicalRoute" packages/cultmesh-ts/src` must return - nothing. -- Operator-only: none. - -## 10. Cut 5 (behaviour): TypeScript provider and the coalescing outbox - -Purpose: the StreamPixels role. A TypeScript listener that broadcasts with an -explicit delivery mode and cannot be backpressured by a slow consumer, proven -against both C# connectors in separate processes. - -Deletes: none. - -Adds (in `packages/cultmesh-ts/src/realtime-quic.ts`): - -- `CultMeshQuicRealtimeProvider.listen({ host, port, serverCertificate: - { pkcs12, password }, handshakeTimeoutMs })`: opens a listener, computes - `advertisedEndpoint` (`cultmesh-state+quic://:?cert-sha256=`, - uppercase, because the Unity connector requires the pin, P18), accepts - connections into a peer set, exposes `connectionCount`, `broadcast(frame)`, - `receive()` for client-originated frames, `dispose()`. -- Delivery semantics, mirroring `CultMeshQuicRealtimeTransport.cs`: - `reliable-ordered` opens one unidirectional stream of kind 1 per peer on - first use and serialises writes behind a per-peer promise chain; `broadcast` - resolves when every peer's `send_complete` arrives or the peer is evicted - (`:216-235`, `:237-255`). `latest-only` enqueues into a per-peer keyed - outbox (`Map<"channel\u001fbody", frame>` plus a ready queue) and returns - synchronously (`:363-367`, `:572-618`); each peer's pump opens a fresh kind-2 - stream per frame, sends with `fin`, and only dequeues the next frame after - `send_complete`, so a stalled peer accumulates at most one pending frame per - `(channel, body)` and never blocks `broadcast`. `unreliable` throws - `NotSupported` with the same sentence as `:373-375` adjusted for the - runtime. -- Peer eviction on send failure or `connection_shutdown`; no reconnect logic - on the provider. - -Interop lanes (all separate processes; all in -`packages/cultnet-ts/test/interop/cultnet-interop.test.ts`): - -- TypeScript provider to C# native connector (the Unity one): the harness - starts `cultmesh-quic-peer.ts serve --frames 5 --delivery latest-only`, - reads its advertised endpoint from stdout, then runs - `dotnet test tests/GameCult.Mesh.Quic.Native.Tests --filter FullyQualifiedName~NativeConnectorReceivesFromExternalManagedProvider` - with `CULTMESH_NATIVE_EXTERNAL_ENDPOINT=` (the existing test at - `CultMeshNativeQuicRealtimeTransportTests.cs:15-33` is reused unchanged: it - already asserts a frame arrives with non-empty schema, body and payload). - Rename the test to `NativeConnectorReceivesFromExternalProvider` since the - provider is no longer necessarily managed; the env var name stays. -- TypeScript provider to C# managed connector: add - `ManagedConnectorReceivesFromExternalProvider` to - `tests/GameCult.Mesh.Quic.Tests/CultMeshQuicRealtimeTransportTests.cs`, - gated by the same `CULTMESH_NATIVE_EXTERNAL_ENDPOINT`, using - `CultMeshQuicRealtimeTransportConnector` with the pin from the endpoint - (`:111-119`) and no validator, asserting frame fields and payload bytes - equal what the TypeScript peer prints. The harness runs it the same way. -- `LatestOnly` under a stalled consumer: `cultmesh-quic-peer.ts serve - --frames 200 --delivery latest-only --interval-ms 1` while the harness dials - twice with the TypeScript consumer, pauses one consumer's pump for 500 ms - (a test hook on the runtime that stops calling `next_event`), and asserts: - the provider's 200 `broadcast` calls complete within a bounded time - regardless of the pause; the healthy consumer reaches sequence 199; the - paused consumer, once resumed, converges on 199 with fewer than 200 frames - received. This is the TypeScript form of - `CultMeshNativeQuicRealtimeTransportTests.cs:101-141` plus the convergence - assertion from `CultMeshQuicRealtimeTransportTests.cs:111-140`. -- Golden bytes across processes: the C# managed connector lane above prints - the received frame's re-encoding (via `CultMeshRealtimeWireProtocol.EncodeFrame`) - as hex; the harness compares it with the TypeScript peer's `encodeRealtimeFrame` - output for the same frame. That closes "TypeScript encodings decode in C#" - by process, not by shared file. - -Verification: - -- `npm run test --workspace packages/cultmesh-ts` (provider unit tests with - the TypeScript consumer in-process but separate connections: reliable - ordering in both directions mirrors `CultMeshQuicRealtimeTransportTests.cs:71-110`; - latest-only coalescing; unreliable fails closed). -- `npm run test:interop --workspace packages/cultnet-ts --test-name-pattern "QUIC realtime"`. -- `dotnet test tests/GameCult.Mesh.Quic.Tests` and - `dotnet test tests/GameCult.Mesh.Quic.Native.Tests` without the env var - (the new tests `Assert.Ignore`, the existing ones still pass). -- Negative greps: `rg -n "setInterval|setTimeout" packages/cultmesh-ts/src/realtime-quic.ts` - must show no polling of the bridge (the pump is the only wait); - `rg -n "reconnect" packages/cultmesh-ts/src/realtime-quic.ts` must return - nothing on the provider side. -- Operator-only: none. - -## 11. Cut 6 (packaging and release, remapped under Q5 C) - -Purpose: a consumer host with no toolchain and no registry access for the -binding gets it by receiving the `cultmesh-ts` package directory, which is how -every consumer already receives the package (P16, P17). Registry publication -is parked, not ruled out; the seam it reattaches to is named at the end of -this section. - -### 11.1 Body facts this cut stands on - -- The deploy tarball is a `git archive`. The Idunn actuator on Starfire runs - `git -C CultLib archive --format=tar --output= origin/main packages/cultnet-ts packages/cultcache-ts` - (`F:\Projects\Odin\scripts\deploy-yggdrasil-streampixels.ps1:59`), uploads - it by `sftp` (`:127`), and `deploy-streampixels-preview.sh:95-99` extracts it - into `/srv/streampixels/CultLib` after deleting only those two package - directories (`:97`) `(source read)`. Two consequences: only committed files - ride (a `git archive` cannot carry untracked or ignored files), and - `packages/cultmesh-ts` is not in the archive today at all, although - StreamPixels declares it (`apps/service/package.json:26`). Adding it to the - archive list and to `:97` is deploy work in Odin and gamecult-ops, outside - this campaign (section 16); the Yggdrasil smoke below therefore ships its - own archive of the same shape rather than waiting on that change. -- pnpm materialises a `file:` directory dependency as a copy of the - package's `files` entries only: with `pnpm 10.33.0`, `node_modules/cultmesh-ts` - held `README.md`, `dist`, `package.json` and nothing else (no `src`, no - `test`) `(probe: scratch consumer with file: overrides)`. `files` is the - gate, so `native` must be listed in it. -- The MsQuic binaries the koffi probe loaded (P2) are the Unity plugin copies - at `unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/` (`probe.js:4-7`), - committed in plain git (`git ls-files`; `.gitattributes` is `* text=auto` - only, no LFS), `gamecult_mesh_quic_native.dll` 40,448 bytes and - `msquic.dll` 536,928 bytes `(probe)`. Committing binaries beside the code - that loads them is therefore existing practice in this repo, not a new one. -- The Linux runtime is one file: `libmsquic.so.2 -> libmsquic.so.2.5.9`, - 7,375,872 bytes, inside the pinned deb (P7); the deb also carries - `libmsquic.lttng.so.2.5.9` and `datapath_raw_xdp_kern.o`, neither needed - `(probe: dpkg-deb -c in debian:13)`. The repo pack is 31.68 MiB today - `(probe: git count-objects)`. -- `debian:13` resolved to digest - `sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9ffc4b1` - on 2026-09-16 and carries `g++ 4:14.2.0-1` `(probe: docker pull; apt-cache - policy)`. The builder is pinned by that digest, not by the moving tag. -- `GameCult/CultLib` is public `(probe: gh repo view)` and already carries two - GitHub Releases with binary assets, `cultlib-unity-v1.0.3` - (`org.gamecult.cultlib-1.0.3.tgz`) and `gamecult-geometry-v0.1.0` (a crate - and nupkgs) `(probe: gh release view)`; nothing in `scripts/`, `docs/` or - `.github/` creates them, so they were cut by hand `(source read: rg)`. - Release assets on a public repo download with plain `curl`, no token. -- The npm registry is reachable from Starfire: `npm ping` answers `PONG`, - `npm view koffi` is HTTP 200, and `npm view @gamecult/cultcache-ts` is a - 404 because it was never published `(probe)`. This confirms the corrected - premise in section 13: C is deferral, not necessity. -- `.gitignore` ignores `artifacts/` (`:66`) and `node_modules`; it has no rule - for `*.so` or `*.dll` `(source read)`, so nothing blocks the commit path. -- The root `package-lock.json` is committed `(probe: git ls-files)`, so - `npm ci` is the real install path (as `publish-packages.yml:55` uses it); - `cultnet-interop.yml:95` uses `npm install --no-package-lock` instead, a - pre-existing inconsistency this cut does not touch. - -### 11.2 Q8, the fork C surfaces (operator decision, recommended option first) - -Where do the built binaries live so that the `git archive` above carries -them to the host? - -- **Option 1, commit them.** `packages/cultmesh-ts/native/{win32-x64,linux-x64}/` - hold the four binaries, committed by the operator from the Release assets - the workflow attaches, with `MANIFEST.txt` and `SHA256SUMS` beside them. - Price: about 8.0 MB in the tree (7,375,872 + 536,928 + 40,448 + the bridge - `.so`, on the order of 60 KB); pack growth roughly the compressed size, - about 3 MB, and that only when the MsQuic pin moves, because - `libmsquic.so.2` and `msquic.dll` are copies of Microsoft's files and - change with the pin, not with the bridge. A bridge rebuild adds tens of KB. - A CI job checks `sha256sum -c SHA256SUMS` on every push, so the manifest - and the bytes cannot drift. Deploy path change required: none beyond the - `packages/cultmesh-ts` archive-list fix already owed. Human step: download, - verify, commit (operator-only, section 11.7). -- **Option 2, fetch them.** `native//` is git-ignored; the tree - commits only `MANIFEST.txt`, `SHA256SUMS` and a `scripts/fetch-quic-native.mjs` - (about 80 lines) that downloads the Release assets and verifies digests. - Price: zero binary bytes in git, but every carrier of the package directory - becomes incomplete until the fetch runs: a fresh clone, every worktree, - Heimdall's vendored copy, `npm pack` (a tarball without binaries looks - valid), and the actuator, which must run the fetch and then `tar --append` - the ignored files after `git archive` (cross-repo edit in Odin), or the - deploy script must fetch from GitHub on Yggdrasil at deploy time (cross-repo - edit in gamecult-ops, plus GitHub egress the StreamPixels path does not - have today). It is the "tarball with a `.so` in it by hand" failure the - original Q5 text warned about, moved one step left. - -**Recommended: Option 1.** The tarball is a `git archive`, so committed is the -only state that rides it without a second mechanism; the repo already commits -the same `msquic.dll` for Unity; and the growth is bounded by the MsQuic pin, -not by bridge iteration. Everything below is written for Option 1; Option 2 -changes only 11.4's manifest paragraph, the closure smoke, and the operator -steps, and is not mapped further unless chosen. - -**Ruled 2026-09-16: Option 1.** Operator: "Commit them, yeah." Option 2 is -history. - -### 11.3 Deletes - -None in the tree. The ruling-A surfaces (`packages/cultmesh-quic-native/*`, -`optionalDependencies`, `cultnet-ts`/`cultmesh-ts` publish jobs, new tag -prefixes in `publish-packages.yml:20-24`) are never created; that is the -subtraction, and it is an estimate change, not a diff (section 11.8). - -The existing `@gamecult/cultcache-ts` publish job -(`.github/workflows/publish-packages.yml:39-78`) stays. It is out of this -campaign's scope: it publishes a package this migration does not touch, its -only consumer today reaches it by `file:` (`F:\Projects\Heimdall\package.json:22` -`(source read)`), and it is the shape the parked registry pass will copy for -`cultnet-ts` and `cultmesh-ts`. Whether it should run before then is a -question for that pass. - -### 11.4 Keeps and moves - -- `scripts/build-quic-native.ps1` and the Unity plugin binaries are untouched - (5.4 deletion line). `scripts/build-quic-native.sh` is the cut-3 script, - unchanged here. -- The MsQuic license text already committed at - `unity/org.gamecult.cultlib/Third Party Notices/MSQUIC-LICENSE.txt` is - copied, not referenced, to `packages/cultmesh-ts/native/MSQUIC-LICENSE.txt`, - because the package directory must be self-contained once copied out of the - repo (11.1, pnpm). Same MIT text for both platforms. - -### 11.5 Adds - -- `packages/cultmesh-ts/native/win32-x64/gamecult_mesh_quic_native.dll`, - `packages/cultmesh-ts/native/win32-x64/msquic.dll`, - `packages/cultmesh-ts/native/linux-x64/libgamecult_mesh_quic_native.so`, - `packages/cultmesh-ts/native/linux-x64/libmsquic.so.2` (the real file, not - the symlink; `RPATH $ORIGIN` from cut 3 resolves it beside the bridge). - Directory names are exactly `${process.platform}-${process.arch}` so the - cut-4 resolver needs no table. -- `packages/cultmesh-ts/native/SHA256SUMS`: `sha256sum` format, one line per - binary, relative paths, generated by the workflow and committed verbatim. -- `packages/cultmesh-ts/native/MANIFEST.txt` (about 20 lines, `key=value`): - `msquic.version=2.5.9`; `msquic.windows.source` = the NuGet URL at - `build-quic-native.ps1:30` with its SHA-256 from `:14`; - `msquic.linux.source` = the deb pool URL from P7 with its SHA-256; - `bridge.commit` = the CultLib commit the bridge was built from; - `build.workflow_run` = the Actions run URL; `build.release` = the Release - tag; `build.win32-x64.host` = runner image plus MSVC version as printed by - the job; `build.linux-x64.host` = `ubuntu-latest` plus the `debian:13` - digest plus `g++` version as printed; and the date. Every value is copied - from the run log by the operator, then the CI check below keeps the file - and the bytes agreeing. The binding never reads this file; it exists for - readers and for the digest check. -- `.github/workflows/quic-native.yml` (about 120 lines), four jobs: - - `build-win32-x64` on `windows-latest`: checkout, run - `scripts/build-quic-native.ps1 -OutputDirectory artifacts/quic-native/win32-x64`, - print `cl.exe` version and `Get-FileHash` of both files, upload - `actions/upload-artifact` named `quic-native-win32-x64`. - - `build-linux-x64` on `ubuntu-latest` with - `container: debian:13@sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9ffc4b1` - (Q6 A; the digest is the pin, the tag is the label): `apt-get install -y - cmake ninja-build g++ curl ca-certificates dpkg`, run - `scripts/build-quic-native.sh`, print `g++ --version` and `sha256sum`, - upload `quic-native-linux-x64`. No Node in this job; loading is proven by - the interop job below, on the same image. - - `release`, `needs` both, `if: startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v')`, - `permissions: contents: write`: download both artifacts, write - `SHA256SUMS` and a draft `MANIFEST.txt` with every value the jobs printed, - and run `gh release create "$GITHUB_REF_NAME" --verify-tag` with the two - per-platform `.tar.gz` files, `SHA256SUMS` and `MANIFEST.txt` as assets - (`GH_TOKEN: ${{ github.token }}`; `gh` is preinstalled on hosted runners, - no third-party release action). The release notes are the manifest. - - `verify-committed` on `push` and `pull_request`, `ubuntu-latest`, one - step: `cd packages/cultmesh-ts/native && sha256sum -c SHA256SUMS`. This is - the check that the tree's bytes are the bytes the manifest names. - - Triggers: `push` to `main` and `pull_request` run `verify-committed` - only; `workflow_dispatch` runs the two builds (dry run, uploads - artifacts, no release); a `cultmesh-quic-native-v*` tag runs all four. - Only `release` has write permission. -- `packages/cultmesh-ts/package.json:14-16`: `"files": ["dist", "native"]`. - No version bump: under C nothing keys on the version; provenance keys on - the commit in `MANIFEST.txt`. -- `packages/cultmesh-ts/src/realtime-quic-native.ts` (cut 4): the runtime - class gains a readonly `nativeDirectory` (the absolute directory it loaded - from). Diagnostic only; the closure smoke asserts on it. -- `scripts/test-typescript-package-closure.mjs`: - - `:39-41`: for `cultmesh-ts`, assert the packed file list contains all - four binaries, `native/SHA256SUMS`, `native/MANIFEST.txt` and - `native/MSQUIC-LICENSE.txt`, on every host (the tarball carries both - platforms; the host only decides which loads). - - `:59-70`: the runtime smoke adds - `assert.equal(typeof mesh.CultMesh.createQuicRealtimeConnector, "function")`, - the same for `createQuicRealtimeProvider`, then opens a - `CultMeshQuicNativeRuntime`, asserts - `runtime.nativeDirectory` ends with `node_modules/cultmesh-ts/native/` - (path separators normalised), and closes it. The child at `:70` runs with - `CULTMESH_QUIC_NATIVE_DIR` deleted from its env so the default path is the - one exercised. The point: the binaries load from the installed copy of the - package, not from a repo path or a registry. -- `.github/workflows/cultnet-interop.yml`: - - After the C# RUDP step (`:127-128`): a step building the Windows bridge - (`scripts/build-quic-native.ps1 -OutputDirectory artifacts/quic-native/win32-x64`) - and a step running the `QUIC realtime` lanes with - `CULTMESH_QUIC_NATIVE_DIR` set to that directory, so CI proves the lanes - against a fresh build, then a second run of - `node scripts/test-typescript-package-closure.mjs` proves the committed - Windows binaries load without the env var. - - A second job `interop-quic-linux` on `ubuntu-latest` with the same pinned - `debian:13` container: `apt-get install` the build tools plus `libicu` - (dotnet's runtime need), `actions/setup-dotnet@v4` 10.0.x and - `actions/setup-node@v4` 24 (both work inside a container), build the - bridge with `scripts/build-quic-native.sh`, `npm ci`, run only the - TypeScript-provider-to-managed-connector and TypeScript-to-TypeScript - lanes (section 15: the native-connector lane is Windows by design), then - the closure script without the env var. The container is not optional: - the artifact is linked against Debian 13's glibc 2.41 and `ubuntu-latest` - (24.04) carries glibc 2.39 `(probe: ldd --version in both images)`, so a - symbol version the bridge or `libmsquic` references can be absent on the - bare runner; run the lanes on the image the artifact targets rather than - discover that per symbol. -- `README.md` of `packages/cultmesh-ts` gains a "QUIC Realtime Plane" section - after "RUDP Helpers" (`README.md:332`) with provider and consumer examples - matching the C# README (`src/GameCult.Mesh.Quic/README.md:13-62`), and a - "Native binaries" paragraph: where they live, that a `file:` dependency or - an `npm pack` tarball carries them, the `CULTMESH_QUIC_NATIVE_DIR` - override, the two provenance files, Windows x64 and Linux x64 (Debian 13 - glibc) only, and the four `libmsquic` runtime dependencies (P7) a Linux host - must already have. - -### 11.6 Per-file changes - -- `packages/cultmesh-ts/package.json:14-16`, `files` gains `"native"`. -- `packages/cultmesh-ts/src/realtime-quic-native.ts` (cut 4 file), one - readonly field `nativeDirectory` set at load. -- `scripts/test-typescript-package-closure.mjs:39-41`, `:59-70` as above; - `:9` unchanged (no new packages). -- `.github/workflows/cultnet-interop.yml`, two steps after `:128`, one job - after `:140`. -- `.github/workflows/quic-native.yml`, new. -- `.github/workflows/publish-packages.yml`, unchanged. -- `packages/cultmesh-ts/native/` (seven files), new, four of them binary and - operator-committed (11.7). -- `packages/cultmesh-ts/README.md`, one section after `:332`. - -### 11.7 Verification - -- `node scripts/test-typescript-package-closure.mjs` on Windows, with - `CULTMESH_QUIC_NATIVE_DIR` unset in the shell. Pins: the tarball carries - both platforms and the provenance files; the installed copy loads its own - binaries. -- `cd packages/cultmesh-ts/native && sha256sum -c SHA256SUMS` (Git Bash on - Windows). Pins: committed bytes equal the manifest. -- `git check-ignore -v packages/cultmesh-ts/native/linux-x64/libmsquic.so.2` - must exit 1 (not ignored), and - `git ls-files packages/cultmesh-ts/native | wc -l` must print 7. Pins: the - binaries are tracked, so a `git archive` carries them. -- `npm pack packages/cultmesh-ts --dry-run --json | node -p "JSON.parse(require('fs').readFileSync(0,'utf8'))[0].files.map(f=>f.path).filter(p=>p.startsWith('native/')).join('\n')"` - must list seven `native/` paths; the closure script already asserts this, - this is the one-liner for a reader. -- `workflow_dispatch` of `quic-native.yml`: both build jobs green, artifacts - present, no release created. A tag push `cultmesh-quic-native-v` - creates the Release with four assets. Pins: Q6 A, the builder is a Linux - host on the deploy OS and the artifact has a public, digest-named home. -- Negative greps (must return nothing): - `rg -n "optionalDependencies|cultmesh-quic-native-(win32|linux)|@gamecult/cultmesh-quic-native" packages scripts .github --glob '!**/node_modules/**' --glob '!**/dist*/**'` - (no platform packages exist); - `rg -n "node_modules|require\.resolve" packages/cultmesh-ts/src/realtime-quic-native.ts` - (the resolver never searches for a package); - `rg -n "\"(pre|post)install\"" packages/*/package.json` (no install-time - compile or download); - `rg -n "NPM_TOKEN|npm publish|registry-url" .github/workflows/quic-native.yml` - (the native workflow publishes to no registry). -- Operator-only, in order: - 1. After the tag build, download the Release assets, compare every digest - with the run log and with `SHA256SUMS`, place the binaries under - `packages/cultmesh-ts/native//`, fill `MANIFEST.txt` from the - log, commit. This is the only path bytes take into the tree; no workflow - writes to `main`. - 2. Yggdrasil smoke, which installs from what C ships (the archived tree) - and nothing else. On Starfire, at the commit from step 1: - `git -C F:\Projects\CultLib archive --format=tar --output=$env:TEMP\cultlib-quic-smoke.tar ` - (the actuator's own mechanism, `deploy-yggdrasil-streampixels.ps1:51,59`, - whole tree), then `sftp` it to `gamecultadmin@yggdrasil.gamecult.org:/home/gamecultadmin/`. - On Yggdrasil: - `mkdir -p /tmp/cultmesh-quic-smoke && tar -xf ~/cultlib-quic-smoke.tar -C /tmp/cultmesh-quic-smoke && cd /tmp/cultmesh-quic-smoke`; - `(cd packages/cultmesh-ts/native && sha256sum -c SHA256SUMS)` (the bytes - that arrived are the bytes the manifest names); - `ldd packages/cultmesh-ts/native/linux-x64/libmsquic.so.2 | grep "not found"` - must print nothing. If it prints, the missing ones are among - `libnuma1`, `libxdp1`, `libnl-route-3-200` (P7; `libssl3t64` is base): - under C nothing installs them because the deb itself is never installed - on the host, so `sudo apt-get install` them once and record it in - `gamecult-ops/inventory.md` beside the Node line (`:660`). Then - `npm ci` (registry access from Yggdrasil is the deploy path's own - assumption, `deploy-streampixels-preview.sh:102`) and - `node scripts/test-typescript-package-closure.mjs` with the env var - unset: this is the same smoke CI runs, now on Node 24.14.1 (P8) on the - deploy host, loading `node_modules/cultmesh-ts/native/linux-x64`. - Then the handshake: `npm run test --workspace packages/cultmesh-ts` - (builds `dist-test` and runs the cut-4/5 suites on the host), then a - throwaway PKCS12 - (`openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj /CN=127.0.0.1 -days 2 -keyout k.pem -out c.pem && openssl pkcs12 -export -inkey k.pem -in c.pem -passout pass:smoke -out smoke.p12`), - `node packages/cultmesh-ts/dist-test/test/interop/cultmesh-quic-peer.js serve --frames 5 --delivery latest-only --pkcs12 smoke.p12 --password smoke` - in one shell (it prints the advertised endpoint), and - `node packages/cultmesh-ts/dist-test/test/interop/cultmesh-quic-peer.js dial --endpoint --expect 5` - in a second, which must print five frames. If cut 5 landed `serve` with - a generated certificate instead of `--pkcs12/--password` flags, add the - flags in this cut; the smoke needs a certificate it can name. Finish - with `rm -rf /tmp/cultmesh-quic-smoke ~/cultlib-quic-smoke.tar`. This is - the only place the Linux artifact is proven on the deploy host, and only - the operator holds the key. - 3. Nothing here touches `/srv/streampixels`; StreamPixels adoption - (archive list, `:97`, its stale lockfile) is consumer work (section 15, - section 16). - -### 11.8 Subtraction estimate delta against section 14 - -Row 6 of the table read `0 removed, 60 (manifests) + 130 (workflow) + 30 -(closure) + 60 (README) = +280` with `+2 optional platform packages` and `+3 -publish jobs` in the dependencies line. Under C: - -- Removed: 0 (the `cultcache-ts` job stays, 11.3). -- Added text: about 120 (`quic-native.yml`) + 40 (interop workflow) + 30 - (closure) + 70 (README) + 25 (`MANIFEST.txt`, `SHA256SUMS`) + 1 - (`package.json`) = about +285, the same order as before; the 60 lines of - platform-package manifests are gone and the release/verify jobs take their - place. -- Added binary: four files, about 8.0 MB in the tree under Option 1, 0 under - Option 2. Section 14 has no column for this; it is the real price of C and - is stated here so it is not hidden in a line count. -- Dependencies line becomes: +0 optional platform packages (was +2); +0 - publish jobs (was +3); +1 workflow file with four jobs (two builds, one - release, one digest check) and +1 job in the interop workflow (was "+1 CI - job"). Runtime dependency unchanged: `koffi` only. -- Campaign total: about +3,900 source lines, unchanged; plus 8.0 MB of - committed binaries. - -### 11.9 Parked seam: registry publication (later pass) - -Not in this campaign, by the operator's choice (section 13). When it is -picked up, it reattaches here without remapping the rest: - -- `publish-packages.yml` gains `cultnet-ts` and `cultmesh-ts` jobs shaped like - `:39-78`, tag prefixes at `:20-24`, dispatch options at `:30-33`; the - `cultmesh-ts` job `needs` the two `quic-native.yml` build jobs (or the - workflow is merged) and publishes the artifacts they already produce as - `@gamecult/cultmesh-quic-native-{win32-x64,linux-x64}` platform packages. -- `packages/cultmesh-ts/package.json` gains `optionalDependencies` on those - two names; `realtime-quic-native.ts` gains a third resolution branch after - the env var and before the committed `native/` directory, or the committed - directory is retired in the same pass. -- Naming (unscoped `cultnet-ts`/`cultmesh-ts` versus `@gamecult/*`) is the - original Q5 A/B fork, still open for that pass; P14 recorded all names - unclaimed on 2026-09-16. -- Its Body precondition is an `NPM_TOKEN` repository secret minted on - npmjs.com, which the operator can now reach (section 13). - -## 12. Cut 7 (documentation and ledgers) - -- `docs/runtime-parity-scope.md:38` TypeScript row: add "CultMesh QUIC - realtime provider and consumer over CultLib-owned MsQuic bindings (Windows - x64, Linux x64), byte-parity with C#, cross-process interop lanes" to - "Claimed parity"; add "QUIC datagrams (`Unreliable`), browser QUIC" to "Not - claimed". -- `src/GameCult.Mesh/docs/transport-planes.md:35-46`: name the TypeScript - provider/consumer beside the .NET and Unity bodies; `:122-125`: state that - `CULTMESH_NATIVE_EXTERNAL_ENDPOINT` now accepts any provider and is driven - by the TypeScript interop harness. -- `docs/cultnet-transport-parity.md`: no change. It maps RUDP/TCP/LiteNetLib - ownership; the realtime plane is a CultMesh plane, not a CultNet transport - profile, and pretending otherwise would put QUIC into a document whose cut - line says "Do not put UDP packet mechanics into CultMesh" the other way - round. -- `docs/parked-features.md`: one entry, "Node Odin WebSocket rendezvous", - pointing at `cultmesh-browser/src/index.ts:84-191` as the browser owner and - the `ICultMeshRealtimeLookupSource` port as the seam (Q7). -- `docs/typescript-quic-realtime-target.md:5-7`: status line updated to point - at this map. -- Under Q5 C, the delivery story every document tells is the one in section - 11: the binding ships inside `packages/cultmesh-ts/native/`, provenance - lives in `MANIFEST.txt` and `SHA256SUMS`, and consumers keep `file:` paths. - `native/GameCult.Mesh.Quic.Native/README.md` (cut 3) and - `packages/cultmesh-ts/README.md` (cut 6) both point at `MANIFEST.txt` as the - provenance owner rather than restating versions or digests. The parity row - above says "bindings shipped in the `cultmesh-ts` package tree", not - "published". Registry publication is described in exactly one place, as - parked: section 11.9 of this map. - -Verification: `rg -n "CultMeshRealtimeTransports.cs" docs src/GameCult.Mesh/docs` -returns only correct paths; -`rg -n -i "registry|npm publish|platform package|optionalDependencies" packages/cultmesh-ts/README.md native/GameCult.Mesh.Quic.Native/README.md docs/runtime-parity-scope.md src/GameCult.Mesh/docs/transport-planes.md` -returns nothing (the only permitted mentions are this map's sections 11.9 and -13 and the target's history). - -## 13. Operator questions - -The four rulings in the target (Q1-Q4) are closed and not reopened. These are -the forks the probes surfaced. - -- **Q5. Registry names.** The binding cannot ship as prebuilt platform - packages without `cultmesh-ts` itself being on a registry, and today every - consumer uses `file:` paths and only `@gamecult/cultcache-ts` is published - (P15, P16). All candidate names are unclaimed (P14). A: publish `cultnet-ts` - and `cultmesh-ts` under their existing unscoped names (no import changes in - any of the 11 consuming repos) and put only the new native platform packages - under `@gamecult/cultmesh-quic-native-{win32-x64,linux-x64}`. B: scope - everything as `@gamecult/*` now and take the import rename across consumers - as a separate consumer task. C: no registry; ship the binaries inside the - CultLib tarball the deploy script already extracts on Yggdrasil - (`deploy-streampixels-preview.sh:95-99`), keeping `file:` consumption. - **Recommended: A.** It reshapes nothing consumers use, matches the - `cultcache-ts` precedent of scoping only what is new, and gives StreamPixels - a versioned artifact instead of a tarball with a `.so` in it. C is the - cheapest but leaves the deploy path owning a native binary by hand, which is - the failure the target's "shipped through a registry" phrase was written to - avoid. -- **Q6. Linux release builder.** A: GitHub Actions `ubuntu-latest` with - `container: debian:13` (same OS as Yggdrasil, P8), artifact published by the - workflow, verified on Yggdrasil by the operator smoke in cut 6. B: build on - Yggdrasil in the deploy script (installs CMake and g++ on a live public - host; every deploy compiles). C: build on Starfire's Docker `debian:13` - engine (P12) and publish from the workstation. **Recommended: A.** It is a - Linux host, it is reproducible from a tag, and it keeps compilers off - Yggdrasil. C is kept for the development loop only. -- **Q7. Node-side Odin rendezvous.** The TypeScript consumer needs a route - source. The browser package has an Odin WebSocket rendezvous - (`cultmesh-browser/src/index.ts:84-191`); Node 24 has a global `WebSocket`, - so it would run, but `cultmesh-ts` may not depend on `cultmesh-browser` - (Q2's spirit), and the first consumer of the Node QUIC path is the interop - harness, not a product. A: ship the lookup-source port plus a static source - now, park the Node Odin rendezvous with a note. B: move the Odin rendezvous - into `cultnet-ts` beside the verifier in this migration so Node and browser - share it. **Recommended: A.** B is the right end state but is a second - ownership move in one pipeline; it should follow once a Node consumer that - discovers through Odin exists. - -Not asked, because the target already decided them: datagrams stay out (Q4); -no TLS on TCP planes (Q1); Windows x64 and Linux x64 only (Q3); the verifier -moves to a shared browser-safe module (Q2, section 6 names the module). - -### Rulings, 2026-09-16 - -The operator took all three recommendations the same day the map was written. -Each is now a standing ruling; the options above are history. - -- **Q5 ruled A, then re-ruled C the same day.** A had `cultnet-ts` and - `cultmesh-ts` publishing under their unscoped names with only the two native - platform packages scoped. The re-ruling was first stated as necessity ("we - don't even have npm access and cannot get it because our entire subnet is - blocked"); the operator corrected that Body fact the same day after - reaching npm signup: "apparently that was true yesterday." The registry is - reachable from Starfire (`npm ping` answers, section 11.1 `(probe)`), and an - `NPM_TOKEN` can be minted. **C still stands for this campaign, by choice: - no registry.** In the operator's words, registry publication is "still for - a later pass". Consumers keep `file:` paths. The native binaries ship inside - the `cultmesh-ts` package tree that the deploy path's `git archive` carries, - and Q6's Actions build attaches both platform artifacts to a GitHub Release - instead of publishing packages. Registry publication of `cultnet-ts`, - `cultmesh-ts` and the native binaries is parked, not ruled out; section 11.9 - names the seam it reattaches to, and the original A/B naming fork stays - open for that pass. Cut 6 is remapped under C (section 11); section 5.4, - Cut 4's binary resolution and dependency lines, and Cut 7's ledger follow - it. The one fork C surfaces, whether the binaries are committed or fetched - from the Release, is Q8 in section 11.2. -- **Q8 ruled Option 1.** The four native binaries are committed at - `packages/cultmesh-ts/native//` with their manifest and digests, - so they ride the `git archive` the deploy path already builds. Operator's - words: "Commit them, yeah." -- **Q6 ruled A.** The Linux release artifact is built by GitHub Actions on - `ubuntu-latest` inside `container: debian:13`, published by the workflow, - and verified by the operator smoke on Yggdrasil in cut 6. The Docker - `debian:13` engine on the workstation is the development loop only; nothing - it produces is a release artifact. No compiler is installed on Yggdrasil. -- **Q7 ruled A.** Cut 4 ships the `ICultMeshRealtimeLookupSource` port and a - static source. The Node Odin WebSocket rendezvous is parked with a note in - cut 7's ledger; it moves into `cultnet-ts` beside the verifier in a later - pipeline, once a Node consumer that discovers through Odin exists. - -## 14. Subtraction estimate - -Both sides of the number, by cut, in source lines (tests included, generated -`dist*` excluded, vector JSON excluded): - -| Cut | Removed or replaced | Added | Net | -| --- | ---: | ---: | ---: | -| 1 trust module | 119 (browser) | 190 (module) + 150 (tests) + 40 (C# vector test) | +261 | -| 2 codec | 0 | 130 + 120 (TS test) + 120 (C# test) | +370 | -| 3 bridge | ~200 rewritten of 440 C++; 24 CMake | ~1,000 C++ (replacing 440), 60 CMake, 70 sh, 40 README | +~700 net C++/build | -| 4 consumer | 0 | 260 (binding) + 400 (consumer/session manager) + 400 (tests) + 200 (C# interop mode) + 150 (harness) + 120 (peer script) | +1,530 | -| 5 provider | 0 | 250 (provider) + 300 (tests) + 150 (harness) + 40 (C# tests) | +740 | -| 6 packaging | 0 | 60 (package manifests) + 130 (workflow) + 30 (closure smoke) + 60 (README) | +280 | -| 7 docs | ~10 | ~40 | +30 | -| Total | ~350 | ~4,260 | about +3,900 | - -Dependencies: +1 runtime dependency on `cultmesh-ts` (`koffi`, 1.7 MB, MIT, -loaded lazily); +0 optional platform packages; +0 build-time Node -dependencies (no node-gyp, no node-addon-api, no cmake-js). Targets: +0 .NET -projects (the C# interop peer gains one project reference); +1 CMake platform -branch; +1 shell build script; +1 workflow file with four jobs; +1 job in -the interop workflow; and, under Q8 option 1, about 8.0 MB of committed -binaries. *(This line first read "+2 optional platform packages; +3 publish -jobs; +1 CI job" under ruling A; section 11.8 carries the delta.)* - -Why the number is positive and still acceptable: the target orders a new -capability (a QUIC realtime plane in a runtime that has none), and every line -above either is that capability, proves it against the C# reference, or ships -it. The subtraction that was available (one verifier instead of two, one -MsQuic body instead of two, one framing implementation per language instead -of one per host) is taken in cuts 1 and 3. Anything that would have shrunk -the number further (a third-party QUIC stack, a callback-driven addon, a -tarball with a `.so`) was rejected in section 4 for reasons that are not -about line count. - -## 15. Not assignable to a cut - -- **The Unity package release** carrying the rebuilt bridge DLL. The v1 ABI - is preserved, so no cut requires it, but the committed DLL will lag the - source until the operator runs `scripts/build-unity-package.ps1` and - commits. Recorded here so nobody reads the stale DLL as evidence the - rewrite did not happen. -- **StreamPixels certificate provisioning** (which PKCS12, where it lives on - Yggdrasil, how its pin reaches the Odin route). Consumer work by the target. -- **A nonce on the QUIC plane.** The target lists "a replayed nonce" among - trust negatives, but the QUIC plane has no nonce exchange in C# either - (`CultMeshQuicRealtimeTransport.cs` opens no session message; trust is the - Odin route plus the TLS pin, `transport-planes.md:71-73`). Cut 4 tests the - replay class that does exist (route generation). Adding a nonce is a - cross-runtime protocol change and needs its own target. -- **Linux coverage of the C# native connector.** `CanConnect` is Windows-only - (`CultMeshNativeQuicRealtimeTransport.cs:40`); the Linux interop job in cut 6 - therefore runs the TypeScript-provider-to-managed-connector lane and the - TypeScript-to-TypeScript lanes only. The native-connector lane is Windows - by design, matching where Unity runs. -- **The `cultcache-ts` naming split** across consumers (section 3). Out of - scope. Q5 C touches no package name and publishes nothing, so it makes the - split neither better nor worse; the split becomes the parked registry - pass's problem (section 11.9), where the A/B naming fork is still open. - One consequence to carry there: StreamPixels' lockfile resolves - `cultmesh-ts`'s cache dependency as unscoped `cultcache-ts` - (`F:\Projects\StreamPixels\pnpm-lock.yaml:2561-2564` `(source read)`) while - `packages/cultmesh-ts/package.json:46` now names `@gamecult/cultcache-ts`, - so a `pnpm install --frozen-lockfile` against the current tree would refuse - before any QUIC code is reached. Consumer work, recorded so the Yggdrasil - smoke in section 11.7 is not mistaken for StreamPixels adoption. - -## 16. Reminder of what a wrong cut looks like - -If a cut touches `src/GameCult.Mesh/CultMeshTcp*.cs`, adds TLS to the TCP -schema or content connectors, adds WebTransport to `cultmesh-browser`, adds -QUIC to `cultnet-py`, `cultnet-rs`, or `cultmesh-kotlin`, or edits anything -under `F:\Projects\StreamPixels`, it has left the target's "Not in this -migration" list and is wrong. Stop and re-read this map. diff --git a/docs/typescript-quic-realtime-target.md b/docs/typescript-quic-realtime-target.md deleted file mode 100644 index 751d5277..00000000 --- a/docs/typescript-quic-realtime-target.md +++ /dev/null @@ -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=` 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. diff --git a/dotnet-tools.json b/dotnet-tools.json deleted file mode 100644 index 6da5949f..00000000 --- a/dotnet-tools.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": 1, - "isRoot": true, - "tools": { - "dotnet-stryker": { - "version": "5.0.0", - "commands": [ - "dotnet-stryker" - ], - "rollForward": false - } - } -} diff --git a/native/GameCult.Mesh.Quic.Native/CMakeLists.txt b/native/GameCult.Mesh.Quic.Native/CMakeLists.txt index c3dfc304..2850951d 100644 --- a/native/GameCult.Mesh.Quic.Native/CMakeLists.txt +++ b/native/GameCult.Mesh.Quic.Native/CMakeLists.txt @@ -1,118 +1,20 @@ cmake_minimum_required(VERSION 3.24) project(gamecult_mesh_quic_native LANGUAGES CXX) -# Two platforms, one source. Windows takes MsQuic from the pinned OpenSSL NuGet -# layout (MSQUIC_ROOT); Linux takes it from the extracted libmsquic deb, whose -# headers and library land in unrelated places, so they are named separately. -if(NOT WIN32 AND NOT (CMAKE_SYSTEM_NAME STREQUAL "Linux")) - message(FATAL_ERROR "The CultMesh native QUIC bridge supports Windows x64 and Linux x64") +if(NOT WIN32) + message(FATAL_ERROR "The current CultMesh native QUIC connector supports Windows desktop only") +endif() +if(NOT DEFINED MSQUIC_ROOT) + message(FATAL_ERROR "MSQUIC_ROOT must point at the extracted Microsoft.Native.Quic.MsQuic.Schannel package build/native directory") endif() add_library(gamecult_mesh_quic_native SHARED cultmesh_quic_native.cpp) target_compile_features(gamecult_mesh_quic_native PRIVATE cxx_std_20) -# include/cultmesh_quic_native.h is the host-facing contract, not an internal -# detail: the bridge includes it, and so does any consumer that can. -target_include_directories(gamecult_mesh_quic_native PUBLIC include) - -if(WIN32) - if(NOT DEFINED MSQUIC_ROOT) - message(FATAL_ERROR "MSQUIC_ROOT must point at the extracted Microsoft.Native.Quic.MsQuic.OpenSSL package build/native directory") - endif() - # Static CRT. A Node or Unity host without the Visual C++ redistributable - # installed cannot load a DLL that imports vcruntime140.dll, and the failure - # arrives as "the specified module could not be found" with nothing naming the - # missing runtime. The bridge carries its own instead. - set_property(TARGET gamecult_mesh_quic_native PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded") - target_compile_definitions(gamecult_mesh_quic_native PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) - target_include_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_ROOT}/include") - target_link_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_ROOT}/lib/x64") - # crypt32 and bcrypt belong to the v1 shim alone: CryptHashCertificate2 - # compares the Unity pin. Nothing in the v2 path needs them. ntdll carries - # RtlIpv4/6StringToAddressEx, which msquic_winuser.h's inline QuicAddrFromString - # calls to parse the listener host; the Linux header parses it in-tree instead. - target_link_libraries(gamecult_mesh_quic_native PRIVATE msquic crypt32 bcrypt ntdll) - # The DLL is committed beside its source, so a rebuild must reproduce it: /Brepro drops the link timestamps. - target_compile_options(gamecult_mesh_quic_native PRIVATE /Brepro) - target_link_options(gamecult_mesh_quic_native PRIVATE /Brepro) -else() - if(NOT DEFINED MSQUIC_INCLUDE_DIR OR NOT DEFINED MSQUIC_LIB_DIR) - message(FATAL_ERROR "MSQUIC_INCLUDE_DIR and MSQUIC_LIB_DIR must point at the msquic v2.5.9 headers and the extracted libmsquic library directory") - endif() - target_include_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_INCLUDE_DIR}") - target_link_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_LIB_DIR}") - target_link_libraries(gamecult_mesh_quic_native PRIVATE msquic pthread) - # Only the CULTMESH_API exports leave the .so; everything else stays internal. - target_compile_options(gamecult_mesh_quic_native PRIVATE -fvisibility=hidden -fvisibility-inlines-hidden) - # libmsquic.so.2 ships beside the bridge rather than being installed - # system-wide, so the loader is pointed at the bridge's own directory. - set_target_properties(gamecult_mesh_quic_native PROPERTIES - BUILD_WITH_INSTALL_RPATH TRUE - INSTALL_RPATH "$ORIGIN") -endif() +target_compile_definitions(gamecult_mesh_quic_native PRIVATE GAMECULT_MESH_QUIC_NATIVE_EXPORTS WIN32_LEAN_AND_MEAN NOMINMAX) +target_include_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_ROOT}/include") +target_link_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_ROOT}/lib/x64") +target_link_libraries(gamecult_mesh_quic_native PRIVATE msquic crypt32 bcrypt) set_target_properties(gamecult_mesh_quic_native PROPERTIES OUTPUT_NAME "gamecult_mesh_quic_native" - RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" - LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") - -# The runtime-lifetime scenarios. Off by default: the shipped artifact is the -# library alone, and a delivery build has no business producing an executable. -# CULTMESH_QUIC_DEBUG_ASSERTS goes with them, never into a shipped binary — it -# makes the bridge state the quiesce invariant instead of leaving it to whatever -# a sanitizer happens to catch. -option(CULTMESH_QUIC_BUILD_TESTS "Build the runtime-lifetime scenario runner" OFF) -option(CULTMESH_QUIC_DEBUG_ASSERTS "Abort the bridge on a violated internal invariant" OFF) - -if(CULTMESH_QUIC_DEBUG_ASSERTS) - target_compile_definitions(gamecult_mesh_quic_native PRIVATE CULTMESH_QUIC_DEBUG_ASSERTS) -endif() - -if(CULTMESH_QUIC_BUILD_TESTS) - add_executable(cultmesh_quic_native_tests tests/cultmesh_quic_native_tests.cpp) - target_compile_features(cultmesh_quic_native_tests PRIVATE cxx_std_20) - # The header only. The scenarios drive the bridge as a host does, so they get - # no more of it than a host has. - target_link_libraries(cultmesh_quic_native_tests PRIVATE gamecult_mesh_quic_native) - # The scenarios see the development seam (section 7 of the header) exactly when - # the library has one, so `holdclose` compiles out of the same builds the - # bridge's own assertions compile out of. - if(CULTMESH_QUIC_DEBUG_ASSERTS) - target_compile_definitions(cultmesh_quic_native_tests PRIVATE CULTMESH_QUIC_DEBUG_ASSERTS) - endif() - set_target_properties(cultmesh_quic_native_tests PROPERTIES - RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") - if(WIN32) - # gamecult_mesh_quic_native.dll imports msquic.dll, and the scenario - # runner loads it from its own directory (or PATH) at process start, not - # from MSQUIC_ROOT. build-quic-native.ps1's packaged output gets a copy - # from its own recipe; a test build has no packaging step, so without this - # the loader falls through to whatever msquic.dll happens to already be on - # PATH — silently running the scenarios against an unpinned, wrong-version - # MsQuic instead of failing to start. CMake already knows MSQUIC_ROOT, so - # it places the pinned DLL itself instead of leaving the reader to guess - # why a scenario passed against the wrong binary. - add_custom_command(TARGET cultmesh_quic_native_tests POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy - "${MSQUIC_ROOT}/bin/x64/msquic.dll" - "$/msquic.dll") - else() - target_link_libraries(cultmesh_quic_native_tests PRIVATE pthread) - # The linker resolves the bridge's own NEEDED libmsquic.so.2 while linking - # this executable, and the runtime copy only lands beside the binaries after - # the build; point it at the extracted package instead. - target_link_options(cultmesh_quic_native_tests PRIVATE "-Wl,-rpath-link,${MSQUIC_LIB_DIR}") - set_target_properties(cultmesh_quic_native_tests PROPERTIES - BUILD_WITH_INSTALL_RPATH TRUE - INSTALL_RPATH "$ORIGIN") - # Both binaries carry INSTALL_RPATH "$ORIGIN" and expect libmsquic.so.2 - # beside them at load time. build-quic-native.sh's packaged output gets - # that copy from its own recipe; a test build has no packaging step, so - # the runner would otherwise die at once with "cannot open shared object - # file". CMake already knows where MSQUIC_LIB_DIR is, so it places the - # file itself instead of leaving the reader to run an extra cp by hand. - add_custom_command(TARGET cultmesh_quic_native_tests POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy - "${MSQUIC_LIB_DIR}/libmsquic.so.2" - "$/libmsquic.so.2") - endif() -endif() + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") diff --git a/native/GameCult.Mesh.Quic.Native/README.md b/native/GameCult.Mesh.Quic.Native/README.md index ae5d78d2..875ebb3b 100644 --- a/native/GameCult.Mesh.Quic.Native/README.md +++ b/native/GameCult.Mesh.Quic.Native/README.md @@ -1,168 +1,10 @@ # GameCult.Mesh.Quic.Native -One MsQuic body for every CultMesh host that is not .NET: two roles (provider -and consumer), two platforms (Windows x64 and Linux x64), and no callbacks -across the host boundary. +This Windows desktop bridge keeps MsQuic's callback-heavy C API below the +managed runtime boundary. It accepts a discovery-authorized SHA-256 certificate +pin, negotiates `cultmesh-state-v1`, and emits complete CultMesh realtime frame +payloads. It owns transport only; frame meaning remains in `GameCult.Mesh`. -The bridge owns MsQuic registration, configuration, listener, connection and -stream handles, TLS credential loading, the `cultmesh-state-v1` ALPN, the -1-byte-kind plus 4-byte-LE-length QUIC stream framing, and one event queue per -runtime. It owns transport and nothing else: it never decodes a CultMesh frame, -never decides delivery semantics, never coalesces, never reconnects, and in the -v2 path never decides whether to trust a certificate. Frame meaning stays in -`GameCult.Mesh` and in the TypeScript codec; trust stays with the host. - -## No host callbacks - -Every crossing is the host calling in, including the one blocking wait. MsQuic's -worker threads only ever push onto the queue and wake that wait, so no host -runtime is called from a thread it does not own. That is what lets one binary -serve a Node process through FFI and a Unity player through P/Invoke without -either one's threading rules leaking into the other. - -It is also why nothing here includes a Node header, a Unity header, or a -JavaScript engine: the bridge does not know what is on the other side. - -## Two ABIs - -**v2** is the runtime-neutral queue API, and the one new hosts should use. It is -stated once, in [`include/cultmesh_quic_native.h`](include/cultmesh_quic_native.h): -the 64-byte event struct with its field order and asserted offsets, the thirteen -exports with their signatures, the id and return-code conventions, the object -lifetime rules and the threading rules. That file is the contract. This README -does not restate it, and where the two ever disagree the header is right. - -A host that can include it should. A host that cannot — koffi, Unity's -P/Invoke — declares the same thing in its own FFI and is checked against the -header by a test rather than by eye. - -Two things worth reading before writing a host, because they are easy to get -wrong from the outside: - -- **Return codes are negative on a bad call, on every platform.** `-1` is a bad - call, `-2` a rejected argument, `<= -1000` a MsQuic refusal. `QUIC_STATUS` - itself is not portable — a negative HRESULT on Windows, a positive errno on - POSIX — so it never reaches a host; `cultmesh_quic_last_status` hands over the - raw value for diagnostics and nothing else. -- **`cultmesh_quic_runtime_close` quiesces.** It marks the runtime closing, wakes - every blocked `cultmesh_quic_next_event`, and waits for every in-flight call to - return before it frees anything. No host call may begin after it starts. - -A client connection defers certificate validation: the bridge indicates the -certificate, emits event 3 carrying the DER, and answers MsQuic only when the -host calls `cultmesh_quic_connection_certificate_complete`. If the host never -answers, MsQuic's 10-second handshake timeout closes the connection and event 4 -reports it. Providers load their certificate as PKCS12; the advertised -`cert-sha256` pin is computed by the host from that same PKCS12, not here. - -**v1** is the five exports the shipped Unity package imports by name -(`cultmesh_quic_open`, `_state`, `_poll`, `_error`, `_close`). They are Windows -only, matching `CultMeshNativeQuicRealtimeTransport.CanConnect`, and are -implemented on top of a private v2 runtime holding one connection. They decide -nothing the v2 path does not, with one exception: a v1 connection carries a -SHA-256 pin that is compared here, in C, because that is the contract the -managed connector was built against. A v2 connection never carries one. - -## Building - -Windows x64, from the repo root: - - powershell -File scripts/build-quic-native.ps1 - -MsQuic comes from `Microsoft.Native.Quic.MsQuic.OpenSSL` 2.5.9, pinned twice: the -NuGet zip by digest and size, and the `msquic.dll` inside it by digest and size, -because the archive is what the download is checked against and the DLL is what -actually ships beside the bridge. The OpenSSL flavour rather than Schannel -because Schannel's MsQuic refuses `QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12`, -which is the only credential type a provider has on both platforms. - -The build uses MSVC with `/Brepro` so that a build reproduces itself: the link -timestamps are dropped, and building the same source twice gives the same bytes. -It links the CRT statically (`/MT`), because a Node or Unity host without the -Visual C++ redistributable cannot load a DLL that imports `vcruntime140.dll`, -and the failure it reports names no missing runtime; the bridge carries its own. - -The committed Unity plugin -(`unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/gamecult_mesh_quic_native.dll`) -is this build: 302,080 bytes, `/MT`, OpenSSL MsQuic, no CRT import. It replaced -an older 40,448-byte build — before `/MT` and before the move off Schannel, -still importing `VCRUNTIME140.dll` and `MSVCP140.dll` — at the CultLib 1.0.60 / -Studio 1.4.0 release. - -Linux x64, on a Debian 13 host: - - scripts/build-quic-native.sh - -MsQuic comes from Microsoft's Debian 13 pool as a pinned `.deb`, and the POSIX -headers from the msquic source tree at the matching tag; all four downloads are -verified by SHA-256. The bridge is linked with `-fvisibility=hidden` so only the -v2 exports leave the `.so`, and with `RPATH $ORIGIN` so the `libmsquic.so.2` -shipped beside it resolves without being installed system-wide. - -A Linux host still needs MsQuic's own runtime dependencies present: -`libssl3t64`, `libnuma1`, `libxdp1`, `libnl-route-3-200`. - -The development loop for it is one committed image, -[`scripts/quic-native-linux-dev.Dockerfile`](../../scripts/quic-native-linux-dev.Dockerfile), -pinned to the same `debian:13` digest the release workflow builds in. It carries -the toolchain, MsQuic's runtime dependencies, and `setarch`, because a -sanitizer build of the runtime-lifetime scenarios needs `personality(ADDR_NO_RANDOMIZE)` -and a container that cannot ask for that cannot run them: - - docker build -t cultlib-quic-native-dev -f scripts/quic-native-linux-dev.Dockerfile scripts - docker run --rm --security-opt seccomp=unconfined -v "${PWD}:/src" -w /src cultlib-quic-native-dev bash -lc " - scripts/build-quic-native.sh - cmake -S native/GameCult.Mesh.Quic.Native -B artifacts/quic-native-tests/linux-x64 -G Ninja \ - -DCMAKE_BUILD_TYPE=Release -DCULTMESH_QUIC_BUILD_TESTS=ON -DCULTMESH_QUIC_DEBUG_ASSERTS=ON \ - -DMSQUIC_INCLUDE_DIR=\$(pwd)/artifacts/dependencies/msquic-linux-2.5.9/include \ - -DMSQUIC_LIB_DIR=\$(pwd)/artifacts/dependencies/msquic-linux-2.5.9/package/usr/lib/x86_64-linux-gnu - cmake --build artifacts/quic-native-tests/linux-x64 - for scenario in closerace holdclose holdtimeout polltimeout pollbusy pollhammer zerotimeout payloadfit latecall waitseam; do - setarch -R artifacts/quic-native-tests/linux-x64/bin/cultmesh_quic_native_tests \"\${scenario}\" || exit 1 - done - " - -Both lines are run from the repository root, and the mount is that root wherever -it is: `${PWD}` in PowerShell, `$(pwd)` in a POSIX shell. `MSQUIC_INCLUDE_DIR` -and `MSQUIC_LIB_DIR` need `$(pwd)` too, even though the `cmake` invocation -itself already runs from `/src`: CMake resolves a relative path passed as a -cache variable against the CMakeLists.txt that consumes it -(`native/GameCult.Mesh.Quic.Native`), not against the directory `cmake` was -invoked from, so a bare `artifacts/...` here looks for the dependency one level -too deep and fails the compile before it reaches the loader. The test target's -own build now copies `libmsquic.so.2` from `MSQUIC_LIB_DIR` beside the built -binaries, so nothing else here needs the packaged output directory `scripts/build-quic-native.sh` -produces. `CULTMESH_QUIC_BUILD_TESTS` -is off in the shipped configuration (see the CMakeLists.txt option comment); it -turns on the scenario runner `tests/cultmesh_quic_native_tests.cpp` compiles into, -and `CULTMESH_QUIC_DEBUG_ASSERTS` turns on both the bridge's internal invariant -assertions and the four scenarios (`holdclose`, `latecall`, `holdtimeout`, -`waitseam`) that need the development seam in section 7 of the header. Each -scenario name is a positional argument to the built binary; run them individually -to bisect a failure, or all ten as shown to cover the runtime-lifetime rules. - -A Windows clone made before `.gitattributes` kept shell scripts at LF still has -`scripts/build-quic-native.sh` with carriage returns, and pulling does not fix -it: the script's content did not change, so git neither rewrites it nor reports -it modified. The container then dies at once on the interpreter `bash\r`. From -the repository root, in either shell, this checks the script out again under the -current attributes: - - git rm --cached -q scripts/build-quic-native.sh; git checkout HEAD -- scripts/build-quic-native.sh - -`--security-opt seccomp=unconfined` is load-bearing, not caution. A -ThreadSanitizer configuration needs the process's address space where it expects -it, so a TSan run must be re-executed under `setarch -R`, which asks the kernel -for `personality(ADDR_NO_RANDOMIZE)`. Docker's default seccomp profile denies -that call. Without it ThreadSanitizer dies before `main` with "unexpected memory -mapping" — a configuration that never starts, not a scenario that passed. - -A win32-x64 build of the scenario runner needs the checkout somewhere short — -near a drive root rather than under a deep temporary directory. MSVC builds the -bridge and its scenario runner through MSBuild, whose file tracker gives out on -long paths, and the build then fails outright rather than naming the path as the -cause. - -Where an artifact was built is part of what it is. The Linux binary is linked -against Debian 13's glibc, so a build from anywhere else is a different artifact -whatever the file name says. +The build consumes Microsoft's pinned +`Microsoft.Native.Quic.MsQuic.Schannel` package. It does not contain +Aetheria-specific code. diff --git a/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp b/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp index 5964de43..bbb6e193 100644 --- a/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp +++ b/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp @@ -1,116 +1,24 @@ -// The CultMesh MsQuic bridge: one MsQuic body, two roles, two platforms. -// -// This file owns MsQuic registration, configuration, listener, connection and -// stream handles, TLS credential loading, the 1-byte-kind plus 4-byte-LE-length -// QUIC stream framing, and a single event queue per runtime. It owns transport -// only. It never decodes a CultMesh frame, never decides delivery semantics, -// never coalesces, never reconnects, and in the v2 path never accepts a -// certificate on its own: the host answers, through -// `cultmesh_quic_connection_certificate_complete`. -// -// No host callbacks. Every crossing is the host calling in, including the one -// blocking wait (`cultmesh_quic_next_event`). MsQuic's worker threads only ever -// push onto the queue and wake that wait, so the bridge is runtime-neutral: it -// includes no Node headers, no Unity headers, and nothing platform-specific -// outside the `_WIN32` block at the bottom of this file. -// -// Two ABIs live here. v2 is the queue API above, for any host. v1 is the five -// exports the Unity managed connector already imports by name -// (`CultMeshNativeQuicRealtimeTransport.cs`, `NativeMethods`), kept Windows-only -// and implemented on top of a private v2 runtime. v1 is the only place a -// certificate pin is compared in C, because that is the contract Unity was -// shipped with; it delegates everything else and decides nothing new. - -// The host-facing contract lives in include/cultmesh_quic_native.h and is -// included here so this file cannot drift from it: the event layout, the -// exports, the return codes, the lifetime rules and the threading rules are -// stated there once and asserted at compile time. -#define CULTMESH_QUIC_BUILD 1 -#include - +#include +#include +#include #include -#include #include #include -#include #include #include -#include -#include #include #include #include #include #include #include -#include -#include #include -#define CULTMESH_API extern "C" CULTMESH_QUIC_API - -// Development builds only, and never in a shipped binary: after -// `cultmesh_quic_runtime_close` has quiesced, no host call may still be inside -// the library. The wait is what makes that true, and its absence is otherwise -// visible only as a use-after-free a sanitizer catches by luck — a poller has to -// be preempted in the right window. With pollers blocked, this states the -// invariant directly, so a build without the wait dies on the first close rather -// than on the run that happens to be unlucky. -#if defined(CULTMESH_QUIC_DEBUG_ASSERTS) -#define CULTMESH_QUIC_ASSERT_QUIESCED(count) \ - do { \ - const int cultmesh_quic_remaining = (count); \ - if (cultmesh_quic_remaining != 0) { \ - std::fprintf(stderr, \ - "cultmesh_quic_runtime_close: %d host call(s) still inside the " \ - "library after the quiesce\n", cultmesh_quic_remaining); \ - std::fflush(stderr); \ - std::abort(); \ - } \ - } while (false) +#if defined(GAMECULT_MESH_QUIC_NATIVE_EXPORTS) +#define CULTMESH_API extern "C" __declspec(dllexport) #else -#define CULTMESH_QUIC_ASSERT_QUIESCED(count) ((void)0) -#endif - -// Development builds only, and never in a shipped binary: the seam the scenario -// runner drives the quiesce through. A scenario that only sleeps and hopes its -// pollers are inside the library cannot tell a bridge that counts host calls -// from one that does not, so the bridge says so itself: -// -// - PEAK raises the high-water mark of calls counted inside, so a scenario can -// assert that the calls it launched were counted rather than assumed. -// - AT_CLOSE records how many were counted when the close began its wait, which -// is the number the wait exists for. -// - HELD parks a call inside the library until the scenario releases it, so the -// close races calls that are genuinely there for as long as the scenario -// wants them there, instead of for as long as the schedule happens to give. -// It returns true when it parked, and a held call makes no further call of -// its own; what it does still do is leave its CallScope, which takes the gate, -// decrements the count and notifies. That is the ordinary exit every export -// makes, and it is the bridge's wait — not the hold — that keeps the runtime -// alive underneath it. -// - RECORD_WAIT wraps the whole duration handed to the condition wait in -// `cultmesh_quic_next_event`, in place, as a pass-through: it stores that -// duration's count and returns the duration unchanged, so a mutation to -// what is actually waited on changes what this reports rather than the two -// ever being able to diverge. It wraps `timeout_ms` after conversion to -// `std::chrono::milliseconds`, not before — Self's ruling of 2026-09-22, -// after arithmetic applied to the converted duration survived a seam that -// only recorded the raw integer underneath it. A scenario reads it and -// asserts equality against the timeout it asked for, deterministically and -// with no timing tolerance, in place of inferring the same fact from how -// long a call took to return. -#if defined(CULTMESH_QUIC_DEBUG_ASSERTS) -#define CULTMESH_QUIC_DEBUG_PEAK(count) DebugRaisePeak(count) -#define CULTMESH_QUIC_DEBUG_AT_CLOSE(count) DebugRecordCallsAtClose(count) -#define CULTMESH_QUIC_DEBUG_HELD(lock) DebugHoldCall(lock) -#define CULTMESH_QUIC_DEBUG_RECORD_WAIT(duration) DebugRecordWait(duration) -#else -#define CULTMESH_QUIC_DEBUG_PEAK(count) ((void)0) -#define CULTMESH_QUIC_DEBUG_AT_CLOSE(count) ((void)0) -#define CULTMESH_QUIC_DEBUG_HELD(lock) (false) -#define CULTMESH_QUIC_DEBUG_RECORD_WAIT(duration) (duration) +#define CULTMESH_API extern "C" __declspec(dllimport) #endif namespace { @@ -118,1168 +26,11 @@ namespace { constexpr uint32_t kApiVersion = 2; constexpr uint64_t kConnectionCloseCode = 0x43554c54; constexpr uint64_t kStreamAbortCode = 0x53544154; -constexpr uint8_t kReliableStream = CULTMESH_QUIC_STREAM_RELIABLE; -constexpr uint8_t kLatestOnlyStream = CULTMESH_QUIC_STREAM_LATEST_ONLY; +constexpr uint8_t kReliableStream = 1; +constexpr uint8_t kLatestOnlyStream = 2; constexpr uint32_t kMaximumEncodedFrameBytes = (64u * 1024u * 1024u) + 37u + (3u * 65535u); constexpr char kAlpn[] = "cultmesh-state-v1"; -// Mirrors the C# server and connector: CultMeshQuicRealtimeTransport.cs:77,:202 -// for the stream ceiling, and the idle/keep-alive pair the v1 bridge already -// used (:335-338). -constexpr uint32_t kIdleTimeoutMs = 30000; -constexpr uint32_t kKeepAliveIntervalMs = 5000; -constexpr uint16_t kPeerUnidiStreamCount = 1024; -// If the host never answers the certificate event, this is what closes the -// connection instead of leaking it: MsQuic's own handshake timeout fires and -// the shutdown arrives as event 4. -constexpr uint32_t kHandshakeIdleTimeoutMs = 10000; - -// Short names for the header's event codes; the header is the contract. -constexpr uint32_t kEventListenerNewConnection = CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION; -constexpr uint32_t kEventConnectionConnected = CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED; -constexpr uint32_t kEventConnectionCertificateReceived = CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED; -constexpr uint32_t kEventConnectionShutdown = CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN; -constexpr uint32_t kEventStreamStarted = CULTMESH_QUIC_EVENT_STREAM_STARTED; -constexpr uint32_t kEventStreamFrame = CULTMESH_QUIC_EVENT_STREAM_FRAME; -constexpr uint32_t kEventStreamSendComplete = CULTMESH_QUIC_EVENT_STREAM_SEND_COMPLETE; -constexpr uint32_t kEventStreamShutdown = CULTMESH_QUIC_EVENT_STREAM_SHUTDOWN; -constexpr uint32_t kEventListenerStopped = CULTMESH_QUIC_EVENT_LISTENER_STOPPED; - -struct Runtime; -struct Connection; - -struct QueuedEvent { - cultmesh_quic_event header{}; - std::vector payload; -}; - -// Objects are shared-owned, and that is the whole lifetime model. 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 entire MsQuic call. MsQuic's -// worker, on shutdown-complete, drops the map's reference only. The handle -// follows the object: it is closed exactly once, in the destructor, by whichever -// holder drops last, under a one-shot exchange guard. See section 4 of -// include/cultmesh_quic_native.h, which is where this is promised to hosts. - -struct Stream { - Runtime* runtime = nullptr; - // Shared, not raw: this is what keeps a connection's handle open for as long - // as any of its streams still exist, so stream handles always close first. - std::shared_ptr owner; - HQUIC handle = nullptr; - uint64_t id = 0; - uint8_t kind = 0; - bool inbound = false; - bool has_kind = false; - std::atomic handle_closed{false}; - // Inbound reassembly only. MsQuic delivers stream bytes in arbitrary - // chunks, so the length prefix and the frame it introduces can arrive in - // separate receives. - std::vector buffered; - - ~Stream(); -}; - -struct Connection : std::enable_shared_from_this { - Runtime* runtime = nullptr; - HQUIC handle = nullptr; - uint64_t id = 0; - uint64_t listener_id = 0; - bool inbound = false; - // Written by the host thread in `cultmesh_quic_connection_shutdown`, read on - // an MsQuic worker when the shutdown completes: the code the host passed is - // what its own event 4 has to carry. - std::atomic closing{false}; - std::atomic shutdown_code{0}; - // Why it ended, recorded by whichever initiated-by callback saw it and read - // by shutdown-complete, which is the single emitter of event 4. Both run on - // this connection's own worker, serialized. - std::string shutdown_reason; - int32_t shutdown_status = 0; - // Set when MsQuic is closing the handle itself (an app close already in - // progress), so the destructor's one-shot close stands down. - std::atomic handle_closed{false}; - // v1 only. A connection carrying a pin has its certificate answered in C - // against that pin; a v2 connection never sets this and is always answered - // by the host. Nothing outside the `_WIN32` block below ever sets it. - bool has_pin = false; - std::array pin{}; - // v1 diagnostics: the MsQuic event sequence, which the v1 error string - // reports and nothing in v2 reads. - std::string trace; - - ~Connection(); -}; - -struct Listener { - Runtime* runtime = nullptr; - HQUIC handle = nullptr; - HQUIC configuration = nullptr; - uint64_t id = 0; - std::atomic handle_closed{false}; - - ~Listener(); -}; - -struct Runtime { - const QUIC_API_TABLE* api = nullptr; - HQUIC registration = nullptr; - // One client configuration serves every outbound connection: the ALPN, - // settings and credential flags are identical for all of them. - HQUIC client_configuration = nullptr; - - std::mutex gate; - std::condition_variable signal; - std::deque events; - std::unordered_map> listeners; - std::unordered_map> connections; - std::unordered_map> streams; - uint64_t next_id = 1; - std::string error; - // The raw platform status behind the last MsQuic refusal, kept because the - // portable return code deliberately throws its bits away. - int32_t last_status = 0; - bool closing = false; - // Host calls currently inside the library. `cultmesh_quic_runtime_close` - // waits for this to reach zero before it touches a handle. - int active_calls = 0; -}; - -#if defined(CULTMESH_QUIC_DEBUG_ASSERTS) -// The development seam behind the three CULTMESH_QUIC_DEBUG_ macros. None of it -// is compiled into a shipped library. -std::atomic debug_peak_calls{0}; -std::atomic debug_calls_at_close{-1}; -std::mutex debug_hold_gate; -std::condition_variable debug_hold_signal; -bool debug_hold_armed = false; -std::atomic debug_last_wait_ms{-1}; -// Separate from the value above on purpose: once the seam records the whole -// duration (not just a millisecond count already known to be positive), a -// mutation could in principle make a wait get recorded at exactly -1, and an -// int32_t alone cannot tell that apart from "nothing recorded yet". This flag -// is the "recorded" bit; the getter below is the only thing that still hands -// out -1 for the unset case. -std::atomic debug_wait_recorded{false}; - -void DebugRaisePeak(int count) { - int peak = debug_peak_calls.load(std::memory_order_relaxed); - while (count > peak && !debug_peak_calls.compare_exchange_weak(peak, count)) { - } -} - -void DebugRecordCallsAtClose(int count) { debug_calls_at_close.store(count); } - -// Parks the calling host thread inside the library until the scenario releases -// it. The runtime gate is dropped first, so nothing else is blocked behind the -// held call, and it is never retaken: the call is still counted, because its -// CallScope is still alive, and that is the whole point. -bool DebugHoldCall(std::unique_lock& lock) { - std::unique_lock hold(debug_hold_gate); - if (!debug_hold_armed) return false; - lock.unlock(); - debug_hold_signal.wait(hold, [] { return !debug_hold_armed; }); - return true; -} - -// A pass-through: it stores the duration's count and returns the duration -// unmodified, so wrapping it around the whole expression handed to the -// condition wait makes the two impossible to desync by construction. Whatever -// that expression evaluates to — the plain converted argument today, or a -// derivation a future change or a mutation introduces inside this call — is -// what gets recorded, because it is the same evaluation. Anything applied -// outside this call, after it returns, is what the wall-clock scenarios exist -// to catch instead; see their generous-tolerance comment. -std::chrono::milliseconds DebugRecordWait(std::chrono::milliseconds duration) { - debug_last_wait_ms.store(static_cast(duration.count())); - debug_wait_recorded.store(true); - return duration; -} -#endif - -// Every host-facing export enters through this. It refuses once -// `cultmesh_quic_runtime_close` has started, and otherwise holds the runtime -// open for the length of the call. -class CallScope { -public: - explicit CallScope(Runtime* runtime) : runtime_(runtime) { - if (runtime_ == nullptr) return; - std::lock_guard lock(runtime_->gate); - if (runtime_->closing) return; - ++runtime_->active_calls; - CULTMESH_QUIC_DEBUG_PEAK(runtime_->active_calls); - entered_ = true; - } - // The notify stays under `gate`, and that is the whole point of it. Released - // first, the closer's predicate is already true, so it can return from its - // wait, run the teardown and destroy the runtime — condition variable - // included — before this thread reaches the notify. Holding the lock across - // both means the closer cannot leave `signal.wait` until this scope has let - // go, so the condition variable is still alive when it is signalled. - ~CallScope() { - if (!entered_) return; - std::lock_guard lock(runtime_->gate); - --runtime_->active_calls; - runtime_->signal.notify_all(); - } - CallScope(const CallScope&) = delete; - CallScope& operator=(const CallScope&) = delete; - bool entered() const { return entered_; } - -private: - Runtime* runtime_ = nullptr; - bool entered_ = false; -}; - -Stream::~Stream() { - if (handle != nullptr && runtime != nullptr && runtime->api != nullptr && - !handle_closed.exchange(true)) - runtime->api->StreamClose(handle); -} - -Connection::~Connection() { - if (handle != nullptr && runtime != nullptr && runtime->api != nullptr && - !handle_closed.exchange(true)) - runtime->api->ConnectionClose(handle); -} - -Listener::~Listener() { - if (runtime == nullptr || runtime->api == nullptr) return; - if (handle != nullptr && !handle_closed.exchange(true)) runtime->api->ListenerClose(handle); - if (configuration != nullptr) runtime->api->ConfigurationClose(configuration); -} - -// Every send buffer outlives its `StreamSend` call: MsQuic reads the bytes -// asynchronously and only releases them at SEND_COMPLETE, where this is freed. -struct SendRequest { - QUIC_BUFFER buffer{}; - std::vector bytes; - // Which send this was. The bridge writes the stream's kind byte itself, and - // that completion is not the host's to hear about; event 7 is one per - // `cultmesh_quic_stream_send_frame` and nothing else. - bool is_frame = false; -}; - -// A `QUIC_STATUS` reaches this through `uint32_t`, never straight to `uint64_t`: -// it is a signed HRESULT on Windows, and widening it signed printed every -// Windows failure as `0xFFFFFFFF80072B18`. The status is 32 bits on both -// platforms and is read as 32 bits here. -std::string Hex(uint64_t value) { - std::ostringstream text; - text << "0x" << std::hex << std::uppercase << value; - return text.str(); -} - -void SetError(Runtime* runtime, const std::string& message) { - if (runtime == nullptr) return; - std::lock_guard lock(runtime->gate); - runtime->error = message; -} - -// `QUIC_STATUS` never reaches a host. 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. One negative space on both platforms instead; the -// raw bits go to `cultmesh_quic_last_status`. See section 3 of the header. -int32_t MsQuicCode(QUIC_STATUS status) { - return CULTMESH_QUIC_RESULT_MSQUIC_BASE - - static_cast(static_cast(status) & 0xffffu); -} - -int32_t Refuse(Runtime* runtime, QUIC_STATUS status, const std::string& message) { - if (runtime != nullptr) { - std::lock_guard lock(runtime->gate); - runtime->error = message; - runtime->last_status = static_cast(status); - } - return MsQuicCode(status); -} - -// The only way anything reaches the host. Callers must not hold `gate`. -void Publish(Runtime* runtime, const cultmesh_quic_event& header, const uint8_t* payload, size_t payload_length) { - QueuedEvent queued; - queued.header = header; - queued.header.payload_length = static_cast(payload_length); - if (payload != nullptr && payload_length > 0) - queued.payload.assign(payload, payload + payload_length); - { - std::lock_guard lock(runtime->gate); - if (runtime->closing) return; - runtime->events.push_back(std::move(queued)); - } - runtime->signal.notify_all(); -} - -void PublishSimple(Runtime* runtime, uint32_t type, uint64_t listener_id, uint64_t connection_id, - uint64_t stream_id, uint64_t code, int32_t status, uint32_t stream_kind = 0) { - cultmesh_quic_event header{}; - header.type = type; - header.stream_kind = stream_kind; - header.listener_id = listener_id; - header.connection_id = connection_id; - header.stream_id = stream_id; - header.code = code; - header.status = status; - Publish(runtime, header, nullptr, 0); -} - -// v1 diagnostics only, and bounded. A v2 connection has no reader for this, and -// a long-lived one appended to it once per MsQuic event for as long as it lived. -constexpr size_t kMaximumTraceBytes = 512; - -void AppendTrace(Connection* connection, const std::string& event) { - if (connection == nullptr || !connection->has_pin) return; - std::lock_guard lock(connection->runtime->gate); - if (connection->trace.size() >= kMaximumTraceBytes) return; - if (!connection->trace.empty()) connection->trace += ","; - connection->trace += event; -} - -uint32_t ReadUInt32LittleEndian(const uint8_t* value) { - return static_cast(value[0]) | - (static_cast(value[1]) << 8) | - (static_cast(value[2]) << 16) | - (static_cast(value[3]) << 24); -} - -void WriteUInt32LittleEndian(uint8_t* destination, uint32_t value) { - destination[0] = static_cast(value & 0xff); - destination[1] = static_cast((value >> 8) & 0xff); - destination[2] = static_cast((value >> 16) & 0xff); - destination[3] = static_cast((value >> 24) & 0xff); -} - -uint64_t NextId(Runtime* runtime) { - std::lock_guard lock(runtime->gate); - return runtime->next_id++; -} - -// The three lookups. Each returns a reference the caller owns for as long as it -// holds it, taken under the lock, so nothing the caller then names can be freed -// under it. A raw pointer here was the use-after-free. -std::shared_ptr FindConnection(Runtime* runtime, uint64_t id) { - std::lock_guard lock(runtime->gate); - const auto found = runtime->connections.find(id); - return found == runtime->connections.end() ? nullptr : found->second; -} - -std::shared_ptr FindStream(Runtime* runtime, uint64_t id) { - std::lock_guard lock(runtime->gate); - const auto found = runtime->streams.find(id); - return found == runtime->streams.end() ? nullptr : found->second; -} - -// Takes the object out of its map and returns it, so the caller's reference — -// and any destructor it ends up running — lives outside the lock. -template -auto Detach(std::mutex& gate, Map& map, uint64_t id) { - typename Map::mapped_type detached; - std::lock_guard lock(gate); - const auto found = map.find(id); - if (found == map.end()) return detached; - detached = std::move(found->second); - map.erase(found); - return detached; -} - -void ShutdownConnectionForProtocolFault(const std::shared_ptr& connection, const std::string& message) { - if (connection == nullptr) return; - SetError(connection->runtime, message); - if (connection->runtime->api != nullptr && connection->handle != nullptr) - connection->runtime->api->ConnectionShutdown( - connection->handle, QUIC_CONNECTION_SHUTDOWN_FLAG_NONE, kStreamAbortCode); -} - -// Reassembles whole encoded frames out of the stream byte sequence and -// publishes one event 6 per frame, length prefix stripped. The first byte of an -// inbound stream is its kind, which becomes event 5. -bool ConsumeFrames(Stream* stream) { - Runtime* runtime = stream->runtime; - auto& bytes = stream->buffered; - if (!stream->has_kind) { - if (bytes.empty()) return true; - stream->kind = bytes.front(); - bytes.erase(bytes.begin()); - stream->has_kind = true; - if (stream->kind != kReliableStream && stream->kind != kLatestOnlyStream) { - ShutdownConnectionForProtocolFault(stream->owner, "CultMesh QUIC stream kind is invalid."); - return false; - } - PublishSimple(runtime, kEventStreamStarted, stream->owner->listener_id, stream->owner->id, - stream->id, 0, 0, stream->kind); - } - - while (bytes.size() >= sizeof(uint32_t)) { - const uint32_t length = ReadUInt32LittleEndian(bytes.data()); - if (length == 0 || length > kMaximumEncodedFrameBytes) { - ShutdownConnectionForProtocolFault(stream->owner, "CultMesh QUIC frame length is invalid."); - return false; - } - const size_t framed_length = sizeof(uint32_t) + static_cast(length); - if (bytes.size() < framed_length) return true; - - cultmesh_quic_event header{}; - header.type = kEventStreamFrame; - header.stream_kind = stream->kind; - header.listener_id = stream->owner->listener_id; - header.connection_id = stream->owner->id; - header.stream_id = stream->id; - Publish(runtime, header, bytes.data() + sizeof(uint32_t), length); - - bytes.erase(bytes.begin(), bytes.begin() + static_cast(framed_length)); - } - return true; -} - -// Drops the map's reference and nothing else. If the map was the last holder the -// object dies here, outside the lock, and its destructor closes the handle; if a -// host call is still inside, the object outlives this by exactly that long. -void DestroyStream(Runtime* runtime, uint64_t stream_id) { - [[maybe_unused]] const auto detached = Detach(runtime->gate, runtime->streams, stream_id); -} - -QUIC_STATUS QUIC_API StreamCallback(HQUIC, void* context, QUIC_STREAM_EVENT* event) { - auto* stream = static_cast(context); - Runtime* runtime = stream->runtime; - switch (event->Type) { - case QUIC_STREAM_EVENT_RECEIVE: - for (uint32_t index = 0; index < event->RECEIVE.BufferCount; ++index) { - const auto& buffer = event->RECEIVE.Buffers[index]; - stream->buffered.insert(stream->buffered.end(), buffer.Buffer, buffer.Buffer + buffer.Length); - } - ConsumeFrames(stream); - break; - case QUIC_STREAM_EVENT_SEND_COMPLETE: { - // Which send finished is the client context's job to say. Without it the - // bridge's own kind byte completed as an anonymous event 7 and the host - // counted six completions for five frames. - auto* request = static_cast(event->SEND_COMPLETE.ClientContext); - const bool is_frame = request != nullptr && request->is_frame; - delete request; - if (is_frame) - PublishSimple(runtime, kEventStreamSendComplete, stream->owner->listener_id, stream->owner->id, - stream->id, - event->SEND_COMPLETE.Canceled - ? CULTMESH_QUIC_SEND_CANCELED - : CULTMESH_QUIC_SEND_COMPLETED, - 0, stream->kind); - break; - } - case QUIC_STREAM_EVENT_PEER_SEND_SHUTDOWN: - if (!stream->buffered.empty()) - ShutdownConnectionForProtocolFault(stream->owner, "CultMesh QUIC stream ended with a truncated frame."); - break; - case QUIC_STREAM_EVENT_SHUTDOWN_COMPLETE: { - // Everything this event reports is read before the map's reference goes, - // because that reference may be the last one and `stream` may not - // survive `DestroyStream`. - const uint64_t stream_id = stream->id; - const uint64_t connection_id = stream->owner->id; - const uint64_t listener_id = stream->owner->listener_id; - const uint32_t kind = stream->kind; - // An app close already in progress means MsQuic closes this handle - // itself; anything else leaves it to the last holder's destructor. - if (event->SHUTDOWN_COMPLETE.AppCloseInProgress) stream->handle_closed.store(true); - DestroyStream(runtime, stream_id); - PublishSimple(runtime, kEventStreamShutdown, listener_id, connection_id, stream_id, 0, 0, kind); - break; - } - default: - break; - } - return QUIC_STATUS_SUCCESS; -} - -void DestroyConnection(Runtime* runtime, uint64_t connection_id) { - // Declared first so it dies last: whatever order these drop in, a stream - // holds its connection, so every stream handle closes before the connection - // handle does. - std::shared_ptr connection; - std::vector> streams; - { - std::lock_guard lock(runtime->gate); - for (auto it = runtime->streams.begin(); it != runtime->streams.end();) { - if (it->second->owner != nullptr && it->second->owner->id == connection_id) { - streams.push_back(std::move(it->second)); - it = runtime->streams.erase(it); - } else { - ++it; - } - } - const auto found = runtime->connections.find(connection_id); - if (found != runtime->connections.end()) { - connection = std::move(found->second); - runtime->connections.erase(found); - } - } -} - -#if defined(_WIN32) -// Declared here, defined in the v1 block at the bottom: the pin comparison is -// the one piece of the certificate path that is Windows-only and v1-only. -bool CertificateMatchesPin(Connection* connection, QUIC_CERTIFICATE* certificate); -#endif - -QUIC_STATUS QUIC_API ConnectionCallback(HQUIC handle, void* context, QUIC_CONNECTION_EVENT* event) { - auto* connection = static_cast(context); - Runtime* runtime = connection->runtime; - AppendTrace(connection, "event=" + std::to_string(static_cast(event->Type))); - switch (event->Type) { - case QUIC_CONNECTION_EVENT_CONNECTED: - PublishSimple(runtime, kEventConnectionConnected, connection->listener_id, connection->id, 0, 0, 0); - break; - case QUIC_CONNECTION_EVENT_PEER_STREAM_STARTED: { - auto stream = std::make_shared(); - stream->runtime = runtime; - stream->owner = connection->shared_from_this(); - stream->handle = event->PEER_STREAM_STARTED.Stream; - stream->inbound = true; - stream->id = NextId(runtime); - Stream* raw = stream.get(); - { - std::lock_guard lock(runtime->gate); - runtime->streams.emplace(raw->id, std::move(stream)); - } - runtime->api->SetCallbackHandler( - event->PEER_STREAM_STARTED.Stream, reinterpret_cast(StreamCallback), raw); - break; - } - case QUIC_CONNECTION_EVENT_PEER_CERTIFICATE_RECEIVED: { - const auto* encoded = reinterpret_cast(event->PEER_CERTIFICATE_RECEIVED.Certificate); - AppendTrace(connection, "certificate-status=" + - Hex(static_cast(event->PEER_CERTIFICATE_RECEIVED.DeferredStatus))); -#if defined(_WIN32) - if (connection->has_pin) { - // The v1 contract: the pin decision is made here, in C, and the host - // is never asked. Only `cultmesh_quic_open` sets `has_pin`. - const bool matches = CertificateMatchesPin( - connection, event->PEER_CERTIFICATE_RECEIVED.Certificate); - if (!matches) - SetError(runtime, - "CultMesh QUIC provider certificate does not match the advertised SHA-256 pin."); - const auto completion = runtime->api->ConnectionCertificateValidationComplete( - handle, - matches ? static_cast(1) : static_cast(0), - matches ? QUIC_TLS_ALERT_CODE_SUCCESS : QUIC_TLS_ALERT_CODE_BAD_CERTIFICATE); - if (QUIC_FAILED(completion)) { - SetError(runtime, "CultMesh QUIC certificate validation completion failed (status=" + - Hex(static_cast(completion)) + ")."); - return completion; - } - return QUIC_STATUS_PENDING; - } -#endif - // v2: the DER goes to the host and the handshake waits. Nothing here - // decides trust. If the host never answers, MsQuic's handshake timeout - // closes the connection and event 4 reports it. - cultmesh_quic_event header{}; - header.type = kEventConnectionCertificateReceived; - header.listener_id = connection->listener_id; - header.connection_id = connection->id; - header.status = static_cast(event->PEER_CERTIFICATE_RECEIVED.DeferredStatus); - const uint8_t* der = (encoded != nullptr) ? encoded->Buffer : nullptr; - const size_t der_length = (encoded != nullptr) ? encoded->Length : 0; - Publish(runtime, header, der, der_length); - return QUIC_STATUS_PENDING; - } - // The two initiated-by events say why a connection is ending, not that it - // has ended; the object and its id are still live here. They record the - // reason and the code, and shutdown-complete below is the one place event 4 - // is emitted, once per connection id, after the id has stopped resolving. - // All three of these run on the connection's own MsQuic worker, serialized, - // which is what lets these fields be plain members. - case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_TRANSPORT: - connection->shutdown_reason = "CultMesh QUIC connection was shut down by the transport (status=" + - Hex(static_cast(event->SHUTDOWN_INITIATED_BY_TRANSPORT.Status)) + ", error=" + - Hex(event->SHUTDOWN_INITIATED_BY_TRANSPORT.ErrorCode) + ")."; - connection->shutdown_code.store(event->SHUTDOWN_INITIATED_BY_TRANSPORT.ErrorCode); - connection->shutdown_status = static_cast(event->SHUTDOWN_INITIATED_BY_TRANSPORT.Status); - break; - case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_PEER: - connection->shutdown_reason = "CultMesh QUIC connection was shut down by the peer."; - connection->shutdown_code.store(event->SHUTDOWN_INITIATED_BY_PEER.ErrorCode); - break; - case QUIC_CONNECTION_EVENT_SHUTDOWN_COMPLETE: { - const uint64_t connection_id = connection->id; - if (event->SHUTDOWN_COMPLETE.AppCloseInProgress) connection->handle_closed.store(true); - // A host that ended the connection itself gets nothing else from MsQuic - // — no initiated-by-peer, no initiated-by-transport — so without this - // its own connection id simply stopped existing and the end of a - // connection was observable only from the other side. - cultmesh_quic_event header{}; - header.type = kEventConnectionShutdown; - header.listener_id = connection->listener_id; - header.connection_id = connection_id; - header.code = connection->shutdown_code.load(); - header.status = connection->shutdown_status; - const std::string reason = connection->shutdown_reason.empty() - ? std::string("CultMesh QUIC connection was shut down by the host.") - : connection->shutdown_reason; - DestroyConnection(runtime, connection_id); - Publish(runtime, header, reinterpret_cast(reason.data()), reason.size()); - break; - } - default: - break; - } - return QUIC_STATUS_SUCCESS; -} - -QUIC_STATUS QUIC_API ListenerCallback(HQUIC, void* context, QUIC_LISTENER_EVENT* event) { - auto* listener = static_cast(context); - Runtime* runtime = listener->runtime; - switch (event->Type) { - case QUIC_LISTENER_EVENT_NEW_CONNECTION: { - auto connection = std::make_shared(); - connection->runtime = runtime; - connection->handle = event->NEW_CONNECTION.Connection; - connection->inbound = true; - connection->listener_id = listener->id; - connection->id = NextId(runtime); - Connection* raw = connection.get(); - { - std::lock_guard lock(runtime->gate); - runtime->connections.emplace(raw->id, std::move(connection)); - } - runtime->api->SetCallbackHandler( - event->NEW_CONNECTION.Connection, reinterpret_cast(ConnectionCallback), raw); - const auto status = runtime->api->ConnectionSetConfiguration( - event->NEW_CONNECTION.Connection, listener->configuration); - if (QUIC_FAILED(status)) { - SetError(runtime, "CultMesh QUIC inbound connection configuration failed (status=" + - Hex(static_cast(status)) + ")."); - // Returning a failure here is MsQuic's signal to reject and close - // this connection itself, so the entry made a moment ago has to go - // with it rather than sitting in the map naming a dead handle. - raw->handle_closed.store(true); - DestroyConnection(runtime, raw->id); - return status; - } - PublishSimple(runtime, kEventListenerNewConnection, listener->id, raw->id, 0, 0, 0); - break; - } - case QUIC_LISTENER_EVENT_STOP_COMPLETE: - PublishSimple(runtime, kEventListenerStopped, listener->id, 0, 0, 0, 0); - break; - default: - break; - } - return QUIC_STATUS_SUCCESS; -} - -void FillSettings(QUIC_SETTINGS& settings) { - settings.IdleTimeoutMs = kIdleTimeoutMs; - settings.IsSet.IdleTimeoutMs = 1; - settings.KeepAliveIntervalMs = kKeepAliveIntervalMs; - settings.IsSet.KeepAliveIntervalMs = 1; - settings.PeerUnidiStreamCount = kPeerUnidiStreamCount; - settings.IsSet.PeerUnidiStreamCount = 1; - settings.HandshakeIdleTimeoutMs = kHandshakeIdleTimeoutMs; - settings.IsSet.HandshakeIdleTimeoutMs = 1; -} - -QUIC_BUFFER AlpnBuffer() { - return QUIC_BUFFER{ - static_cast(sizeof(kAlpn) - 1), - reinterpret_cast(const_cast(kAlpn)) - }; -} - -// Opened once per runtime, on first outbound connection. -QUIC_STATUS EnsureClientConfiguration(Runtime* runtime) { - if (runtime->client_configuration != nullptr) return QUIC_STATUS_SUCCESS; - - QUIC_SETTINGS settings{}; - FillSettings(settings); - QUIC_BUFFER alpn = AlpnBuffer(); - HQUIC configuration = nullptr; - auto status = runtime->api->ConfigurationOpen( - runtime->registration, &alpn, 1, &settings, sizeof(settings), nullptr, &configuration); - if (QUIC_FAILED(status)) return status; - - QUIC_CREDENTIAL_CONFIG credentials{}; - credentials.Type = QUIC_CREDENTIAL_TYPE_NONE; - credentials.Flags = static_cast( - QUIC_CREDENTIAL_FLAG_CLIENT | - QUIC_CREDENTIAL_FLAG_INDICATE_CERTIFICATE_RECEIVED | - QUIC_CREDENTIAL_FLAG_DEFER_CERTIFICATE_VALIDATION | - QUIC_CREDENTIAL_FLAG_USE_PORTABLE_CERTIFICATES); - status = runtime->api->ConfigurationLoadCredential(configuration, &credentials); - if (QUIC_FAILED(status)) { - runtime->api->ConfigurationClose(configuration); - return status; - } - runtime->client_configuration = configuration; - return QUIC_STATUS_SUCCESS; -} - -// `pin` is the v1 shim's and nothing else passes it. It must be attached before -// `ConnectionStart`, not after it returns: the handshake runs on an MsQuic -// worker thread and reaches the certificate event immediately on loopback, so a -// pin applied afterwards loses the race, the connection takes the v2 path, and -// the handshake stalls waiting for an answer from a host that was never told to -// give one. -int32_t OpenConnection(Runtime* runtime, const char* host, uint16_t port, uint64_t* out_connection_id, - const std::array* pin) { - if (runtime == nullptr || host == nullptr || *host == '\0' || port == 0 || out_connection_id == nullptr) - return -1; - *out_connection_id = 0; - - auto status = EnsureClientConfiguration(runtime); - if (QUIC_FAILED(status)) { - return Refuse(runtime, status, "CultMesh QUIC client configuration failed (status=" + - Hex(static_cast(status)) + ")."); - } - - auto connection = std::make_shared(); - connection->runtime = runtime; - connection->id = NextId(runtime); - if (pin != nullptr) { - connection->has_pin = true; - connection->pin = *pin; - } - Connection* raw = connection.get(); - - status = runtime->api->ConnectionOpen(runtime->registration, ConnectionCallback, raw, &raw->handle); - if (QUIC_FAILED(status)) { - return Refuse(runtime, status, "CultMesh QUIC connection open failed (status=" + - Hex(static_cast(status)) + ")."); - } - { - std::lock_guard lock(runtime->gate); - runtime->connections.emplace(raw->id, connection); - } - - status = runtime->api->ConnectionStart( - raw->handle, runtime->client_configuration, QUIC_ADDRESS_FAMILY_UNSPEC, host, port); - if (QUIC_FAILED(status)) { - // The map's reference goes and this local one goes with the scope; the - // handle is closed by whichever of the two drops last, exactly once. - DestroyConnection(runtime, raw->id); - return Refuse(runtime, status, "CultMesh QUIC connection start failed (status=" + - Hex(static_cast(status)) + ")."); - } - - *out_connection_id = raw->id; - return 0; -} - -} // namespace - -// --------------------------------------------------------------------------- -// C ABI v2: the runtime-neutral bridge. -// --------------------------------------------------------------------------- - -CULTMESH_API int32_t cultmesh_quic_runtime_open(const char* app_name, void** out_runtime) { - if (out_runtime == nullptr) return -1; - *out_runtime = nullptr; - auto runtime = std::make_unique(); - - // No runtime yet to hang `last_status` on, so these two refusals lose the - // raw bits. Everything after this point keeps them. - auto status = MsQuicOpenVersion(kApiVersion, reinterpret_cast(&runtime->api)); - if (QUIC_FAILED(status)) return MsQuicCode(status); - - const QUIC_REGISTRATION_CONFIG registration_config = { - (app_name != nullptr && *app_name != '\0') ? app_name : "GameCult.Mesh.Quic.Native", - QUIC_EXECUTION_PROFILE_TYPE_REAL_TIME - }; - status = runtime->api->RegistrationOpen(®istration_config, &runtime->registration); - if (QUIC_FAILED(status)) { - MsQuicClose(runtime->api); - return MsQuicCode(status); - } - - *out_runtime = runtime.release(); - return 0; -} - -CULTMESH_API void cultmesh_quic_runtime_close(void* handle) { - auto* runtime = static_cast(handle); - if (runtime == nullptr) return; - - // Quiesce before anything is closed or freed. Marking the runtime closing - // refuses every call that has not started; waking the pollers gets the - // blocked ones out of `next_event`; waiting for `active_calls` to reach zero - // is what makes the teardown below safe at all. A wake without the wait is - // how a poller returns into freed memory, so the wait is the contract, not - // the wake. Objects are taken out of the maps here and released after the - // lock, so their destructors run on this thread and close their handles. - std::vector> streams; - std::vector> connections; - std::vector> listeners; - { - std::unique_lock lock(runtime->gate); - if (runtime->closing) return; - runtime->closing = true; - runtime->signal.notify_all(); - CULTMESH_QUIC_DEBUG_AT_CLOSE(runtime->active_calls); - runtime->signal.wait(lock, [runtime] { return runtime->active_calls == 0; }); - CULTMESH_QUIC_ASSERT_QUIESCED(runtime->active_calls); - for (auto& entry : runtime->streams) streams.push_back(std::move(entry.second)); - for (auto& entry : runtime->connections) connections.push_back(std::move(entry.second)); - for (auto& entry : runtime->listeners) listeners.push_back(std::move(entry.second)); - runtime->streams.clear(); - runtime->connections.clear(); - runtime->listeners.clear(); - } - - // Bottom-up, and by the app's own hand. `RegistrationClose` blocks until - // every child handle the app opened has been closed *by the app*, and a - // silent shutdown delivers no shutdown events at all, so leaning on the - // callbacks to do the closing deadlocks the caller forever. - streams.clear(); - for (auto& connection : connections) - if (connection->handle != nullptr && !connection->handle_closed.load()) - runtime->api->ConnectionShutdown( - connection->handle, QUIC_CONNECTION_SHUTDOWN_FLAG_SILENT, kConnectionCloseCode); - connections.clear(); - listeners.clear(); - if (runtime->client_configuration != nullptr) { - runtime->api->ConfigurationClose(runtime->client_configuration); - runtime->client_configuration = nullptr; - } - - // No callback can be running past this point. - if (runtime->registration != nullptr) { - runtime->api->RegistrationClose(runtime->registration); - runtime->registration = nullptr; - } - if (runtime->api != nullptr) { - MsQuicClose(runtime->api); - runtime->api = nullptr; - } - delete runtime; -} - -CULTMESH_API int32_t cultmesh_quic_listener_open( - void* handle, const char* host, uint16_t port, - const uint8_t* pkcs12, int32_t pkcs12_len, const char* password, - uint64_t* out_listener_id, uint16_t* out_bound_port) { - auto* runtime = static_cast(handle); - if (runtime == nullptr || pkcs12 == nullptr || pkcs12_len <= 0 || out_listener_id == nullptr) - return -1; - CallScope scope(runtime); - if (!scope.entered()) return -1; - *out_listener_id = 0; - if (out_bound_port != nullptr) *out_bound_port = 0; - - // Held here until the listener is fully started. Every failure path below - // simply returns: the destructor closes whatever was opened, and a partial - // open therefore leaves neither a handle nor a map entry behind. - auto listener = std::make_shared(); - listener->runtime = runtime; - listener->id = NextId(runtime); - - QUIC_SETTINGS settings{}; - FillSettings(settings); - QUIC_BUFFER alpn = AlpnBuffer(); - auto status = runtime->api->ConfigurationOpen( - runtime->registration, &alpn, 1, &settings, sizeof(settings), nullptr, &listener->configuration); - if (QUIC_FAILED(status)) { - return Refuse(runtime, status, "CultMesh QUIC listener configuration failed (status=" + - Hex(static_cast(status)) + ")."); - } - - // PKCS12 is the one credential type Schannel and OpenSSL both take, which - // is why the provider ships a PKCS12 rather than a platform store handle. - QUIC_CERTIFICATE_PKCS12 pkcs12_certificate{}; - pkcs12_certificate.Asn1Blob = pkcs12; - pkcs12_certificate.Asn1BlobLength = static_cast(pkcs12_len); - pkcs12_certificate.PrivateKeyPassword = (password != nullptr && *password != '\0') ? password : nullptr; - - QUIC_CREDENTIAL_CONFIG credentials{}; - credentials.Type = QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12; - credentials.CertificatePkcs12 = &pkcs12_certificate; - credentials.Flags = QUIC_CREDENTIAL_FLAG_NONE; - status = runtime->api->ConfigurationLoadCredential(listener->configuration, &credentials); - if (QUIC_FAILED(status)) { - return Refuse(runtime, status, "CultMesh QUIC listener certificate could not be loaded (status=" + - Hex(static_cast(status)) + ")."); - } - - Listener* raw = listener.get(); - status = runtime->api->ListenerOpen(runtime->registration, ListenerCallback, raw, &raw->handle); - if (QUIC_FAILED(status)) { - return Refuse(runtime, status, "CultMesh QUIC listener open failed (status=" + - Hex(static_cast(status)) + ")."); - } - - QUIC_ADDR address{}; - QuicAddrSetFamily(&address, QUIC_ADDRESS_FAMILY_UNSPEC); - QuicAddrSetPort(&address, port); - if (host != nullptr && *host != '\0' && !QuicAddrFromString(host, port, &address)) { - SetError(runtime, "CultMesh QUIC listener host is not an IP address."); - return -2; - } - - // In the map before it starts accepting, because the callback that accepts - // an inbound connection reads the listener through it. - { - std::lock_guard lock(runtime->gate); - runtime->listeners.emplace(raw->id, listener); - } - - QUIC_BUFFER listener_alpn = AlpnBuffer(); - status = runtime->api->ListenerStart(raw->handle, &listener_alpn, 1, &address); - if (QUIC_FAILED(status)) { - Detach(runtime->gate, runtime->listeners, raw->id); - return Refuse(runtime, status, "CultMesh QUIC listener start failed (status=" + - Hex(static_cast(status)) + ")."); - } - - // Port 0 means "pick one"; the host needs the chosen port to advertise it. - QUIC_ADDR bound{}; - uint32_t bound_size = sizeof(bound); - status = runtime->api->GetParam(raw->handle, QUIC_PARAM_LISTENER_LOCAL_ADDRESS, &bound_size, &bound); - if (QUIC_FAILED(status)) { - Detach(runtime->gate, runtime->listeners, raw->id); - return Refuse(runtime, status, "CultMesh QUIC listener address could not be read (status=" + - Hex(static_cast(status)) + ")."); - } - if (out_bound_port != nullptr) *out_bound_port = QuicAddrGetPort(&bound); - *out_listener_id = raw->id; - return 0; -} - -CULTMESH_API void cultmesh_quic_listener_close(void* handle, uint64_t listener_id) { - auto* runtime = static_cast(handle); - if (runtime == nullptr) return; - CallScope scope(runtime); - if (!scope.entered()) return; - // Taken out of the map, then released here: ~Listener closes the handle - // (which is what produces event 9) and its configuration, exactly once. - [[maybe_unused]] const auto detached = Detach(runtime->gate, runtime->listeners, listener_id); -} - -CULTMESH_API int32_t cultmesh_quic_connection_open( - void* handle, const char* host, uint16_t port, uint64_t* out_connection_id) { - auto* runtime = static_cast(handle); - if (runtime == nullptr) return -1; - CallScope scope(runtime); - if (!scope.entered()) return -1; - return OpenConnection(runtime, host, port, out_connection_id, nullptr); -} - -CULTMESH_API int32_t cultmesh_quic_connection_certificate_complete( - void* handle, uint64_t connection_id, int32_t accept) { - auto* runtime = static_cast(handle); - if (runtime == nullptr) return -1; - CallScope scope(runtime); - if (!scope.entered()) return -1; - const auto connection = FindConnection(runtime, connection_id); - if (connection == nullptr) return -1; - const auto status = runtime->api->ConnectionCertificateValidationComplete( - connection->handle, - accept != 0 ? static_cast(1) : static_cast(0), - accept != 0 ? QUIC_TLS_ALERT_CODE_SUCCESS : QUIC_TLS_ALERT_CODE_BAD_CERTIFICATE); - if (QUIC_FAILED(status)) { - return Refuse(runtime, status, "CultMesh QUIC certificate validation completion failed (status=" + - Hex(static_cast(status)) + ")."); - } - return 0; -} - -CULTMESH_API void cultmesh_quic_connection_shutdown(void* handle, uint64_t connection_id, uint64_t code) { - auto* runtime = static_cast(handle); - if (runtime == nullptr) return; - CallScope scope(runtime); - if (!scope.entered()) return; - const auto connection = FindConnection(runtime, connection_id); - if (connection == nullptr || connection->closing.exchange(true)) return; - // Kept for the shutdown-complete callback: this is the code the initiator's - // own event 4 carries, matching the one the peer sees. - connection->shutdown_code.store(code); - if (connection->handle != nullptr) - runtime->api->ConnectionShutdown(connection->handle, QUIC_CONNECTION_SHUTDOWN_FLAG_NONE, code); -} - -CULTMESH_API int32_t cultmesh_quic_stream_open( - void* handle, uint64_t connection_id, uint8_t kind, uint64_t* out_stream_id) { - auto* runtime = static_cast(handle); - if (runtime == nullptr || out_stream_id == nullptr) return -1; - CallScope scope(runtime); - if (!scope.entered()) return -1; - *out_stream_id = 0; - if (kind != kReliableStream && kind != kLatestOnlyStream) return -2; - const auto connection = FindConnection(runtime, connection_id); - if (connection == nullptr) return -1; - - auto stream = std::make_shared(); - stream->runtime = runtime; - stream->owner = connection; - stream->kind = kind; - stream->has_kind = true; - stream->id = NextId(runtime); - Stream* raw = stream.get(); - - auto status = runtime->api->StreamOpen( - connection->handle, QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL, - StreamCallback, raw, &raw->handle); - if (QUIC_FAILED(status)) { - return Refuse(runtime, status, "CultMesh QUIC stream open failed (status=" + - Hex(static_cast(status)) + ")."); - } - // In the map before it starts. `StreamStart` can reach shutdown-complete on - // an MsQuic worker before it returns here, and that callback has to find the - // stream to report it and to drop the map's reference. - { - std::lock_guard lock(runtime->gate); - runtime->streams.emplace(raw->id, stream); - } - status = runtime->api->StreamStart(raw->handle, QUIC_STREAM_START_FLAG_IMMEDIATE); - if (QUIC_FAILED(status)) { - DestroyStream(runtime, raw->id); - return Refuse(runtime, status, "CultMesh QUIC stream start failed (status=" + - Hex(static_cast(status)) + ")."); - } - - // The kind byte is the first thing on the stream, which is what the peer's - // reassembly reads before any length prefix. - auto* request = new SendRequest(); - request->bytes.assign(1, kind); - request->buffer.Buffer = request->bytes.data(); - request->buffer.Length = 1; - status = runtime->api->StreamSend(raw->handle, &request->buffer, 1, QUIC_SEND_FLAG_NONE, request); - if (QUIC_FAILED(status)) { - delete request; - return Refuse(runtime, status, "CultMesh QUIC stream kind could not be sent (status=" + - Hex(static_cast(status)) + ")."); - } - - *out_stream_id = raw->id; - return 0; -} - -CULTMESH_API int32_t cultmesh_quic_stream_send_frame( - void* handle, uint64_t stream_id, const uint8_t* encoded_frame, int32_t length, int32_t fin) { - auto* runtime = static_cast(handle); - if (runtime == nullptr || encoded_frame == nullptr || length <= 0) return -1; - CallScope scope(runtime); - if (!scope.entered()) return -1; - if (static_cast(length) > kMaximumEncodedFrameBytes) return -2; - // The reference is held for the whole `StreamSend` below, so the worker - // cannot free the stream or close its handle underneath this call. - const auto stream = FindStream(runtime, stream_id); - if (stream == nullptr || stream->handle == nullptr) return -1; - - // The bytes are copied because MsQuic reads them after this returns; the - // 4-byte LE length prefix is the QUIC stream framing, and it is added here - // so that framing lives in one place for both directions. - auto* request = new SendRequest(); - request->is_frame = true; - request->bytes.resize(sizeof(uint32_t) + static_cast(length)); - WriteUInt32LittleEndian(request->bytes.data(), static_cast(length)); - std::memcpy(request->bytes.data() + sizeof(uint32_t), encoded_frame, static_cast(length)); - request->buffer.Buffer = request->bytes.data(); - request->buffer.Length = static_cast(request->bytes.size()); - - const auto flags = (fin != 0) ? QUIC_SEND_FLAG_FIN : QUIC_SEND_FLAG_NONE; - const auto status = runtime->api->StreamSend(stream->handle, &request->buffer, 1, flags, request); - if (QUIC_FAILED(status)) { - delete request; - return Refuse(runtime, status, "CultMesh QUIC frame send failed (status=" + - Hex(static_cast(status)) + ")."); - } - return 0; -} - -CULTMESH_API void cultmesh_quic_stream_shutdown(void* handle, uint64_t stream_id, uint64_t code) { - auto* runtime = static_cast(handle); - if (runtime == nullptr) return; - CallScope scope(runtime); - if (!scope.entered()) return; - const auto stream = FindStream(runtime, stream_id); - if (stream == nullptr || stream->handle == nullptr) return; - runtime->api->StreamShutdown(stream->handle, QUIC_STREAM_SHUTDOWN_FLAG_ABORT, code); -} - -// The one blocking crossing; see section 6 of the header for its returns. The -// call scope is what `cultmesh_quic_runtime_close` waits on: a poller blocked -// here is woken by `closing` and counted out before anything is freed. -CULTMESH_API int32_t cultmesh_quic_next_event( - void* handle, int32_t timeout_ms, cultmesh_quic_event* out_event, - uint8_t* payload, int32_t payload_capacity, int32_t* out_required) { - auto* runtime = static_cast(handle); - if (runtime == nullptr || out_event == nullptr || out_required == nullptr || payload_capacity < 0) - return -1; - // A null buffer with a non-zero capacity is a lie about the buffer. Taking - // it meant the event was consumed and its payload silently dropped. - if (payload == nullptr && payload_capacity > 0) return -1; - CallScope scope(runtime); - if (!scope.entered()) return -1; - *out_required = 0; - - std::unique_lock lock(runtime->gate); - if (runtime->events.empty() && timeout_ms > 0) { - // The host's timeout bounds the wait; an event or the close ends it - // sooner, and `woken` is which of the two happened. RECORD_WAIT wraps - // this exact argument; see the development seam note near its macros. - const bool woken = runtime->signal.wait_for( - lock, CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)), - [runtime] { return !runtime->events.empty() || runtime->closing; }); - // Development builds only; a no-op unless a scenario has armed the hold. - // A held call returns the same 0 an empty queue would have, and makes no - // further call of its own. Only a woken call is held: one whose own - // timeout expired is on its way out, and parking it would let a scenario - // report a call the bridge kept when what kept it was the fixture. - if (woken && CULTMESH_QUIC_DEBUG_HELD(lock)) return 0; - } - if (runtime->events.empty()) return 0; - - const QueuedEvent& queued = runtime->events.front(); - const auto required = static_cast(queued.payload.size()); - if (required > payload_capacity) { - *out_required = required; - return 2; - } - *out_event = queued.header; - if (required > 0 && payload != nullptr) - std::memcpy(payload, queued.payload.data(), static_cast(required)); - *out_required = required; - runtime->events.pop_front(); - return 1; -} - -CULTMESH_API int32_t cultmesh_quic_last_error(void* handle, char* destination, int32_t capacity) { - auto* runtime = static_cast(handle); - if (runtime == nullptr || destination == nullptr || capacity <= 0) return -1; - CallScope scope(runtime); - if (!scope.entered()) return -1; - std::lock_guard lock(runtime->gate); - const size_t count = (std::min)(runtime->error.size(), static_cast(capacity - 1)); - std::memcpy(destination, runtime->error.data(), count); - destination[count] = '\0'; - return static_cast(count); -} - -CULTMESH_API int32_t cultmesh_quic_last_status(void* handle) { - auto* runtime = static_cast(handle); - if (runtime == nullptr) return 0; - CallScope scope(runtime); - if (!scope.entered()) return 0; - std::lock_guard lock(runtime->gate); - return runtime->last_status; -} - -// --------------------------------------------------------------------------- -// C ABI v1: the Unity shim. Windows only, by the same construction as its only -// caller (`CultMeshNativeQuicRealtimeTransport.CanConnect` is Windows-gated). -// -// These five exports are the contract the shipped Unity package imports by -// name. They are preserved byte for byte in signature and behaviour, and they -// decide nothing the v2 path does not: a v1 client is a private runtime with one -// connection. The single exception is the pin, which v1 compares here in C -// because that is what the managed connector was built against. -// --------------------------------------------------------------------------- - -#if defined(_WIN32) - -#include -#include -#include - -namespace { - enum class ClientState : int32_t { Connecting = 0, Connected = 1, @@ -1287,33 +38,50 @@ enum class ClientState : int32_t { Closed = 3, }; -struct V1Client { - Runtime* runtime = nullptr; - uint64_t connection_id = 0; - ClientState state = ClientState::Connecting; - std::deque> frames; - std::string error; - bool closing = false; - // The managed connector polls `state` and `poll` from its own loop and may - // read `error` from the caller's thread, so the drain and everything it - // mutates sit behind one lock. Held only around `Pump` and the fields; it is - // always taken before the runtime's, never after. - std::mutex gate; +struct Client; + +struct StreamContext { + explicit StreamContext(Client* client) : owner(client) {} + Client* owner; + std::vector buffered; + uint8_t kind = 0; + bool has_kind = false; }; -std::string LoadedMsQuicPath() { - const auto module = GetModuleHandleW(L"msquic.dll"); - if (module == nullptr) return "unresolved"; - std::array path{}; - const auto length = GetModuleFileNameW(module, path.data(), static_cast(path.size())); - if (length == 0 || length >= path.size()) return "unresolved"; - const auto required = WideCharToMultiByte( - CP_UTF8, 0, path.data(), static_cast(length), nullptr, 0, nullptr, nullptr); - if (required <= 0) return "unresolved"; - std::string utf8(static_cast(required), '\0'); - WideCharToMultiByte( - CP_UTF8, 0, path.data(), static_cast(length), utf8.data(), required, nullptr, nullptr); - return utf8; +struct Client { + const QUIC_API_TABLE* api = nullptr; + HQUIC registration = nullptr; + HQUIC configuration = nullptr; + HQUIC connection = nullptr; + std::array certificate_pin{}; + std::atomic state{ClientState::Connecting}; + std::atomic closing{false}; + std::mutex gate; + std::deque> received; + std::string error; + std::string trace; +}; + +void AppendTrace(Client* client, const std::string& event) { + if (client == nullptr || client->state.load() == ClientState::Connected) return; + std::lock_guard lock(client->gate); + if (!client->trace.empty()) client->trace += ","; + client->trace += event; +} + +std::string TraceSnapshot(Client* client) { + if (client == nullptr) return "none"; + std::lock_guard lock(client->gate); + return client->trace.empty() ? "none" : client->trace; +} + +void SetFailure(Client* client, const std::string& message) { + if (client == nullptr || client->closing.load()) return; + { + std::lock_guard lock(client->gate); + if (client->error.empty()) client->error = message; + } + client->state.store(ClientState::Failed); } bool ParseHexNibble(char value, uint8_t& nibble) { @@ -1336,90 +104,287 @@ bool ParsePin(const char* value, std::array& pin) { return true; } -// Drains the runtime's event queue into the v1 client's own shape: frames for -// `poll`, a state for `state`, an error string for `error`. This is where the -// v1 contract is reconstructed from v2 events, and the only place it is. -void Pump(V1Client* client) { - cultmesh_quic_event header{}; - std::vector payload; - for (;;) { - int32_t required = 0; - auto result = cultmesh_quic_next_event( - client->runtime, 0, &header, - payload.empty() ? nullptr : payload.data(), - static_cast(payload.size()), &required); - if (result == 2) { - payload.resize(static_cast(required)); - continue; - } - if (result != 1) break; - - switch (header.type) { - case kEventConnectionConnected: - if (client->state == ClientState::Connecting) client->state = ClientState::Connected; - break; - case kEventStreamFrame: - client->frames.emplace_back(payload.begin(), payload.begin() + header.payload_length); - break; - case kEventConnectionShutdown: - if (client->closing) { - client->state = ClientState::Closed; - } else if (client->state != ClientState::Failed) { - client->state = ClientState::Failed; - if (client->error.empty()) { - char runtime_error[1024]{}; - cultmesh_quic_last_error(client->runtime, runtime_error, sizeof(runtime_error)); - // The pin refusal is the runtime's, and the managed - // connector matches on its wording; anything else is the - // transport's own reason plus where msquic came from. - client->error = runtime_error[0] != '\0' - ? std::string(runtime_error) - : std::string(reinterpret_cast(payload.data()), - static_cast(header.payload_length)); - client->error += " (msquic=" + LoadedMsQuicPath() + ")"; - } - } - break; - default: - break; - } - } +uint32_t ReadUInt32LittleEndian(const uint8_t* value) { + return static_cast(value[0]) | + (static_cast(value[1]) << 8) | + (static_cast(value[2]) << 16) | + (static_cast(value[3]) << 24); } -bool CertificateMatchesPin(Connection* connection, QUIC_CERTIFICATE* certificate) { +void FailStream(StreamContext* stream, const char* message) { + SetFailure(stream->owner, message); + if (stream->owner != nullptr && stream->owner->api != nullptr && stream->owner->connection != nullptr) + stream->owner->api->ConnectionShutdown( + stream->owner->connection, + QUIC_CONNECTION_SHUTDOWN_FLAG_SILENT, + kStreamAbortCode); +} + +bool ConsumeFrames(StreamContext* stream) { + auto& bytes = stream->buffered; + if (!stream->has_kind) { + if (bytes.empty()) return true; + stream->kind = bytes.front(); + bytes.erase(bytes.begin()); + stream->has_kind = true; + if (stream->kind != kReliableStream && stream->kind != kLatestOnlyStream) { + FailStream(stream, "CultMesh QUIC stream kind is invalid."); + return false; + } + } + + while (bytes.size() >= sizeof(uint32_t)) { + const uint32_t length = ReadUInt32LittleEndian(bytes.data()); + if (length == 0 || length > kMaximumEncodedFrameBytes) { + FailStream(stream, "CultMesh QUIC frame length is invalid."); + return false; + } + const size_t framed_length = sizeof(uint32_t) + static_cast(length); + if (bytes.size() < framed_length) return true; + std::vector frame(length); + std::memcpy(frame.data(), bytes.data() + sizeof(uint32_t), length); + { + std::lock_guard lock(stream->owner->gate); + stream->owner->received.emplace_back(std::move(frame)); + } + bytes.erase(bytes.begin(), bytes.begin() + static_cast(framed_length)); + if (stream->kind == kLatestOnlyStream && !bytes.empty()) { + FailStream(stream, "CultMesh latest-only QUIC stream carried multiple frames."); + return false; + } + } + return true; +} + +QUIC_STATUS QUIC_API StreamCallback(HQUIC stream_handle, void* context, QUIC_STREAM_EVENT* event) { + auto* stream = static_cast(context); + switch (event->Type) { + case QUIC_STREAM_EVENT_RECEIVE: + for (uint32_t index = 0; index < event->RECEIVE.BufferCount; ++index) { + const auto& buffer = event->RECEIVE.Buffers[index]; + stream->buffered.insert(stream->buffered.end(), buffer.Buffer, buffer.Buffer + buffer.Length); + } + ConsumeFrames(stream); + break; + case QUIC_STREAM_EVENT_PEER_SEND_SHUTDOWN: + if (!stream->buffered.empty()) + FailStream(stream, "CultMesh QUIC stream ended with a truncated frame."); + break; + case QUIC_STREAM_EVENT_SHUTDOWN_COMPLETE: + if (!event->SHUTDOWN_COMPLETE.AppCloseInProgress) + stream->owner->api->StreamClose(stream_handle); + delete stream; + break; + default: + break; + } + return QUIC_STATUS_SUCCESS; +} + +bool CertificateMatchesPin(Client* client, QUIC_CERTIFICATE* certificate) { if (certificate == nullptr) return false; const auto* encoded = reinterpret_cast(certificate); if (encoded->Buffer == nullptr || encoded->Length == 0) return false; BYTE digest[32]{}; DWORD digest_length = sizeof(digest); if (!CryptHashCertificate2( - BCRYPT_SHA256_ALGORITHM, 0, nullptr, - encoded->Buffer, encoded->Length, digest, &digest_length) || - digest_length != sizeof(digest)) + BCRYPT_SHA256_ALGORITHM, + 0, + nullptr, + encoded->Buffer, + encoded->Length, + digest, + &digest_length) || digest_length != sizeof(digest)) return false; - return std::memcmp(digest, connection->pin.data(), sizeof(digest)) == 0; + return std::memcmp(digest, client->certificate_pin.data(), sizeof(digest)) == 0; +} + +std::string Hex(uint64_t value) { + std::ostringstream text; + text << "0x" << std::hex << std::uppercase << value; + return text.str(); +} + +std::string LoadedMsQuicPath() { + const auto module = GetModuleHandleW(L"msquic.dll"); + if (module == nullptr) return "unresolved"; + std::array path{}; + const auto length = GetModuleFileNameW(module, path.data(), static_cast(path.size())); + if (length == 0 || length >= path.size()) return "unresolved"; + const auto required = WideCharToMultiByte( + CP_UTF8, 0, path.data(), static_cast(length), nullptr, 0, nullptr, nullptr); + if (required <= 0) return "unresolved"; + std::string utf8(static_cast(required), '\0'); + WideCharToMultiByte( + CP_UTF8, 0, path.data(), static_cast(length), utf8.data(), required, nullptr, nullptr); + return utf8; +} + +QUIC_STATUS QUIC_API ConnectionCallback(HQUIC connection, void* context, QUIC_CONNECTION_EVENT* event) { + auto* client = static_cast(context); + AppendTrace(client, "event=" + std::to_string(static_cast(event->Type))); + switch (event->Type) { + case QUIC_CONNECTION_EVENT_CONNECTED: + client->state.store(ClientState::Connected); + break; + case QUIC_CONNECTION_EVENT_PEER_STREAM_STARTED: { + auto* stream = new (std::nothrow) StreamContext(client); + if (stream == nullptr) return QUIC_STATUS_OUT_OF_MEMORY; + client->api->SetCallbackHandler( + event->PEER_STREAM_STARTED.Stream, + reinterpret_cast(StreamCallback), + stream); + break; + } + case QUIC_CONNECTION_EVENT_PEER_CERTIFICATE_RECEIVED: + { + AppendTrace( + client, + "certificate-status=" + + Hex(static_cast(event->PEER_CERTIFICATE_RECEIVED.DeferredStatus))); + const auto matches = CertificateMatchesPin( + client, + event->PEER_CERTIFICATE_RECEIVED.Certificate); + if (!matches) { + SetFailure(client, "CultMesh QUIC provider certificate does not match the advertised SHA-256 pin."); + } + const auto completion = client->api->ConnectionCertificateValidationComplete( + connection, + matches ? TRUE : FALSE, + matches ? QUIC_TLS_ALERT_CODE_SUCCESS : QUIC_TLS_ALERT_CODE_BAD_CERTIFICATE); + if (QUIC_FAILED(completion)) { + SetFailure( + client, + "CultMesh QUIC certificate validation completion failed (status=" + + Hex(static_cast(completion)) + ")."); + return completion; + } + return QUIC_STATUS_PENDING; + } + case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_TRANSPORT: + if (!client->closing.load()) { + SetFailure( + client, + "CultMesh QUIC connection was shut down by the transport " + "(status=" + Hex(static_cast(event->SHUTDOWN_INITIATED_BY_TRANSPORT.Status)) + + ", error=" + Hex(event->SHUTDOWN_INITIATED_BY_TRANSPORT.ErrorCode) + + ", msquic=" + LoadedMsQuicPath() + + ", events=" + TraceSnapshot(client) + ")."); + } + break; + case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_PEER: + if (!client->closing.load()) + SetFailure(client, "CultMesh QUIC connection was shut down by the provider."); + break; + case QUIC_CONNECTION_EVENT_SHUTDOWN_COMPLETE: + if (client->closing.load()) client->state.store(ClientState::Closed); + break; + default: + break; + } + return QUIC_STATUS_SUCCESS; +} + +void CloseHandles(Client* client) { + if (client->connection != nullptr) { + client->api->ConnectionClose(client->connection); + client->connection = nullptr; + } + if (client->configuration != nullptr) { + client->api->ConfigurationClose(client->configuration); + client->configuration = nullptr; + } + if (client->registration != nullptr) { + client->api->RegistrationClose(client->registration); + client->registration = nullptr; + } + if (client->api != nullptr) { + MsQuicClose(client->api); + client->api = nullptr; + } } } // namespace CULTMESH_API int32_t cultmesh_quic_open( - const char* host, uint16_t port, const char* certificate_sha256, V1Client** result) { + const char* host, + uint16_t port, + const char* certificate_sha256, + Client** result) { if (host == nullptr || *host == '\0' || port == 0 || result == nullptr) return -1; *result = nullptr; + auto client = std::make_unique(); + if (!ParsePin(certificate_sha256, client->certificate_pin)) return -2; - std::array pin{}; - if (!ParsePin(certificate_sha256, pin)) return -2; + QUIC_STATUS status = MsQuicOpenVersion( + kApiVersion, + reinterpret_cast(&client->api)); + if (QUIC_FAILED(status)) return status; - auto client = std::make_unique(); - void* runtime_handle = nullptr; - const auto opened = cultmesh_quic_runtime_open("GameCult.Mesh.Quic.Native", &runtime_handle); - if (opened != 0) return opened; - client->runtime = static_cast(runtime_handle); + const QUIC_REGISTRATION_CONFIG registration_config = { + "GameCult.Mesh.Quic.Native", + QUIC_EXECUTION_PROFILE_TYPE_REAL_TIME + }; + status = client->api->RegistrationOpen(®istration_config, &client->registration); + if (QUIC_FAILED(status)) { + CloseHandles(client.get()); + return status; + } - // The pin goes in before the connection starts; see OpenConnection. - const auto status = OpenConnection(client->runtime, host, port, &client->connection_id, &pin); - if (status != 0) { - cultmesh_quic_runtime_close(client->runtime); + QUIC_SETTINGS settings{}; + settings.IdleTimeoutMs = 30000; + settings.IsSet.IdleTimeoutMs = TRUE; + settings.KeepAliveIntervalMs = 5000; + settings.IsSet.KeepAliveIntervalMs = TRUE; + settings.PeerUnidiStreamCount = 1024; + settings.IsSet.PeerUnidiStreamCount = TRUE; + const QUIC_BUFFER alpn = { + static_cast(sizeof(kAlpn) - 1), + reinterpret_cast(const_cast(kAlpn)) + }; + status = client->api->ConfigurationOpen( + client->registration, + &alpn, + 1, + &settings, + sizeof(settings), + nullptr, + &client->configuration); + if (QUIC_FAILED(status)) { + CloseHandles(client.get()); + return status; + } + + QUIC_CREDENTIAL_CONFIG credentials{}; + credentials.Type = QUIC_CREDENTIAL_TYPE_NONE; + credentials.Flags = static_cast( + QUIC_CREDENTIAL_FLAG_CLIENT | + QUIC_CREDENTIAL_FLAG_INDICATE_CERTIFICATE_RECEIVED | + QUIC_CREDENTIAL_FLAG_DEFER_CERTIFICATE_VALIDATION | + QUIC_CREDENTIAL_FLAG_USE_PORTABLE_CERTIFICATES); + status = client->api->ConfigurationLoadCredential(client->configuration, &credentials); + if (QUIC_FAILED(status)) { + CloseHandles(client.get()); + return status; + } + + status = client->api->ConnectionOpen( + client->registration, + ConnectionCallback, + client.get(), + &client->connection); + if (QUIC_FAILED(status)) { + CloseHandles(client.get()); + return status; + } + status = client->api->ConnectionStart( + client->connection, + client->configuration, + QUIC_ADDRESS_FAMILY_UNSPEC, + host, + port); + if (QUIC_FAILED(status)) { + CloseHandles(client.get()); return status; } @@ -1427,87 +392,49 @@ CULTMESH_API int32_t cultmesh_quic_open( return 0; } -CULTMESH_API int32_t cultmesh_quic_state(V1Client* client) { +CULTMESH_API int32_t cultmesh_quic_state(Client* client) { if (client == nullptr) return static_cast(ClientState::Failed); - std::lock_guard lock(client->gate); - Pump(client); - return static_cast(client->state); + return static_cast(client->state.load()); } CULTMESH_API int32_t cultmesh_quic_poll( - V1Client* client, uint8_t* destination, int32_t destination_length, int32_t* required_length) { + Client* client, + uint8_t* destination, + int32_t destination_length, + int32_t* required_length) { if (client == nullptr || required_length == nullptr || destination_length < 0) return -1; std::lock_guard lock(client->gate); - Pump(client); - if (client->frames.empty()) { + if (client->received.empty()) { *required_length = 0; - return client->state == ClientState::Failed ? -2 : 0; + return client->state.load() == ClientState::Failed ? -2 : 0; } - const auto& frame = client->frames.front(); + const auto& frame = client->received.front(); if (frame.size() > static_cast(INT32_MAX)) return -3; *required_length = static_cast(frame.size()); if (destination == nullptr || destination_length < *required_length) return 2; std::memcpy(destination, frame.data(), frame.size()); - client->frames.pop_front(); + client->received.pop_front(); return 1; } -CULTMESH_API int32_t cultmesh_quic_error(V1Client* client, char* destination, int32_t destination_length) { +CULTMESH_API int32_t cultmesh_quic_error( + Client* client, + char* destination, + int32_t destination_length) { if (client == nullptr || destination == nullptr || destination_length <= 0) return -1; std::lock_guard lock(client->gate); - Pump(client); const size_t count = (std::min)(client->error.size(), static_cast(destination_length - 1)); std::memcpy(destination, client->error.data(), count); destination[count] = '\0'; return static_cast(count); } -CULTMESH_API void cultmesh_quic_close(V1Client* client) { +CULTMESH_API void cultmesh_quic_close(Client* client) { if (client == nullptr) return; - { - std::lock_guard lock(client->gate); - client->closing = true; - } - // `runtime_close` already shuts the connection down silently with the same - // CULT close code and then closes the handle, which is what v1 did before - // it delegated. Shutting it down here as well would only race that. - cultmesh_quic_runtime_close(client->runtime); - client->state = ClientState::Closed; + client->closing.store(true); + if (client->connection != nullptr && client->api != nullptr) + client->api->ConnectionShutdown(client->connection, QUIC_CONNECTION_SHUTDOWN_FLAG_SILENT, kConnectionCloseCode); + CloseHandles(client); + client->state.store(ClientState::Closed); delete client; } - -#endif // _WIN32 - -#if defined(CULTMESH_QUIC_DEBUG_ASSERTS) -// The development seam's three exports. They exist only in a build configured -// with CULTMESH_QUIC_DEBUG_ASSERTS; a shipped library exports neither these nor -// the counters behind them. See section 7 of the header. -CULTMESH_API void cultmesh_quic_debug_hold_calls(int32_t hold) { - if (hold != 0) { - debug_peak_calls.store(0); - debug_calls_at_close.store(-1); - } - { - std::lock_guard lock(debug_hold_gate); - debug_hold_armed = hold != 0; - } - debug_hold_signal.notify_all(); -} - -CULTMESH_API int32_t cultmesh_quic_debug_peak_calls(void) { - return debug_peak_calls.load(); -} - -CULTMESH_API int32_t cultmesh_quic_debug_calls_at_close(void) { - return debug_calls_at_close.load(); -} - -CULTMESH_API int32_t cultmesh_quic_debug_last_wait_ms(void) { - return debug_wait_recorded.load() ? debug_last_wait_ms.load() : -1; -} - -CULTMESH_API void cultmesh_quic_debug_reset_last_wait_ms(void) { - debug_wait_recorded.store(false); - debug_last_wait_ms.store(-1); -} -#endif diff --git a/native/GameCult.Mesh.Quic.Native/include/cultmesh_quic_native.h b/native/GameCult.Mesh.Quic.Native/include/cultmesh_quic_native.h deleted file mode 100644 index 758ecb8a..00000000 --- a/native/GameCult.Mesh.Quic.Native/include/cultmesh_quic_native.h +++ /dev/null @@ -1,371 +0,0 @@ -/* GameCult.Mesh.Quic.Native — C ABI v2, the runtime-neutral CultMesh QUIC bridge. - * - * This header is the contract. It is the single place the event layout, the - * exports, the id and return-code conventions, the object lifetime rules and the - * threading rules are stated; `cultmesh_quic_native.cpp` includes it, the README - * points at it, and a host that cannot include it (koffi, P/Invoke) declares the - * same thing in its own FFI and is checked against this file by a test. - * - * It is C. It includes nothing of MsQuic, nothing of Node, nothing of Unity, and - * nothing platform-specific: a host binds these twelve queue exports plus - * `cultmesh_quic_last_status` and needs no other build input. - * - * The v1 Unity exports (`cultmesh_quic_open`, `_state`, `_poll`, `_error`, - * `_close`) are deliberately not here. They are a Windows-only shim for a - * shipped managed connector, not a contract offered to new hosts. - */ - -#ifndef CULTMESH_QUIC_NATIVE_H -#define CULTMESH_QUIC_NATIVE_H - -#include -#include - -#if defined(__cplusplus) -#define CULTMESH_QUIC_STATIC_ASSERT(condition, message) static_assert(condition, message) -#elif defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L -#define CULTMESH_QUIC_STATIC_ASSERT(condition, message) _Static_assert(condition, message) -#else -#define CULTMESH_QUIC_STATIC_ASSERT(condition, message) -#endif - -/* `CULTMESH_QUIC_BUILD` is defined by the bridge's own translation unit and by - * nothing else. A consumer gets plain declarations and links by name, which is - * what every host here does: all three load the library at runtime. */ -#if defined(CULTMESH_QUIC_BUILD) -# if defined(_WIN32) -# define CULTMESH_QUIC_API __declspec(dllexport) -# else -# define CULTMESH_QUIC_API __attribute__((visibility("default"))) -# endif -#else -# define CULTMESH_QUIC_API -#endif - -#if defined(__cplusplus) -extern "C" { -#endif - -/* --------------------------------------------------------------------------- - * 1. The event - * - * Exactly 64 bytes, no implicit padding, field order below is the ABI. A host - * that reorders a field reads a different struct; the offsets are asserted here - * so a compiler that pads differently fails the build rather than the wire. - * --------------------------------------------------------------------------- */ - -struct cultmesh_quic_event { - uint32_t type; /* offset 0 — one of the cultmesh_quic_event_type values */ - uint32_t stream_kind; /* offset 4 — 1 reliable, 2 latest-only; 0 when not a stream event */ - uint64_t listener_id; /* offset 8 — 0 for an outbound connection */ - uint64_t connection_id; /* offset 16 */ - uint64_t stream_id; /* offset 24 */ - uint64_t code; /* offset 32 — meaning depends on `type`; see below */ - int32_t status; /* offset 40 — the raw platform QUIC status, diagnostics only */ - int32_t payload_length; /* offset 44 — bytes written into the caller's payload buffer */ - uint8_t reserved[16]; /* offset 48 — zero today; do not read meaning into it */ -}; - -CULTMESH_QUIC_STATIC_ASSERT(sizeof(struct cultmesh_quic_event) == 64, - "the event struct is a 64-byte ABI contract"); -CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, type) == 0, - "event field offsets are an ABI contract"); -CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, stream_kind) == 4, - "event field offsets are an ABI contract"); -CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, listener_id) == 8, - "event field offsets are an ABI contract"); -CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, connection_id) == 16, - "event field offsets are an ABI contract"); -CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, stream_id) == 24, - "event field offsets are an ABI contract"); -CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, code) == 32, - "event field offsets are an ABI contract"); -CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, status) == 40, - "event field offsets are an ABI contract"); -CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, payload_length) == 44, - "event field offsets are an ABI contract"); -CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, reserved) == 48, - "event field offsets are an ABI contract"); - -enum cultmesh_quic_event_type { - /* A listener accepted an inbound connection. `listener_id`, `connection_id`. */ - CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION = 1, - /* The handshake completed. `connection_id`. */ - CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED = 2, - /* Payload: the peer's DER certificate. The handshake is blocked until the - * host answers with `cultmesh_quic_connection_certificate_complete`. */ - CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED = 3, - /* The connection is gone, whoever ended it, and its id no longer resolves. - * Payload: a UTF-8 reason. `code` is the QUIC application error code — the - * peer's when the peer ended it, and the code the host passed to - * `cultmesh_quic_connection_shutdown` when the host did. Emitted exactly - * once per connection id, including for the initiator of a local shutdown: - * every connection id's end is observable from the host that owns it. */ - CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN = 4, - /* An inbound stream's first byte named its kind. `stream_id`, `stream_kind`. */ - CULTMESH_QUIC_EVENT_STREAM_STARTED = 5, - /* Payload: one whole encoded frame, the 4-byte length prefix stripped. */ - CULTMESH_QUIC_EVENT_STREAM_FRAME = 6, - /* One per `cultmesh_quic_stream_send_frame` call that returned 0, and never - * for the bridge's own kind byte. `code` is a cultmesh_quic_send_result. */ - CULTMESH_QUIC_EVENT_STREAM_SEND_COMPLETE = 7, - /* The stream is gone. `stream_id`, `stream_kind`. */ - CULTMESH_QUIC_EVENT_STREAM_SHUTDOWN = 8, - /* The listener stopped. `listener_id`. */ - CULTMESH_QUIC_EVENT_LISTENER_STOPPED = 9 -}; - -/* The `code` of event 7. A canceled send is a send MsQuic released without - * putting on the wire — the stream was aborted or the connection ended under it. - * It is reported, not silently dropped. */ -enum cultmesh_quic_send_result { - CULTMESH_QUIC_SEND_COMPLETED = 0, - CULTMESH_QUIC_SEND_CANCELED = 1 -}; - -/* The first byte of every CultMesh QUIC stream. */ -enum cultmesh_quic_stream_kind { - CULTMESH_QUIC_STREAM_RELIABLE = 1, - CULTMESH_QUIC_STREAM_LATEST_ONLY = 2 -}; - -/* --------------------------------------------------------------------------- - * 2. Ids - * - * Listeners, connections and streams are named by `uint64_t` ids drawn from one - * per-runtime counter that starts at 1 and never repeats. 0 is never a valid id - * and is what an out parameter holds when a call fails. An id is the only thing - * a host ever holds: there is no host-visible pointer to a connection or stream, - * so a host cannot outlive an object it is naming. An id whose object is gone is - * a bad call (-1), not undefined behaviour. - * --------------------------------------------------------------------------- */ - -/* --------------------------------------------------------------------------- - * 3. Return codes — negative on a bad call, on every platform - * - * Every int32_t-returning export returns: - * - * 0 the call succeeded - * 1, 2 `cultmesh_quic_next_event` only; see its comment - * -1 a bad call: a null handle, a null out parameter, - * or an id whose object is gone - * -2 an argument the bridge rejects on its own terms: - * an unknown stream kind, an oversized frame, a - * listener host that is not an IP literal - * <= -1000 MsQuic refused. The bridge code is - * -(1000 + (raw_status & 0xffff)); the raw - * platform status is available from - * `cultmesh_quic_last_status` and a sentence about - * it from `cultmesh_quic_last_error`. - * - * This mapping exists because `QUIC_STATUS` itself is not portable: it is a - * negative HRESULT on Windows and a positive errno on POSIX, so a host that - * checked for a negative value saw every Linux failure as success. - * - * What the mapping makes portable is the sign, and only the sign. A failure is - * negative everywhere, and that is what a host may branch on. The magnitude of a - * <= -1000 code is diagnostic and platform-specific: the same MsQuic failure - * carries different numbers on different platforms (a TLS error is -12032 on - * win32-x64 and -1126 on linux-x64; an ALPN already in use is -1009 against - * -1091), and the 16-bit mask can give two distinct statuses the same code - * (`QUIC_STATUS_USER_CANCELED` and `QUIC_STATUS_FILE_NOT_FOUND` are both -1002 - * on Windows). Switching on a particular code is therefore not portable and not - * a reliable identification of the failure. Report it, log it, and read - * `cultmesh_quic_last_status` and `cultmesh_quic_last_error` beside it. - * --------------------------------------------------------------------------- */ - -#define CULTMESH_QUIC_RESULT_BAD_CALL (-1) -#define CULTMESH_QUIC_RESULT_BAD_ARGUMENT (-2) -#define CULTMESH_QUIC_RESULT_MSQUIC_BASE (-1000) - -/* --------------------------------------------------------------------------- - * 4. Object lifetime — resolved from an id, kept alive across the call - * - * The bridge's objects are shared-owned. The runtime's maps hold one reference - * each; an export that names an id takes its own reference under the lock and - * holds it for the whole MsQuic call, so an object cannot be freed under a host - * thread that is using it. MsQuic's worker, on shutdown-complete, removes the - * map entry and drops the map's reference only — it never frees an object a host - * call is inside. - * - * The MsQuic handle follows the object: it is closed exactly once, by whichever - * holder drops the last reference (or by `cultmesh_quic_runtime_close`), under a - * one-shot exchange guard. A handle is therefore valid for as long as any holder - * has a reference to its object, which is the property the host thread needs. A - * stream holds a reference to its connection, so stream handles are always - * closed before the connection handle they belong to. - * - * `cultmesh_quic_runtime_close` quiesces before it tears anything down. It marks - * the runtime closing, wakes every blocked `cultmesh_quic_next_event`, and waits - * for every in-flight export to return before it closes a handle or frees a - * byte. No host call may begin after `cultmesh_quic_runtime_close` starts. That - * ordering is the host's, and it is a prohibition rather than a race the bridge - * arbitrates: once the close returns, the handle names freed memory, and a call - * made with it has nothing left to refuse it. A call that races the start of the - * close, while it is still running, is refused with -1 and touches nothing; that - * narrows one mis-ordering and does not make the prohibition optional. - * --------------------------------------------------------------------------- */ - -/* --------------------------------------------------------------------------- - * 5. Threading - * - * There are no host callbacks. Every crossing is the host calling in. - * - * - MsQuic's worker threads run inside this library only. They push events onto - * the runtime's queue and wake the one blocking wait. No host code is ever - * called from a thread the host does not own. - * - `cultmesh_quic_next_event` is called from host threads, one or many. It is - * the only blocking export. Concurrent callers each get a distinct event. - * - Every other export may be called from any host thread at any time, except - * during or after `cultmesh_quic_runtime_close`. - * - `cultmesh_quic_runtime_close` is called once, from one thread, and - * quiesces as described above. - * --------------------------------------------------------------------------- */ - -/* --------------------------------------------------------------------------- - * 6. The exports - * --------------------------------------------------------------------------- */ - -/* Opens a runtime: one MsQuic registration, one event queue. `app_name` may be - * null. `*out_runtime` is the opaque handle every other export takes. */ -CULTMESH_QUIC_API int32_t cultmesh_quic_runtime_open( - const char* app_name, void** out_runtime); - -/* Quiesces, tears down and frees the runtime. Safe to call with null. After it - * returns, the handle is dead; see section 4 for the ordering rule. */ -CULTMESH_QUIC_API void cultmesh_quic_runtime_close(void* runtime); - -/* Opens a provider listener on `host`:`port` with a PKCS12 credential. `host` - * may be null or empty for any address, otherwise it must be an IP literal. - * `port` 0 binds an ephemeral port, which `*out_bound_port` reports. A failure - * leaves no listener behind and no id allocated to the host. */ -CULTMESH_QUIC_API int32_t cultmesh_quic_listener_open( - void* runtime, const char* host, uint16_t port, - const uint8_t* pkcs12, int32_t pkcs12_length, const char* password, - uint64_t* out_listener_id, uint16_t* out_bound_port); - -/* Stops and closes a listener. Its accepted connections are not affected. - * Emits event 9. Unknown ids are ignored. */ -CULTMESH_QUIC_API void cultmesh_quic_listener_close( - void* runtime, uint64_t listener_id); - -/* Starts an outbound connection. Certificate validation is deferred: the host - * gets event 3 and must answer. */ -CULTMESH_QUIC_API int32_t cultmesh_quic_connection_open( - void* runtime, const char* host, uint16_t port, uint64_t* out_connection_id); - -/* Answers event 3. `accept` non-zero continues the handshake; zero fails it with - * a bad-certificate alert. */ -CULTMESH_QUIC_API int32_t cultmesh_quic_connection_certificate_complete( - void* runtime, uint64_t connection_id, int32_t accept); - -/* Ends a connection with a QUIC application error code. The initiator gets its - * own event 4 carrying `code`, as the peer does. Unknown ids are ignored. */ -CULTMESH_QUIC_API void cultmesh_quic_connection_shutdown( - void* runtime, uint64_t connection_id, uint64_t code); - -/* Opens an outbound unidirectional stream and puts `kind` on the wire as its - * first byte. That byte is the bridge's own and never produces an event 7. */ -CULTMESH_QUIC_API int32_t cultmesh_quic_stream_open( - void* runtime, uint64_t connection_id, uint8_t kind, uint64_t* out_stream_id); - -/* Queues one whole encoded frame, length-prefixed by the bridge. A call that - * returns 0 produces exactly one event 7 later, completed or canceled. */ -CULTMESH_QUIC_API int32_t cultmesh_quic_stream_send_frame( - void* runtime, uint64_t stream_id, - const uint8_t* encoded_frame, int32_t length, int32_t fin); - -/* Aborts a stream with a QUIC application error code. Unknown ids are ignored. */ -CULTMESH_QUIC_API void cultmesh_quic_stream_shutdown( - void* runtime, uint64_t stream_id, uint64_t code); - -/* The one blocking crossing. - * - * 0 nothing within `timeout_ms`. A poller already blocked here when - * `cultmesh_quic_runtime_close` starts is woken and returns 0. A poll that - * begins after the close starts is forbidden by section 4, and is not a - * case this return code covers. - * 1 `*out_event` filled and up to `payload_capacity` payload bytes copied; - * `*out_required` is the payload size - * 2 `payload_capacity` is too small. Nothing is consumed and nothing is - * written to `*out_event`; `*out_required` is the size to allocate. Ask - * again with a big enough buffer and the same event is delivered. - * -1 a bad call, including a null `payload` with a non-zero - * `payload_capacity`, which is a lie about the buffer and is refused - * rather than treated as a discard. - * - * `payload` may be null when `payload_capacity` is 0: that is the two-phase - * poll's first ask. */ -CULTMESH_QUIC_API int32_t cultmesh_quic_next_event( - void* runtime, int32_t timeout_ms, struct cultmesh_quic_event* out_event, - uint8_t* payload, int32_t payload_capacity, int32_t* out_required); - -/* The last error sentence, NUL-terminated, truncated to `capacity`. Returns the - * number of bytes written, or -1 on a bad call. */ -CULTMESH_QUIC_API int32_t cultmesh_quic_last_error( - void* runtime, char* destination, int32_t capacity); - -/* The raw platform `QUIC_STATUS` behind the last <= -1000 return, as its own - * bits: a negative HRESULT on Windows, a positive errno on POSIX. Diagnostics - * only; what is portable about the return code is its sign, not its value. 0 - * when nothing has failed. */ -CULTMESH_QUIC_API int32_t cultmesh_quic_last_status(void* runtime); - -/* --------------------------------------------------------------------------- - * 7. The development seam - * - * Present only in a library configured with `CULTMESH_QUIC_DEBUG_ASSERTS`, which - * a shipped build never is. It exists for the runtime-lifetime scenarios: the - * quiesce is a promise about host calls that are inside the library, and the - * wait scenarios need the same kind of promise about the exact duration the - * bridge hands its own condition wait. A scenario that can only sleep and hope - * cannot tell a bridge that counts and waits correctly from one that does not. - * - * It is process-wide, not per-runtime, because the numbers outlive the runtime - * they describe: `cultmesh_quic_runtime_close` frees the runtime, and the count - * it waited on is read afterwards. The scenarios open one runtime at a time. - * --------------------------------------------------------------------------- */ -#if defined(CULTMESH_QUIC_DEBUG_ASSERTS) - -/* Non-zero arms the hold and resets both counters: every call that reaches the - * blocking wait in `cultmesh_quic_next_event` parks inside the library instead - * of returning. Zero releases every held call and disarms it. A held call - * returns 0 and touches the runtime no further. */ -CULTMESH_QUIC_API void cultmesh_quic_debug_hold_calls(int32_t hold); - -/* The high-water mark of host calls counted inside the library since the hold - * was armed. Zero means no call was ever counted. */ -CULTMESH_QUIC_API int32_t cultmesh_quic_debug_peak_calls(void); - -/* How many host calls were counted inside the library when the last - * `cultmesh_quic_runtime_close` began its wait, or -1 if no close has begun one - * since the hold was armed. This is the number the wait exists for. */ -CULTMESH_QUIC_API int32_t cultmesh_quic_debug_calls_at_close(void); - -/* The whole duration, in milliseconds, most recently handed to the condition - * wait inside `cultmesh_quic_next_event`, recorded at the point the wait is - * entered rather than observed from outside. A poll whose queue is not empty, - * or whose `timeout_ms` is not greater than zero, never enters that wait and - * leaves this unchanged. -1 until the first such wait, or after - * `cultmesh_quic_debug_reset_last_wait_ms` — tracked by a separate internal - * flag, not by -1 being stored here, so a wait that ever recorded exactly -1 - * itself could not be confused with nothing having been recorded. - * - * This exists because a wall-clock probe cannot tell the host's timeout from - * any function of it that is the identity at the probe's own value — a floor, - * a round, a clamp, a later-poll fault, there is always another one that - * matches. This reads what the bridge actually handed its wait instead of - * inferring it from how long a call took. */ -CULTMESH_QUIC_API int32_t cultmesh_quic_debug_last_wait_ms(void); - -/* Resets the value `cultmesh_quic_debug_last_wait_ms` reports to -1, without - * touching the hold or the two call counters above. */ -CULTMESH_QUIC_API void cultmesh_quic_debug_reset_last_wait_ms(void); - -#endif /* CULTMESH_QUIC_DEBUG_ASSERTS */ - -#if defined(__cplusplus) -} -#endif - -#endif /* CULTMESH_QUIC_NATIVE_H */ diff --git a/native/GameCult.Mesh.Quic.Native/tests/cultmesh_quic_native_tests.cpp b/native/GameCult.Mesh.Quic.Native/tests/cultmesh_quic_native_tests.cpp deleted file mode 100644 index e42b6d25..00000000 --- a/native/GameCult.Mesh.Quic.Native/tests/cultmesh_quic_native_tests.cpp +++ /dev/null @@ -1,1305 +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 pollbusy [iterations] -// cultmesh_quic_native_tests latecall [iterations] -// cultmesh_quic_native_tests holdtimeout [iterations] -// cultmesh_quic_native_tests waitseam [iterations] -// cultmesh_quic_native_tests pollhammer [iterations] -// cultmesh_quic_native_tests zerotimeout [iterations] -// cultmesh_quic_native_tests payloadfit [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, and `pollbusy` measures it again with the host's other thread -// calling in throughout, which wakes the wait. `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. -// -// Self's ruling of 2026-09-22, after seven Soul passes against `polltimeout`, -// `pollbusy` and `holdtimeout` alone: a wall-clock probe cannot tell the host's -// timeout from any function of it that is the identity at the probe's own -// value, because there is always another function that matches. `waitseam` -// puts the observation where the rule is decided instead of where its effect -// eventually shows: it reads `cultmesh_quic_debug_last_wait_ms`, the bridge's -// own account of what it handed its condition wait, and asserts equality -// against the host's argument, deterministically, with no timing tolerance. -// `polltimeout`, `pollbusy` and `holdtimeout` stay only as generous-margin -// proof that the recorded wait is really waited and not merely recorded. -// `pollhammer` covers the wait's predicate the same way `pollbusy` used to try -// to: one thread hammers every gate-touching entry point that cannot itself -// queue an event, in a yield loop, while another holds a single idle poll, and -// the poll must still stay for its timeout. -// -// 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 - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { - -// `CloseRaceOnce`'s own settle budget, named here — ahead of `kPollTimeoutMs` -// below — so the poll timeout can be derived from it instead of drifting apart -// as a second freestanding number. Under CPU starvation the settle can spend -// this whole budget waiting for every poller to be counted inside the library -// before the close begins; a poll timeout shorter than that lets the first -// pollers time out for real while the fixture is still waiting on the last -// ones, which is exactly the failure this scenario exists to catch, not the -// one it is supposed to be running. -constexpr int32_t kCloseRaceCountedDeadlineMs = 30000; // asserts-on: cap on the peak_calls wait -constexpr int32_t kCloseRaceAssertsSettleMs = 50; // asserts-on: fixed remainder after counted -constexpr int32_t kCloseRaceNoSeamSettleMs = 500; // no-seam #else: fixed settle window -constexpr int32_t kCloseRaceSettleBudgetMs = - (kCloseRaceCountedDeadlineMs + kCloseRaceAssertsSettleMs) > kCloseRaceNoSeamSettleMs - ? (kCloseRaceCountedDeadlineMs + kCloseRaceAssertsSettleMs) - : kCloseRaceNoSeamSettleMs; - -// Long enough that a poller is still parked when the close begins, in every -// configuration, even when the settle above burns its whole budget. The close -// is what ends the wait; this timeout only bounds the scenario if the close -// never arrives, which is itself the failure, so a long poll timeout costs -// nothing on a passing run. The flat 5 s margin is headroom above the settle -// budget, not a second guess at it. -constexpr int32_t kPollTimeoutMs = kCloseRaceSettleBudgetMs + 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 entering{0}; - std::atomic returned{0}; - std::atomic undefined_return{0}; - std::atomic 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 closing_started{false}; - std::atomic left_before_the_close{0}; - -#if defined(CULTMESH_QUIC_DEBUG_ASSERTS) - // Reset before any poller thread exists, so nothing here can race a - // `CallScope` that has already been counted: `cultmesh_quic_next_event` - // raises `debug_peak_calls` the moment its `CallScope` is constructed, at - // the very top of the call, and a reset performed even microseconds after - // that construction would zero a count no further call is coming to raise - // again, stranding the settle loop below at "not yet" until its deadline. - cultmesh_quic_debug_hold_calls(1); - cultmesh_quic_debug_hold_calls(0); -#endif - - std::vector threads; - threads.reserve(static_cast(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 payload(static_cast(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::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(); -#if defined(CULTMESH_QUIC_DEBUG_ASSERTS) - // A fixed settle races CPU starvation: under enough contending threads a - // poller that has only incremented `entering` can sit preempted for far - // longer than any sleep this scenario could afford, so the close would - // begin before it ever reached the blocking wait — not the race this - // scenario exists to run, just a fixture that gave up too soon. Where the - // development seam is compiled in, this reads the bridge's own count - // instead of guessing at a duration: the settle ends only once every - // poller is actually counted inside, however long the scheduler took. - const auto counted_deadline = std::chrono::steady_clock::now() + - std::chrono::milliseconds(kCloseRaceCountedDeadlineMs); - while (cultmesh_quic_debug_peak_calls() < pollers && - std::chrono::steady_clock::now() < counted_deadline) - std::this_thread::sleep_for(std::chrono::milliseconds(1)); - - // The fixture's own settle can fail to reach every poller inside the - // deadline above, distinct from the race this scenario exists to run. - // Proceeding to the close anyway would then produce the pollers' own - // failure message — "N of M pollers returned before the close began" — - // which blames the pollers for a race the fixture never set up. Report - // this plainly instead, and unstick the parked pollers before returning so - // this iteration does not just sit out `kPollTimeoutMs`. - const int32_t counted = cultmesh_quic_debug_peak_calls(); - if (counted < pollers) { - closing_started.store(true); - cultmesh_quic_runtime_close(runtime); - for (auto& thread : threads) thread.join(); - return "fixture could not settle: " + std::to_string(counted) + " of " + - std::to_string(pollers) + " pollers counted inside after " + - std::to_string(kCloseRaceCountedDeadlineMs) + " ms"; - } - - // `active_calls` — what `peak_calls` reflects — is raised the moment a - // poller's `CallScope` is constructed, at the top of - // `cultmesh_quic_next_event`, not when it reaches the condition wait a few - // statements later. Counted-inside is the expensive part to wait for under - // starvation (getting the scheduler to run a thread at all); a short fixed - // settle covers the cheap remainder (a lock acquisition and an empty-queue - // check) once every poller is already scheduled and running. - std::this_thread::sleep_for(std::chrono::milliseconds(kCloseRaceAssertsSettleMs)); -#else - // No seam in this configuration (the sanitizer build deliberately ships - // without one), so the settle falls back to a longer fixed window rather - // than the 50 ms that starved under 16 contending burners. It is still a - // window, not a guarantee — the honest limit of a config with no way to - // ask the bridge what it has actually counted. - std::this_thread::sleep_for(std::chrono::milliseconds(kCloseRaceNoSeamSettleMs)); -#endif - - 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 three timeouts `polltimeout` asks for: a spread, not a hunt for any -// particular derivation. `waitseam` is what proves the bridge hands its -// condition wait the host's own argument, exactly, at every value that matters -// — these three exist only to prove that value is then really waited on, so -// the spread just needs to be a short one, an ordinary one and a long one. -constexpr int32_t kShortestPollMs = 15; -constexpr int32_t kShortPollMs = 200; -constexpr int32_t kLongPollMs = 7300; - -// 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. Generous on purpose: these -// scenarios are no longer where a clamp, a floor, a round or a scale is caught -// — `waitseam` catches those by equality, with no clock involved — so this only -// has to catch a wait that is wrong by a lot: a constant of the bridge's own, a -// wait that never happens, a hold that parks a call its own timeout already -// ended, or the W1-W3 family Self's ruling of 2026-09-22 added, which apply -// their arithmetic to the duration `wait_for` actually receives *after* the -// seam has already recorded it and so cannot be caught by `waitseam`'s -// equality check at all. 400 ms sits above the worst overshoot a 16-burner -// stress pass measured on a loaded win32 host (180 ms; 27 ms on linux) and -// below every one of W1-W3's smallest offsets (485 ms), so one flat margin -// still serves every probe this file times without going blind to the family -// it was widened for. -constexpr int kGenerousLateToleranceMs = 400; - -struct TimedPoll { - int32_t result; - long long elapsed_ms; -}; - -TimedPoll PollFor(void* runtime, int32_t timeout_ms) { - std::vector payload(static_cast(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::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. -// The bands every timed poll is held to, and the worst overshoot seen, which is -// printed so a tolerance can be checked against a machine under load rather than -// trusted. -struct Overshoot { - long long worst_ms = LLONG_MIN; - void Record(const TimedPoll& poll, int32_t timeout_ms) { - worst_ms = (std::max)(worst_ms, poll.elapsed_ms - timeout_ms); - } -}; - -std::string CheckTimedPoll(const TimedPoll& poll, int32_t timeout_ms, int late_tolerance_ms) { - const std::string asked = "a poll asking for " + std::to_string(timeout_ms) + " ms "; - if (poll.result != 0) - return asked + "returned " + std::to_string(poll.result) + ", not the 0 an idle runtime owes it"; - if (poll.elapsed_ms < timeout_ms - kEarlyToleranceMs) - return asked + "returned after " + std::to_string(poll.elapsed_ms) + - " ms: the wait ended on some shorter duration than the one it was given"; - if (poll.elapsed_ms > static_cast(timeout_ms) + late_tolerance_ms) - return asked + "returned after " + std::to_string(poll.elapsed_ms) + - " ms: the wait ended on some longer duration than the one it was given"; - return {}; -} - -constexpr std::array kTimedProbesMs{kShortestPollMs, kShortPollMs, kLongPollMs}; - -std::string PollTimeoutOnce(std::array& overshoot) { - 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 (size_t probe = 0; probe < kTimedProbesMs.size() && failure.empty(); ++probe) { - const TimedPoll poll = PollFor(runtime, kTimedProbesMs[probe]); - overshoot[probe].Record(poll, kTimedProbesMs[probe]); - failure = CheckTimedPoll(poll, kTimedProbesMs[probe], kGenerousLateToleranceMs); - } - - cultmesh_quic_runtime_close(runtime); - return failure; -} - -int PollTimeout(int iterations) { - std::array overshoot{}; - for (int iteration = 0; iteration < iterations; ++iteration) { - const std::string failure = PollTimeoutOnce(overshoot); - if (!failure.empty()) - return Fail("polltimeout iteration " + std::to_string(iteration) + ": " + failure); - } - std::printf("polltimeout %dx: ok (worst overshoot %lld ms at %d, %lld ms at %d, %lld ms at %d)\n", - iterations, overshoot[0].worst_ms, kTimedProbesMs[0], overshoot[1].worst_ms, kTimedProbesMs[1], - overshoot[2].worst_ms, kTimedProbesMs[2]); - return 0; -} - -// How long `pollbusy`'s poll asks for, and how often the host's other thread -// calls into the library while it waits. -constexpr int32_t kBusyPollMs = 1000; -constexpr int kBusyCallEveryMs = 20; - -// The same rule as `polltimeout` — a poll with nothing to deliver stays for its -// timeout — for a host with more than one thread, which is every real host: one -// thread polls, another sends, shuts streams down, reads the last error. -// -// Every host call leaves through a scope that wakes every waiter on the -// runtime's condition variable, because that is how the close learns a call has -// left. So a poll is woken each time any other thread makes any call, and what -// keeps it inside is the wait's predicate: nothing queued and no close means -// wait again, for the rest of the time the host asked for. With one host thread -// nothing else ever calls during the wait, and the predicate could be deleted -// with every other scenario still green. -// -// The other thread keeps calling for twice the poll's timeout and then stops, -// so a wait that restarts its whole timeout on every wake is seen as late -// rather than hanging the scenario. -std::string PollBusyOnce(Overshoot& overshoot) { - 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::atomic calls{0}; - std::atomic refused{0}; - std::thread other([runtime, &calls, &refused] { - const auto until = std::chrono::steady_clock::now() + std::chrono::milliseconds(2 * kBusyPollMs); - std::array message{}; - while (std::chrono::steady_clock::now() < until) { - if (cultmesh_quic_last_error(runtime, message.data(), static_cast(message.size())) < 0) - refused.fetch_add(1); - calls.fetch_add(1); - std::this_thread::sleep_for(std::chrono::milliseconds(kBusyCallEveryMs)); - } - }); - - const TimedPoll poll = PollFor(runtime, kBusyPollMs); - const int calls_during = calls.load(); - other.join(); - cultmesh_quic_runtime_close(runtime); - - overshoot.Record(poll, kBusyPollMs); - if (refused.load() != 0) - return std::to_string(refused.load()) + " call(s) from the host's other thread were refused " - "by a runtime that was not closing"; - // The timing first: a poll that left on the first wake returns before the - // other thread has had time to make many calls, and that is the bridge - // failing, not the fixture. - const std::string failure = CheckTimedPoll(poll, kBusyPollMs, kGenerousLateToleranceMs); - if (!failure.empty()) return failure + ", while the host's other thread was calling in"; - // A poll that passed with fewer calls than this was never woken, and passed - // because the fixture did nothing rather than because the bridge held. - if (calls_during < 3) - return "the host's other thread made " + std::to_string(calls_during) + - " call(s) during the poll, so nothing woke it"; - return {}; -} - -int PollBusy(int iterations) { - Overshoot overshoot{}; - for (int iteration = 0; iteration < iterations; ++iteration) { - const std::string failure = PollBusyOnce(overshoot); - if (!failure.empty()) - return Fail("pollbusy iteration " + std::to_string(iteration) + ": " + failure); - } - std::printf("pollbusy %dx: ok (worst overshoot %lld ms at %d)\n", iterations, overshoot.worst_ms, - kBusyPollMs); - 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 payload(static_cast(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(payload.size()), - &required_delivered); - const std::string reason(reinterpret_cast(payload.data()), - static_cast(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(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 returned{0}; - std::atomic undefined_return{0}; - std::vector threads; - threads.reserve(static_cast(pollers)); - for (int index = 0; index < pollers; ++index) { - threads.emplace_back([runtime, &returned, &undefined_return] { - std::vector payload(static_cast(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 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 payload(static_cast(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 late_poll{0}; - std::atomic late_error{0}; - std::thread late([runtime, &late_poll, &late_error] { - std::array message{}; - late_error.store(cultmesh_quic_last_error(runtime, message.data(), - static_cast(message.size()))); - std::vector payload(static_cast(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 two polls ask for, and how long the hold stays armed -// under them. The gap between each poll and the hold is the whole measurement: -// a poll the hold parks leaves when the hold is released, so its elapsed time -// lands at the last number instead of its own. -// -// Two polls, a short one and a long one, because a guard that tests the timeout -// instead of the wake — park it unless it was long — is identity on any single -// value on one side of its threshold. The long one sits above 1500, the stated -// ceiling on how long the hold guard may treat a long wait as surely worth -// parking; the hold stays armed well past it so a guard that parks anyway is -// held for the rest of the arming instead of leaving on its own timeout, which -// is the overshoot this scenario's late tolerance is wide enough to see. -constexpr int32_t kHeldTimeoutPollMs = 150; -constexpr int32_t kHeldLongTimeoutPollMs = 1600; -constexpr int kHoldArmedMs = 3000; - -// 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 calls in both states: the hold is armed, and the runtime is -// idle, so each wait ends on its 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(std::array& overshoot) { - 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); - } - - // Each 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. - constexpr std::array timeouts{kHeldTimeoutPollMs, kHeldLongTimeoutPollMs}; - std::array polls{}; - std::array pollers; - for (size_t index = 0; index < timeouts.size(); ++index) - pollers[index] = std::thread([runtime, &polls, &timeouts, index] { - polls[index] = PollFor(runtime, timeouts[index]); - }); - - std::this_thread::sleep_for(std::chrono::milliseconds(kHoldArmedMs)); - cultmesh_quic_debug_hold_calls(0); - for (auto& poller : pollers) poller.join(); - cultmesh_quic_runtime_close(runtime); - - for (size_t index = 0; index < timeouts.size(); ++index) { - const TimedPoll& poll = polls[index]; - const int32_t timeout_ms = timeouts[index]; - overshoot[index].Record(poll, timeout_ms); - 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 < timeout_ms - kEarlyToleranceMs) - return "a poll asking for " + std::to_string(timeout_ms) + " ms returned after " + - std::to_string(poll.elapsed_ms) + " ms, so the fixture never let its timeout run out"; - if (poll.elapsed_ms > timeout_ms + kGenerousLateToleranceMs) - return "a poll asking for " + std::to_string(timeout_ms) + " 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) { - std::array overshoot{}; - for (int iteration = 0; iteration < iterations; ++iteration) { - const std::string failure = HoldTimeoutOnce(overshoot); - if (!failure.empty()) - return Fail("holdtimeout iteration " + std::to_string(iteration) + ": " + failure); - } - std::printf("holdtimeout %dx: ok (worst overshoot %lld ms at %d, %lld ms at %d)\n", iterations, - overshoot[0].worst_ms, kHeldTimeoutPollMs, overshoot[1].worst_ms, kHeldLongTimeoutPollMs); - 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; -} - -// The values `waitseam` asks for: the smallest, the seam's own sentinel-versus- -// zero boundary, one on either side of a 16 ms quantum and of a 40 ms one, the -// ordinary `pollbusy` timeout and its neighbours, `polltimeout`'s long probe, -// and INT32_MAX. Nothing in section 6 of the header states a maximum on -// `timeout_ms` narrower than what `int32_t` itself holds, so INT32_MAX is the -// ceiling this checks and nothing wider is claimed. -constexpr std::array kWaitSeamProbesMs{ - 0, 1, 15, 16, 39, 40, 999, 1000, 1001, 7300, INT32_MAX}; - -// How long a probe's own poller thread is given to reach the wait before this -// gives up on it and fails with the probe named, rather than hanging. -constexpr int kWaitSeamDeadlineMs = 2000; - -// One probe: ask a fresh, otherwise-idle runtime for `timeout_ms` and read -// `cultmesh_quic_debug_last_wait_ms` — the bridge's own account of what it -// handed its condition wait — instead of measuring how long the call took. -// Every clamp, floor, round, scale, offset and later-poll mapping Soul's seven -// passes found is the identity at some wall-clock probe; none of them is the -// identity here unless it is also the identity at every value below, because -// this reads the argument itself rather than inferring it from elapsed time. -// -// `timeout_ms <= 0` never reaches the wait at all — `cultmesh_quic_next_event` -// returns at once when the queue is empty — so there is nothing for the seam to -// record, and the honest check is the converse: it must still say nothing, -// which is exactly what a seam that recorded the raw argument on every call, -// instead of only the value it actually handed its wait, would get wrong. -std::string WaitSeamProbeOnce(int32_t timeout_ms) { - 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); - cultmesh_quic_debug_reset_last_wait_ms(); - - std::string failure; - if (timeout_ms <= 0) { - std::vector payload(static_cast(kPayloadCapacity)); - cultmesh_quic_event event{}; - int32_t required = 0; - const int32_t result = cultmesh_quic_next_event( - runtime, timeout_ms, &event, payload.data(), kPayloadCapacity, &required); - const int32_t recorded = cultmesh_quic_debug_last_wait_ms(); - if (result != 0) - failure = "a poll asking for " + std::to_string(timeout_ms) + " ms returned " + - std::to_string(result) + ", not the 0 an idle runtime owes it"; - else if (recorded != -1) - failure = "a poll asking for " + std::to_string(timeout_ms) + " ms recorded " + - std::to_string(recorded) + " ms on the wait seam, though the bridge's own guard " - "never enters a wait for a timeout that is not greater than zero"; - cultmesh_quic_runtime_close(runtime); - return failure; - } - - // A real wait, on a thread of its own: a probe at INT32_MAX has to be - // released by the close, not waited out. - std::thread poller([runtime, timeout_ms] { - std::vector payload(static_cast(kPayloadCapacity)); - cultmesh_quic_event event{}; - int32_t required = 0; - cultmesh_quic_next_event(runtime, timeout_ms, &event, payload.data(), kPayloadCapacity, &required); - }); - - const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(kWaitSeamDeadlineMs); - int32_t recorded = cultmesh_quic_debug_last_wait_ms(); - while (recorded == -1 && std::chrono::steady_clock::now() < deadline) { - std::this_thread::sleep_for(std::chrono::milliseconds(1)); - recorded = cultmesh_quic_debug_last_wait_ms(); - } - - cultmesh_quic_runtime_close(runtime); - poller.join(); - - if (recorded != timeout_ms) - failure = "a poll asking for " + std::to_string(timeout_ms) + " ms recorded " + - std::to_string(recorded) + " ms on the wait seam: the bridge handed its condition " - "wait something other than the host's own argument"; - return failure; -} - -// Past the twelfth poll on one runtime: no scenario before this one ever asked -// a runtime for a thirteenth, so a wait that only changes shape after twelve -// had nowhere to be seen. Forty short waits on a runtime that stays open for -// all of them, each checked by equality. -constexpr int32_t kWaitSeamRepeatedMs = 5; -constexpr int kWaitSeamRepeatedPolls = 40; - -std::string WaitSeamRepeatedOnce() { - 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 (int poll = 0; poll < kWaitSeamRepeatedPolls && failure.empty(); ++poll) { - cultmesh_quic_debug_reset_last_wait_ms(); - std::vector payload(static_cast(kPayloadCapacity)); - cultmesh_quic_event event{}; - int32_t required = 0; - cultmesh_quic_next_event( - runtime, kWaitSeamRepeatedMs, &event, payload.data(), kPayloadCapacity, &required); - const int32_t recorded = cultmesh_quic_debug_last_wait_ms(); - if (recorded != kWaitSeamRepeatedMs) - failure = "poll " + std::to_string(poll + 1) + " of " + - std::to_string(kWaitSeamRepeatedPolls) + " on one runtime recorded " + - std::to_string(recorded) + " ms, not " + std::to_string(kWaitSeamRepeatedMs); - } - cultmesh_quic_runtime_close(runtime); - return failure; -} - -int WaitSeam(int iterations) { - for (int iteration = 0; iteration < iterations; ++iteration) { - for (int32_t probe : kWaitSeamProbesMs) { - const std::string failure = WaitSeamProbeOnce(probe); - if (!failure.empty()) - return Fail("waitseam iteration " + std::to_string(iteration) + " probe " + - std::to_string(probe) + ": " + failure); - } - const std::string repeated = WaitSeamRepeatedOnce(); - if (!repeated.empty()) - return Fail("waitseam iteration " + std::to_string(iteration) + ": " + repeated); - } - std::printf("waitseam %dx: ok (%zu probes, %d repeated polls on one runtime)\n", - iterations, kWaitSeamProbesMs.size(), kWaitSeamRepeatedPolls); - return 0; -} - -#endif // CULTMESH_QUIC_DEBUG_ASSERTS - -// How long `pollhammer`'s idle poll asks for. One host thread hammers every -// gate-touching entry point that cannot itself queue an event, in a yield -// loop, while another holds this one poll idle; each hammered call's -// `CallScope` destructor notifies every waiter on the runtime's condition -// variable (cultmesh_quic_native.cpp:286), so the idle poll is woken many times -// over for every timeout it is given here. That is exactly the shape a wake -// count or an error-state predicate exploits, and exactly what a revert that -// returns on any wake at all fails immediately. -// -// `cultmesh_quic_connection_open`, `cultmesh_quic_listener_open` and -// `cultmesh_quic_stream_open` touch the same gate but can each queue a real -// event on this runtime, and a genuine event is allowed to end the idle poll -// early — hammering with those would make an early return ambiguous between -// the fault this scenario hunts and a real delivery, so they are left out. The -// refused `listener_open` below is a setup step run once, not part of the -// loop, and it leaves no listener behind. -constexpr int32_t kHammerPollMs = 1000; - -// A self-signed ECDSA P-256 PKCS12, generated once with OpenSSL and embedded so -// this scenario needs no crypto library of its own and no network fetch: -// openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \ -// -keyout key.pem -out cert.pem -days 3650 -nodes -subj "/CN=cultmesh-quic-native-tests" -// openssl pkcs12 -export -out test.p12 -inkey key.pem -in cert.pem -passout pass: -// Used only to give pollhammer's live-objects run a listener that can actually -// load a credential; nothing ever connects to it, so it never accepts and never -// queues an event this scenario would have to tell apart from a bug. -constexpr std::array kSelfSignedPkcs12{{ - 0x30, 0x82, 0x04, 0x3c, 0x02, 0x01, 0x03, 0x30, 0x82, 0x03, 0xf2, 0x06, - 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01, 0xa0, 0x82, - 0x03, 0xe3, 0x04, 0x82, 0x03, 0xdf, 0x30, 0x82, 0x03, 0xdb, 0x30, 0x82, - 0x02, 0x8a, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, - 0x06, 0xa0, 0x82, 0x02, 0x7b, 0x30, 0x82, 0x02, 0x77, 0x02, 0x01, 0x00, - 0x30, 0x82, 0x02, 0x70, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, - 0x01, 0x07, 0x01, 0x30, 0x5f, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, - 0x0d, 0x01, 0x05, 0x0d, 0x30, 0x52, 0x30, 0x31, 0x06, 0x09, 0x2a, 0x86, - 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x05, 0x0c, 0x30, 0x24, 0x04, 0x10, 0x48, - 0xdf, 0x04, 0x5a, 0x31, 0x34, 0x6d, 0xe0, 0x7c, 0xde, 0x3c, 0xa2, 0xde, - 0xf2, 0x8d, 0x20, 0x02, 0x02, 0x08, 0x00, 0x30, 0x0c, 0x06, 0x08, 0x2a, - 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x02, 0x09, 0x05, 0x00, 0x30, 0x1d, 0x06, - 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x2a, 0x04, 0x10, - 0x44, 0x04, 0xd9, 0x9b, 0x99, 0x3a, 0xcb, 0x89, 0xc0, 0xcb, 0x53, 0xe3, - 0xe3, 0x5b, 0xb4, 0x48, 0x80, 0x82, 0x02, 0x00, 0x92, 0xcc, 0x04, 0xcf, - 0x7d, 0x58, 0xec, 0x68, 0x1a, 0x1b, 0xd5, 0x3b, 0x17, 0x57, 0x22, 0xd5, - 0x45, 0x00, 0xff, 0x05, 0x0f, 0x0d, 0x18, 0x8b, 0x72, 0x5b, 0xcc, 0xd9, - 0x87, 0xf4, 0x99, 0x44, 0x7c, 0xd6, 0xc3, 0x02, 0x2a, 0xaf, 0xd9, 0x10, - 0x14, 0xd3, 0x9f, 0x1a, 0x2c, 0x7b, 0x4f, 0xe6, 0x0b, 0x1d, 0x7f, 0xb9, - 0xf4, 0x01, 0x5c, 0x4b, 0xd5, 0xa1, 0x57, 0xb0, 0x3d, 0xe0, 0xbc, 0x70, - 0x72, 0xe0, 0xe9, 0xb8, 0x9a, 0xde, 0x68, 0xfe, 0x7c, 0xc7, 0xca, 0xcc, - 0x61, 0x32, 0x88, 0x72, 0xdd, 0x97, 0x28, 0x7b, 0x5b, 0x0d, 0x30, 0xf5, - 0xa2, 0xd1, 0xe8, 0x89, 0xd4, 0x1d, 0x88, 0x28, 0xd2, 0xc3, 0x02, 0x93, - 0xa1, 0x27, 0xfb, 0x4a, 0xe7, 0xdc, 0xac, 0xf3, 0x0d, 0xa9, 0xd3, 0x14, - 0x37, 0x71, 0xb7, 0xe5, 0xaf, 0xdb, 0xb6, 0xda, 0x42, 0xb7, 0x24, 0x7a, - 0x94, 0xad, 0x73, 0x79, 0xde, 0x21, 0xb9, 0x13, 0x63, 0x98, 0x33, 0x62, - 0x4a, 0x36, 0xb8, 0x6d, 0x14, 0x8b, 0x84, 0xf0, 0x8e, 0x55, 0xcc, 0x95, - 0x14, 0x54, 0xcc, 0x68, 0x4b, 0x09, 0x24, 0xb4, 0x79, 0x49, 0x05, 0xc7, - 0x0e, 0xfe, 0xed, 0x23, 0x8c, 0x87, 0xc6, 0xc3, 0x76, 0x25, 0xd9, 0xd6, - 0xae, 0x5a, 0xf4, 0x86, 0x74, 0xb8, 0x44, 0x22, 0xe8, 0xfc, 0xa5, 0xc2, - 0x25, 0xbd, 0x84, 0xfd, 0x92, 0x96, 0xe2, 0x66, 0xa9, 0xb0, 0x1f, 0xd3, - 0x8d, 0xf7, 0x1c, 0xce, 0xed, 0x09, 0x11, 0x61, 0xec, 0x2d, 0xa4, 0x9d, - 0xb9, 0xdb, 0xd4, 0x2e, 0xa4, 0xff, 0xe6, 0x09, 0x8e, 0x9a, 0x0b, 0xdf, - 0x30, 0xf4, 0xea, 0xe6, 0x09, 0x2d, 0x16, 0xec, 0x5d, 0xb6, 0x39, 0xda, - 0xd4, 0x43, 0x41, 0x95, 0xc8, 0x32, 0xd2, 0xac, 0x17, 0x58, 0x05, 0x7e, - 0x10, 0x24, 0xae, 0xd6, 0x58, 0x80, 0x0a, 0x29, 0xcb, 0x4b, 0x78, 0x62, - 0xbe, 0xf6, 0x6c, 0x7d, 0x44, 0x07, 0x7b, 0xfb, 0xd9, 0xb6, 0x74, 0x75, - 0x9f, 0xe6, 0xb4, 0xc3, 0x54, 0x53, 0x46, 0xdb, 0xf2, 0xa1, 0x47, 0xb8, - 0xf1, 0xff, 0x01, 0xba, 0xde, 0xf9, 0xca, 0xd6, 0xc0, 0x38, 0x40, 0xd0, - 0x86, 0x72, 0xc8, 0xcf, 0xdd, 0xd6, 0xf6, 0x36, 0xe0, 0xf3, 0x2e, 0x95, - 0x23, 0xdb, 0x99, 0x72, 0x1c, 0xa6, 0xa0, 0xf7, 0x17, 0x93, 0x1e, 0x20, - 0x6c, 0x75, 0x25, 0x0e, 0x1c, 0xab, 0xa4, 0xce, 0x35, 0xd4, 0xf4, 0xc6, - 0x63, 0x89, 0x0d, 0xa9, 0x91, 0x0b, 0x7e, 0xce, 0x7d, 0x4d, 0x5e, 0xbf, - 0xfd, 0xa9, 0xbd, 0xb6, 0x1e, 0x2b, 0x08, 0x5d, 0x7a, 0xdd, 0x61, 0xdf, - 0x24, 0xe0, 0xff, 0x4b, 0xeb, 0xa2, 0xeb, 0xb6, 0x2f, 0x2c, 0xbf, 0x9b, - 0x5f, 0x6a, 0x1a, 0x33, 0xae, 0x02, 0x6d, 0xbf, 0x8e, 0x6f, 0xeb, 0xda, - 0x17, 0x88, 0x79, 0xf8, 0xe0, 0x1c, 0x1d, 0x64, 0x7c, 0xc3, 0xc2, 0xc1, - 0xce, 0x12, 0x67, 0x74, 0xbc, 0xef, 0x9f, 0x2f, 0x35, 0xef, 0x40, 0x24, - 0xc1, 0x5f, 0x82, 0xc9, 0x00, 0x2c, 0xbf, 0x2b, 0xbf, 0x64, 0x39, 0x63, - 0x0c, 0xcd, 0x6f, 0xe5, 0x18, 0xfa, 0xeb, 0x02, 0x61, 0xc3, 0x22, 0xb0, - 0xfd, 0xfb, 0x9d, 0x32, 0xbb, 0x2f, 0xd8, 0x9d, 0x63, 0x8d, 0x9e, 0xb1, - 0x5a, 0x7d, 0xc3, 0xe5, 0xcc, 0xae, 0xe4, 0x71, 0x0d, 0x7b, 0xa9, 0x80, - 0xbd, 0xef, 0x86, 0x12, 0x13, 0x42, 0xf8, 0x14, 0xcb, 0xa9, 0xfd, 0x86, - 0xea, 0xd5, 0x81, 0x54, 0x87, 0xbc, 0x66, 0x24, 0xfd, 0x12, 0xf5, 0x6b, - 0xf4, 0x45, 0x7e, 0x41, 0x8e, 0x00, 0xf3, 0x63, 0xc7, 0x24, 0xc5, 0xa1, - 0xfd, 0x70, 0xbc, 0xfa, 0x26, 0xa0, 0x07, 0xc2, 0x97, 0xb1, 0xe0, 0x42, - 0x5a, 0x2b, 0x65, 0xec, 0xda, 0x45, 0x79, 0xe5, 0x18, 0xf2, 0xce, 0x8e, - 0xc3, 0xe3, 0x74, 0x6d, 0x30, 0x82, 0x01, 0x49, 0x06, 0x09, 0x2a, 0x86, - 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01, 0xa0, 0x82, 0x01, 0x3a, 0x04, - 0x82, 0x01, 0x36, 0x30, 0x82, 0x01, 0x32, 0x30, 0x82, 0x01, 0x2e, 0x06, - 0x0b, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x0c, 0x0a, 0x01, 0x02, - 0xa0, 0x81, 0xf7, 0x30, 0x81, 0xf4, 0x30, 0x5f, 0x06, 0x09, 0x2a, 0x86, - 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x05, 0x0d, 0x30, 0x52, 0x30, 0x31, 0x06, - 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x05, 0x0c, 0x30, 0x24, - 0x04, 0x10, 0xd8, 0x91, 0xb9, 0x92, 0x3a, 0x1e, 0x6b, 0xa2, 0x57, 0x44, - 0x16, 0x3a, 0xea, 0x36, 0x8d, 0x60, 0x02, 0x02, 0x08, 0x00, 0x30, 0x0c, - 0x06, 0x08, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x02, 0x09, 0x05, 0x00, - 0x30, 0x1d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, - 0x2a, 0x04, 0x10, 0x35, 0x48, 0x03, 0x23, 0x6a, 0x3d, 0x5f, 0x96, 0xb7, - 0xfb, 0xb1, 0xcb, 0xdc, 0x0c, 0x1f, 0x79, 0x04, 0x81, 0x90, 0x4d, 0x36, - 0x5b, 0x1e, 0xc1, 0xc1, 0xa3, 0xfa, 0x85, 0xe6, 0x58, 0x89, 0x5d, 0xbe, - 0x17, 0xb4, 0x90, 0x96, 0x2d, 0x3d, 0x32, 0xe2, 0x73, 0x6c, 0x2c, 0xfd, - 0x7b, 0xe4, 0x9c, 0x82, 0x3c, 0xe4, 0x7a, 0x58, 0x45, 0xdd, 0xcd, 0x00, - 0xa2, 0x52, 0xf9, 0x98, 0xd4, 0x0f, 0x64, 0x0b, 0x58, 0x27, 0xfe, 0xfc, - 0xfd, 0x60, 0x3f, 0x7e, 0x6f, 0x73, 0x73, 0xda, 0x16, 0x34, 0x83, 0x38, - 0x89, 0x3d, 0xbe, 0xde, 0x0a, 0x47, 0x7b, 0xcd, 0xc1, 0x88, 0x72, 0x38, - 0x95, 0xb5, 0x54, 0xfa, 0x2a, 0x84, 0x2b, 0xcd, 0x98, 0xe3, 0x20, 0xd2, - 0xac, 0xd8, 0xf5, 0xa8, 0xd2, 0xef, 0x1f, 0x99, 0x88, 0x16, 0x6f, 0xf2, - 0x08, 0xe9, 0x28, 0xc8, 0x7a, 0x02, 0x57, 0x5c, 0x1c, 0x94, 0x0f, 0x57, - 0xfc, 0x0e, 0x85, 0x63, 0xc5, 0x44, 0x45, 0x4f, 0xc6, 0x35, 0x52, 0x3d, - 0xd4, 0xe2, 0x22, 0x6f, 0x72, 0xde, 0x55, 0xad, 0x20, 0x84, 0x91, 0x14, - 0x7e, 0x5f, 0x98, 0x2a, 0x5e, 0x2b, 0xf4, 0xc5, 0x10, 0x1b, 0x31, 0x25, - 0x30, 0x23, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x09, - 0x15, 0x31, 0x16, 0x04, 0x14, 0xbd, 0x8a, 0x08, 0x3c, 0x66, 0xf7, 0x9d, - 0xa0, 0x25, 0x8b, 0x33, 0xac, 0xc2, 0x5e, 0xdc, 0x76, 0xd4, 0x33, 0x1a, - 0xd6, 0x30, 0x41, 0x30, 0x31, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, - 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, 0x05, 0x00, 0x04, 0x20, 0x4e, 0x6f, - 0x75, 0x90, 0x57, 0xb4, 0x39, 0x1e, 0xc3, 0x97, 0x50, 0x5e, 0x4d, 0xf2, - 0xa8, 0xf9, 0x0e, 0xcd, 0x7f, 0x57, 0x31, 0xae, 0xf2, 0x87, 0x0a, 0x55, - 0xb0, 0xbc, 0xd6, 0x11, 0x83, 0xc3, 0x04, 0x08, 0x52, 0x79, 0xb1, 0x83, - 0xe6, 0xe4, 0x6e, 0x4d, 0x02, 0x02, 0x08, 0x00, -}}; - -std::string PollHammerOnce(bool with_recorded_error, bool with_live_objects) { - 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); - - if (with_recorded_error) { - // Not a certificate at all: PKCS12 parsing fails before any network - // I/O, so this is refused synchronously and leaves no listener behind, - // and `runtime->error` is non-empty for the rest of this runtime's life. - const uint8_t junk[] = {0x00, 0x01, 0x02, 0x03}; - uint64_t listener_id = 0; - uint16_t bound_port = 0; - const int32_t result = cultmesh_quic_listener_open( - runtime, nullptr, 0, junk, static_cast(sizeof(junk)), "", &listener_id, &bound_port); - if (result >= 0) { - cultmesh_quic_runtime_close(runtime); - return "cultmesh_quic_listener_open accepted a junk PKCS12 credential"; - } - } - - // Self's ruling of 2026-09-22, F3: the predicate covered by the hammer set - // above is only ever exercised on a runtime with nothing live in it. An - // unroutable address (RFC 5737 TEST-NET-1) gets a connection into - // `runtime->connections` at once — `cultmesh_quic_connection_open` inserts - // it before the handshake starts — and that handshake then never completes - // inside this scenario's poll window, so it queues no event of its own. A - // best-effort stream rides the same connection. The listener is real (a - // credential MsQuic actually loads) but nothing ever connects to it, so it - // never accepts and never queues an event either: every object here is - // live in the runtime's maps without ever waking the idle poll on its own. - uint64_t live_connection_id = 0; - uint64_t live_stream_id = 0; - uint64_t live_listener_id = 0; - if (with_live_objects) { - const int32_t connecting = - cultmesh_quic_connection_open(runtime, "192.0.2.1", 9, &live_connection_id); - if (connecting != 0) { - cultmesh_quic_runtime_close(runtime); - return "cultmesh_quic_connection_open (live-objects setup) returned " + - std::to_string(connecting); - } - // Best-effort: whether this succeeds is not the point of the - // scenario, and P1's predicate names `connections`, not `streams`. - cultmesh_quic_stream_open( - runtime, live_connection_id, CULTMESH_QUIC_STREAM_RELIABLE, &live_stream_id); - uint16_t bound_port = 0; - cultmesh_quic_listener_open(runtime, "127.0.0.1", 0, kSelfSignedPkcs12.data(), - static_cast(kSelfSignedPkcs12.size()), "", &live_listener_id, &bound_port); - } - - std::atomic hammering{true}; - std::thread hammer([runtime, &hammering] { - constexpr uint64_t kBogusId = 0xffffffffffffffffull; - const uint8_t frame[] = {0x00}; - std::array message{}; - while (hammering.load(std::memory_order_relaxed)) { - cultmesh_quic_last_error(runtime, message.data(), static_cast(message.size())); - cultmesh_quic_last_status(runtime); - cultmesh_quic_connection_shutdown(runtime, kBogusId, 0); - cultmesh_quic_stream_shutdown(runtime, kBogusId, 0); - cultmesh_quic_stream_send_frame(runtime, kBogusId, frame, 1, 0); - cultmesh_quic_connection_certificate_complete(runtime, kBogusId, 0); - cultmesh_quic_listener_close(runtime, kBogusId); - std::this_thread::yield(); - } - }); - - const TimedPoll poll = PollFor(runtime, kHammerPollMs); - hammering.store(false, std::memory_order_relaxed); - hammer.join(); - if (live_listener_id != 0) cultmesh_quic_listener_close(runtime, live_listener_id); - if (live_connection_id != 0) cultmesh_quic_connection_shutdown(runtime, live_connection_id, 0); - (void)live_stream_id; - cultmesh_quic_runtime_close(runtime); - - if (poll.result != 0) - return "the idle poll returned " + std::to_string(poll.result) + - ", not the 0 an idle runtime owes it"; - if (poll.elapsed_ms < kHammerPollMs - kEarlyToleranceMs) - return "the idle poll returned after " + std::to_string(poll.elapsed_ms) + - " ms while another thread hammered the gate, ending its wait early"; - if (poll.elapsed_ms > static_cast(kHammerPollMs) + kGenerousLateToleranceMs) - return "the idle poll returned after " + std::to_string(poll.elapsed_ms) + - " ms: well past its own timeout"; - return {}; -} - -int PollHammer(int iterations) { - for (int iteration = 0; iteration < iterations; ++iteration) { - std::string failure = PollHammerOnce(false, false); - if (!failure.empty()) - return Fail("pollhammer iteration " + std::to_string(iteration) + " (clean runtime): " + failure); - failure = PollHammerOnce(true, false); - if (!failure.empty()) - return Fail("pollhammer iteration " + std::to_string(iteration) + - " (after a refused call recorded an error): " + failure); - failure = PollHammerOnce(false, true); - if (!failure.empty()) - return Fail("pollhammer iteration " + std::to_string(iteration) + - " (with a live connection, stream and listener): " + failure); - } - std::printf("pollhammer %dx: ok\n", iterations); - return 0; -} - -// Self's ruling of 2026-09-22, F4: any `timeout_ms <= 0` has to return within a -// small, generously-margined bound, and a negative timeout has to be probed -// too — the bridge's own guard (`timeout_ms > 0`) already treats zero and -// negative alike, so 0, -1 and INT32_MIN all take the same fast path and none -// of them may collide with the seam's own "nothing recorded" sentinel. -constexpr std::array kZeroTimeoutProbesMs{0, -1, INT32_MIN}; - -// Generous next to `kEarlyToleranceMs`/`kGenerousLateToleranceMs` because a -// non-positive timeout never enters the condition wait at all, so this is -// bounding a runtime lock acquisition and an empty-queue check, not a -// scheduler wakeup — the margin only has to be well clear of a mistake that -// makes this path sleep or spin, not of ordinary jitter. -constexpr int kZeroTimeoutBoundMs = 50; - -std::string ZeroTimeoutOnce(int32_t timeout_ms) { - 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); - - const TimedPoll poll = PollFor(runtime, timeout_ms); - cultmesh_quic_runtime_close(runtime); - - if (poll.result != 0) - return "a poll asking for " + std::to_string(timeout_ms) + " ms returned " + - std::to_string(poll.result) + ", not the 0 an idle runtime owes it"; - if (poll.elapsed_ms > kZeroTimeoutBoundMs) - return "a poll asking for " + std::to_string(timeout_ms) + " ms returned after " + - std::to_string(poll.elapsed_ms) + " ms: a non-positive timeout must return within " + - std::to_string(kZeroTimeoutBoundMs) + " ms, not stall or spin"; - return {}; -} - -int ZeroTimeout(int iterations) { - for (int iteration = 0; iteration < iterations; ++iteration) { - for (int32_t probe : kZeroTimeoutProbesMs) { - const std::string failure = ZeroTimeoutOnce(probe); - if (!failure.empty()) - return Fail("zerotimeout iteration " + std::to_string(iteration) + " probe " + - std::to_string(probe) + ": " + failure); - } - } - std::printf("zerotimeout %dx: ok (%zu probes)\n", iterations, kZeroTimeoutProbesMs.size()); - return 0; -} - -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|pollbusy|pollhammer|zerotimeout|" - "payloadfit|latecall|waitseam [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 == "pollbusy") return PollBusy(iterations); - if (scenario == "pollhammer") return PollHammer(iterations); - if (scenario == "zerotimeout") return ZeroTimeout(iterations); - if (scenario == "payloadfit") return PayloadFit(iterations); - if (scenario == "holdclose" || scenario == "latecall" || scenario == "holdtimeout" || - scenario == "waitseam") { -#if defined(CULTMESH_QUIC_DEBUG_ASSERTS) - if (scenario == "latecall") return LateCall(iterations); - if (scenario == "holdtimeout") return HoldTimeout(iterations); - if (scenario == "waitseam") return WaitSeam(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; -} diff --git a/package-lock.json b/package-lock.json index 5c0a5591..f027ecd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -489,326 +489,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@koromix/koffi-android-arm64": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@koromix/koffi-android-arm64/-/koffi-android-arm64-3.3.0.tgz", - "integrity": "sha512-lHXE2mq+uG6CVeX1iepn2ATcAWMZPagr+J0ICYge7wTMDYc5pPRQMq0Rr/16775i6QlUqQqpk8c5Gu66+u3gGA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-android-x64": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@koromix/koffi-android-x64/-/koffi-android-x64-3.3.0.tgz", - "integrity": "sha512-7EeVK/KS9XNbOsUfy0B3uuSjPQZenjJ35y9QSi6R6eK4Fv0oGvREQaBzIJRJwonxp7sJfGObnwnQTnKTVK2DWA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-darwin-arm64": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.3.0.tgz", - "integrity": "sha512-2R9bihYmYFyRP98wRG2m5WQVKMONf7DoPJI8RkGCOUB2afOXJEVXFepiSKxBZGPml8i1USwAxMUwkzbJ5uMG1A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-darwin-x64": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.3.0.tgz", - "integrity": "sha512-OecYCQfzhUx+XyAtatIHlk0jIz87wKNjtYvwlwgMwgaGQwO/TxFeReNo+s3167Rh1a9c8rUIOjq1jfzAPBxbKA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-freebsd-arm64": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.3.0.tgz", - "integrity": "sha512-6ytHzV1DuyxTP5LtFmQ3imBk71j/F/Nb0Hcwhjcn35esnknm5rQbgRl6tNRldrkGnM4GIAJpsagooUBh1xAzYg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-freebsd-ia32": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.3.0.tgz", - "integrity": "sha512-hINo5z1Iz2u3Rx3oHfzcmi5A03gYAoYf4nCJJYaY0bb5IuGdIC7noQTyyI9YxfyTJgoqw3wKFWWYtvsAmEo9Hw==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-freebsd-x64": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.3.0.tgz", - "integrity": "sha512-7XQYAZh828nW6+y3IdyUNdo2VbVHL5ZC8Qb+yqHrlDjTjVXSyGYZJHAyJZ5c7eSTTKBhtIMTX238rIsdexxrog==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-linux-arm": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm/-/koffi-linux-arm-3.3.0.tgz", - "integrity": "sha512-NbahRKKKFkHoFN/ZpLY+w4F6eYSqV4KMN40PmjZ1xQjG/XjYb6PIYgE41c7/acAPxOYqujrcaI3B4BAgWYm5/A==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-linux-arm64": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.3.0.tgz", - "integrity": "sha512-Eo/+wGeawGICzJkUlkzi/KerAJ6U1L/Hf7cnJKH7aGgCOY5RRnjUqXs/eL+UDpRcFHzGeUn+Fq+8q2VxYxvAfw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-linux-ia32": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.3.0.tgz", - "integrity": "sha512-u9tKuj42RFWJU8ZgZqcBhkFCkIZE/SE6oR1ljpukttJUhOVuaf/lmsWxyOxiVu3U8T1gtD658FlLgrbMVIn6aQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-linux-loong64": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.3.0.tgz", - "integrity": "sha512-2hKlNZ6OXkTUp6fcueZcP0sQ6yhpdfANg9/mWxDKzT1o8qIj4qJD7UcZLCcRIC8JXiP8JQwZBtlPuyXIqofDQg==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-linux-ppc64": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ppc64/-/koffi-linux-ppc64-3.3.0.tgz", - "integrity": "sha512-bIVAoHgzyRN7xs529BK7rF3BbZUTJgIr0t/wvBvAgaIpreKsdZ/QWxhedKRbTN7ljlDHkPZFmu5XcCjb/vqUag==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-linux-riscv64": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.3.0.tgz", - "integrity": "sha512-2niYAe+TBXbvf7yog/aiHiDUrKQn+RkMSH5x3ZYo65OQyII1CY3Jk2kP3Pe735gq0LzIYJsDuGMW+02ighSadQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-linux-x64": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.3.0.tgz", - "integrity": "sha512-apU8ZMOrOh1eqMP/Pp0sDZ/WY6A4r6JWph26h63VM9nZ+5KCTYuPgojfoLPNvrfI/bn1PY3+bpXKRhZHI3gxbQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-openbsd-arm64": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-arm64/-/koffi-openbsd-arm64-3.3.0.tgz", - "integrity": "sha512-iTyHMCSSs+X579sWJv7XVFMp9gOfROI8IUEwp09YxJpGvmiSoipI0J7kytFuFq6dyuwO0Ymy4JdlmKnCM0qkxg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-openbsd-ia32": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.3.0.tgz", - "integrity": "sha512-xCvMV+4VNHjTxgVPEsBzU0k8dlA7aoFO5QjRF9lVU/NzGhUedlYvFgaF6IWsGOoWEaNt9hgP4KK/wxMbm9v8EQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-openbsd-x64": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.3.0.tgz", - "integrity": "sha512-xItIwx1elM8k1pTudEfuV/1U2gQ06+rFfxCVXrZkyWYYGTzmA5To00f8garJFR+E9gbjQJbn8QDFvWMI+i2sMA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-win32-arm64": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.3.0.tgz", - "integrity": "sha512-fD41LgKBx5i1YlJDuds4tRHAaG5vV55tvagzJwKFDR/5BDeaFvH9QFMUMDoNl2LkmVyeug0dIElHr/oxejh3IA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-win32-ia32": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.3.0.tgz", - "integrity": "sha512-BfDbDVEuQwPZOvWXawc3JT/7i9cN7QHYotgo014jjXb9pNv46Ykhb0RRglkcHg+SdytW/7pxYn18YwcQP6LLvQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-win32-x64": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.3.0.tgz", - "integrity": "sha512-D+ORp73rTmvGVfq01+4uBmgVZqrPqU9N9xCZCfexaCDlVk0WQsaqid1dGsi6eScJuHt4Cp8s1i5/4CsecxFTKQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, "node_modules/@msgpack/msgpack": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz", @@ -1035,38 +715,6 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, - "node_modules/koffi": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.3.0.tgz", - "integrity": "sha512-UeyQtptuPeCP0BqKD+Q8ftTQhIUBHO7bXyK9DVIaW9hJSg0BrICm4K5N8Qtn7zKk20sb5vpnES9KBqVksM2kpQ==", - "hasInstallScript": true, - "license": "MIT", - "funding": { - "url": "https://liberapay.com/Koromix" - }, - "optionalDependencies": { - "@koromix/koffi-android-arm64": "3.3.0", - "@koromix/koffi-android-x64": "3.3.0", - "@koromix/koffi-darwin-arm64": "3.3.0", - "@koromix/koffi-darwin-x64": "3.3.0", - "@koromix/koffi-freebsd-arm64": "3.3.0", - "@koromix/koffi-freebsd-ia32": "3.3.0", - "@koromix/koffi-freebsd-x64": "3.3.0", - "@koromix/koffi-linux-arm": "3.3.0", - "@koromix/koffi-linux-arm64": "3.3.0", - "@koromix/koffi-linux-ia32": "3.3.0", - "@koromix/koffi-linux-loong64": "3.3.0", - "@koromix/koffi-linux-ppc64": "3.3.0", - "@koromix/koffi-linux-riscv64": "3.3.0", - "@koromix/koffi-linux-x64": "3.3.0", - "@koromix/koffi-openbsd-arm64": "3.3.0", - "@koromix/koffi-openbsd-ia32": "3.3.0", - "@koromix/koffi-openbsd-x64": "3.3.0", - "@koromix/koffi-win32-arm64": "3.3.0", - "@koromix/koffi-win32-ia32": "3.3.0", - "@koromix/koffi-win32-x64": "3.3.0" - } - }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -1219,8 +867,7 @@ "dependencies": { "@gamecult/cultcache-ts": "^0.14.0", "@msgpack/msgpack": "^3.1.2", - "cultnet-ts": "^0.2.0", - "koffi": "3.3.0" + "cultnet-ts": "^0.2.0" }, "devDependencies": { "@types/node": "^24.0.0", diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index 0980f743..697882e0 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -14,8 +14,6 @@ from .backing_store import ( ) STORE_FORMAT_VERSION = "cultcache.store.v1" -_STORE_FORMAT_PREFIX = "cultcache.store." -_PERSISTED_RECORD_SLOTS = 4 class JsonLinesBackingStore: @@ -155,14 +153,7 @@ def _encode_v1_snapshot(envelopes: list[CultCacheEnvelope]) -> list[Any]: def _decode_v1_snapshot(decoded: Any) -> list[CultCacheEnvelope] | None: - if not isinstance(decoded, list) or not decoded: - return None - if isinstance(decoded[0], str) and decoded[0].startswith(_STORE_FORMAT_PREFIX) and decoded[0] != STORE_FORMAT_VERSION: - raise ValueError( - f"CultCache store format {decoded[0]!r} is not readable; this runtime reads {STORE_FORMAT_VERSION!r} only. " - "The store needs a runtime that resolves document variants." - ) - if decoded[0] != STORE_FORMAT_VERSION: + if not isinstance(decoded, list) or not decoded or decoded[0] != STORE_FORMAT_VERSION: return None if len(decoded) < 3 or not isinstance(decoded[1], list) or not isinstance(decoded[2], list): raise ValueError("CultCache v1 snapshot must contain a schema catalog and record array") @@ -178,12 +169,7 @@ def _decode_v1_snapshot(decoded: Any) -> list[CultCacheEnvelope] | None: for raw_record in decoded[2]: if not isinstance(raw_record, list) or len(raw_record) < 4: raise ValueError("CultCache persisted records must be MessagePack arrays") - if len(raw_record) > _PERSISTED_RECORD_SLOTS: - raise ValueError( - f"CultCache record {raw_record[0]!r} (schema {raw_record[1]!r}) has {len(raw_record)} slots; " - f"this runtime reads {_PERSISTED_RECORD_SLOTS}. The store needs a runtime that resolves document variants." - ) - key, schema_id, stored_at, payload = raw_record + key, schema_id, stored_at, payload = raw_record[:4] if not isinstance(key, str) or not key: raise ValueError("CultCache persisted records must declare a key") if not isinstance(schema_id, str) or not schema_id: diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index 6001c905..ac81cc6d 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -802,45 +802,5 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(cache.get_required_envelope(document, "item:one").key, "item:one") - # Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests. - _VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "document-variants-c0" - _ITEM_SCHEMA_ID = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788" - - def _pull_vector(self, name: str) -> list[CultCacheEnvelope]: - with tempfile.TemporaryDirectory() as tmp: - store_path = Path(tmp) / "store.msgpack" - store_path.write_bytes((self._VECTORS / name).read_bytes()) - return SingleFileMessagePackBackingStore(store_path).pull_all() - - def test_single_file_refuses_unknown_header_by_name(self) -> None: - with self.assertRaises(ValueError) as caught: - self._pull_vector("unknown-header.msgpack") - self.assertIn("cultcache.store.v9", str(caught.exception)) - - def test_single_file_refuses_extra_record_slot_naming_the_record(self) -> None: - with self.assertRaises(ValueError) as caught: - self._pull_vector("extra-slot-full-payload.msgpack") - self.assertIn("item:anvil", str(caught.exception)) - self.assertIn(self._ITEM_SCHEMA_ID, str(caught.exception)) - - def test_single_file_refuses_variant_store_by_version_or_record(self) -> None: - with self.assertRaises(ValueError) as caught: - self._pull_vector("variant-v2.msgpack") - message = str(caught.exception) - self.assertTrue("cultcache.store.v2" in message or "item:anvil-big" in message, message) - - def test_single_file_refuses_the_csharp_written_variant_store_by_version_or_record(self) -> None: - with self.assertRaises(ValueError) as caught: - self._pull_vector("../document-variants-c1/variant-store.msgpack") - message = str(caught.exception) - self.assertTrue("cultcache.store.v2" in message or "laser-big" in message, message) - - def test_v1_store_written_at_the_base_commit_still_reads_byte_for_byte(self) -> None: - envelopes = self._pull_vector("v1-base.msgpack") - self.assertEqual([(e.key, e.type) for e in envelopes], [("alpha", "vectors.item"), ("beta", "vectors.item")]) - self.assertEqual(envelopes[0].payload, b"\x92\xa5alpha\x01") - self.assertEqual(envelopes[1].payload, b"\x92\xa4beta\x02") - - if __name__ == "__main__": unittest.main() diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index c9a7d282..ed9cae32 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -271,7 +271,7 @@ struct PersistedSchemaCatalogMember( Option, ); -#[derive(Clone, Debug, serde::Serialize)] +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] struct PersistedRecord( String, String, @@ -279,67 +279,6 @@ struct PersistedRecord( #[serde(with = "serde_bytes")] Vec, ); -impl<'de> serde::Deserialize<'de> for PersistedRecord { - /// Reads the four v1 slots and refuses a fifth, naming the record. A record with more - /// slots was written by a runtime that resolves document variants. - fn deserialize>(deserializer: D) -> std::result::Result { - use serde::de::{Error, IgnoredAny, SeqAccess, Visitor}; - - struct RecordVisitor; - impl<'de> Visitor<'de> for RecordVisitor { - type Value = PersistedRecord; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("a CultCache persisted record of 4 slots") - } - - fn visit_seq>(self, mut seq: A) -> std::result::Result { - let key: String = seq.next_element()?.ok_or_else(|| A::Error::invalid_length(0, &self))?; - let schema_id: String = seq.next_element()?.ok_or_else(|| A::Error::invalid_length(1, &self))?; - let stored_at: String = seq.next_element()?.ok_or_else(|| A::Error::invalid_length(2, &self))?; - let payload: serde_bytes::ByteBuf = - seq.next_element()?.ok_or_else(|| A::Error::invalid_length(3, &self))?; - if seq.next_element::()?.is_some() { - return Err(A::Error::custom(format!( - "CultCache record {key:?} (schema {schema_id:?}) has more than 4 slots; this runtime reads 4. The store needs a runtime that resolves document variants." - ))); - } - Ok(PersistedRecord(key, schema_id, stored_at, payload.into_vec())) - } - } - - deserializer.deserialize_seq(RecordVisitor) - } -} - -/// The store header string, or `None` when the bytes are not a snapshot-shaped array -/// (the legacy envelope array starts with a map). -fn store_header(bytes: &[u8]) -> Option { - struct Header(String); - impl<'de> serde::Deserialize<'de> for Header { - fn deserialize>(deserializer: D) -> std::result::Result { - struct HeaderVisitor; - impl<'de> serde::de::Visitor<'de> for HeaderVisitor { - type Value = Header; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("an array whose first slot is the store header") - } - - fn visit_seq>(self, mut seq: A) -> std::result::Result { - let header: String = seq - .next_element()? - .ok_or_else(|| ::invalid_length(0, &self))?; - while seq.next_element::()?.is_some() {} - Ok(Header(header)) - } - } - deserializer.deserialize_seq(HeaderVisitor) - } - } - rmp_serde::from_slice::
(bytes).ok().map(|header| header.0) -} - #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct PushAllOptions { pub soft: bool, @@ -719,11 +658,9 @@ impl SingleFileMessagePackBackingStore { if bytes.is_empty() { return Ok(Vec::new()); } - match store_header(&bytes) { - Some(header) if header.starts_with("cultcache.store.") => decode_store_snapshot(&bytes), - _ => rmp_serde::from_slice(&bytes).map_err(anyhow::Error::from), - } - .map_err(|error| anyhow!("failed to decode MessagePack {}: {error:#}", self.path.display())) + decode_store_snapshot(&bytes) + .or_else(|_| rmp_serde::from_slice(&bytes)) + .with_context(|| format!("failed to decode MessagePack {}", self.path.display())) } /// Reads one filesystem snapshot without creating or opening the sibling @@ -2581,15 +2518,11 @@ fn encode_store_snapshot(entries: &[CultCacheEnvelope]) -> Result Result> { - if let Some(header) = store_header(bytes) { - if header != "cultcache.store.v1" { - return Err(anyhow!( - "CultCache store format {header:?} is not readable; this runtime reads \"cultcache.store.v1\" only. The store needs a runtime that resolves document variants." - )); - } - } let snapshot: PersistedStoreSnapshot = rmp_serde::from_slice(bytes).context("failed to decode CultCache v1 snapshot")?; + if snapshot.0 != "cultcache.store.v1" { + return Err(anyhow!("unsupported CultCache snapshot {}", snapshot.0)); + } let catalog = snapshot .1 @@ -4640,62 +4573,4 @@ mod tests { assert_eq!(store.pull_all_read_only_snapshot()?, vec![admitted]); Ok(()) } - - // Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests. - const ITEM_SCHEMA_ID: &str = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788"; - - fn pull_vector(name: &str) -> Result> { - let temp = tempfile::tempdir()?; - let path = temp.path().join("store.msgpack"); - let vector = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../tests/vectors/document-variants-c0") - .join(name); - std::fs::copy(vector, &path)?; - SingleFileMessagePackBackingStore::new(&path).pull_all() - } - - fn refusal(name: &str) -> String { - format!("{:#}", pull_vector(name).unwrap_err()) - } - - #[test] - fn single_file_refuses_unknown_header_by_name() { - assert!(refusal("unknown-header.msgpack").contains("cultcache.store.v9")); - } - - #[test] - fn single_file_refuses_extra_record_slot_naming_the_record() { - let message = refusal("extra-slot-full-payload.msgpack"); - assert!(message.contains("item:anvil"), "{message}"); - assert!(message.contains(ITEM_SCHEMA_ID), "{message}"); - } - - #[test] - fn single_file_refuses_variant_store_by_version_or_record() { - let message = refusal("variant-v2.msgpack"); - assert!( - message.contains("cultcache.store.v2") || message.contains("item:anvil-big"), - "{message}" - ); - } - - // The store the C# runtime wrote through its own variant API (tests/vectors/document-variants-c1). - #[test] - fn single_file_refuses_the_csharp_written_variant_store_by_version_or_record() { - let message = refusal("../document-variants-c1/variant-store.msgpack"); - assert!( - message.contains("cultcache.store.v2") || message.contains("laser-big"), - "{message}" - ); - } - - #[test] - fn v1_store_written_at_the_base_commit_still_reads_byte_for_byte() -> Result<()> { - let envelopes = pull_vector("v1-base.msgpack")?; - let rows: Vec<_> = envelopes.iter().map(|e| (e.key.as_str(), e.r#type.as_str())).collect(); - assert_eq!(rows, vec![("alpha", "vectors.item"), ("beta", "vectors.item")]); - assert_eq!(envelopes[0].payload, b"\x92\xa5alpha\x01"); - assert_eq!(envelopes[1].payload, b"\x92\xa4beta\x02"); - Ok(()) - } } diff --git a/packages/cultcache-ts/package.json b/packages/cultcache-ts/package.json index c708bfc9..67f8d5ed 100644 --- a/packages/cultcache-ts/package.json +++ b/packages/cultcache-ts/package.json @@ -45,7 +45,7 @@ }, "homepage": "https://github.com/GameCult/CultLib/tree/main/packages/cultcache-ts#readme", "engines": { - "node": ">=20" + "node": ">=18" }, "dependencies": { "@msgpack/msgpack": "^3.1.3", diff --git a/packages/cultcache-ts/src/cult-cache-inspector.ts b/packages/cultcache-ts/src/cult-cache-inspector.ts index 54e75700..1301fe47 100644 --- a/packages/cultcache-ts/src/cult-cache-inspector.ts +++ b/packages/cultcache-ts/src/cult-cache-inspector.ts @@ -1,7 +1,5 @@ import { decode } from "@msgpack/msgpack"; -import { STORE_FORMAT_VERSION, isV1Snapshot, requireV1RecordSlots } from "./store-format"; - export interface CultCacheInspection { filePath: string; fileSizeBytes: number; @@ -41,6 +39,8 @@ export interface InspectedRecord { payloadDecodeError?: string; } +const STORE_FORMAT_VERSION = "cultcache.store.v1"; + export function inspectCultCacheBytes( filePath: string, bytes: Uint8Array, @@ -197,7 +197,6 @@ function decodeV1Record(value: unknown, catalogBySchemaId: Map { return typeof value === "object" && value !== null; } diff --git a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts index b7d9684c..4d2bb4f9 100644 --- a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts +++ b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts @@ -4,7 +4,6 @@ import { dirname, resolve } from "node:path"; import { decode, encode } from "@msgpack/msgpack"; import { z } from "zod"; -import { STORE_FORMAT_VERSION, isV1Snapshot, requireV1RecordSlots } from "./store-format"; import type { CacheBackingStore, CultCacheEnvelope, @@ -13,6 +12,8 @@ import type { PushAllOptions, } from "./types"; +const STORE_FORMAT_VERSION = "cultcache.store.v1"; + const envelopeSchema = z.object({ key: z.string().min(1), type: z.string().min(1), @@ -284,7 +285,7 @@ function encodeCatalogMember(member: CultCacheSchemaCatalogMember): unknown[] { } function decodeSnapshot(decoded: unknown): DecodedSnapshot | undefined { - if (!isV1Snapshot(decoded)) { + if (!Array.isArray(decoded) || decoded.length === 0 || decoded[0] !== STORE_FORMAT_VERSION) { return undefined; } @@ -435,7 +436,6 @@ function decodeRecord(value: unknown): PersistedRecord { throw new Error("CultCache persisted records must be MessagePack arrays."); } - requireV1RecordSlots(value); const [key = "", schemaId = "", storedAt = "", payload = new Uint8Array()] = value; if (!isNonEmptyString(key) || !isNonEmptyString(schemaId) || !isNonEmptyString(storedAt)) { throw new Error("CultCache persisted records must declare key, schemaId, and storedAt."); diff --git a/packages/cultcache-ts/src/store-format.ts b/packages/cultcache-ts/src/store-format.ts deleted file mode 100644 index 02de60e7..00000000 --- a/packages/cultcache-ts/src/store-format.ts +++ /dev/null @@ -1,33 +0,0 @@ -export const STORE_FORMAT_VERSION = "cultcache.store.v1"; - -const STORE_FORMAT_PREFIX = "cultcache.store."; -const PERSISTED_RECORD_SLOTS = 4; - -/** - * True for a v1 snapshot, false for anything that is not a CultCache store header (the legacy - * envelope array). Any other `cultcache.store.*` header is refused by name. - */ -export function isV1Snapshot(decoded: unknown): decoded is unknown[] { - if (!Array.isArray(decoded) || typeof decoded[0] !== "string" || !decoded[0].startsWith(STORE_FORMAT_PREFIX)) { - return false; - } - - if (decoded[0] !== STORE_FORMAT_VERSION) { - throw new Error( - `CultCache store format "${decoded[0]}" is not readable; this runtime reads "${STORE_FORMAT_VERSION}" only. ` + - "The store needs a runtime that resolves document variants.", - ); - } - - return true; -} - -/** Refuses a persisted record with more slots than v1 defines, naming its key and schema id. */ -export function requireV1RecordSlots(record: unknown[]): void { - if (record.length > PERSISTED_RECORD_SLOTS) { - throw new Error( - `CultCache record "${String(record[0])}" (schema "${String(record[1])}") has ${record.length} slots; ` + - `this runtime reads ${PERSISTED_RECORD_SLOTS}. The store needs a runtime that resolves document variants.`, - ); - } -} diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index 8753af47..5932b2f8 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -1413,55 +1413,3 @@ async function runJsonCommand( return JSON.parse(trimmed.split(/\r?\n/).at(-1) as string); } - -// Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests. -const variantVectors = resolve(cultLibRoot, "tests", "vectors", "document-variants-c0"); -const itemSchemaId = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788"; - -async function pullVector(name: string): Promise { - const dir = await mkdtemp(join(tmpdir(), "cultcache-vector-")); - const file = join(dir, "store.msgpack"); - await writeFile(file, await readFile(join(variantVectors, name))); - return new SingleFileMessagePackBackingStore(file).pullAll(); -} - -test("SingleFileMessagePackBackingStore refuses an unknown store header by name", async () => { - await assert.rejects(() => pullVector("unknown-header.msgpack"), /cultcache\.store\.v9/u); -}); - -test("SingleFileMessagePackBackingStore refuses an extra record slot naming the record", async () => { - await assert.rejects( - () => pullVector("extra-slot-full-payload.msgpack"), - (error: Error) => error.message.includes("item:anvil") && error.message.includes(itemSchemaId), - ); -}); - -test("SingleFileMessagePackBackingStore refuses a variant store by version or record", async () => { - await assert.rejects( - () => pullVector("variant-v2.msgpack"), - (error: Error) => error.message.includes("cultcache.store.v2") || error.message.includes("item:anvil-big"), - ); -}); - -test("SingleFileMessagePackBackingStore refuses the C#-written variant store by version or record", async () => { - await assert.rejects( - () => pullVector("../document-variants-c1/variant-store.msgpack"), - (error: Error) => error.message.includes("cultcache.store.v2") || error.message.includes("laser-big"), - ); -}); - -test("CultCache inspector refuses the same vectors by name", async () => { - const inspect = async (name: string) => inspectCultCacheBytes(name, await readFile(join(variantVectors, name))); - await assert.rejects(() => inspect("unknown-header.msgpack"), /cultcache\.store\.v9/u); - await assert.rejects( - () => inspect("extra-slot-full-payload.msgpack"), - (error: Error) => error.message.includes("item:anvil") && error.message.includes(itemSchemaId), - ); -}); - -test("a v1 store written at the base commit still reads byte for byte", async () => { - const envelopes = await pullVector("v1-base.msgpack"); - assert.deepEqual(envelopes.map((entry) => [entry.key, entry.type]), [["alpha", "vectors.item"], ["beta", "vectors.item"]]); - assert.deepEqual([...envelopes[0]!.payload], [0x92, 0xa5, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x01]); - assert.deepEqual([...envelopes[1]!.payload], [0x92, 0xa4, 0x62, 0x65, 0x74, 0x61, 0x02]); -}); diff --git a/packages/cultmath/README.md b/packages/cultmath/README.md index f70df445..0be46738 100644 --- a/packages/cultmath/README.md +++ b/packages/cultmath/README.md @@ -26,15 +26,13 @@ shader semantics. acceleration and semi-implicit Euler integration - `math` intrinsics: `radians`, `degrees`, `abs`, `floor`, `ceil`, `frac`, `min`, `max`, `clamp`, `saturate`, `lerp`, `step`, `smoothstep`, `dot`, - `cross`, `length`, `distance`, `normalize`, `reflect`, `csum`, `erf`, - `erfinv` (scalar only), exponential + `cross`, `length`, `distance`, `normalize`, `reflect`, `csum`, exponential `decay`/`damp`, intercept/segment helpers, Catmull-Rom, Bezier curves, and deterministic value-noise primitives - `shaders/CultMath.hlsl`, a canonical HLSL mirror include for shader-side parity. HLSL already owns `float2`, `float3`, and `float4`; the include - exposes `cultmath_*` functions for shared semantics HLSL does not provide. - It includes `shaders/CultMath.Phacelle.hlsl` (MPL-2.0) from the same - directory, so ship both files. + 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. @@ -61,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)); diff --git a/packages/cultmath/THIRD-PARTY-NOTICES.md b/packages/cultmath/THIRD-PARTY-NOTICES.md index 625d63a9..06ebb6c3 100644 --- a/packages/cultmath/THIRD-PARTY-NOTICES.md +++ b/packages/cultmath/THIRD-PARTY-NOTICES.md @@ -6,36 +6,6 @@ simplex-noise functions by Ian McEwan and Ashima Arts, as distributed by Stefan Gustavson's `webgl-noise` project and Unity Mathematics. -`math.snoise(float3)` and `cultmath_snoise(float3)` follow `src/noise3D.glsl` and -`math.snoise(float2)` follows `src/noise2D.glsl` of stegu/webgl-noise at commit -`22434e04d7753f7e949e8d724ab3da2864c17a0f` (the simplex-boundary fix is -`21d9fe23d7`, scale `ff3b5d34ea`, permutation `a3e6d57095`). - -`math.snoise_grad`/`cultmath_snoise_grad` (and the `fbm_grad`/`ridged_grad` -octave sums built on them) follow the analytic-gradient differentiation from -the same project's `src/noise3Dgrad.glsl`, with the same kernel radius, -permutation and scale as `snoise(float3)`. - - Copyright 2011 Ashima Arts. - Upstream: - License: MIT. - -## Phacelle Noise - -`math.phacelle` and `cultmath_phacelle` follow Rune Skovbo Johansen's Phacelle -noise (Shadertoy `t3dyWl`; erosion filter `wXcfWn`; blog, March 2026), read -from the C# port `PhacelleNoise` in `lpmitchell/AdvancedTerrainErosion` -(`AdvancedTerrainErosion.cs`, converted to burstable C# by Luke Mitchell, -2026). The cell weight, the phase blend and the normalization follow it. The -3D-cell generalisation, the caller-supplied stripe wave vector, the exact -gradient and the exact pruning are new here. - -- Copyright (c) 2025 Rune Skovbo Johansen. -- Upstream: -- License: MPL-2.0 (). MPL-2.0 is file-level, so it - covers exactly the files that carry the port and nothing else: - `src/CultMath/math.Phacelle.cs`, `src/CultMath/CultPhasor.cs`, - `shaders/CultMath.Phacelle.hlsl` and the Unity copy of that include. Each - carries the MPL-2.0 header and this attribution. The rest of CultMath is MIT, - so the `GameCult.Math` NuGet package declares `MIT AND MPL-2.0` and ships this - file. The Unity package as a whole is MPL-2.0. diff --git a/packages/cultmath/docs/design.md b/packages/cultmath/docs/design.md index 0de8eb14..de09ee26 100644 --- a/packages/cultmath/docs/design.md +++ b/packages/cultmath/docs/design.md @@ -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`, @@ -100,10 +83,7 @@ needs anything else, the test fails: no CultMath analog. 3. File-scope `static const` becomes `const`. 4. Floating literals gain `f` (`0.5` becomes `0.5f`). -5. Struct fields gain `public`. HLSL struct members have no access-modifier - concept; a C# struct's fields default to `private`, which would hide them - from the mirror test's reflection-based field comparison. -6. The file body is wrapped in a class, since C# has no free functions; HLSL +5. The file body is wrapped in a class, since C# has no free functions; HLSL functions become private instance methods. HLSL constructs that stay outside the target and must not appear in the mirror: @@ -123,216 +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. -The comparison also allows exactly one struct return shape: a mirror function -whose HLSL return type is a plain struct of `float`/`floatN`/`int`/`intN` -fields is compared to its C# counterpart field by field, recursing into each -field's own components, down to bit-for-bit scalars, matching each leaf's -declaration path (field names, not just position) and declared type as well -as its value. `CultCellular` (`math.cs`, `cellular`) and `CultPhasor` -(`math.Phacelle.cs`, `phacelle`) are its consumers. A struct return with a mismatched, reordered-by-name, or -retyped field does not get a second comparison path or a shape of its own; -it fails the same walk that already handles a bare vector return. - -## Invariant 8: Value-and-Gradient Primitives - -A primitive tagged invariant 8 returns its value and analytic gradient -together, laid out as `float4(gradient.xyz, value.w)`, with no value-only twin -(a value-only form would be a second path with no consumer). `smin_grad` and -`cellular` are the first primitives in this family: - -- `smin_grad(float4 a, float4 b, float k)` is Inigo Quilez's quadratic-polynomial - smooth minimum ("smooth minimum", - ), carried to value-and-gradient - form. Its blend factor `h` is affine in `b.w - a.w`, so differentiating the - value with respect to position, the terms carrying `dh/dp` cancel exactly; - the surviving gradient is `lerp(∇b, ∇a, h)`, the same `h` the value uses. - This is the analytic gradient, not an approximation, and it is continuous - across the `|a.w - b.w| = k` seam where `h` saturates to 0 or 1. -- `cellular(float3 p)` is Worley's cellular texture basis function ("A - Cellular Texture Basis Function", SIGGRAPH 1996), searched over the - jittered 5×5×5 neighbourhood of feature points selected by `pcg3d` (never - the sin-based `hash`), with cells whose position-only lower bound already - exceeds the running F2 skipped before their hash is even computed. It - returns `CultCellular { nearest, edge, id }`: `nearest` is `(∇F1, F1)`, - `edge` is `(∇F2 - ∇F1, F2 - F1)`, and `id` is the nearest cell's `pcg4d` - hash (over the cell coordinate alone, never the `pcg3d` hash that produced - its jitter) mapped to `[0, 1)`. `∇F1` and `∇F2` are each undefined exactly - at their own feature point (`F1 = 0` or `F2 = 0`); `cellular` follows the - repo's existing degenerate-normal convention - (`GameCult.Geometry.CultGeometryIsoSurface.EmitOrientedTriangle`, which - guards a zero-length normal and returns the zero vector instead of the NaN - a bare `normalize` gives there) and returns the zero vector rather than NaN - for either. `F1 = 0` is ordinary (`p` sits on a feature point); `F2 = 0` - needs two distinct cells' `pcg3d`-jittered feature points to land on the - same float32 value in every component, which is unreachable at ordinary - magnitudes but confirmed reachable (see "Precision domain" below). The - identity of the nearest and second-nearest feature point changes - discontinuously across the `F1 = F2` set (a genuine kink, not a numerical - artifact), so `∇F1` and `∇F2` are only continuous away from that set. - - The 5×5×5 radius is exact, not merely safer than the classic 3×3×3 radius: - for a query at position `u` inside its own cell (`u` in `[0, 1)^3` per - component) and a candidate feature at integer cell offset `d` with jitter - `j` in `[0, 1)^3`, the per-axis displacement `(u_i - j_i) - d_i` is always - strictly greater in magnitude than `max(0, |d_i| - 1)`, because - `u_i - j_i` is always strictly inside `(-1, 1)`. So any cell with `|d_i| >= - 3` on some axis is strictly farther than 2 from the query. Two real, - always-present candidates bound `F1` and `F2`: the query's own cell, whose - distance is always strictly less than `sqrt(3)` (every axis strictly under - 1); and the near-side neighbour on whichever single axis has `u_i` closest - to a cell boundary. Let `m = min(u_i, 1 - u_i)` on that chosen axis, so - `m` is in `[0, 1/2]` by construction. Stepping one cell towards that - nearer boundary puts the query within `1 + m` of the far face on the - offset axis and within `1 - m` of the near face on each of the other two - axes, so that neighbour's squared distance is at most - `(1 + m)^2 + 2*(1 - m)^2 = 3 - 2m + 3m^2 <= 3` for every `m` in `[0, 1/2]` - (3 at `m = 0`, dipping to a minimum of `8/3 ~= 2.667` at `m = 1/3`, then - rising back to 2.75 at `m = 1/2`), and strictly below 3 because - the jitter never reaches exactly 0 or 1. `F2` is at most the larger of - these two real candidates (any two real candidates upper-bound the - 2nd-smallest value over the full infinite candidate set), so `F1 < - sqrt(3)` and `F2 < sqrt(3)` always, comfortably under the radius-2 - exclusion distance of 2, with margin `2 - sqrt(3) ~= 0.268` (Soul's - adversarial check, cut 2a-i: the worst realizable in-box `F2` is exactly - `sqrt(3)`, reached only in the unattainable limit at a cell corner). 3×3×3's - matching argument only reaches radius 1, whose excluded cells start at - distance 1, short of the `sqrt(3)` ceiling — the gap its measured failures - (9 of 400,000 points in `[-50, 50]^3`, worst error 0.088) live in. - - An earlier version of this proof picked the near-side neighbour on a fixed - axis instead of whichever axis sits closest to its own boundary, and that - version is wrong (Soul, cut 2a-i): `u = (0.5, 0, 0)` with the x-neighbour's - jittered feature at `(-1, 1 - 2^-24, 1 - 2^-24)` gives a distance of about - 2.06, outside the radius-2 exclusion the proof needs — `u.x = 0.5` is the - point on the cell farthest from any boundary, so `x` was the wrong axis to - fix there. Choosing the axis by proximity to its own boundary is what keeps - `m <= 1/2` and the bound at `sqrt(3)`. - - `CellularAndSminGradTests` pins both the prune's bit-identical equivalence - to an unpruned 5×5×5 reference and the search's exact agreement with an - independent 7×7×7 brute-force oracle. - - Precision domain: `cellular`'s exactness above is a statement about the - search radius, not about float32. Past `|p|` of roughly `2^23`, adjacent - integer cells stop being distinguishable from their neighbours at float32 - precision in some directions, and distinct cells' jittered features can - round onto the same value, making `F1 = F2` ties (and, per-component, - `F2 = 0`) increasingly common from about `1e6` and confirmed at `2^24` - (measured: 4096 of 4096 sampled integer points at `2^24` hit this before - the guard above). `cellular` stays finite there; it does not stay accurate - in the sense of matching the idealized real-valued Worley field. - - The lower-bound prune's bit-identity to the unpruned 5×5×5 search also has - a domain: it holds for `|p| < 2^25`. Above that, `cell + d` (an integer - cell coordinate plus a small integer offset) starts to round in float32, - so a pruned cell's own recomputed offset can land on a different neighbour - than the unpruned loop visits at that same nominal `(dx, dy, dz)`, and the - two search paths diverge (Soul, cut 2a-i: measured 180 of 1,100,000 points - differ at `|p| ~= 3.4e7`). This is a float32-precision limit on the prune's - equivalence proof, not a bug in the prune itself: below `2^25` the two - paths are proven bit-identical (`PrunedSearchIsBitIdenticalToTheUnprunedFiveCubedSearch`, - which samples `[-50, 50]^3`, is comfortably inside that domain). - -- `snoise(float3)` follows webgl-noise `src/noise3D.glsl` as of - stegu/webgl-noise `22434e04d7`: kernel radius squared `0.5`, output scale - `105`, permutation `mod289((34x+10)x)`. Upstream's `21d9fe23d7` (2020-10-14, - "an age-old bug that caused slight discontinuities along simplex - boundaries") moved the radius from `0.6` to `0.5`; at `0.6` a lattice vertex - outside the four summed corners can lie inside the kernel, leaving a value - jump at the cell boundary (3.1e-3 over the committed probe's 400 lines). `ff3b5d34ea` set the scale - to `105` and `a3e6d57095` (2021-06-30) the permutation; the 2D `snoise` shares - that permutation helper, so its values changed too, while its own kernel - (`0.5`) and scale (`130`) already matched upstream. - `NoiseGradTests.SnoiseAndItsGradientAreContinuousAcrossSimplexCellBoundaries` - pins the continuity. -- `snoise_grad(float3 p)` carries `snoise(float3)` to value-and-gradient form, - following the differentiation in webgl-noise `src/noise3Dgrad.glsl` (Ashima - Arts / Ian McEwan, MIT), which uses the same radius, permutation and scale as - `snoise(float3)`: per corner, `m0 = max(0.5 - dot(x,x), 0)`, so - `d(m0^4 * dot(p,x))/dx = -8*m0^3*dot(p,x)*x + m0^4*p`, summed over the four - corners and scaled by the same constant as the value, so `.w` is exactly - `snoise(float3)`. `fbm_grad` and - `ridged_grad` are octave sums of `snoise_grad`: each octave samples at - `p * frequency`, so by the chain rule its gradient scales by that same - frequency, and both amplitude (`gain`) and frequency (`lacunarity`) compound - per octave rather than applying once to the whole sum. `ridged_grad` folds - each octave about zero (`Σ aᵢ(1 − |nᵢ|)`, gradient `−Σ aᵢfᵢ sign(nᵢ)∇nᵢ`, - Musgrave's ridged multifractal, *Texturing and Modeling* ch. 16); the crease - at `nᵢ = 0` is deliberate (the operator's dune term, 2026-09-25), not a bug. - Both clamp `octaves` to `[0, 16]`: `HlslSourceCompatibilityTests`'s bit-parity - oracle drives every mirrored `int` parameter across the full int32 range, - which is the right domain for `pcg3d`/`pcg4d`'s O(1) hash inputs but would - turn an unclamped octave count into a multi-billion-iteration loop for these - two mirrors. -- `phacelle(float3 p, float3 side, float offset, float normalization)` is Rune - Skovbo Johansen's Phacelle noise ("phase + cell": a stripe pattern built by - blending one cosine/sine wave per jittered cell), generalised to 3D cells. It - returns `CultPhasor { cos, sin }`, each `(∇, value)`. `side` is the stripe - wave vector, whose length is 2π times the stripe frequency; in 3D the - perpendicular to the flow is not unique, so the caller chooses it (on a unit - sphere, `cross(p̂, flow)`). `offset` is the phase in cycles. It visits 4×4×4 - cells with `pcg3d`-hashed jitter in `[-0.5, 0.5)`, weights each wave by - `max(0, exp(-2d²) - 0.01111)`, and normalizes the blended phasor as upstream - does (length 1 where the raw length is at least `1 - normalization`, scaled - by `1 / (1 - normalization)` below). The gradient is exact, weight - derivatives and normalization included, rather than upstream's - `∇cos ≈ -sin·side`, which ignores the weight gradient. It is discontinuous - where the raw length crosses the normalization threshold and where a cell's - weight leaves its support (d² = 2.24995, distance 1.49998); the value is - continuous at both. At the support edge `∇w` jumps from `-4·0.01111·v` to 0, - a per-cell jump of length `0.0444·|v|` (0.0667 at `|v| = 1.5`); measured over - random points, the output gradient jumps by a median 1.5% of its length and - at most about 27%. `normalization` is meant to lie in `[0, 1]`: 1 or more - saturates (every output is stretched to length 1), a negative value scales - every output down by `1 / (1 - normalization)`, and the normalized gradient - scales as `1 / |I|` for the blended phasor `I`, so it grows where the blend - nearly cancels. The total weight `W` is positive for every `pcg3d` input (the - smallest found by hill-climbing is 0.056); `W = 0`, and a NaN output, needs - adversarial jitter no hash yields. - Cells whose per-axis lower bound `Σ max(0, |local - g| - 0.5)²` is at least - 2.25 lie outside the support and have weight exactly 0, so they are skipped, - which changes no output bit. Cells whose bound sits just under 2.25 can still - carry a nonzero weight, so the cut cannot be lowered; `PhacelleTests` pins - the cut with points next to such cells, against an internal overload with the - prune disabled, and the HLSL mirror test pins it on the shader side. The prune - keeps about 45 of the 64 cells on average (a saving of about 30%); about 14 - have a nonzero weight. The port sits in its own MPL-2.0 files - (`math.Phacelle.cs`, `CultPhasor.cs`, `CultMath.Phacelle.hlsl`); see - `THIRD-PARTY-NOTICES.md`. No 2D variant exists; nothing consumes one. - -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; they -exist for callers that already hold a hash key as float bits, and `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. - -`cellular`'s jitter and id always hash the integer cell coordinate directly -(`pcg3d(int3(neighbor))`, `pcg4d(int4(int3(cellId), 0))`), never a cell -coordinate's float32 bit pattern. Hashing the bit pattern of an -integer-valued float is not the same permutation as hashing the integer: two -adjacent cells' bit patterns share more structure (both have the same -exponent and a mantissa that differs by one increment) than two adjacent -integers do going into `pcg3d`'s own mixing, and that residual structure -survived into the jitter. Hashing the integer cell coordinate directly is -exact and runtime-independent, and it is the HLSL mirror's own hash, so C# -and shader agree bit-for-bit. What pins this is the oracle and mirror tests, -not a uniformity measurement: reverting to hashing the float bits (or the -sin-based hash) is caught by `ProductionSearchMatchesTheBruteForceOracleExactly`, -`EveryMirrorFunctionMatchesCSharpMath`, `F1NeverExceedsF2`, and the other -tests that key off the integer-hash fixtures (cut 2a-i). `CellularAndSminGradTests` -(`JitterAndIdPassAChiSquareUniformityTest`) checks the uniformity of the hash -as `cellular` uses it: the jitter marginals and the id pass a chi-square test -at a stated significance against `pcg3d(int3(...))`. - 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. diff --git a/packages/cultmath/scripts/build-unity-package.ps1 b/packages/cultmath/scripts/build-unity-package.ps1 index 228d735c..b64e9446 100644 --- a/packages/cultmath/scripts/build-unity-package.ps1 +++ b/packages/cultmath/scripts/build-unity-package.ps1 @@ -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 ` @@ -78,9 +59,7 @@ $requiredTemplateFiles = @( "Runtime\UnityBridge\UnityConversions.cs", "Runtime\UnityBridge\UnityConversions.cs.meta", "Shaders\CultMath.hlsl", - "Shaders\CultMath.hlsl.meta", - "Shaders\CultMath.Phacelle.hlsl", - "Shaders\CultMath.Phacelle.hlsl.meta" + "Shaders\CultMath.hlsl.meta" ) foreach ($relativePath in $requiredTemplateFiles) { if (-not (Test-Path -LiteralPath (Join-Path $templateRoot $relativePath) -PathType Leaf)) { diff --git a/packages/cultmath/shaders/CultMath.Phacelle.hlsl b/packages/cultmath/shaders/CultMath.Phacelle.hlsl deleted file mode 100644 index 1db0a2fc..00000000 --- a/packages/cultmath/shaders/CultMath.Phacelle.hlsl +++ /dev/null @@ -1,99 +0,0 @@ -#ifndef CULTMATH_PHACELLE_HLSL -#define CULTMATH_PHACELLE_HLSL - -// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of -// the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. -// -// Phacelle noise. Copyright (c) 2025 Rune Skovbo Johansen (Shadertoy t3dyWl; MPL-2.0), read from the -// C# port PhacelleNoise in lpmitchell/AdvancedTerrainErosion, converted to burstable C# by Luke -// Mitchell (2026). Modified by GameCult: 3D cells, a caller-supplied stripe wave vector, an exact -// gradient and exact pruning. This file is an MPL-2.0 file; CultMath.hlsl, which includes it after -// its own definitions of CULTMATH_TAU and cultmath_pcg3d, is MIT (see THIRD-PARTY-NOTICES.md). - -// Invariant 8's struct return shape for cultmath_phacelle, compared field by field and bit for bit -// against CultMath.CultPhasor, same field order. -struct CultPhasor -{ - float4 cos; - float4 sin; -}; - -// Johansen's Phacelle noise on 3D cells. See math.phacelle's comment (math.Phacelle.cs) for the stripe wave -// vector, the weight, the normalization, the exact gradient and why the prune is exact. Mirrors -// the C# body operation for operation; the C#-only internal overload that switches the prune off -// has no shader twin. -CultPhasor cultmath_phacelle(float3 p, float3 side, float offset, float normalization) -{ - float3 cell = floor(p); - float3 local = p - cell; - float phaseOffset = offset * CULTMATH_TAU; - float sumW = 0.0; - float sumCos = 0.0; - float sumSin = 0.0; - float3 gradW = float3(0.0, 0.0, 0.0); - float3 gradCos = float3(0.0, 0.0, 0.0); - float3 gradSin = float3(0.0, 0.0, 0.0); - - for (int dz = -1; dz <= 2; dz++) - { - float gz = max(0.0, abs(local.z - (float)dz) - 0.5); - for (int dy = -1; dy <= 2; dy++) - { - float gy = max(0.0, abs(local.y - (float)dy) - 0.5); - for (int dx = -1; dx <= 2; dx++) - { - float gx = max(0.0, abs(local.x - (float)dx) - 0.5); - if (gx * gx + gy * gy + gz * gz >= 2.25) - continue; - - float3 gridStep = float3(dx, dy, dz); - int3 hash = cultmath_pcg3d(int3(cell + gridStep)); - float3 jitter = float3( - ((uint)hash.x >> 8) * (1.0 / 16777216.0) - 0.5, - ((uint)hash.y >> 8) * (1.0 / 16777216.0) - 0.5, - ((uint)hash.z >> 8) * (1.0 / 16777216.0) - 0.5); - float3 v = local - gridStep - jitter; - float falloff = exp(-2.0 * dot(v, v)); - float w = max(0.0, falloff - 0.01111); - float3 dw = w > 0.0 ? (-4.0 * falloff) * v : float3(0.0, 0.0, 0.0); - float phase = dot(v, side) + phaseOffset; - float c = cos(phase); - float s = sin(phase); - - sumW += w; - sumCos += w * c; - sumSin += w * s; - gradW += dw; - gradCos += dw * c - (w * s) * side; - gradSin += dw * s + (w * c) * side; - } - } - } - - float invW = 1.0 / sumW; - float rawCos = sumCos * invW; - float rawSin = sumSin * invW; - float3 gradRawCos = (gradCos - rawCos * gradW) * invW; - float3 gradRawSin = (gradSin - rawSin * gradW) * invW; - float rawLength = sqrt(rawCos * rawCos + rawSin * rawSin); - float floorLength = 1.0 - normalization; - - CultPhasor result; - if (rawLength > floorLength) - { - float outCos = rawCos / rawLength; - float outSin = rawSin / rawLength; - float3 gradLength = (rawCos * gradRawCos + rawSin * gradRawSin) / rawLength; - result.cos = float4((gradRawCos - outCos * gradLength) / rawLength, outCos); - result.sin = float4((gradRawSin - outSin * gradLength) / rawLength, outSin); - } - else - { - result.cos = float4(gradRawCos / floorLength, rawCos / floorLength); - result.sin = float4(gradRawSin / floorLength, rawSin / floorLength); - } - - return result; -} - -#endif diff --git a/packages/cultmath/shaders/CultMath.Phacelle.hlsl.meta b/packages/cultmath/shaders/CultMath.Phacelle.hlsl.meta deleted file mode 100644 index 69308465..00000000 --- a/packages/cultmath/shaders/CultMath.Phacelle.hlsl.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: e07a3b95c1d84f2aa6b83d4c5f19a0d7 -ShaderIncludeImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/shaders/CultMath.hlsl b/packages/cultmath/shaders/CultMath.hlsl index af2d3741..365705f5 100644 --- a/packages/cultmath/shaders/CultMath.hlsl +++ b/packages/cultmath/shaders/CultMath.hlsl @@ -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); } @@ -76,8 +77,8 @@ float cultmath_smootherstep(float value) float2 cultmath_snoise_mod289(float2 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } float3 cultmath_snoise_mod289(float3 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } float4 cultmath_snoise_mod289(float4 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } -float3 cultmath_snoise_permute(float3 value) { return cultmath_snoise_mod289(((value * 34.0) + 10.0) * value); } -float4 cultmath_snoise_permute(float4 value) { return cultmath_snoise_mod289(((value * 34.0) + 10.0) * value); } +float3 cultmath_snoise_permute(float3 value) { return cultmath_snoise_mod289(((value * 34.0) + 1.0) * value); } +float4 cultmath_snoise_permute(float4 value) { return cultmath_snoise_mod289(((value * 34.0) + 1.0) * value); } // Ashima Arts / Ian McEwan 3D simplex noise (MIT), same float32 evaluation // order as the C# math.snoise(float3) mirror. @@ -125,9 +126,9 @@ float cultmath_snoise(float3 value) p2 *= norm.z; p3 *= norm.w; - float4 m = max(0.5 - float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); + float4 m = max(0.6 - float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); m = m * m; - return 105.0 * dot(m * m, float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3))); + return 42.0 * dot(m * m, float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3))); } float cultmath_snoise(float2 value) @@ -162,114 +163,6 @@ float cultmath_snoise(float2 value) return 130.0 * dot(m, g); } -// Analytic gradient of cultmath_snoise(float3) (invariant 8), following webgl-noise -// src/noise3Dgrad.glsl (Ashima Arts / McEwan, MIT); same kernel, permutation and scale as -// cultmath_snoise(float3) (stegu/webgl-noise 22434e04d7). See the C# math.snoise_grad comment for the -// derivation. Every local up through p0..p3 and x0..x3 is cultmath_snoise(float3)'s own derivation -// verbatim. -float4 cultmath_snoise_grad(float3 value) -{ - float2 c = float2(1.0 / 6.0, 1.0 / 3.0); - float3 i = floor(value + dot(value, c.yyy)); - float3 x0 = value - i + dot(i, c.xxx); - float3 g = step(x0.yzx, x0.xyz); - float3 l = 1.0 - g; - float3 i1 = min(g.xyz, l.zxy); - float3 i2 = max(g.xyz, l.zxy); - float3 x1 = x0 - i1 + c.xxx; - float3 x2 = x0 - i2 + c.yyy; - float3 x3 = x0 - 0.5; - - i = cultmath_snoise_mod289(i); - float4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute( - i.z + float4(0.0, i1.z, i2.z, 1.0)) + i.y + float4(0.0, i1.y, i2.y, 1.0)) + i.x + float4(0.0, i1.x, i2.x, 1.0)); - - const float n_ = 0.142857142857; - float3 ns = float3(2.0 * n_, 0.5 * n_ - 1.0, n_); - float4 j = p - 49.0 * floor(p * ns.z * ns.z); - float4 x_ = floor(j * ns.z); - float4 y_ = floor(j - 7.0 * x_); - float4 x = x_ * ns.x + ns.yyyy; - float4 y = y_ * ns.x + ns.yyyy; - float4 h = 1.0 - abs(x) - abs(y); - - float4 b0 = float4(x.xy, y.xy); - float4 b1 = float4(x.zw, y.zw); - float4 s0 = floor(b0) * 2.0 + 1.0; - float4 s1 = floor(b1) * 2.0 + 1.0; - float4 sh = -step(h, 0.0); - float4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; - float4 a1 = b1.xzyw + s1.xzyw * sh.zzww; - - float3 p0 = float3(a0.xy, h.x); - float3 p1 = float3(a0.zw, h.y); - float3 p2 = float3(a1.xy, h.z); - float3 p3 = float3(a1.zw, h.w); - float4 norm = 1.79284291400159 - 0.85373472095314 * float4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)); - p0 *= norm.x; - p1 *= norm.y; - p2 *= norm.z; - p3 *= norm.w; - - float4 m0 = max(0.5 - float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); - float4 m2 = m0 * m0; - float4 m3 = m2 * m0; - float4 m4 = m2 * m2; - - float4 px = float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)); - float value2 = 105.0 * dot(m4, px); - float3 grad = -8.0 * m3.x * x0 * px.x + m4.x * p0 - + -8.0 * m3.y * x1 * px.y + m4.y * p1 - + -8.0 * m3.z * x2 * px.z + m4.z * p2 - + -8.0 * m3.w * x3 * px.w + m4.w * p3; - grad *= 105.0; - - return float4(grad, value2); -} - -// Octave sum of cultmath_snoise_grad (invariant 8); see the C# math.fbm_grad comment for the -// per-octave frequency/amplitude derivation and why octaves is clamped. -float4 cultmath_fbm_grad(float3 p, int octaves, float lacunarity, float gain) -{ - octaves = clamp(octaves, 0, 16); - float amplitude = 1.0; - float frequency = 1.0; - float value = 0.0; - float3 gradient = float3(0.0, 0.0, 0.0); - for (int i = 0; i < octaves; i++) - { - float4 n = cultmath_snoise_grad(p * frequency); - value += amplitude * n.w; - gradient += amplitude * frequency * n.xyz; - frequency *= lacunarity; - amplitude *= gain; - } - - return float4(gradient, value); -} - -// Ridged multifractal noise (Musgrave, Texturing and Modeling, ch. 16); see the C# math.ridged_grad -// comment for the fold/sign derivation, the deliberate crease at n = 0, and why octaves is clamped. -float4 cultmath_ridged_grad(float3 p, int octaves, float lacunarity, float gain) -{ - octaves = clamp(octaves, 0, 16); - float amplitude = 1.0; - float frequency = 1.0; - float value = 0.0; - float3 gradient = float3(0.0, 0.0, 0.0); - for (int i = 0; i < octaves; i++) - { - float4 n = cultmath_snoise_grad(p * frequency); - float s = sign(n.w); - value += amplitude * (1.0 - abs(n.w)); - gradient += -amplitude * frequency * s * n.xyz; - frequency *= lacunarity; - amplitude *= gain; - } - - return float4(gradient, value); -} - float cultmath_lengthsq(float2 value) { return dot(value, value); } float cultmath_lengthsq(float3 value) { return dot(value, value); } float cultmath_lengthsq(float4 value) { return dot(value, value); } @@ -278,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; @@ -389,122 +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))); } - -// Inigo Quilez, "smooth minimum": quadratic-polynomial smin carried to value-and-gradient form. See -// math.smin_grad's comment (math.cs) for why the gradient blend is exact, not an approximation. -float4 cultmath_smin_grad(float4 a, float4 b, float k) -{ - float h = saturate(0.5 + 0.5 * (b.w - a.w) / k); - float value = lerp(b.w, a.w, h) - k * h * (1.0 - h); - float3 gradient = lerp(b.xyz, a.xyz, h); - return float4(gradient, value); -} - -// Invariant 8's one struct return shape (design.md, "HLSL Target: Source Transformations"): -// compared field by field and bit for bit against CultMath.CultCellular, same field order. -struct CultCellular -{ - float4 nearest; - float4 edge; - float id; -}; - -// Worley, "A Cellular Texture Basis Function" (SIGGRAPH 1996). See math.cellular's comment -// (math.cs) for the hashing, gradient, search-radius exactness proof, id source and -// degenerate-point conventions this mirrors exactly, including the 5x5x5 lower-bound prune. -CultCellular cultmath_cellular(float3 p) -{ - float3 cell = floor(p); - float f1 = 1.0e30; - float f2 = 1.0e30; - float3 c1 = float3(0.0, 0.0, 0.0); - float3 c2 = float3(0.0, 0.0, 0.0); - float3 cellId = float3(0.0, 0.0, 0.0); - - for (int dz = -2; dz <= 2; dz++) - { - float lz = max(0.0, abs((float)dz) - 1.0); - for (int dy = -2; dy <= 2; dy++) - { - float ly = max(0.0, abs((float)dy) - 1.0); - for (int dx = -2; dx <= 2; dx++) - { - float lx = max(0.0, abs((float)dx) - 1.0); - float lowerBound = sqrt(lx * lx + ly * ly + lz * lz); - if (lowerBound >= f2) - continue; - - float3 neighbor = cell + float3(dx, dy, dz); - int3 hash = cultmath_pcg3d(int3(neighbor)); - float3 jitter = float3( - ((uint)hash.x >> 8) * (1.0 / 16777216.0), - ((uint)hash.y >> 8) * (1.0 / 16777216.0), - ((uint)hash.z >> 8) * (1.0 / 16777216.0)); - float3 feature = neighbor + jitter; - float d = cultmath_distance(p, feature); - - if (d < f1) - { - f2 = f1; c2 = c1; - f1 = d; c1 = feature; cellId = neighbor; - } - else if (d < f2) - { - f2 = d; c2 = feature; - } - } - } - } - - // F1 = 0 and F2 = 0 share the same degenerate-gradient guard; see math.cellular's comment - // (math.cs) for why F2 = 0 is reachable past roughly |p| = 2^23. - float3 grad1 = f1 > 0.0 ? (p - c1) / f1 : float3(0.0, 0.0, 0.0); - float3 grad2 = f2 > 0.0 ? (p - c2) / f2 : float3(0.0, 0.0, 0.0); - - // id comes from pcg4d over the winning cell's integer coordinate alone (same int-hashing rule - // as the jitter above), never from the pcg3d hash that produced its jitter (F4). - int4 idHash = cultmath_pcg4d(int4(int3(cellId), 0)); - - CultCellular result; - result.nearest = float4(grad1, f1); - result.edge = float4(grad2 - grad1, f2 - f1); - result.id = ((uint)idHash.w >> 8) * (1.0 / 16777216.0); - return result; -} - -#include "CultMath.Phacelle.hlsl" - float cultmath_value_noise(float2 position) { float2 cell = floor(position); diff --git a/packages/cultmath/src/CultMath/BoundedLeastSquares.cs b/packages/cultmath/src/CultMath/BoundedLeastSquares.cs deleted file mode 100644 index 4cfbad74..00000000 --- a/packages/cultmath/src/CultMath/BoundedLeastSquares.cs +++ /dev/null @@ -1,347 +0,0 @@ -using System.Runtime.InteropServices; - -namespace CultMath; - -/// Outcome of . -public enum BoundedLeastSquaresStatus -{ - /// The KKT conditions hold: every free variable has zero gradient and every bound has a gradient pointing into it. - Converged, - - /// The iteration cap was reached. x holds the last feasible iterate, which is never worse than the clamped start. - IterationLimit, - - /// The input broke the contract; nothing was solved and x is exactly as passed. - InvalidInput, -} - -/// -/// Dense bounded least squares: min ||A x - b||^2 subject to lo <= x <= hi, for small problems -/// (tens of rows and columns). C#-only, like the intercept helpers in ; there is no HLSL mirror. -/// -/// -/// Primal active set. Each iteration solves the free variables by Cholesky on the normal equations, takes the -/// step to the first bound it would cross, and, once a full step lands, releases the bound with the most -/// violated gradient sign. Free columns that are linearly dependent on earlier free columns (a zero column, a -/// duplicate) get a zero step instead of a failed pivot, so a singular free set never throws. -/// -/// Precision. A, b and x are float at the API; everything else, including the working iterate, the -/// gradient and the KKT test, is double, carved from the caller's workspace. x is converted in at the start -/// and back out at the end, so a returned float x is the nearest float to the double solution and always -/// inside the box. -/// -/// -/// Stopping. KKT termination uses a tolerance of 1e-6 times the problem's gradient scale (the largest gradient -/// component at the box point nearest the origin), so scaling A, b or the bounds, and the choice of warm start, do -/// not change it. The tolerance is only a resolution: once a stationarity miss or a -/// bound release has been followed by a pass that lowers the cost by no more than rounding, the solver reports -/// , so an optimal point whose double gradient sits above the -/// tolerance costs one confirming iteration rather than the iteration cap. -/// -/// -/// Near-singular problems. The normal equations square the condition number, so the cost gap grows with cond(A) -/// (measured: below 1e-6 of |b|^2 through cond(A) 3e5, about 2e-5 at 1e6; the gap is relative to |b|^2), independent of the absolute scale of A. -/// The solver owns optimality up to that range. Columns whose pivot falls below 1e-12 of their own diagonal (cond(A) -/// beyond roughly 1e6) are treated as dependent and not moved, so the answer there is one optimum among many, not the -/// minimum-norm one. Callers beyond that range, or that need the minimum-norm answer, add a small ridge row to -/// A and b; that stays the caller's policy. -/// -/// The solver allocates nothing and is deterministic. -/// -/// Input contract: a and b must be finite; lo and hi must not be NaN and need -/// lo <= hi. Infinite bounds are allowed and mean unbounded on that side (lo = -Inf, -/// hi = +Inf); lo = +Inf or hi = -Inf is invalid. Any violation returns -/// without throwing, with iterations 0 and -/// x exactly as passed. A NaN or infinite warm start is legal: it is clamped into the box (an infinite -/// result on an unbounded side becomes 0 clamped to the box). Spans shorter than the stated dimensions are a -/// caller bug and throw . -/// -/// -public static class BoundedLeastSquares -{ - /// Default iteration cap for . - public const int DefaultMaxIterations = 100; - - private const double PivotRelativeTolerance = 1e-12; - private const double KktRelativeTolerance = 1e-6; - private const double StallRelativeTolerance = 1e-15; - - /// Floats of caller-supplied workspace needed for columns. - public static int WorkspaceLength(int n) => 2 * (2 * n * n + 6 * n) + 3 * n; - - /// - /// Minimises ||A x - b||^2 subject to lo <= x <= hi. is the warm start - /// on entry (clamped to the box first) and the result on exit. - /// - /// Rows of . - /// Columns of , and length of , and . - /// Row-major m * n matrix. - /// Length m target. - /// Lower bounds; each must be <= hi; -Inf means unbounded below. - /// Upper bounds; +Inf means unbounded above. - /// Warm start in, solution out. - /// At least floats; contents on entry are ignored. - /// Active-set iterations used. - /// Iteration cap. - public static BoundedLeastSquaresStatus Solve( - int m, int n, - ReadOnlySpan a, ReadOnlySpan b, - ReadOnlySpan lo, ReadOnlySpan hi, - Span x, Span workspace, - out int iterations, - int maxIterations = DefaultMaxIterations) - { - if (m < 0 || n < 0 || a.Length < m * n || b.Length < m || lo.Length < n || hi.Length < n - || x.Length < n || workspace.Length < WorkspaceLength(n)) - throw new ArgumentException("BoundedLeastSquares: span shorter than the stated dimensions."); - - // Double-precision vectors and matrices first, then the integer flags, all carved from the caller's floats. - var doubleCount = 2 * n * n + 6 * n; - var dbl = MemoryMarshal.Cast(workspace.Slice(0, 2 * doubleCount)); - var ata = Take(ref dbl, n * n); - var chol = Take(ref dbl, n * n); - var atb = Take(ref dbl, n); - var g = Take(ref dbl, n); - var p = Take(ref dbl, n); - var y = Take(ref dbl, n); - var rhs = Take(ref dbl, n); - var xd = Take(ref dbl, n); - // One flag per column: 0 free, -1 at lo, +1 at hi. Free-list and dependency flags follow. - var ints = MemoryMarshal.Cast(workspace.Slice(2 * doubleCount, 3 * n)); - var state = Take(ref ints, n); - var freeIndex = Take(ref ints, n); - var dependent = Take(ref ints, n); - - iterations = 0; - for (var j = 0; j < n; j++) - if (!(lo[j] <= hi[j]) || float.IsPositiveInfinity(lo[j]) || float.IsNegativeInfinity(hi[j])) - return BoundedLeastSquaresStatus.InvalidInput; - - // A float is never large enough to overflow a double product or a sum of tens of them, so a non-finite - // A^T b entry is exactly a non-finite entry of A or b (NaN and infinity never cancel to a finite value). - for (var j = 0; j < n; j++) - { - var sb = 0.0; - for (var r = 0; r < m; r++) - sb += (double)a[r * n + j] * b[r]; - if (!double.IsFinite(sb)) - return BoundedLeastSquaresStatus.InvalidInput; - atb[j] = sb; - for (var k = 0; k <= j; k++) - { - var sa = 0.0; - for (var r = 0; r < m; r++) - sa += (double)a[r * n + j] * a[r * n + k]; - ata[j * n + k] = sa; - ata[k * n + j] = sa; - } - } - - // From here x is written. A warm start that is NaN is moved to the lower bound, and an infinite result - // (an unbounded side) restarts from 0 clamped to the box. The working iterate lives in double; x is only - // the API's float view of it. - for (var j = 0; j < n; j++) - { - var l = lo[j]; - var h = hi[j]; - var v = float.IsNaN(x[j]) ? l : Math.Clamp(x[j], l, h); - if (float.IsInfinity(v)) - v = Math.Clamp(0f, l, h); - x[j] = v; - xd[j] = v; - state[j] = v == l ? -1 : (v == h ? 1 : 0); - } - - // The tolerance's scale is the gradient magnitude at the box point nearest the origin (the origin itself - // when it is inside the box, where the gradient is -A^T b). It depends on A, b and the bounds, never on the - // warm start, so a far start cannot be judged converged by its own steepness, and b = 0 with a box that - // excludes the origin still gets a real scale. It is zero only when that point is already stationary, and - // then the progress rule below does the stopping. - var gScale = 0.0; - for (var j = 0; j < n; j++) - { - var s = -atb[j]; - for (var k = 0; k < n; k++) - s += ata[j * n + k] * Math.Clamp(0.0, lo[k], hi[k]); - gScale = Math.Max(gScale, Math.Abs(s)); - } - var kktTol = KktRelativeTolerance * gScale; - - // Cost at the last point from which the solver chose to continue past a stationarity miss or a - // release. A pass that ends without lowering it has nothing left to gain: the tolerance is a - // resolution, not the stopping rule, and a noise floor above it must not spin to the cap. - var fMark = double.PositiveInfinity; - var status = BoundedLeastSquaresStatus.IterationLimit; - while (true) - { - Gradient(ata, atb, xd, g, n); - - var freeCount = 0; - for (var j = 0; j < n; j++) - if (state[j] == 0) - freeIndex[freeCount++] = j; - SolveFreeSet(ata, chol, g, p, y, rhs, freeIndex.Slice(0, freeCount), dependent, n); - - var t = 1.0; - var block = -1; - for (var i = 0; i < freeCount; i++) - { - var j = freeIndex[i]; - var pj = p[j]; - if (pj == 0.0) continue; - var tj = pj > 0.0 ? (hi[j] - xd[j]) / pj : (lo[j] - xd[j]) / pj; - if (tj < t) - { - t = tj; - block = j; - } - } - - if (block >= 0) - { - if (iterations >= maxIterations) { status = BoundedLeastSquaresStatus.IterationLimit; break; } - iterations++; - for (var i = 0; i < freeCount; i++) - { - var j = freeIndex[i]; - xd[j] = Math.Clamp(xd[j] + t * p[j], lo[j], hi[j]); - } - if (p[block] > 0.0) { xd[block] = hi[block]; state[block] = 1; } - else { xd[block] = lo[block]; state[block] = -1; } - continue; - } - - for (var i = 0; i < freeCount; i++) - { - var j = freeIndex[i]; - xd[j] = Math.Clamp(xd[j] + p[j], lo[j], hi[j]); - } - Gradient(ata, atb, xd, g, n); - - // Stationarity of the free set is part of KKT: a Newton step on a near-singular free set can leave a residual gradient. - var freeViolated = false; - for (var i = 0; i < freeCount; i++) - if (Math.Abs(g[freeIndex[i]]) > kktTol) - freeViolated = true; - - var release = -1; - var worst = kktTol; - for (var j = 0; j < n; j++) - { - if (lo[j] == hi[j]) continue; - // A bound at lo wants a positive gradient and one at hi a negative one; a free column has state 0. - var violation = state[j] * g[j]; - if (violation > worst) - { - worst = violation; - release = j; - } - } - if (release < 0 && !freeViolated) { status = BoundedLeastSquaresStatus.Converged; break; } - - var f = Objective(xd, g, atb, n, out var fScale); - if (f >= fMark - StallRelativeTolerance * fScale) { status = BoundedLeastSquaresStatus.Converged; break; } - fMark = f; - if (iterations >= maxIterations) { status = BoundedLeastSquaresStatus.IterationLimit; break; } - iterations++; - // A stationarity miss re-solves the same free set from the improved point. - if (release >= 0) state[release] = 0; - } - - for (var j = 0; j < n; j++) - x[j] = (float)xd[j]; - return status; - } - - private static Span Take(ref Span pool, int length) - { - var head = pool.Slice(0, length); - pool = pool.Slice(length); - return head; - } - - // 0.5 x^T A^T A x - x^T A^T b (the cost up to a constant), from the gradient at x; scale is the magnitude of - // the terms summed, which is what the rounding error of the sum is proportional to. - private static double Objective(ReadOnlySpan x, ReadOnlySpan g, ReadOnlySpan atb, int n, out double scale) - { - var f = 0.0; - scale = 0.0; - for (var j = 0; j < n; j++) - { - f += 0.5 * x[j] * (g[j] - atb[j]); - scale += 0.5 * Math.Abs(x[j]) * (Math.Abs(g[j]) + Math.Abs(atb[j])); - } - return f; - } - - private static void Gradient(ReadOnlySpan ata, ReadOnlySpan atb, ReadOnlySpan x, Span g, int n) - { - for (var j = 0; j < n; j++) - { - var s = -atb[j]; - for (var k = 0; k < n; k++) - s += ata[j * n + k] * x[k]; - g[j] = s; - } - } - - // Newton step over the free set: solves (A^T A)_FF p_F = -g_F by Cholesky. A free column whose pivot collapses - // (dependent on earlier free columns) is dropped from the factor and gets p = 0. No refinement step: Cholesky is - // backward stable, so the residual, which is the gradient the KKT test reads, is already at rounding level, and - // refining in the same precision does not improve the forward error. - private static void SolveFreeSet( - ReadOnlySpan ata, Span l, ReadOnlySpan g, Span p, Span y, - Span rhs, ReadOnlySpan free, Span dependent, int n) - { - var k = free.Length; - for (var i = 0; i < k; i++) - { - var fi = free[i]; - var d = ata[fi * n + fi]; - for (var c = 0; c < i; c++) - d -= l[i * n + c] * l[i * n + c]; - if (d <= PivotRelativeTolerance * ata[fi * n + fi]) - { - dependent[i] = 1; - for (var c = 0; c < i; c++) l[i * n + c] = 0.0; - l[i * n + i] = 1.0; - for (var r = i + 1; r < k; r++) l[r * n + i] = 0.0; - continue; - } - dependent[i] = 0; - var lii = Math.Sqrt(d); - l[i * n + i] = lii; - for (var r = i + 1; r < k; r++) - { - var s = ata[free[r] * n + fi]; - for (var c = 0; c < i; c++) - s -= l[r * n + c] * l[i * n + c]; - l[r * n + i] = s / lii; - } - } - - for (var i = 0; i < k; i++) - rhs[i] = dependent[i] != 0 ? 0.0 : -g[free[i]]; - Substitute(l, rhs, y, p, free, n); - } - - // Solves L L^T out = rhs; rhs is indexed by free-list position, out by column. - private static void Substitute(ReadOnlySpan l, ReadOnlySpan rhs, Span y, Span outByColumn, ReadOnlySpan free, int n) - { - var k = free.Length; - for (var i = 0; i < k; i++) - { - var s = rhs[i]; - for (var c = 0; c < i; c++) - s -= l[i * n + c] * y[c]; - y[i] = s / l[i * n + i]; - } - for (var i = k - 1; i >= 0; i--) - { - var s = y[i]; - for (var r = i + 1; r < k; r++) - s -= l[r * n + i] * outByColumn[free[r]]; - outByColumn[free[i]] = s / l[i * n + i]; - } - } -} diff --git a/packages/cultmath/src/CultMath/BoundedLeastSquares.cs.meta b/packages/cultmath/src/CultMath/BoundedLeastSquares.cs.meta deleted file mode 100644 index fb5bd819..00000000 --- a/packages/cultmath/src/CultMath/BoundedLeastSquares.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 4ba991e1b6c54e099bc7ea16541734c5 diff --git a/packages/cultmath/src/CultMath/CultCellular.cs b/packages/cultmath/src/CultMath/CultCellular.cs deleted file mode 100644 index f856236c..00000000 --- a/packages/cultmath/src/CultMath/CultCellular.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace CultMath; - -/// -/// The value-and-gradient result of (invariant 8's one struct -/// return shape). Every field follows the same float4(gradient.xyz, value.w) layout as a -/// bare value-and-gradient return. -/// -public struct CultCellular -{ - /// (∇F1, F1): the gradient and distance to the nearest feature point. - public float4 nearest; - - /// (∇F2 - ∇F1, F2 - F1): for lineae along cell borders. - public float4 edge; - - /// The nearest cell's pcg4d hash, mapped to [0, 1); independent of the pcg3d hash that - /// produced that cell's jitter. - public float id; - - public CultCellular(float4 nearest, float4 edge, float id) - { - this.nearest = nearest; - this.edge = edge; - this.id = id; - } -} diff --git a/packages/cultmath/src/CultMath/CultCellular.cs.meta b/packages/cultmath/src/CultMath/CultCellular.cs.meta deleted file mode 100644 index 8c9ed1ae..00000000 --- a/packages/cultmath/src/CultMath/CultCellular.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 76be9498b63e410891715b3ec965caf8 diff --git a/packages/cultmath/src/CultMath/CultMath.csproj b/packages/cultmath/src/CultMath/CultMath.csproj index b4a6ce8b..7c567105 100644 --- a/packages/cultmath/src/CultMath/CultMath.csproj +++ b/packages/cultmath/src/CultMath/CultMath.csproj @@ -8,9 +8,7 @@ CultMath CultMath GameCult.Math - - MIT AND MPL-2.0 - 0.2.4 + 0.2.0 Clean-room C# math shaped for HLSL-style renderer code. GameCult README.md @@ -23,8 +21,6 @@ - - diff --git a/packages/cultmath/src/CultMath/CultPhasor.cs b/packages/cultmath/src/CultMath/CultPhasor.cs deleted file mode 100644 index fbc1f9d7..00000000 --- a/packages/cultmath/src/CultMath/CultPhasor.cs +++ /dev/null @@ -1,27 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of -// the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. -// -// The result type of math.phacelle (math.Phacelle.cs), Rune Skovbo Johansen's Phacelle noise -// (Copyright (c) 2025, MPL-2.0; see THIRD-PARTY-NOTICES.md). - -namespace CultMath; - -/// -/// The value-and-gradient result of -/// (invariant 8's struct return shape). Both fields use the float4(gradient.xyz, value.w) -/// layout of a bare value-and-gradient return. -/// -public struct CultPhasor -{ - /// (∇cos, cos): the normalized cosine wave and its gradient. - public float4 cos; - - /// (∇sin, sin): the normalized sine wave and its gradient. - public float4 sin; - - public CultPhasor(float4 cos, float4 sin) - { - this.cos = cos; - this.sin = sin; - } -} diff --git a/packages/cultmath/src/CultMath/CultPhasor.cs.meta b/packages/cultmath/src/CultMath/CultPhasor.cs.meta deleted file mode 100644 index d586aa3f..00000000 --- a/packages/cultmath/src/CultMath/CultPhasor.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 807ae2c8a758cf31896da12f1e5047bd diff --git a/packages/cultmath/src/CultMath/Swizzles.g.cs b/packages/cultmath/src/CultMath/Swizzles.g.cs index db1c56bd..ce84bbc4 100644 --- a/packages/cultmath/src/CultMath/Swizzles.g.cs +++ b/packages/cultmath/src/CultMath/Swizzles.g.cs @@ -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); } diff --git a/packages/cultmath/src/CultMath/math.Phacelle.cs b/packages/cultmath/src/CultMath/math.Phacelle.cs deleted file mode 100644 index 25689737..00000000 --- a/packages/cultmath/src/CultMath/math.Phacelle.cs +++ /dev/null @@ -1,127 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of -// the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. -// -// Phacelle noise. Copyright (c) 2025 Rune Skovbo Johansen (Shadertoy t3dyWl; MPL-2.0), read from the -// C# port PhacelleNoise in lpmitchell/AdvancedTerrainErosion, converted to burstable C# by Luke -// Mitchell (2026). Modified by GameCult: 3D cells, a caller-supplied stripe wave vector, an exact -// gradient and exact pruning. This file and CultPhasor.cs are the MPL-2.0 files of CultMath; the -// rest of the package is MIT (see THIRD-PARTY-NOTICES.md). - -namespace CultMath; - -public static partial class math -{ - // Johansen's Phacelle noise (phase + cell), generalised to 3D cells with a caller-supplied - // stripe wave vector. Each of the 4x4x4 cells around p carries one jittered feature point - // (jitter in [-0.5, 0.5) per axis, from pcg3d over the integer cell coordinate, the same - // integer-hashing rule as cellular) and one cosine/sine wave that runs along `side` (the wave - // vector: its length is 2*pi times the stripe frequency, and the caller chooses which - // perpendicular it is; on a unit sphere that is cross(p_hat, flow)). The waves are blended with - // the bell weight max(0, exp(-2 d^2) - 0.01111) (exactly 0 at distance 1.5, the nearest a - // feature outside the 4x4x4 block can be in 2D, which is what keeps cell-grid lines out), and - // the blended phasor is normalized: its length is stretched to 1 wherever the raw length is at - // least 1 - normalization, and scaled by 1 / (1 - normalization) below that. `offset` is the - // phase in cycles (1 is a full turn). Value and normalization follow Johansen's - // PhacelleNoise as ported in lpmitchell/AdvancedTerrainErosion. - // - // `normalization` is meant to lie in [0, 1]. At 0 the output is the raw blend (a phasor of - // length at most 1); at 1 the floor is 0 and every output is stretched to length 1. Values of - // 1 or more saturate at that (the floor is never above the raw length), and a negative value - // scales every output down by 1 / (1 - normalization) < 1. The normalized gradient scales as - // 1 / |I| where I is the blended phasor (below), so it grows wherever the blend nearly - // cancels; a normalization near 1 lets that happen at every point. - // - // The gradient is exact: it carries the weight derivative (grad w = -4 v exp(-2 |v|^2) where - // w > 0) and the normalization, not upstream's convention grad(cos) ~ -sin * side, which - // ignores the weight gradient (Asura probe: 16.8 deg off at the median, 97.8 at the 95th - // percentile; the exact form is 0.00 and 0.03). With P = sum(w * (cos, sin)) and W = sum(w), - // I = P / W has grad I = (grad P - I * grad W) / W; above the threshold the output is I / |I| - // with gradient (grad I - out * grad|I|) / |I|, below it grad I / (1 - normalization). The - // value is continuous at both seams below; the gradient is not. - // * Where |I| crosses the threshold, the gradient jumps between the two branches. - // * Where a cell's weight leaves its support (d^2 = ln(1 / 0.01111) / 2 = 2.24995, distance - // 1.49998), grad w jumps from -4 * 0.01111 * v to 0: a per-cell jump of length - // 0.0444 * |v|, 0.0667 at |v| = 1.5. Measured over random points, the resulting jump in - // the output gradient is a median 1.5% of its length and at most about 27%. - // - // W is the sum of 64 weights and is positive for every input pcg3d produces (the smallest - // W found by hill-climbing the hash-driven cell contents is 0.056). It is 0, and the output - // NaN, only if all 64 weights vanish, which takes adversarial jitter no hash yields. - // - // Pruning is exact: per axis, |local - g| - 0.5 (floored at 0) lower-bounds |v| for any jitter, - // so a cell whose summed square is at least 2.25 lies outside the support (2.24995 < 2.25) and - // has weight exactly 0: it contributes nothing to any sum. Cells whose bound sits just under - // 2.25 can still have a nonzero weight, so the cut cannot be lowered. On average the prune - // keeps about 45 of the 64 cells (a saving of about 30%); only about 14 have a nonzero weight. - // Only the internal overload can switch the prune off; PhacelleTests uses it to pin bit equality. - public static CultPhasor phacelle(float3 p, float3 side, float offset, float normalization) => - phacelle(p, side, offset, normalization, true); - - internal static CultPhasor phacelle(float3 p, float3 side, float offset, float normalization, bool prune) - { - var cell = floor(p); - var local = p - cell; - var phaseOffset = offset * TAU; - var sumW = 0.0f; - var sumCos = 0.0f; - var sumSin = 0.0f; - var gradW = new float3(0.0f, 0.0f, 0.0f); - var gradCos = new float3(0.0f, 0.0f, 0.0f); - var gradSin = new float3(0.0f, 0.0f, 0.0f); - - for (var dz = -1; dz <= 2; dz++) - { - var gz = max(0.0f, abs(local.z - (float)dz) - 0.5f); - for (var dy = -1; dy <= 2; dy++) - { - var gy = max(0.0f, abs(local.y - (float)dy) - 0.5f); - for (var dx = -1; dx <= 2; dx++) - { - var gx = max(0.0f, abs(local.x - (float)dx) - 0.5f); - if (prune && gx * gx + gy * gy + gz * gz >= 2.25f) - continue; - - var gridStep = new float3(dx, dy, dz); - var hash = pcg3d(int3(cell + gridStep)); - var jitter = new float3(cellular_unit(hash.x) - 0.5f, cellular_unit(hash.y) - 0.5f, cellular_unit(hash.z) - 0.5f); - var v = local - gridStep - jitter; - var falloff = exp(-2.0f * dot(v, v)); - var w = max(0.0f, falloff - 0.01111f); - var dw = w > 0.0f ? (-4.0f * falloff) * v : new float3(0.0f, 0.0f, 0.0f); - var phase = dot(v, side) + phaseOffset; - var c = cos(phase); - var s = sin(phase); - - sumW += w; - sumCos += w * c; - sumSin += w * s; - gradW += dw; - gradCos += dw * c - (w * s) * side; - gradSin += dw * s + (w * c) * side; - } - } - } - - var invW = 1.0f / sumW; - var rawCos = sumCos * invW; - var rawSin = sumSin * invW; - var gradRawCos = (gradCos - rawCos * gradW) * invW; - var gradRawSin = (gradSin - rawSin * gradW) * invW; - var rawLength = sqrt(rawCos * rawCos + rawSin * rawSin); - var floorLength = 1.0f - normalization; - - if (rawLength > floorLength) - { - var outCos = rawCos / rawLength; - var outSin = rawSin / rawLength; - var gradLength = (rawCos * gradRawCos + rawSin * gradRawSin) / rawLength; - return new CultPhasor( - new float4((gradRawCos - outCos * gradLength) / rawLength, outCos), - new float4((gradRawSin - outSin * gradLength) / rawLength, outSin)); - } - - return new CultPhasor( - new float4(gradRawCos / floorLength, rawCos / floorLength), - new float4(gradRawSin / floorLength, rawSin / floorLength)); - } -} diff --git a/packages/cultmath/src/CultMath/math.Phacelle.cs.meta b/packages/cultmath/src/CultMath/math.Phacelle.cs.meta deleted file mode 100644 index 898abc4c..00000000 --- a/packages/cultmath/src/CultMath/math.Phacelle.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 2c9d4e6b1a8f47d3b05e7a3c9d1f8e26 diff --git a/packages/cultmath/src/CultMath/math.cs b/packages/cultmath/src/CultMath/math.cs index 23916b6b..ca74857f 100644 --- a/packages/cultmath/src/CultMath/math.cs +++ b/packages/cultmath/src/CultMath/math.cs @@ -1,10 +1,5 @@ using System; -// Cut 2a-i fix batch 1: cellular_unit's [0,1) mapping bug (F7) is only reachable from cellular's -// private call sites; this lets CultMath.Tests probe it directly instead of hunting for a hash -// input that happens to land on the bit pattern under test. -[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("CultMath.Tests")] - namespace CultMath; public static partial class math @@ -110,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. @@ -138,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)); @@ -218,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); @@ -387,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)); @@ -526,79 +475,8 @@ public static partial class math a._m20 * b._m01 + a._m21 * b._m11 + a._m22 * b._m21, a._m20 * b._m02 + a._m21 * b._m12 + a._m22 * b._m22); - // Abramowitz & Stegun 7.1.26, a single-precision rational/exponential fit with a stated - // max absolute error of 1.5e-7. erf is odd, so only x >= 0 is fit and the sign is restored. - // Measured against an independent reference: worst absolute error 6.621e-7, inside the - // 2e-6 bar this package's tests pin. - public static float erf(float value) - { - const float p = 0.3275911f; - const float a1 = 0.254829592f; - const float a2 = -0.284496736f; - const float a3 = 1.421413741f; - const float a4 = -1.453152027f; - const float a5 = 1.061405429f; - - var s = sign(value); - var x = abs(value); - var t = 1.0f / (1.0f + p * x); - var poly = ((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t; - var result = 1.0f - poly * exp(-x * x); - return s * result; - } - - // Giles, "Approximating the erfinv function" (GPU Computing Gems, 2010): a single-precision - // minimax polynomial in two ranges of w = -ln((1-x)(1+x)), central and tail. - // Domain is [-1, 1]. Outside it, including +-infinity, the result is NaN (there is no real - // inverse). At the edges the mathematical limit is infinite: erfinv(1) = +infinity and - // erfinv(-1) = -infinity. The unguarded polynomial hits log(0) = -infinity at those edges and - // the tail branch's leading coefficient is negative, so it delivers the wrong sign; guard the - // edges explicitly instead of trusting the fit through the singularity. - // Measured against an independent reference: worst absolute error 5.066e-7, and the - // erf(erfinv(y)) round trip 6.109e-7 worst case, both inside the 2e-6 bar this package's - // tests pin. - public static float erfinv(float value) - { - if (value >= 1.0f) return value > 1.0f ? float.NaN : float.PositiveInfinity; - if (value <= -1.0f) return value < -1.0f ? float.NaN : float.NegativeInfinity; - - var w = -log((1.0f - value) * (1.0f + value)); - float p; - if (w < 5.0f) - { - w -= 2.5f; - p = 2.81022636e-08f; - p = 3.43273939e-07f + p * w; - p = -3.5233877e-06f + p * w; - p = -4.39150654e-06f + p * w; - p = 0.00021858087f + p * w; - p = -0.00125372503f + p * w; - p = -0.00417768164f + p * w; - p = 0.246640727f + p * w; - p = 1.50140941f + p * w; - } - else - { - w = sqrt(w) - 3.0f; - p = -0.000200214257f; - p = 0.000100950558f + p * w; - p = 0.00134934322f + p * w; - p = -0.00367342844f + p * w; - p = 0.00573950773f + p * w; - p = -0.0076224613f + p * w; - p = 0.00943887047f + p * w; - p = 1.00167406f + p * w; - p = 2.83297682f + p * w; - } - - return p * value; - } - // Ashima Arts / Ian McEwan 3D simplex noise (MIT), mirrored by cultmath_snoise(float3) - // in shaders/CultMath.hlsl with the same float32 evaluation order. Follows stegu/webgl-noise - // src/noise3D.glsl at 22434e04d7 (kernel radius^2 0.5, scale 105, permute (34x+10)x); see - // THIRD-PARTY-NOTICES.md. Continuous across simplex-cell boundaries - // (NoiseGradTests.SnoiseAndItsGradientAreContinuousAcrossSimplexCellBoundaries). + // in shaders/CultMath.hlsl with the same float32 evaluation order. public static float snoise(float3 value) { const float cx = 1.0f / 6.0f; @@ -645,9 +523,9 @@ public static partial class math p2 *= norm.z; p3 *= norm.w; - var m = max(0.5f - new float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0f); + var m = max(0.6f - new float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0f); m = m * m; - return 105.0f * dot(m * m, new float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3))); + return 42.0f * dot(m * m, new float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3))); } // Ashima Arts / Ian McEwan 2D simplex noise, kept component-explicit so @@ -693,280 +571,8 @@ public static partial class math private static float2 snoise_mod289(float2 value) => value - floor(value * (1.0f / 289.0f)) * 289.0f; private static float3 snoise_mod289(float3 value) => value - floor(value * (1.0f / 289.0f)) * 289.0f; private static float4 snoise_mod289(float4 value) => value - floor(value * (1.0f / 289.0f)) * 289.0f; - private static float3 snoise_permute(float3 value) => snoise_mod289(((value * 34.0f) + 10.0f) * value); - private static float4 snoise_permute(float4 value) => snoise_mod289(((value * 34.0f) + 10.0f) * value); - - // Analytic gradient of snoise(float3) (invariant 8), following the differentiation in - // webgl-noise src/noise3Dgrad.glsl (Ashima Arts / McEwan, MIT), which uses the same kernel - // radius^2 0.5, permutation (34x+10)x and scale 105 as snoise(float3) (stegu/webgl-noise - // 22434e04d7). Every local up through p0..p3 and x0..x3 below is snoise(float3)'s own derivation - // verbatim, so .w is that same field (NoiseGradTests.SnoiseGradValueIsBitEqualToSnoise pins the - // two bit-equal over a random sweep). Per corner, m0 = max(0.5 - dot(x,x), 0), so dm0/dx = -2x, - // and d(m0^4 * dot(p,x))/dx = 4*m0^3*(-2x)*dot(p,x) + m0^4*p = -8*m0^3*dot(p,x)*x + m0^4*p (p is - // that corner's own gradient constant, independent of x); summed over the four corners and - // scaled by the same 105 as the value. - public static float4 snoise_grad(float3 value) - { - const float cx = 1.0f / 6.0f; - const float cy = 1.0f / 3.0f; - var i = floor(value + dot(value, new float3(cy, cy, cy))); - var x0 = value - i + dot(i, new float3(cx, cx, cx)); - var g = step(new float3(x0.y, x0.z, x0.x), x0); - var l = 1.0f - g; - var lzxy = new float3(l.z, l.x, l.y); - var i1 = min(g, lzxy); - var i2 = max(g, lzxy); - var x1 = x0 - i1 + cx; - var x2 = x0 - i2 + cy; - var x3 = x0 - 0.5f; - - i = snoise_mod289(i); - var p = snoise_permute(snoise_permute(snoise_permute( - i.z + new float4(0.0f, i1.z, i2.z, 1.0f)) + i.y + new float4(0.0f, i1.y, i2.y, 1.0f)) + i.x + new float4(0.0f, i1.x, i2.x, 1.0f)); - - const float n = 0.142857142857f; - var ns = new float3(2.0f * n, 0.5f * n - 1.0f, n); - var j = p - 49.0f * floor(p * ns.z * ns.z); - var xs = floor(j * ns.z); - var ys = floor(j - 7.0f * xs); - var x = xs * ns.x + ns.y; - var y = ys * ns.x + ns.y; - var h = 1.0f - abs(x) - abs(y); - - var b0 = new float4(x.x, x.y, y.x, y.y); - var b1 = new float4(x.z, x.w, y.z, y.w); - var s0 = floor(b0) * 2.0f + 1.0f; - var s1 = floor(b1) * 2.0f + 1.0f; - var sh = -step(h, 0.0f); - var a0 = new float4(b0.x, b0.z, b0.y, b0.w) + new float4(s0.x, s0.z, s0.y, s0.w) * new float4(sh.x, sh.x, sh.y, sh.y); - var a1 = new float4(b1.x, b1.z, b1.y, b1.w) + new float4(s1.x, s1.z, s1.y, s1.w) * new float4(sh.z, sh.z, sh.w, sh.w); - - var p0 = new float3(a0.x, a0.y, h.x); - var p1 = new float3(a0.z, a0.w, h.y); - var p2 = new float3(a1.x, a1.y, h.z); - var p3 = new float3(a1.z, a1.w, h.w); - var norm = 1.79284291400159f - 0.85373472095314f * new float4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)); - p0 *= norm.x; - p1 *= norm.y; - p2 *= norm.z; - p3 *= norm.w; - - var m0 = max(0.5f - new float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0f); - var m2 = m0 * m0; - var m3 = m2 * m0; - var m4 = m2 * m2; - - var px = new float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)); - var value2 = 105.0f * dot(m4, px); - var grad = -8.0f * m3.x * x0 * px.x + m4.x * p0 - + -8.0f * m3.y * x1 * px.y + m4.y * p1 - + -8.0f * m3.z * x2 * px.z + m4.z * p2 - + -8.0f * m3.w * x3 * px.w + m4.w * p3; - grad *= 105.0f; - - return new float4(grad, value2); - } - - // Octave sum of snoise_grad (invariant 8). Each octave samples snoise_grad at p scaled by the - // running frequency, so by the chain rule its gradient scales by that same frequency - // (d/dp[n(f*p)] = f * grad_n(f*p)); amplitude and frequency both compound per octave (gain, - // lacunarity respectively), not applied once for the whole sum, so with 1 octave this is exactly - // snoise_grad (NoiseGradTests.FbmGradWithOneOctaveEqualsSnoiseGrad). octaves is clamped to - // [0, 16]: HlslSourceCompatibilityTests' bit-parity oracle drives every mirrored int parameter - // across the full int32 range, which is the right domain for pcg3d/pcg4d's O(1) hash inputs but - // would turn an unclamped octave count into a multi-billion-iteration loop for this mirror. - public static float4 fbm_grad(float3 p, int octaves, float lacunarity, float gain) - { - octaves = clamp(octaves, 0, 16); - var amplitude = 1.0f; - var frequency = 1.0f; - var value = 0.0f; - var gradient = new float3(0.0f, 0.0f, 0.0f); - for (var i = 0; i < octaves; i++) - { - var n = snoise_grad(p * frequency); - value += amplitude * n.w; - gradient += amplitude * frequency * new float3(n.x, n.y, n.z); - frequency *= lacunarity; - amplitude *= gain; - } - - return new float4(gradient, value); - } - - // Ridged multifractal noise (Musgrave, Texturing and Modeling, ch. 16; the operator's dune term, - // 2026-09-25): each octave folds snoise_grad about zero, value = Sum a_i*(1 - |n_i|), gradient = - // -Sum a_i*f_i*sign(n_i)*grad(n_i), the same per-octave frequency scaling as fbm_grad's chain - // rule plus the fold's sign flip (d|n|/dp = sign(n)*dn/dp). The crease at n_i = 0 is real and - // deliberate, not a bug: NoiseGradTests excludes a band around it, the same way cellular excludes - // its F1 = F2 seam. sign follows this codebase's HLSL-matching convention (design.md: sign - // returns int, 0 at exactly 0), so the crease itself contributes no gradient rather than an - // arbitrary one. Same octave clamp as fbm_grad, for the same bit-parity-oracle reason. - public static float4 ridged_grad(float3 p, int octaves, float lacunarity, float gain) - { - octaves = clamp(octaves, 0, 16); - var amplitude = 1.0f; - var frequency = 1.0f; - var value = 0.0f; - var gradient = new float3(0.0f, 0.0f, 0.0f); - for (var i = 0; i < octaves; i++) - { - var n = snoise_grad(p * frequency); - var s = (float)sign(n.w); - value += amplitude * (1.0f - abs(n.w)); - gradient += -amplitude * frequency * s * new float3(n.x, n.y, n.z); - frequency *= lacunarity; - amplitude *= gain; - } - - return new float4(gradient, value); - } - - // Inigo Quilez, "smooth minimum" (https://iquilezles.org/articles/smin/): the quadratic-polynomial - // smin, carried to value-and-gradient form (invariant 8). h is affine in (b.w - a.w) inside the - // smoothing band, so differentiating value = lerp(b.w, a.w, h) - k*h*(1-h) with respect to - // position, the terms carrying dh/dp cancel exactly (dh/dp * [(a.w-b.w) - k + 2*k*h] and the - // bracket is identically zero given how h is built from a.w-b.w). What is left is exactly - // lerp(∇b, ∇a, h): the gradient blend is not an approximation, it is the analytic gradient. - // Outside the band (|a.w - b.w| >= k), h saturates to 0 or 1 and this is exactly min(a, b) with - // that input's own gradient, continuously (h and the correction term both reach the boundary at - // the same value from the smooth side, so there is no kink to exclude here the way cellular's - // F1 = F2 seam needs one). - public static float4 smin_grad(float4 a, float4 b, float k) - { - var h = saturate(0.5f + 0.5f * (b.w - a.w) / k); - var value = lerp(b.w, a.w, h) - k * h * (1.0f - h); - var gradient = lerp(new float3(b.x, b.y, b.z), new float3(a.x, a.y, a.z), h); - return new float4(gradient, value); - } - - // Worley, "A Cellular Texture Basis Function" (SIGGRAPH 1996): F1/F2 and their analytic - // gradients, searched over the jittered neighbourhood of feature points. Feature points are - // selected with pcg3d over each neighbour's integer cell coordinate (design.md, "Integer - // hashing"), never the float32 bit pattern of that coordinate and never the sin-based hash. - // Hashing the integer cell coordinate directly is exact and runtime-independent, and it is what - // the HLSL mirror does, so C# and shader stay bit-for-bit aligned. What pins this is the oracle - // and mirror tests, not a uniformity measurement: reverting to hashing the float32 bit pattern - // (or the sin-based hash) is caught by ProductionSearchMatchesTheBruteForceOracleExactly, - // EveryMirrorFunctionMatchesCSharpMath, F1NeverExceedsF2, and the other tests that key off the - // integer-hash fixtures. The - // gradient of a distance field, del|p - c|, is the unit vector (p - c)/|p - c|; that is undefined exactly at - // a feature point (F1 = 0 or F2 = 0), so cellular follows the repo's existing degenerate-normal - // convention (GameCult.Geometry.CultGeometryIsoSurface.EmitOrientedTriangle: guard the zero-length - // case and return the zero vector instead of the NaN a bare normalize would produce there). - // - // The search radius is 2 cells on every axis (5x5x5), not the classic 1-cell (3x3x3) radius, - // because 3x3x3 is not exact: measured 9 of 400,000 points in [-50,50]^3 land on the wrong F2 - // (worst error 0.088), and past |p| of about 2^23 the float32 jitter can put two distinct cells' - // features on the same value, which 3x3x3 has no chance of noticing either way. 5x5x5 is exact, - // not merely safer, by the following argument: - // - // Let u = p - cell be the query's position inside its own cell (u in [0,1)^3 per component), and - // let a candidate feature at integer cell offset d = (dx, dy, dz) sit at d + j for some jitter j - // in [0,1)^3. Per axis, the displacement from p to that feature is (u_i - j_i) - d_i; since - // u_i - j_i is always strictly inside (-1, 1) regardless of the actual u_i and j_i values, - // |displacement_i| is always strictly greater than max(0, |d_i| - 1). So every cell with |d_i| >= - // 3 on some axis is strictly farther than 2 from p (that axis alone already gives max(0, 3-1) = - // 2). Now bound F1 and F2 from above using two real, always-present candidates: the query's own - // cell (d = 0), whose distance is always strictly less than sqrt(3) (every axis strictly under - // 1); and the near-side neighbour on whichever single axis's u_i sits closest to a cell - // boundary. Let m = min(u_i, 1 - u_i) on that chosen axis (m is in [0, 1/2] by construction, - // since it is the smaller of the two). Stepping one cell towards that nearer boundary puts the - // query within (1 + m) of the far face on the offset axis and within (1 - m) of the near face on - // each of the other two axes, so that neighbour's squared distance is at most - // (1 + m)^2 + 2*(1 - m)^2 = 3 - 2m + 3m^2, which is <= 3 for every m in [0, 1/2] (3 at m = 0, - // dipping to a minimum of 8/3 ~= 2.667 at m = 1/3, then rising back to 2.75 at m = 1/2), and - // strictly below 3 because the jitter never reaches exactly 0 or - // 1 (u_i - j_i stays strictly inside (-1, 1), so this bound, like the own-cell one, is never - // tight). F2 is at most the larger of these two real candidates (adding candidates to a set can - // only lower or hold its 2nd-smallest value), so F1 < sqrt(3) and F2 < sqrt(3) always: roughly - // 1.732 < 2, margin 2 - sqrt(3) ~= 0.268 (Soul's adversarial check, cut 2a-i: the worst - // realizable in-box F2 is exactly sqrt(3), reached only in the unattainable limit at a cell - // corner). Every cell outside the 5x5x5 block is farther than 2, hence farther than either F1 or - // F2, so the true global F1 and F2 are always inside it. (The matching argument for 3x3x3 only - // reaches radius 1, whose excluded cells start at distance > 1, which is less than the 1.732 - // ceiling — the gap 3x3x3's failures live in.) - // - // An earlier version of this proof picked the near-side neighbour on a fixed axis rather than - // whichever axis sits closest to its boundary, and that version is wrong (Soul, cut 2a-i): - // u = (0.5, 0, 0) with the x-neighbour's jittered feature at (-1, 1 - 2^-24, 1 - 2^-24) gives a - // distance of about 2.06, outside the radius-2 exclusion the proof needs. Choosing the axis by - // proximity to its own boundary is what keeps m <= 1/2 and the bound at sqrt(3); x was the wrong - // axis to fix there because u.x = 0.5 is the point on the whole cube farthest from any boundary. - // - // The loop below still visits every one of the 125 cells in a fixed order, but skips the - // pcg3d/jitter/distance work for a cell whose distance cannot possibly beat the current f2: that - // per-cell lower bound (the max(0, |d_i| - 1) term above, combined across axes) depends only on - // the integer offset, never on p or the hash, so pruning a cell the unpruned loop would also - // have rejected changes no output bit (CellularExactSearchTests pins the pruned/unpruned - // equivalence and the exactness against a 7x7x7 brute-force oracle). - public static CultCellular cellular(float3 p) - { - var cell = floor(p); - var f1 = 1.0e30f; - var f2 = 1.0e30f; - var c1 = new float3(0.0f, 0.0f, 0.0f); - var c2 = new float3(0.0f, 0.0f, 0.0f); - var cellId = new float3(0.0f, 0.0f, 0.0f); - - for (var dz = -2; dz <= 2; dz++) - { - var lz = max(0.0f, abs((float)dz) - 1.0f); - for (var dy = -2; dy <= 2; dy++) - { - var ly = max(0.0f, abs((float)dy) - 1.0f); - for (var dx = -2; dx <= 2; dx++) - { - var lx = max(0.0f, abs((float)dx) - 1.0f); - var lowerBound = sqrt(lx * lx + ly * ly + lz * lz); - if (lowerBound >= f2) - continue; - - var neighbor = cell + new float3(dx, dy, dz); - var hash = pcg3d(int3(neighbor)); - var jitter = new float3(cellular_unit(hash.x), cellular_unit(hash.y), cellular_unit(hash.z)); - var feature = neighbor + jitter; - var d = distance(p, feature); - - if (d < f1) - { - f2 = f1; c2 = c1; - f1 = d; c1 = feature; cellId = neighbor; - } - else if (d < f2) - { - f2 = d; c2 = feature; - } - } - } - } - - // F1 = 0 is reachable (p sits exactly on a feature point). F2 = 0 needs two distinct cells' - // pcg3d-jittered features to land on the same float32 value in every component: unreachable - // at ordinary magnitudes, but confirmed reachable past roughly |p| = 2^23 (design.md, - // "Precision domain"), so both share the same degenerate-gradient guard. - var grad1 = f1 > 0.0f ? (p - c1) / f1 : new float3(0.0f, 0.0f, 0.0f); - var grad2 = f2 > 0.0f ? (p - c2) / f2 : new float3(0.0f, 0.0f, 0.0f); - - // id comes from pcg4d over the winning cell's integer coordinate alone (same int-hashing - // rule as the jitter above), never from the pcg3d hash that produced its jitter: sharing a - // hash between id and jitter would make id a deterministic function of the jitter it is - // supposed to be independent from, correlated across every cell (F4). - var idHash = pcg4d(new int4(int3(cellId), 0)); - - return new CultCellular( - new float4(grad1, f1), - new float4(grad2 - grad1, f2 - f1), - cellular_unit(idHash.w)); - } - - // Maps a hash component's uint bit pattern to [0, 1) using the top 24 bits, so the result is - // always strictly less than 1: a plain divide-by-2^32 rounds 0xFFFFFF80..0xFFFFFFFF up to - // exactly 1.0f in float32, outside the documented range. (uint >> 8) has at most 24 significant - // bits, which float32's 24-bit mantissa represents exactly, and the division is by an exact power - // of two, so both the shift-and-convert and the divide are the same IEEE-754 round-to-nearest on - // dxc and C#, bit-exact on both sides. - internal static float cellular_unit(int bits) => ((uint)bits >> 8) * (1.0f / 16777216.0f); + private static float3 snoise_permute(float3 value) => snoise_mod289(((value * 34.0f) + 1.0f) * value); + private static float4 snoise_permute(float4 value) => snoise_mod289(((value * 34.0f) + 1.0f) * value); public static float value_noise(float2 position) { diff --git a/packages/cultmath/tests/CultMath.Tests/BatchMathTests.cs b/packages/cultmath/tests/CultMath.Tests/BatchMathTests.cs deleted file mode 100644 index 62280c74..00000000 --- a/packages/cultmath/tests/CultMath.Tests/BatchMathTests.cs +++ /dev/null @@ -1,427 +0,0 @@ -using System; -using CultMath; -using Xunit; - -namespace CultMath.Tests; - -// Stryker's mtp baseline on hands/adopt-stryker found BatchMath undefended in three ways: -// both existing tests used 2-3 particles while LaneCount >= 4, so the vector body never ran -// and only the scalar tail executed; both held y at 0 for every particle, so every y-axis -// mutation was invisible; and the guard branches (early return, negative deltaTime, length -// mismatch) were never exercised. These tests read LaneCount at runtime, use -// LaneCount * 3 + 1 particles (a non-empty vector body AND a non-empty remainder), give every -// particle distinct nonzero x and y, and pin results against a scalar reference computed here -// rather than by calling the code under test. -public sealed class BatchMathTests -{ - private const float Tolerance = 1e-4f; - - [Fact] - public void RadialFalloffMatchesScalarReferenceAcrossVectorBodyAndRemainder() - { - var lanes = BatchMath.LaneCount; - var count = lanes * 3 + 1; - Assert.True(count > lanes, "fixture must exceed one lane to exercise the vector body"); - Assert.True((count % lanes) != 0, "fixture must leave a non-empty scalar remainder"); - - var px = new float[count]; - var py = new float[count]; - var ax = new float[count]; - var ay = new float[count]; - // Distinct, asymmetric per-particle values: x and y follow different formulas so an - // x/y swap mutation is observable, and no particle sits at the falloff center. - for (var i = 0; i < count; i++) - { - px[i] = i * 1.3f - 4.0f; - py[i] = i * -0.7f + 2.5f; - } - - const float centerX = 3.0f; - const float centerY = -1.0f; - const float strength = 5.0f; - // Chosen so some particles fall inside the radius and some fall outside it, exercising - // both the ConditionalSelect(inRange, ...) vector path and the scalar "continue". - const float radius = 6.0f; - - // The scalar remainder is exactly one particle (index count-1). The generator above puts - // it far outside the radius, which would leave any y-axis mutation in the scalar tail's - // "in range" arithmetic unobserved (the only remainder particle never reaches it). Pin it - // inside the radius with a nonzero, non-degenerate y offset instead. - px[count - 1] = centerX + 2.0f; - py[count - 1] = centerY + 1.5f; - - BatchMath.AddRadialFalloffAcceleration2D(px, py, centerX, centerY, strength, radius, ax, ay); - - var insideCount = 0; - var outsideCount = 0; - for (var i = 0; i < count; i++) - { - var dx = centerX - px[i]; - var dy = centerY - py[i]; - var distanceSquared = Math.Max(dx * dx + dy * dy, 1.0e-8f); - var distance = MathF.Sqrt(distanceSquared); - - if (distance > radius) - { - outsideCount++; - Assert.Equal(0.0f, ax[i], Tolerance); - Assert.Equal(0.0f, ay[i], Tolerance); - continue; - } - - insideCount++; - var falloff = Math.Max(0.0f, 1.0f - distance / radius); - var magnitude = strength * falloff; - var expectedAx = dx / distance * magnitude; - var expectedAy = dy / distance * magnitude; - Assert.Equal(expectedAx, ax[i], Tolerance); - Assert.Equal(expectedAy, ay[i], Tolerance); - } - - Assert.True(insideCount > 0, "fixture must include particles inside the radius"); - Assert.True(outsideCount > 0, "fixture must include particles outside the radius"); - } - - [Fact] - public void RadialFalloffAccumulatesOntoExistingAcceleration() - { - // Guards against a mutation that assigns instead of accumulating: seed a nonzero - // acceleration and confirm the falloff contribution is added to it, not overwritten. - var px = new[] { 0.0f }; - var py = new[] { 0.0f }; - var ax = new[] { 10.0f }; - var ay = new[] { -20.0f }; - - BatchMath.AddRadialFalloffAcceleration2D(px, py, 1.0f, 0.0f, 4.0f, 8.0f, ax, ay); - - var dx = 1.0f - 0.0f; - var dy = 0.0f - 0.0f; - var distance = MathF.Sqrt(dx * dx + dy * dy); - var falloff = Math.Max(0.0f, 1.0f - distance / 8.0f); - var magnitude = 4.0f * falloff; - Assert.Equal(10.0f + dx / distance * magnitude, ax[0], Tolerance); - Assert.Equal(-20.0f + dy / distance * magnitude, ay[0], Tolerance); - } - - [Fact] - public void RadialFalloffValidatesEqualSpanLengths() - { - var px = new float[3]; - var py = new float[2]; - var ax = new float[3]; - var ay = new float[3]; - - Assert.Throws(() => - BatchMath.AddRadialFalloffAcceleration2D(px, py, 0.0f, 0.0f, 1.0f, 1.0f, ax, ay)); - } - - [Fact] - public void RadialFalloffIsNoOpWhenRadiusIsNonPositive() - { - var px = new[] { 1.0f, 2.0f }; - var py = new[] { 3.0f, -4.0f }; - var ax = new[] { 5.0f, 6.0f }; - var ay = new[] { 7.0f, 8.0f }; - - BatchMath.AddRadialFalloffAcceleration2D(px, py, 0.0f, 0.0f, 9.0f, 0.0f, ax, ay); - - Assert.Equal(new[] { 5.0f, 6.0f }, ax); - Assert.Equal(new[] { 7.0f, 8.0f }, ay); - } - - [Fact] - public void RadialFalloffIsNoOpWhenStrengthIsZero() - { - var px = new[] { 1.0f, 2.0f }; - var py = new[] { 3.0f, -4.0f }; - var ax = new[] { 5.0f, 6.0f }; - var ay = new[] { 7.0f, 8.0f }; - - BatchMath.AddRadialFalloffAcceleration2D(px, py, 0.0f, 0.0f, 0.0f, 10.0f, ax, ay); - - Assert.Equal(new[] { 5.0f, 6.0f }, ax); - Assert.Equal(new[] { 7.0f, 8.0f }, ay); - } - - [Fact] - public void RadialFalloffIsNoOpWhenSpansAreEmpty() - { - var px = Array.Empty(); - var py = Array.Empty(); - var ax = Array.Empty(); - var ay = Array.Empty(); - - // Must not throw for a valid but degenerate zero-length batch. - BatchMath.AddRadialFalloffAcceleration2D(px, py, 0.0f, 0.0f, 1.0f, 1.0f, ax, ay); - - Assert.Empty(ax); - Assert.Empty(ay); - } - - [Fact] - public void EulerIntegrationMatchesScalarReferenceAcrossVectorBodyAndRemainder() - { - var lanes = BatchMath.LaneCount; - var count = lanes * 3 + 1; - Assert.True((count % lanes) != 0, "fixture must leave a non-empty scalar remainder"); - - const float deltaTime = 0.25f; - var dynamicMask = new float[count]; - var px = new float[count]; - var py = new float[count]; - var vx = new float[count]; - var vy = new float[count]; - var ax = new float[count]; - var ay = new float[count]; - - for (var i = 0; i < count; i++) - { - // Every third particle is frozen (mask 0) so the mask branch is exercised across - // both the vector body and the remainder, not only at the array's edges. - dynamicMask[i] = i % 3 == 0 ? 0.0f : 1.0f; - px[i] = i * 0.4f - 1.0f; - py[i] = i * -0.6f + 3.0f; - vx[i] = i * 0.2f + 1.0f; - vy[i] = i * -0.3f - 2.0f; - ax[i] = i * 0.5f - 2.5f; - ay[i] = i * -0.9f + 1.5f; - } - - // The scalar remainder is exactly one particle (index count-1). The mask pattern above - // would leave it frozen for lane counts divisible by 3, which masks every y-axis (and - // x-axis) mutation in the scalar tail's arithmetic to zero regardless of the mutation. - // Force it dynamic so the remainder actually exercises the scalar tail's math. - dynamicMask[count - 1] = 1.0f; - - // Independent copies for the scalar reference; the arrays under test are mutated in place. - var expectedPx = (float[])px.Clone(); - var expectedPy = (float[])py.Clone(); - var expectedVx = (float[])vx.Clone(); - var expectedVy = (float[])vy.Clone(); - - BatchMath.IntegrateSemiImplicitEuler2D(deltaTime, dynamicMask, px, py, vx, vy, ax, ay); - - var anyFrozen = false; - var anyMoving = false; - for (var i = 0; i < count; i++) - { - var mask = dynamicMask[i]; - expectedVx[i] += ax[i] * deltaTime * mask; - expectedVy[i] += ay[i] * deltaTime * mask; - expectedPx[i] += expectedVx[i] * deltaTime * mask; - expectedPy[i] += expectedVy[i] * deltaTime * mask; - - if (mask == 0.0f) anyFrozen = true; else anyMoving = true; - - Assert.Equal(expectedVx[i], vx[i], Tolerance); - Assert.Equal(expectedVy[i], vy[i], Tolerance); - Assert.Equal(expectedPx[i], px[i], Tolerance); - Assert.Equal(expectedPy[i], py[i], Tolerance); - } - - Assert.True(anyFrozen, "fixture must include masked-off (frozen) particles"); - Assert.True(anyMoving, "fixture must include dynamic particles"); - } - - [Fact] - public void EulerIntegrationRejectsNonPositiveDeltaTime() - { - var dynamicMask = new[] { 1.0f }; - var px = new[] { 0.0f }; - var py = new[] { 0.0f }; - var vx = new[] { 0.0f }; - var vy = new[] { 0.0f }; - var ax = new[] { 0.0f }; - var ay = new[] { 0.0f }; - - Assert.Throws(() => - BatchMath.IntegrateSemiImplicitEuler2D(0.0f, dynamicMask, px, py, vx, vy, ax, ay)); - Assert.Throws(() => - BatchMath.IntegrateSemiImplicitEuler2D(-1.0f, dynamicMask, px, py, vx, vy, ax, ay)); - } - - [Fact] - public void EulerIntegrationValidatesEqualSpanLengths() - { - var dynamicMask = new float[3]; - var px = new float[3]; - var py = new float[3]; - var vx = new float[2]; - var vy = new float[3]; - var ax = new float[3]; - var ay = new float[3]; - - Assert.Throws(() => - BatchMath.IntegrateSemiImplicitEuler2D(0.5f, dynamicMask, px, py, vx, vy, ax, ay)); - } - - [Fact] - public void Float2RadialFalloffMatchesScalarReferenceWithNonzeroYAndOutOfRangeParticles() - { - var count = 5; - var positions = new float2[count]; - for (var i = 0; i < count; i++) - { - positions[i] = new float2(i * 1.1f - 2.0f, i * -0.8f + 1.0f); - } - - var center = new float2(1.5f, -0.5f); - const float strength = 3.0f; - const float radius = 3.0f; - var acceleration = new float2[count]; - - BatchMath.AddRadialFalloffAcceleration2D(positions, center, strength, radius, acceleration); - - var insideCount = 0; - var outsideCount = 0; - for (var i = 0; i < count; i++) - { - var delta = center - positions[i]; - var distanceSquared = Math.Max(delta.x * delta.x + delta.y * delta.y, 1.0e-8f); - var distance = MathF.Sqrt(distanceSquared); - - if (distance > radius) - { - outsideCount++; - Assert.Equal(0.0f, acceleration[i].x, Tolerance); - Assert.Equal(0.0f, acceleration[i].y, Tolerance); - continue; - } - - insideCount++; - var falloff = Math.Max(0.0f, 1.0f - distance / radius); - var expected = delta / distance * (strength * falloff); - Assert.Equal(expected.x, acceleration[i].x, Tolerance); - Assert.Equal(expected.y, acceleration[i].y, Tolerance); - } - - Assert.True(insideCount > 0, "fixture must include particles inside the radius"); - Assert.True(outsideCount > 0, "fixture must include particles outside the radius"); - } - - [Fact] - public void Float2RadialFalloffAccumulatesOntoExistingAcceleration() - { - // Guards against a mutation that assigns instead of accumulating (BatchMath.cs:115): - // seed a nonzero, per-element-distinct acceleration and confirm the falloff - // contribution is added to it, not overwritten. An out-of-range particle must be left - // exactly at its seed value, not reset to zero. - var count = 5; - var positions = new float2[count]; - var seedAcceleration = new float2[count]; - for (var i = 0; i < count; i++) - { - positions[i] = new float2(i * 1.1f - 2.0f, i * -0.8f + 1.0f); - seedAcceleration[i] = new float2(i * 2.3f + 4.0f, i * -1.7f - 6.0f); - } - - var center = new float2(1.5f, -0.5f); - const float strength = 3.0f; - const float radius = 3.0f; - var acceleration = (float2[])seedAcceleration.Clone(); - - BatchMath.AddRadialFalloffAcceleration2D(positions, center, strength, radius, acceleration); - - var insideCount = 0; - var outsideCount = 0; - for (var i = 0; i < count; i++) - { - var delta = center - positions[i]; - var distanceSquared = Math.Max(delta.x * delta.x + delta.y * delta.y, 1.0e-8f); - var distance = MathF.Sqrt(distanceSquared); - - if (distance > radius) - { - outsideCount++; - Assert.Equal(seedAcceleration[i].x, acceleration[i].x, Tolerance); - Assert.Equal(seedAcceleration[i].y, acceleration[i].y, Tolerance); - continue; - } - - insideCount++; - var falloff = Math.Max(0.0f, 1.0f - distance / radius); - var contribution = delta / distance * (strength * falloff); - Assert.Equal(seedAcceleration[i].x + contribution.x, acceleration[i].x, Tolerance); - Assert.Equal(seedAcceleration[i].y + contribution.y, acceleration[i].y, Tolerance); - } - - Assert.True(insideCount > 0, "fixture must include particles inside the radius"); - Assert.True(outsideCount > 0, "fixture must include particles outside the radius"); - } - - [Fact] - public void Float2EulerIntegrationMatchesScalarReferenceWithNonzeroYAndMask() - { - const float deltaTime = 0.2f; - var dynamicMask = new[] { 1.0f, 0.0f, 1.0f }; - var position = new[] { new float2(0.0f, 0.0f), new float2(1.0f, 1.0f), new float2(-2.0f, 3.0f) }; - var velocity = new[] { new float2(1.0f, -1.0f), new float2(2.0f, 2.0f), new float2(-1.0f, 0.5f) }; - var acceleration = new[] { new float2(0.5f, 1.5f), new float2(3.0f, -3.0f), new float2(2.0f, -0.5f) }; - - var expectedPosition = (float2[])position.Clone(); - var expectedVelocity = (float2[])velocity.Clone(); - - BatchMath.IntegrateSemiImplicitEuler2D(deltaTime, dynamicMask, position, velocity, acceleration); - - for (var i = 0; i < position.Length; i++) - { - var mask = dynamicMask[i]; - expectedVelocity[i] += acceleration[i] * deltaTime * mask; - expectedPosition[i] += expectedVelocity[i] * deltaTime * mask; - - Assert.Equal(expectedVelocity[i].x, velocity[i].x, Tolerance); - Assert.Equal(expectedVelocity[i].y, velocity[i].y, Tolerance); - Assert.Equal(expectedPosition[i].x, position[i].x, Tolerance); - Assert.Equal(expectedPosition[i].y, position[i].y, Tolerance); - } - } - - [Fact] - public void Float2EulerIntegrationRejectsNonPositiveDeltaTime() - { - var dynamicMask = new[] { 1.0f }; - var position = new[] { float2.zero }; - var velocity = new[] { float2.zero }; - var acceleration = new[] { float2.zero }; - - Assert.Throws(() => - BatchMath.IntegrateSemiImplicitEuler2D(0.0f, dynamicMask, position, velocity, acceleration)); - } - - [Fact] - public void Float2EulerIntegrationValidatesEqualSpanLengths() - { - var dynamicMask = new float[3]; - var position = new float2[3]; - var velocity = new float2[2]; - var acceleration = new float2[3]; - - Assert.Throws(() => - BatchMath.IntegrateSemiImplicitEuler2D(0.5f, dynamicMask, position, velocity, acceleration)); - } - - [Fact] - public void Float2RadialFalloffValidatesEqualSpanLengths() - { - var positions = new float2[3]; - var acceleration = new float2[2]; - - Assert.Throws(() => - BatchMath.AddRadialFalloffAcceleration2D(positions, float2.zero, 1.0f, 1.0f, acceleration)); - } - - [Fact] - public void Float2RadialFalloffIsNoOpWhenRadiusIsNonPositiveOrStrengthIsZero() - { - var positions = new[] { new float2(1.0f, 3.0f), new float2(2.0f, -4.0f) }; - var acceleration = new[] { new float2(5.0f, 7.0f), new float2(6.0f, 8.0f) }; - - BatchMath.AddRadialFalloffAcceleration2D(positions, float2.zero, 9.0f, 0.0f, acceleration); - Assert.Equal(new float2(5.0f, 7.0f), acceleration[0]); - Assert.Equal(new float2(6.0f, 8.0f), acceleration[1]); - - BatchMath.AddRadialFalloffAcceleration2D(positions, float2.zero, 0.0f, 10.0f, acceleration); - Assert.Equal(new float2(5.0f, 7.0f), acceleration[0]); - Assert.Equal(new float2(6.0f, 8.0f), acceleration[1]); - } -} diff --git a/packages/cultmath/tests/CultMath.Tests/BoundedLeastSquaresTests.cs b/packages/cultmath/tests/CultMath.Tests/BoundedLeastSquaresTests.cs deleted file mode 100644 index a9a950be..00000000 --- a/packages/cultmath/tests/CultMath.Tests/BoundedLeastSquaresTests.cs +++ /dev/null @@ -1,1141 +0,0 @@ -using System; -using CultMath; -using Xunit; - -namespace CultMath.Tests; - -// Fixtures are deliberately non-degenerate: A is non-square and non-identity, and b is outside A's range, -// so the unconstrained optimum has a real residual and the box actually bites. -public sealed class BoundedLeastSquaresTests -{ - private static double Cost(int m, int n, float[] a, float[] b, float[] x) - { - var c = 0.0; - for (var r = 0; r < m; r++) - { - var s = -(double)b[r]; - for (var j = 0; j < n; j++) s += (double)a[r * n + j] * x[j]; - c += s * s; - } - return c; - } - - // Gradient of 0.5*||Ax-b||^2, in double. - private static double[] Gradient(int m, int n, float[] a, float[] b, float[] x) - { - var g = new double[n]; - for (var r = 0; r < m; r++) - { - var s = -(double)b[r]; - for (var j = 0; j < n; j++) s += (double)a[r * n + j] * x[j]; - for (var j = 0; j < n; j++) g[j] += a[r * n + j] * s; - } - return g; - } - - private static double AtbScale(int m, int n, float[] a, float[] b) - { - var mx = 0.0; - for (var j = 0; j < n; j++) - { - var s = 0.0; - for (var r = 0; r < m; r++) s += (double)a[r * n + j] * b[r]; - mx = Math.Max(mx, Math.Abs(s)); - } - return 1 + mx; - } - - // Asserts feasibility and the KKT sign rule: free columns have ~zero gradient, columns at lo have - // gradient >= 0, columns at hi have gradient <= 0. - private static void AssertKkt(int m, int n, float[] a, float[] b, float[] lo, float[] hi, float[] x) - { - var g = Gradient(m, n, a, b, x); - var tol = 1e-3 * AtbScale(m, n, a, b); - for (var j = 0; j < n; j++) - { - Assert.True(x[j] >= lo[j] && x[j] <= hi[j], $"column {j} outside its box: {x[j]}"); - if (lo[j] == hi[j]) continue; - if (x[j] == lo[j]) Assert.True(g[j] >= -tol, $"column {j} at lo with gradient {g[j]}"); - else if (x[j] == hi[j]) Assert.True(g[j] <= tol, $"column {j} at hi with gradient {g[j]}"); - else Assert.True(Math.Abs(g[j]) <= tol, $"column {j} free with gradient {g[j]}"); - } - } - - // Accelerated projected gradient (FISTA) with step 1/L, L bounded by the Frobenius norm squared of A. - // Independent of the solver's active-set logic; run to a fixed point. - private static double ReferenceCost(int m, int n, float[] a, float[] b, float[] lo, float[] hi) - { - var frob = 0.0; - foreach (var v in a) frob += (double)v * v; - var step = 1.0 / Math.Max(frob, 1e-12); - var x = new double[n]; - var y = new double[n]; - var xf = new float[n]; - for (var j = 0; j < n; j++) x[j] = y[j] = Math.Clamp(0.0, lo[j], hi[j]); - var t = 1.0; - for (var it = 0; it < 100000; it++) - { - for (var j = 0; j < n; j++) xf[j] = (float)y[j]; - var g = Gradient(m, n, a, b, xf); - var moved = 0.0; - var tNext = (1.0 + Math.Sqrt(1.0 + 4.0 * t * t)) / 2.0; - for (var j = 0; j < n; j++) - { - var next = Math.Clamp(y[j] - step * g[j], lo[j], hi[j]); - moved = Math.Max(moved, Math.Abs(next - x[j])); - y[j] = next + (t - 1.0) / tNext * (next - x[j]); - x[j] = next; - } - t = tNext; - if (moved < 1e-10) break; - } - for (var j = 0; j < n; j++) xf[j] = (float)x[j]; - return Cost(m, n, a, b, xf); - } - - private static BoundedLeastSquaresStatus Solve(int m, int n, float[] a, float[] b, float[] lo, float[] hi, float[] x, - out int iterations, int maxIterations = BoundedLeastSquares.DefaultMaxIterations) - => BoundedLeastSquares.Solve(m, n, a, b, lo, hi, x, new float[BoundedLeastSquares.WorkspaceLength(n)], out iterations, maxIterations); - - private static float[] Fill(float value, int n) - { - var v = new float[n]; - Array.Fill(v, value); - return v; - } - - private static double[] NormalEquationSolution(int m, int n, float[] a, float[] b) - { - var mat = new double[n, n + 1]; - for (var i = 0; i < n; i++) - { - for (var j = 0; j < n; j++) - for (var r = 0; r < m; r++) mat[i, j] += (double)a[r * n + i] * a[r * n + j]; - for (var r = 0; r < m; r++) mat[i, n] += (double)a[r * n + i] * b[r]; - } - for (var c = 0; c < n; c++) - { - var piv = mat[c, c]; - for (var j = 0; j <= n; j++) mat[c, j] /= piv; - for (var i = 0; i < n; i++) - { - if (i == c) continue; - var f = mat[i, c]; - for (var j = 0; j <= n; j++) mat[i, j] -= f * mat[c, j]; - } - } - var x = new double[n]; - for (var i = 0; i < n; i++) x[i] = mat[i, n]; - return x; - } - - private static (int m, int n, float[] a, float[] b, float[] lo, float[] hi) RandomProblem(uint seed) - { - var rng = new CultMath.Random(seed * 7919u); - var n = rng.NextInt(2, 17); - var m = rng.NextInt(n, 25); - var a = new float[m * n]; - var b = new float[m]; - for (var i = 0; i < a.Length; i++) a[i] = rng.NextFloat(-1f, 1f); - for (var i = 0; i < m; i++) b[i] = rng.NextFloat(-3f, 3f); - var lo = new float[n]; - var hi = new float[n]; - for (var j = 0; j < n; j++) - { - switch (rng.NextInt(3)) - { - case 0: lo[j] = -1f; hi[j] = 1f; break; - case 1: lo[j] = 0f; hi[j] = 1f; break; - default: lo[j] = -0.25f; hi[j] = 0.1f; break; - } - } - return (m, n, a, b, lo, hi); - } - - private static readonly float[] A54 = - { - 1.0f, 0.3f, -0.2f, - 0.2f, 1.1f, 0.4f, - -0.5f, 0.6f, 0.9f, - 0.7f, -0.4f, 0.5f, - 0.1f, 0.8f, -0.6f, - }; - - private static readonly float[] B5 = { 0.9f, -0.4f, 1.3f, 0.2f, -0.7f }; - - [Fact] - public void InteriorOptimumEqualsNormalEquations() - { - const int m = 5, n = 3; - var x = new float[n]; - var status = Solve(m, n, A54, B5, Fill(-100f, n), Fill(100f, n), x, out _); - Assert.Equal(BoundedLeastSquaresStatus.Converged, status); - - var expected = NormalEquationSolution(m, n, A54, B5); - for (var i = 0; i < n; i++) Assert.Equal(expected[i], x[i], 1e-4); - } - - [Fact] - public void KktHoldsOnRandomBoxedProblems() - { - var anyActive = false; - for (uint seed = 1; seed <= 150; seed++) - { - var (m, n, a, b, lo, hi) = RandomProblem(seed); - var x = new float[n]; - var status = Solve(m, n, a, b, lo, hi, x, out _); - Assert.True(status == BoundedLeastSquaresStatus.Converged, $"seed {seed} ({m}x{n}) hit the cap"); - AssertKkt(m, n, a, b, lo, hi, x); - for (var j = 0; j < n; j++) anyActive |= x[j] == lo[j] || x[j] == hi[j]; - - var cs = Cost(m, n, a, b, x); - var cr = ReferenceCost(m, n, a, b, lo, hi); - Assert.True(Math.Abs(cs - cr) <= 1e-4 * cr + 1e-6, $"seed {seed} ({m}x{n}): solver cost {cs}, reference {cr}"); - } - Assert.True(anyActive); - } - - [Fact] - public void AllBoundsActive() - { - const int m = 4, n = 3; - var a = new[] { 1.0f, 0.2f, 0.1f, 0.1f, 1.0f, 0.3f, -0.2f, 0.2f, 1.0f, 0.5f, 0.5f, 0.4f }; - var b = new[] { 10f, -10f, 10f, 1f }; - var lo = Fill(-1f, n); - var hi = Fill(1f, n); - var x = new float[n]; - var status = Solve(m, n, a, b, lo, hi, x, out var iterations); - Assert.Equal(BoundedLeastSquaresStatus.Converged, status); - Assert.Equal(new[] { 1f, -1f, 1f }, x); - Assert.Equal(3, iterations); - AssertKkt(m, n, a, b, lo, hi, x); - } - - [Fact] - public void ZeroColumn() - { - const int m = 5, n = 3; - var a = (float[])A54.Clone(); - for (var r = 0; r < m; r++) a[r * n + 1] = 0f; - var lo = Fill(-1f, n); - var hi = Fill(1f, n); - var x = new float[n]; - var status = Solve(m, n, a, B5, lo, hi, x, out _); - Assert.Equal(BoundedLeastSquaresStatus.Converged, status); - AssertKkt(m, n, a, B5, lo, hi, x); - var cs = Cost(m, n, a, B5, x); - var cr = ReferenceCost(m, n, a, B5, lo, hi); - Assert.True(Math.Abs(cs - cr) <= 1e-4 * cr + 1e-6); - } - - [Fact] - public void DuplicateColumns() - { - const int m = 5, n = 3; - var a = (float[])A54.Clone(); - for (var r = 0; r < m; r++) a[r * n + 2] = a[r * n + 0]; - var lo = Fill(-1f, n); - var hi = new[] { 0.3f, 1f, 0.3f }; - var x = new float[n]; - var status = Solve(m, n, a, B5, lo, hi, x, out _); - Assert.Equal(BoundedLeastSquaresStatus.Converged, status); - AssertKkt(m, n, a, B5, lo, hi, x); - var cs = Cost(m, n, a, B5, x); - var cr = ReferenceCost(m, n, a, B5, lo, hi); - Assert.True(Math.Abs(cs - cr) <= 1e-4 * cr + 1e-6); - } - - [Fact] - public void WarmStartMatchesColdStart() - { - const int m = 5, n = 3; - var lo = new[] { -0.2f, -1f, 0f }; - var hi = new[] { 0.5f, 0.3f, 1f }; - var cold = new float[n]; - Solve(m, n, A54, B5, lo, hi, cold, out _); - AssertKkt(m, n, A54, B5, lo, hi, cold); - - var starts = new[] { new[] { 0.5f, -1f, 1f }, new[] { 9f, 9f, -9f }, new[] { 0.1f, 0.1f, 0.5f }, (float[])cold.Clone() }; - foreach (var start in starts) - { - var status = Solve(m, n, A54, B5, lo, hi, start, out var iterations); - Assert.Equal(BoundedLeastSquaresStatus.Converged, status); - for (var j = 0; j < n; j++) Assert.Equal(cold[j], start[j], 1e-4); - if (ReferenceEquals(start, starts[^1])) Assert.Equal(0, iterations); - } - } - - [Fact] - public void AsymmetricBounds() - { - const int m = 5, n = 3; - var lo = new[] { -1f, 0f, -1f }; - var hi = new[] { 1f, 1f, 1f }; - // Column 1 wants to go negative (b has a strong component against it) but its floor is 0. - var b = new[] { 0.2f, -2.5f, 0.4f, -1.5f, -2.0f }; - var x = new float[n]; - var status = Solve(m, n, A54, b, lo, hi, x, out _); - Assert.Equal(BoundedLeastSquaresStatus.Converged, status); - AssertKkt(m, n, A54, b, lo, hi, x); - Assert.Equal(0f, x[1]); - Assert.True(x[0] < 0f || x[2] < 0f, "a symmetric-bounds column should use its negative side"); - var cs = Cost(m, n, A54, b, x); - var cr = ReferenceCost(m, n, A54, b, lo, hi); - Assert.True(Math.Abs(cs - cr) <= 1e-4 * cr + 1e-6); - } - - [Fact] - public void IterationCapReturnsFeasibleIterateAndLimitStatus() - { - const int m = 4, n = 3; - var a = new[] { 1.0f, 0.2f, 0.1f, 0.1f, 1.0f, 0.3f, -0.2f, 0.2f, 1.0f, 0.5f, 0.5f, 0.4f }; - var b = new[] { 10f, -10f, 10f, 1f }; - var lo = Fill(-1f, n); - var hi = Fill(1f, n); - var x = new float[n]; - var start = Cost(m, n, a, b, x); - var status = Solve(m, n, a, b, lo, hi, x, out var iterations, maxIterations: 1); - Assert.Equal(BoundedLeastSquaresStatus.IterationLimit, status); - Assert.Equal(1, iterations); - for (var j = 0; j < n; j++) Assert.InRange(x[j], lo[j], hi[j]); - Assert.True(Cost(m, n, a, b, x) < start); - - var zero = new float[n]; - Assert.Equal(BoundedLeastSquaresStatus.IterationLimit, Solve(m, n, a, b, lo, hi, zero, out iterations, maxIterations: 0)); - Assert.Equal(0, iterations); - Assert.Equal(new float[n], zero); - } - - [Fact] - public void StartOutsideTheBoxIsClampedFirst() - { - const int m = 5, n = 3; - var lo = Fill(-0.1f, n); - var hi = Fill(0.1f, n); - var x = new[] { 50f, -50f, float.NaN }; - Solve(m, n, A54, B5, lo, hi, x, out _, maxIterations: 0); - Assert.Equal(new[] { 0.1f, -0.1f, -0.1f }, x); - } - - [Fact] - public void FixedColumnStaysPutEvenWhenItsGradientWantsOut() - { - const int m = 5, n = 3; - var lo = new[] { -100f, -1f, -100f }; - var hi = new[] { 100f, -1f, 100f }; - var x = new float[n]; - var status = Solve(m, n, A54, B5, lo, hi, x, out var iterations); - Assert.Equal(BoundedLeastSquaresStatus.Converged, status); - Assert.Equal(-1f, x[1]); - Assert.True(Gradient(m, n, A54, B5, x)[1] < -1e-3, "fixture must pull the fixed column inward"); - Assert.Equal(0, iterations); - AssertKkt(m, n, A54, B5, lo, hi, x); - } - - [Fact] - public void WarmCallAllocatesNothing() - { - const int m = 5, n = 3; - var lo = new[] { -0.2f, -1f, 0f }; - var hi = new[] { 0.5f, 0.3f, 1f }; - var x = new float[n]; - var workspace = new float[BoundedLeastSquares.WorkspaceLength(n)]; - BoundedLeastSquares.Solve(m, n, A54, B5, lo, hi, x, workspace, out _); - - var before = GC.GetAllocatedBytesForCurrentThread(); - var status = BoundedLeastSquares.Solve(m, n, A54, B5, lo, hi, x, workspace, out _); - var after = GC.GetAllocatedBytesForCurrentThread(); - Assert.Equal(BoundedLeastSquaresStatus.Converged, status); - Assert.Equal(0L, after - before); - } - - [Fact] - public void LargeInteriorOptimumEqualsNormalEquations() - { - const int m = 24, n = 16; - var rng = new CultMath.Random(4242u); - var a = new float[m * n]; - var b = new float[m]; - for (var i = 0; i < a.Length; i++) a[i] = rng.NextFloat(-1f, 1f); - for (var i = 0; i < m; i++) b[i] = rng.NextFloat(-3f, 3f); - var x = new float[n]; - var status = Solve(m, n, a, b, Fill(-1000f, n), Fill(1000f, n), x, out var iterations); - Assert.Equal(BoundedLeastSquaresStatus.Converged, status); - Assert.Equal(0, iterations); - var expected = NormalEquationSolution(m, n, a, b); - for (var i = 0; i < n; i++) Assert.Equal(expected[i], x[i], 1e-3); - } - - [Fact] - public void ScaledAndNearDuplicateColumnsAreOptimal() - { - const int m = 5, n = 3; - foreach (var noise in new[] { 0f, 1e-5f }) - { - var a = (float[])A54.Clone(); - for (var r = 0; r < m; r++) a[r * n + 2] = 0.37f * a[r * n + 0] + noise * (r - 2); - var lo = Fill(-1f, n); - var hi = new[] { 0.6f, 1f, 0.6f }; - var x = new float[n]; - var status = Solve(m, n, a, B5, lo, hi, x, out _); - Assert.Equal(BoundedLeastSquaresStatus.Converged, status); - AssertKkt(m, n, a, B5, lo, hi, x); - var cs = Cost(m, n, a, B5, x); - var cr = ReferenceCost(m, n, a, B5, lo, hi); - Assert.True(Math.Abs(cs - cr) <= 1e-4 * cr + 1e-6, $"noise {noise}: solver cost {cs}, reference {cr}"); - } - } - - [Fact] - public void WorkspaceContentsOnEntryAreIgnored() - { - const int m = 5, n = 3; - var zeroCol = (float[])A54.Clone(); - for (var r = 0; r < m; r++) zeroCol[r * n + 1] = 0f; - var dupCol = (float[])A54.Clone(); - for (var r = 0; r < m; r++) dupCol[r * n + 2] = dupCol[r * n + 0]; - foreach (var a in new[] { A54, zeroCol, dupCol }) - { - var lo = new[] { -1f, -1f, -1f }; - var hi = new[] { 0.3f, 1f, 0.3f }; - var clean = new float[n]; - Solve(m, n, a, B5, lo, hi, clean, out var cleanIterations); - foreach (var junk in new[] { float.NaN, 7f, -1e30f }) - { - var workspace = new float[BoundedLeastSquares.WorkspaceLength(n)]; - Array.Fill(workspace, junk); - var x = new float[n]; - BoundedLeastSquares.Solve(m, n, a, B5, lo, hi, x, workspace, out var iterations); - Assert.Equal(clean, x); - Assert.Equal(cleanIterations, iterations); - } - } - } - - // Junk in the workspace, including NaN doubles, must not reach any decision: random boxed problems (which release - // bounds and use the tolerance) give bit-identical x and the same iteration count. - [Fact] - public void WorkspaceJunkNeverReachesTheSolveOnRandomProblems() - { - for (uint seed = 1; seed <= 25; seed++) - { - var (m, n, a, b, lo, hi) = RandomProblem(seed); - var clean = new float[n]; - Solve(m, n, a, b, lo, hi, clean, out var cleanIterations); - foreach (var junk in new[] { float.NaN, 7f, -1e30f }) - { - var workspace = new float[BoundedLeastSquares.WorkspaceLength(n)]; - Array.Fill(workspace, junk); - var x = new float[n]; - BoundedLeastSquares.Solve(m, n, a, b, lo, hi, x, workspace, out var iterations); - Assert.Equal(cleanIterations, iterations); - for (var j = 0; j < n; j++) - Assert.Equal(BitConverter.SingleToInt32Bits(clean[j]), BitConverter.SingleToInt32Bits(x[j])); - } - } - } - - [Fact] - public void WarmStartAtAVertexTakesNoIterations() - { - const int m = 4, n = 3; - var a = new[] { 1.0f, 0.2f, 0.1f, 0.1f, 1.0f, 0.3f, -0.2f, 0.2f, 1.0f, 0.5f, 0.5f, 0.4f }; - var b = new[] { 10f, -10f, 10f, 1f }; - var x = new[] { 1f, -1f, 1f }; - var status = Solve(m, n, a, b, Fill(-1f, n), Fill(1f, n), x, out var iterations); - Assert.Equal(BoundedLeastSquaresStatus.Converged, status); - Assert.Equal(0, iterations); - Assert.Equal(new[] { 1f, -1f, 1f }, x); - } - - [Fact] - public void IterationCountFollowsTheCapExactly() - { - var sawRelease = false; - for (uint seed = 1; seed <= 40; seed++) - { - var (m, n, a, b, lo, hi) = RandomProblem(seed); - var full = new float[n]; - Solve(m, n, a, b, lo, hi, full, out var needed); - var previousCost = double.MaxValue; - for (var cap = 0; cap <= needed; cap++) - { - var x = new float[n]; - var status = Solve(m, n, a, b, lo, hi, x, out var iterations, cap); - Assert.Equal(cap, iterations); - Assert.Equal(cap < needed ? BoundedLeastSquaresStatus.IterationLimit : BoundedLeastSquaresStatus.Converged, status); - for (var j = 0; j < n; j++) Assert.InRange(x[j], lo[j], hi[j]); - var cost = Cost(m, n, a, b, x); - Assert.True(cost <= previousCost * (1 + 1e-6) + 1e-9, $"seed {seed} cap {cap}: cost rose from {previousCost} to {cost}"); - previousCost = cost; - } - sawRelease |= needed > n; - } - Assert.True(sawRelease, "no random problem needed more iterations than columns, so no bound was ever released"); - } - - [Fact] - public void EachShortSpanIsRejected() - { - const int m = 5, n = 3; - var ws = new float[BoundedLeastSquares.WorkspaceLength(n)]; - var lo = Fill(-1f, n); - var hi = Fill(1f, n); - void Expect(Action call) => Assert.Contains("BoundedLeastSquares", Assert.Throws(call).Message); - Expect(() => BoundedLeastSquares.Solve(m, n, new float[m * n - 1], B5, lo, hi, new float[n], ws, out _)); - Expect(() => BoundedLeastSquares.Solve(m, n, A54, new float[m - 1], lo, hi, new float[n], ws, out _)); - Expect(() => BoundedLeastSquares.Solve(m, n, A54, B5, new float[n - 1], hi, new float[n], ws, out _)); - Expect(() => BoundedLeastSquares.Solve(m, n, A54, B5, lo, new float[n - 1], new float[n], ws, out _)); - Expect(() => BoundedLeastSquares.Solve(m, n, A54, B5, lo, hi, new float[n - 1], ws, out _)); - Expect(() => BoundedLeastSquares.Solve(m, n, A54, B5, lo, hi, new float[n], new float[ws.Length - 1], out _)); - Expect(() => BoundedLeastSquares.Solve(-1, n, A54, B5, lo, hi, new float[n], ws, out _)); - Expect(() => BoundedLeastSquares.Solve(m, -1, A54, B5, lo, hi, new float[n], ws, out _)); - // Exactly sized spans, and empty problems, are accepted. - BoundedLeastSquares.Solve(m, n, A54, B5, lo, hi, new float[n], ws, out _); - Assert.Equal(BoundedLeastSquaresStatus.Converged, BoundedLeastSquares.Solve(0, 0, default, default, default, default, default, default, out _)); - Assert.Equal(BoundedLeastSquaresStatus.Converged, BoundedLeastSquares.Solve(0, n, default, default, lo, hi, new float[n], ws, out _)); - } - - // Well-conditioned enough (m = n + 6) that x is comparable across scales. - private static (int m, int n, float[] a, float[] b, float[] lo, float[] hi) ScaleProblem(uint seed) - { - var rng = new CultMath.Random(seed * 104729u); - var n = rng.NextInt(3, 11); - var m = n + 6; - var a = new float[m * n]; - var b = new float[m]; - for (var i = 0; i < a.Length; i++) a[i] = rng.NextFloat(-1f, 1f); - for (var i = 0; i < m; i++) b[i] = rng.NextFloat(-3f, 3f); - var lo = new float[n]; - var hi = new float[n]; - for (var j = 0; j < n; j++) - { - lo[j] = rng.NextFloat(-0.6f, -0.05f); - hi[j] = rng.NextFloat(0.05f, 0.6f); - } - return (m, n, a, b, lo, hi); - } - - private static float[] Scaled(float[] v, float k) - { - var r = new float[v.Length]; - for (var i = 0; i < v.Length; i++) r[i] = v[i] * k; - return r; - } - - [Fact] - public void SolutionIsScaleFree() - { - var sawActive = false; - for (uint seed = 1; seed <= 40; seed++) - { - var (m, n, a, b, lo, hi) = ScaleProblem(seed); - var x = (float[])lo.Clone(); - Assert.Equal(BoundedLeastSquaresStatus.Converged, Solve(m, n, a, b, lo, hi, x, out _)); - AssertKkt(m, n, a, b, lo, hi, x); - for (var j = 0; j < n; j++) sawActive |= x[j] == lo[j] || x[j] == hi[j]; - - foreach (var k in new[] { 1e-6f, 1e3f, 1e6f }) - { - // b and the bounds scaled: x scales with them. - var xb = Scaled(lo, k); - var status = Solve(m, n, a, Scaled(b, k), Scaled(lo, k), Scaled(hi, k), xb, out _); - Assert.True(status == BoundedLeastSquaresStatus.Converged, $"seed {seed} k {k}: {status}"); - for (var j = 0; j < n; j++) - Assert.True(Math.Abs(xb[j] - x[j] * k) <= 1e-3 * Math.Abs(k) * (1 + Math.Abs(x[j])), $"seed {seed} b*{k} col {j}: {xb[j]} vs {x[j] * k}"); - - // A scaled: x scales inversely, so the bounds stay put and only b is scaled with A to keep the same box. - var xa = (float[])lo.Clone(); - var lo2 = Scaled(lo, 1f / k); - var hi2 = Scaled(hi, 1f / k); - for (var j = 0; j < n; j++) xa[j] = lo2[j]; - status = Solve(m, n, Scaled(a, k), b, lo2, hi2, xa, out _); - Assert.True(status == BoundedLeastSquaresStatus.Converged, $"seed {seed} A*{k}: {status}"); - for (var j = 0; j < n; j++) - Assert.True(Math.Abs(xa[j] - x[j] / k) <= 1e-3 * Math.Abs(1 / k) * (1 + Math.Abs(x[j])), $"seed {seed} A*{k} col {j}: {xa[j]} vs {x[j] / k}"); - } - } - Assert.True(sawActive); - } - - [Fact] - public void NonFiniteOrInvertedInputIsRejectedAndLeavesXUntouched() - { - const int m = 5, n = 3; - var nan = float.NaN; - var inf = float.PositiveInfinity; - var cases = new (string name, Action corrupt)[] - { - ("A NaN", (a, b, lo, hi) => a[4] = nan), - ("A +Inf", (a, b, lo, hi) => a[7] = inf), - ("A -Inf", (a, b, lo, hi) => a[1] = -inf), - ("b NaN", (a, b, lo, hi) => b[2] = nan), - ("b +Inf", (a, b, lo, hi) => b[0] = inf), - ("lo NaN", (a, b, lo, hi) => lo[1] = nan), - ("hi NaN", (a, b, lo, hi) => hi[2] = nan), - ("lo > hi", (a, b, lo, hi) => { lo[1] = 0.5f; hi[1] = 0.25f; }), - ("lo = +Inf", (a, b, lo, hi) => { lo[0] = inf; hi[0] = inf; }), - ("hi = -Inf", (a, b, lo, hi) => { lo[0] = -inf; hi[0] = -inf; }), - }; - foreach (var (name, corrupt) in cases) - { - var a = (float[])A54.Clone(); - var b = (float[])B5.Clone(); - var lo = Fill(-1f, n); - var hi = Fill(1f, n); - corrupt(a, b, lo, hi); - foreach (var start in new[] { new[] { 0.5f, -3f, 9f }, new[] { nan, 0f, 0f } }) - { - var x = (float[])start.Clone(); - var status = Solve(m, n, a, b, lo, hi, x, out var iterations); - Assert.True(status == BoundedLeastSquaresStatus.InvalidInput, $"{name}: {status}"); - Assert.Equal(0, iterations); - for (var j = 0; j < n; j++) - Assert.Equal(BitConverter.SingleToInt32Bits(start[j]), BitConverter.SingleToInt32Bits(x[j])); - } - } - } - - [Fact] - public void InfiniteBoundsMeanUnbounded() - { - const int m = 5, n = 3; - var inf = float.PositiveInfinity; - var x = new float[n]; - Assert.Equal(BoundedLeastSquaresStatus.Converged, Solve(m, n, A54, B5, Fill(-inf, n), Fill(inf, n), x, out _)); - var expected = NormalEquationSolution(m, n, A54, B5); - for (var j = 0; j < n; j++) Assert.Equal(expected[j], x[j], 1e-4); - - // One-sided, with an infinite warm start: the start is clamped to a finite point first. - var y = new[] { -inf, inf, float.NaN }; - var lo = new[] { -inf, 0.2f, -inf }; - var hi = new[] { inf, inf, 0.1f }; - Assert.Equal(BoundedLeastSquaresStatus.Converged, Solve(m, n, A54, B5, lo, hi, y, out _)); - AssertKkt(m, n, A54, B5, lo, hi, y); - Assert.All(y, v => Assert.True(float.IsFinite(v))); - } - - // Exact reference: every assignment of each column to {lo, hi, free}, free set solved in double. - private static double ExactBoxedCost(int m, int n, float[] a, float[] b, float[] lo, float[] hi, double pivotTol = 1e-9, double ridge = 0.0) - { - var maxDiag = 0.0; - for (var j = 0; j < n; j++) - { - var d = 0.0; - for (var r = 0; r < m; r++) d += (double)a[r * n + j] * a[r * n + j]; - maxDiag = Math.Max(maxDiag, d); - } - var best = double.MaxValue; - var total = 1; - for (var j = 0; j < n; j++) total *= 3; - var x = new double[n]; - for (var code = 0; code < total; code++) - { - var c = code; - var free = new System.Collections.Generic.List(); - for (var j = 0; j < n; j++) - { - var d = c % 3; c /= 3; - if (d == 0) x[j] = lo[j]; else if (d == 1) x[j] = hi[j]; else free.Add(j); - } - var k = free.Count; - if (k > 0) - { - var mat = new double[k, k + 1]; - for (var i = 0; i < k; i++) - { - for (var l = 0; l < k; l++) - for (var r = 0; r < m; r++) mat[i, l] += (double)a[r * n + free[i]] * a[r * n + free[l]]; - for (var r = 0; r < m; r++) - { - var fixedPart = 0.0; - for (var j = 0; j < n; j++) if (!free.Contains(j)) fixedPart += (double)a[r * n + j] * x[j]; - mat[i, k] += (double)a[r * n + free[i]] * (b[r] - fixedPart); - } - } - for (var i = 0; i < k; i++) mat[i, i] += ridge * maxDiag; - var ok = true; - for (var col = 0; col < k && ok; col++) - { - var piv = col; - for (var r = col + 1; r < k; r++) if (Math.Abs(mat[r, col]) > Math.Abs(mat[piv, col])) piv = r; - if (Math.Abs(mat[piv, col]) < pivotTol) { ok = false; break; } - for (var l = 0; l <= k; l++) (mat[col, l], mat[piv, l]) = (mat[piv, l], mat[col, l]); - for (var r = 0; r < k; r++) - { - if (r == col) continue; - var f = mat[r, col] / mat[col, col]; - for (var l = col; l <= k; l++) mat[r, l] -= f * mat[col, l]; - } - } - if (!ok) continue; - for (var i = 0; i < k; i++) - { - x[free[i]] = mat[i, k] / mat[i, i]; - if (x[free[i]] < lo[free[i]] - 1e-12 || x[free[i]] > hi[free[i]] + 1e-12) ok = false; - } - if (!ok) continue; - } - var cost = 0.0; - for (var r = 0; r < m; r++) - { - var s = -(double)b[r]; - for (var j = 0; j < n; j++) s += (double)a[r * n + j] * x[j]; - cost += s * s; - } - best = Math.Min(best, cost); - } - return best; - } - - // m < n with columns that are nearly parallel (condition number of A around 1e2), b outside the reachable set. - // The absolute scale of A must not matter: the KKT tolerance is relative, and the working iterate is double, so a - // 1e6 A with untouched bounds reaches the same optimum to the same allowance. - [Theory] - [InlineData(1f)] - [InlineData(1e6f)] - public void NearCollinearColumnsReachTheExactOptimum(float scale) - { - const int m = 2, n = 7; - for (var family = 0; family < 4; family++) - { - for (uint seed = 1; seed <= 120; seed++) - { - var rng = new CultMath.Random(seed * 15485863u + (uint)family * 977u); - var a = new float[m * n]; - for (var j = 0; j < n; j++) - { - switch (family) - { - case 0: a[j] = rng.NextFloat(0.5f, 1f); a[n + j] = 0.01f * rng.NextFloat(-1f, 1f); break; - case 1: a[j] = 1f + 0.02f * rng.NextFloat(-1f, 1f); a[n + j] = 0.01f * rng.NextFloat(-1f, 1f); break; - case 2: a[j] = rng.NextFloat(-1f, 1f); a[n + j] = 0.01f * a[j] + 0.01f * rng.NextFloat(-1f, 1f); break; - default: a[j] = rng.NextFloat(0.5f, 1f); a[n + j] = a[j] * 0.9f + 0.01f * rng.NextFloat(-1f, 1f); break; - } - } - for (var i = 0; i < a.Length; i++) a[i] *= scale; - var b = new[] { rng.NextFloat(-8f, 8f), rng.NextFloat(-8f, 8f) }; - var lo = new float[n]; - var hi = new float[n]; - for (var j = 0; j < n; j++) { lo[j] = rng.NextFloat() < 0.5f ? -1f : 0f; hi[j] = 1f; } - var x = new float[n]; - var status = Solve(m, n, a, b, lo, hi, x, out _); - Assert.True(status == BoundedLeastSquaresStatus.Converged, $"family {family} seed {seed}: {status}"); - var gap = Cost(m, n, a, b, x) - ExactBoxedCost(m, n, a, b, lo, hi); - var allowed = 1e-5 * (b[0] * b[0] + b[1] * b[1]); - Assert.True(gap <= allowed, $"family {family} seed {seed}: cost {gap} above the exact optimum (allowed {allowed})"); - } - } - } - - // A = U diag(s) V^T with s geometric from 1 down to 1/cond, so cond(A) is the requested value (float rounding of A - // perturbs it slightly). m x n with m >= n; b is generic, so it has a real residual when m > n. - private static (float[] a, float[] b) ConditionedProblem(int m, int n, double cond, uint seed, double aScale = 1.0, bool bAlongSmallestSingularVector = false) - { - var rng = new CultMath.Random(seed * 2654435761u + 17u); - double[][] Orthonormal(int dim, int count) - { - var q = new double[count][]; - for (var k = 0; k < count; k++) - { - var v = new double[dim]; - for (var i = 0; i < dim; i++) v[i] = rng.NextFloat(-1f, 1f); - for (var pass = 0; pass < 2; pass++) - for (var l = 0; l < k; l++) - { - var dot = 0.0; - for (var i = 0; i < dim; i++) dot += v[i] * q[l][i]; - for (var i = 0; i < dim; i++) v[i] -= dot * q[l][i]; - } - var norm = 0.0; - for (var i = 0; i < dim; i++) norm += v[i] * v[i]; - norm = Math.Sqrt(norm); - for (var i = 0; i < dim; i++) v[i] /= norm; - q[k] = v; - } - return q; - } - var u = Orthonormal(m, n); - var w = Orthonormal(n, n); - var a = new float[m * n]; - for (var k = 0; k < n; k++) - { - var s = aScale * Math.Pow(cond, -(double)k / Math.Max(1, n - 1)); - for (var r = 0; r < m; r++) - for (var c = 0; c < n; c++) - a[r * n + c] += (float)(u[k][r] * s * w[k][c]); - } - var b = new float[m]; - for (var i = 0; i < m; i++) b[i] = bAlongSmallestSingularVector ? (float)u[n - 1][i] : rng.NextFloat(-3f, 3f); - return (a, b); - } - - // A pass can end with the KKT test still failing and the cost exactly where it was: a dependent free column keeps a - // gradient above the tolerance, or a bound release lands on a column the factor drops. The solver must call that - // converged; if the stall rule mis-reads the cost scale or the progress, it spins to the iteration cap instead. - // Each kind is a fixed-seed batch of small boxed problems with warm starts on and off the bounds and pinned columns: - // rank-deficient (duplicate and zero columns), nearly collinear (1e-2 to 1e-5), integer-tied, wide, cond(A) 1e5 to - // 3e5, and "weak", where b lies along the weakest singular direction of a cond(A) 1e6 to 1e7 matrix so the gradient - // tolerance sits below the rounding noise with interior columns nonzero. Every kind must converge; all but "weak" - // (past the documented condition range) must also land within the documented cost gap, relative to |b|^2, of an - // exact enumeration of the active sets (a 1e-15 ridge keeps singular free sets solvable). - [Theory] - [InlineData("plain", 300)] - [InlineData("rankdef", 300)] - [InlineData("collinear", 300)] - [InlineData("integer-tie", 300)] - [InlineData("wide", 300)] - [InlineData("cond", 300)] - [InlineData("weak", 6000)] - public void StalledPassesConvergeInsteadOfSpinningToTheCap(string kind, int seeds) - { - var failures = new System.Collections.Generic.List(); - for (uint seed = 1; seed <= seeds; seed++) - { - var rng = new CultMath.Random(seed * 2246822519u + (uint)kind.Length * 3266489917u); - double Gauss() => Math.Sqrt(-2.0 * Math.Log(1.0 - rng.NextFloat() * 0.999999)) * Math.Cos(2.0 * Math.PI * rng.NextFloat()); - var n = rng.NextInt(2, 8); - var m = rng.NextInt(2, 12); - if (kind == "wide") m = Math.Max(1, n - rng.NextInt(1, 4)); - if (kind == "cond" || kind == "weak") m = n + rng.NextInt(0, 5); - float[] a; - float[] bWeak = null; - if (kind == "weak") - { - (a, bWeak) = ConditionedProblem(m, n, Math.Pow(10.0, 6.0 + rng.NextFloat()), seed, 1.0, true); - } - else if (kind == "cond") - { - a = ConditionedProblem(m, n, Math.Pow(10.0, 5.0 + 0.5 * rng.NextFloat()), seed).a; - } - else - { - a = new float[m * n]; - for (var i = 0; i < m; i++) - { - var row = new double[n]; - for (var j = 0; j < n; j++) row[j] = kind == "integer-tie" ? rng.NextInt(-2, 3) : Gauss(); - for (var j = 1; j < n; j++) - { - if (kind == "rankdef" && j % 3 == 1) row[j] = row[j - 1]; - if (kind == "rankdef" && j % 3 == 2) row[j] = 0.0; - if (kind == "collinear" && j % 2 == 1) row[j] = row[j - 1] + Math.Pow(10.0, -rng.NextInt(2, 6)) * Gauss(); - } - for (var j = 0; j < n; j++) a[i * n + j] = (float)row[j]; - } - } - var lo = new float[n]; - var hi = new float[n]; - var x = new float[n]; - var xt = new double[n]; - for (var j = 0; j < n; j++) - { - var w = Math.Pow(10.0, rng.NextInt(-2, 2)); - lo[j] = (float)(-w * rng.NextFloat()); - hi[j] = (float)(w * rng.NextFloat()); - if (rng.NextInt(6) == 0) hi[j] = lo[j]; - else if (rng.NextInt(8) == 0) lo[j] = hi[j] = 0f; - else if (rng.NextInt(8) == 0) { lo[j] = (float)(0.5 + rng.NextFloat()); hi[j] = lo[j] + 0.5f; } - if (kind == "weak") { var c = (float)Math.Pow(10.0, rng.NextInt(3, 9)); lo[j] = rng.NextInt(2) == 0 ? -c : 0f; hi[j] = c; } - if (kind == "integer-tie") { lo[j] = -1f; hi[j] = rng.NextInt(4) == 0 ? -1f : 1f; } - x[j] = rng.NextInt(3) == 0 ? lo[j] : rng.NextInt(2) == 0 ? hi[j] : lo[j] + (hi[j] - lo[j]) * rng.NextFloat(); - xt[j] = (lo[j] + hi[j]) / 2.0 + (hi[j] - lo[j]) * 3.0 * (rng.NextFloat() - 0.5); - } - var noise = Math.Pow(10.0, rng.NextInt(-2, 4)); - var mode = rng.NextInt(4); - var b = new float[m]; - for (var i = 0; i < m; i++) - { - var s = 0.0; - for (var j = 0; j < n; j++) s += (double)a[i * n + j] * xt[j]; - b[i] = bWeak != null ? bWeak[i] : mode == 0 ? 0f : (float)(s + noise * Gauss() * (mode == 3 ? 1.0 : 0.01)); - } - var status = Solve(m, n, a, b, lo, hi, x, out var iterations); - if (status != BoundedLeastSquaresStatus.Converged) - { - failures.Add($"seed {seed}: {status} after {iterations}"); - continue; - } - if (kind == "weak") continue; - var bb = 0.0; - foreach (var v in b) bb += (double)v * v; - if (bb == 0.0) bb = 1.0; - var gap = Cost(m, n, a, b, x) - ExactBoxedCost(m, n, a, b, lo, hi, 1e-300, 1e-15); - if (gap > 1e-5 * bb) failures.Add($"seed {seed}: cost gap {gap / bb:E2} of |b|^2"); - } - Assert.True(failures.Count == 0, $"{kind}: {failures.Count} of {seeds} failed; first: {string.Join("; ", failures.GetRange(0, Math.Min(3, failures.Count)))}"); - } - - // Unsaturated allocator-shaped ticks: 24 columns, every column free at the optimum. The solve is one Newton step; - // at most one more may confirm it. Before the working iterate moved to double, the float gradient's noise floor - // exceeded the tolerance from cond ~1e3 and the solver spun to the 100-iteration cap on optimal points. - [Theory] - [InlineData(1e2)] - [InlineData(1e3)] - [InlineData(1e4)] - public void UnsaturatedIllConditionedProblemsConvergeInAtMostTwoIterations(double cond) - { - const int m = 28, n = 24; - for (uint seed = 1; seed <= 30; seed++) - { - var (a, b) = ConditionedProblem(m, n, cond, seed); - var x = new float[n]; - var status = Solve(m, n, a, b, Fill(-1e9f, n), Fill(1e9f, n), x, out var iterations); - Assert.True(status == BoundedLeastSquaresStatus.Converged, $"cond {cond} seed {seed}: {status} after {iterations}"); - Assert.True(iterations <= 2, $"cond {cond} seed {seed}: {iterations} iterations"); - var bb = 0.0; - foreach (var v in b) bb += (double)v * v; - var expected = NormalEquationSolution(m, n, a, b); - var xs = new float[n]; - for (var j = 0; j < n; j++) xs[j] = (float)expected[j]; - Assert.True(Cost(m, n, a, b, x) - Cost(m, n, a, b, xs) <= 1e-6 * bb, $"cond {cond} seed {seed}: cost above the exact optimum"); - } - } - - // Near the pivot cutoff (cond(A) ~ 3e7, cond(A^T A) ~ 1e15) a Newton step leaves a free gradient above the - // tolerance on some problems. Those take one confirming iteration and stop because it lowers nothing; the rest - // are stationary at once. Never the cap, and never spinning. - [Fact] - public void NearSingularFreeSetsConfirmOnceAndStop() - { - const int m = 28, n = 24; - var confirmed = 0; - for (uint seed = 1; seed <= 60; seed++) - { - var (a, b) = ConditionedProblem(m, n, 3e7, seed); - var x = new float[n]; - var status = Solve(m, n, a, b, Fill(-1e9f, n), Fill(1e9f, n), x, out var iterations); - Assert.True(status == BoundedLeastSquaresStatus.Converged, $"seed {seed}: {status}"); - Assert.True(iterations <= 3, $"seed {seed}: {iterations} iterations"); - if (iterations > 0) confirmed++; - } - Assert.InRange(confirmed, 1, 20); - } - - // b along the smallest left singular vector makes the optimum lie along the weakest direction: |A^T b| is tiny, so - // the tolerance (1e-6 of it) sits below the rounding noise of the gradient (~1e-16 |A^T A| |x|) for cond(A) >= 1e6. - // The tolerance is then unreachable, and the solver must stop on the progress rule, after the handful of Newton - // passes that still lower the cost, never at the 100-iteration cap. - [Theory] - [InlineData(1e6)] - [InlineData(1e7)] - public void ToleranceBelowTheNoiseFloorStopsOnProgressNotOnTheCap(double cond) - { - const int m = 28, n = 24; - var confirmed = 0; - for (uint seed = 1; seed <= 30; seed++) - { - var (a, b) = ConditionedProblem(m, n, cond, seed, 1.0, bAlongSmallestSingularVector: true); - var x = new float[n]; - var status = Solve(m, n, a, b, Fill(-1e9f, n), Fill(1e9f, n), x, out var iterations); - Assert.True(status == BoundedLeastSquaresStatus.Converged, $"cond {cond} seed {seed}: {status} after {iterations}"); - Assert.True(iterations <= 6, $"cond {cond} seed {seed}: {iterations} iterations"); - if (iterations >= 1) confirmed++; - } - Assert.True(confirmed >= 15, $"cond {cond}: only {confirmed} of 30 reached the tolerance floor"); - } - - // Scale-freeness was only claimed for scaling A, b and the bounds together; A alone at 1e6 with the bounds left - // alone must also converge, and as fast. - [Theory] - [InlineData(1e2)] - [InlineData(1e4)] - public void LargeAWithUnscaledBoundsConvergesInAtMostTwoIterations(double cond) - { - const int m = 28, n = 24; - for (uint seed = 1; seed <= 30; seed++) - { - var (a, b) = ConditionedProblem(m, n, cond, seed, 1e6); - var x = new float[n]; - var status = Solve(m, n, a, b, Fill(-1e3f, n), Fill(1e3f, n), x, out var iterations); - Assert.True(status == BoundedLeastSquaresStatus.Converged, $"cond {cond} seed {seed}: {status} after {iterations}"); - Assert.True(iterations <= 2, $"cond {cond} seed {seed}: {iterations} iterations"); - } - } - - // b = 0 must not borrow its tolerance from the start point: a far start's own gradient is enormous, so that - // scale judged it converged after one iteration at cost 5001. The optimum here is ~1e-9. - [Fact] - public void ZeroTargetFromAFarStartIsNotJudgedConvergedByItsOwnSteepness() - { - var a = new[] { 1f, 1f, 1f, 1.0001f }; - var b = new float[2]; - var lo = new[] { 0.5f, -1e6f }; - var hi = new[] { 1e6f, 1e6f }; - var x = new[] { 1e6f, 1e6f }; - var status = Solve(2, 2, a, b, lo, hi, x, out _); - Assert.Equal(BoundedLeastSquaresStatus.Converged, status); - AssertKkt(2, 2, a, b, lo, hi, x); - Assert.True(Cost(2, 2, a, b, x) < 1e-6, $"cost {Cost(2, 2, a, b, x)}"); - Assert.True(x[0] >= 0.5f); - - // The origin inside the box: the answer is the origin, from any start. - var lo2 = new[] { -1e6f, -1e6f }; - var y = new[] { 1e6f, -1e6f }; - Assert.Equal(BoundedLeastSquaresStatus.Converged, Solve(2, 2, a, b, lo2, hi, y, out _)); - Assert.True(Cost(2, 2, a, b, y) < 1e-9, $"cost {Cost(2, 2, a, b, y)}"); - } - - // The tolerance scale is the gradient at the box point nearest the origin. Here that point is 1000 and the - // wanted release is a gradient of about 6e-5 against a 1000-scale problem: only a scale taken from the box point - // (about 6e-5, tolerance 6e-11) releases it; a scale taken from A^T b (about 1000, tolerance 1e-3) would not. - // Run with the excluded column in each position so the scale must come from the right entry. - [Fact] - public void ToleranceScaleIsTheGradientAtTheBoxPointNearestTheOrigin() - { - var a = new[] { 1f, 0f, 0f, 1f }; - foreach (var excluded in new[] { 0, 1 }) - { - var b = new float[2]; - var lo = new[] { -1e6f, -1e6f }; - var hi = new[] { 1e6f, 1e6f }; - b[excluded] = 1000.0001f; - lo[excluded] = 1000f; - hi[excluded] = 2000f; - var x = new float[2]; - x[excluded] = 1000f; - var status = Solve(2, 2, a, b, lo, hi, x, out var iterations); - Assert.Equal(BoundedLeastSquaresStatus.Converged, status); - Assert.True(iterations >= 1, $"column {excluded}: no release"); - Assert.Equal(b[excluded], x[excluded]); - Assert.Equal(0f, x[1 - excluded]); - } - } - - // b = 0 with the origin in the box and the start at the origin: the gradient is exactly zero and so is the - // tolerance. Nothing is violated, so the solve is over without an iteration. - [Fact] - public void ZeroTargetAtTheOriginTakesNoIterations() - { - const int m = 5, n = 3; - var x = new float[n]; - var status = Solve(m, n, A54, new float[m], Fill(-1f, n), Fill(1f, n), x, out var iterations); - Assert.Equal(BoundedLeastSquaresStatus.Converged, status); - Assert.Equal(0, iterations); - Assert.Equal(new float[n], x); - } - - // One column, A = [1], with b beyond the box: the first Newton step would cross a bound, so with a cap of 0 the - // solver returns the clamped start untouched. That makes the clamp itself the thing under test, for every - // combination of a finite, infinite or NaN start with finite and infinite bounds. - [Fact] - public void WarmStartClampIsExact() - { - var inf = float.PositiveInfinity; - var cases = new (float lo, float hi, float start, float expected)[] - { - (-1f, 1f, 0.25f, 0.25f), - (-1f, 1f, 5f, 1f), - (-1f, 1f, -5f, -1f), - (-1f, 1f, float.NaN, -1f), - (0.5f, 2f, inf, 2f), - (0.5f, 2f, -inf, 0.5f), - (0.2f, inf, inf, 0.2f), - (0.2f, inf, -inf, 0.2f), - (0.2f, inf, float.NaN, 0.2f), - (-inf, -2f, -inf, -2f), - (-inf, -2f, 3f, -2f), - (-inf, 3f, float.NaN, 0f), - (-inf, 3f, -inf, 0f), - (-2f, inf, float.NaN, -2f), - (-inf, -2f, float.NaN, -2f), - (-inf, 3f, 1f, 1f), - }; - foreach (var (l, h, start, expected) in cases) - { - // b beyond the box on the side the start is not on the bound of, so the step crosses a bound (or the - // start already sits on one). - var b = new[] { float.IsInfinity(h) ? l - 10f : h + 10f }; - if (float.IsInfinity(l) && float.IsInfinity(h)) b[0] = 0f; - var x = new[] { start }; - Solve(1, 1, new[] { 1f }, b, new[] { l }, new[] { h }, x, out var iterations, maxIterations: 0); - Assert.Equal(0, iterations); - Assert.True(x[0] == expected, $"box [{l}, {h}] start {start}: {x[0]}, expected {expected}"); - } - } - - // A duplicate column that is free and interior gets no step: the columns share one gradient, and moving both - // along it would overshoot. - [Fact] - public void FreeDuplicateColumnGetsNoStep() - { - const int m = 5, n = 3; - var a = (float[])A54.Clone(); - for (var r = 0; r < m; r++) a[r * n + 2] = a[r * n + 0]; - var x = new float[n]; - var status = Solve(m, n, a, B5, Fill(-100f, n), Fill(100f, n), x, out var iterations); - Assert.Equal(BoundedLeastSquaresStatus.Converged, status); - Assert.Equal(0, iterations); - Assert.Equal(0f, x[2]); - var reduced = new float[m * 2]; - for (var r = 0; r < m; r++) { reduced[r * 2] = a[r * n]; reduced[r * 2 + 1] = a[r * n + 1]; } - var xr = NormalEquationSolution(m, 2, reduced, B5); - Assert.Equal(xr[0], x[0], 1e-4); - Assert.Equal(xr[1], x[1], 1e-4); - } - - // Columns 1000x apart in scale and exactly parallel: the pivot tolerance is relative to the column's own - // diagonal, so it catches the duplicate at any absolute scale. - [Theory] - [InlineData(1e3f)] - [InlineData(1e-3f)] - public void ParallelColumnsAreDependentAtAnyAbsoluteScale(float scale) - { - const int m = 5, n = 3; - var a = (float[])A54.Clone(); - for (var r = 0; r < m; r++) a[r * n + 2] = 0.37f * a[r * n + 0]; - for (var i = 0; i < a.Length; i++) a[i] *= scale; - var lo = Fill(-1000f / scale, n); - var hi = Fill(1000f / scale, n); - var x = new float[n]; - var status = Solve(m, n, a, B5, lo, hi, x, out var iterations); - Assert.Equal(BoundedLeastSquaresStatus.Converged, status); - Assert.True(iterations <= 2, $"{iterations} iterations"); - var reduced = new float[m * 2]; - for (var r = 0; r < m; r++) { reduced[r * 2] = a[r * n]; reduced[r * 2 + 1] = a[r * n + 1]; } - var xr = NormalEquationSolution(m, 2, reduced, B5); - var xs = new float[n]; - xs[0] = (float)xr[0]; - xs[1] = (float)xr[1]; - var bb = 0.0; - foreach (var v in B5) bb += (double)v * v; - Assert.True(Cost(m, n, a, B5, x) - Cost(m, n, a, B5, xs) <= 1e-6 * bb, "cost above the reduced optimum"); - } - - // No path allocates: not the first InvalidInput, not a cap hit, not a solve that releases bounds. Each path is - // measured on its first call and again after, so a one-time runtime cost shows up as a difference between the two - // rather than as a solver allocation. - [Fact] - public void EveryPathAllocatesNothing() - { - const int m = 4, n = 3; - var a = new[] { 1.0f, 0.2f, 0.1f, 0.1f, 1.0f, 0.3f, -0.2f, 0.2f, 1.0f, 0.5f, 0.5f, 0.4f }; - var b = new[] { 10f, -10f, 10f, 1f }; - var lo = Fill(-1f, n); - var hi = Fill(1f, n); - var workspace = new float[BoundedLeastSquares.WorkspaceLength(n)]; - var x = new float[n]; - var bad = (float[])a.Clone(); - bad[4] = float.NaN; - - var paths = new (string name, float[] a, int cap, BoundedLeastSquaresStatus expected)[] - { - ("invalid", bad, 100, BoundedLeastSquaresStatus.InvalidInput), - ("converged", a, 100, BoundedLeastSquaresStatus.Converged), - ("limit", a, 1, BoundedLeastSquaresStatus.IterationLimit), - }; - foreach (var (name, matrix, cap, expected) in paths) - { - var bytes = new long[2]; - for (var call = 0; call < 2; call++) - { - Array.Clear(x); - var before = GC.GetAllocatedBytesForCurrentThread(); - var status = BoundedLeastSquares.Solve(m, n, matrix, b, lo, hi, x, workspace, out _, cap); - bytes[call] = GC.GetAllocatedBytesForCurrentThread() - before; - Assert.Equal(expected, status); - } - Assert.True(bytes[0] == 0 && bytes[1] == 0, $"{name}: first call {bytes[0]} B, second {bytes[1]} B"); - } - } -} diff --git a/packages/cultmath/tests/CultMath.Tests/CellularAndSminGradTests.cs b/packages/cultmath/tests/CultMath.Tests/CellularAndSminGradTests.cs deleted file mode 100644 index 465f6961..00000000 --- a/packages/cultmath/tests/CultMath.Tests/CellularAndSminGradTests.cs +++ /dev/null @@ -1,686 +0,0 @@ -using System; -using CultMath; -using Xunit; - -namespace CultMath.Tests; - -public sealed class CellularAndSminGradTests -{ - // Central-difference tolerances for smin_grad and cellular (F5), sized separately: the two - // functions have different curvature, so the same eps does not carry the same truncation - // error, and an earlier shared 3e-4 tolerance was fragile against both (Soul, cut 2a-i: measured - // max error smin 2.74e-4, cellular nearest 1.25e-4, cellular edge 1.88e-4 on a small sample — - // and re-measured here at 5.3e-4, 2.0e-4, 2.6e-4 on a larger one — against that 3e-4 tolerance, - // sometimes under 1.1x headroom, not the "~6e-5 noise" an earlier version of this comment - // claimed). Each tolerance below is set from its own measured worst case with real headroom. - - // smin_grad's value is exactly quadratic in position within a smoothing band (math.cs comment on - // smin_grad: h is affine in b.w - a.w, which is itself affine in position, so value(q) has zero - // third derivative there): central differences have no truncation error from curvature, only - // float32 rounding noise, so a larger eps directly dilutes that noise with no accuracy cost. - // Measured max error at eps=0.01f over 10,000 samples: 4.37e-5 (up from eps=0.001f's 5.3e-4, - // confirming the noise is eps-limited, not curvature-limited); this tolerance keeps about 5.7x - // headroom over that. A uniform-scale mutant on the returned gradient (a 1.01x M9/M16-style - // mutant applied here) still dies down to a 1.0005x scale (0.05%); it survives at 1.0002x. - private const float SminGradientTolerance = 2.5e-4f; - - // cellular's F1/F2 are Euclidean distance fields: real curvature away from a feature point means - // central differences DO carry O(eps^2) truncation error, so (unlike smin_grad) a larger eps - // makes things worse, not better; a sweep at eps in {0.0002f, 0.0003f, 0.0005f, 0.0007f, 0.001f} - // over 8,000 samples found the committed eps=0.0005f already close to the sweet spot. Measured - // max error at that eps: nearest 2.04e-4, edge 2.57e-4; this tolerance keeps just over 5x - // headroom over the larger of the two. M9 (grad1 * 1.01) and M16 (grad2 * 1.01) both still die - // down to a 1.002x scale (0.2%); both survive at 1.001x. - private const float CellularGradientTolerance = 1.3e-3f; - - // ---- smin_grad ---- - - [Fact] - public void SminValueNeverExceedsTheOrdinaryMinimum() - { - var random = new System.Random(0xACE1); - for (var i = 0; i < 256; i++) - { - var a = new float4(RandomUnit(random), RandomScalar(random)); - var b = new float4(RandomUnit(random), RandomScalar(random)); - var k = 0.05f + random.NextSingle() * 5.0f; - - var result = math.smin_grad(a, b, k); - - Assert.True(result.w <= math.min(a.w, b.w) + 1.0e-4f, $"a.w={a.w} b.w={b.w} k={k} smin={result.w}"); - } - } - - [Fact] - public void SminMatchesTheHardMinimumAndItsGradientOutsideTheBand() - { - var random = new System.Random(0xACE2); - for (var i = 0; i < 64; i++) - { - var gradA = RandomUnit(random); - var gradB = RandomUnit(random); - var k = 0.1f + random.NextSingle() * 2.0f; - - // Push b comfortably above a, past the smoothing band, so a is the ordinary minimum. - var aw = random.NextSingle() * 10.0f - 5.0f; - var bw = aw + k * (1.5f + random.NextSingle()); - var a = new float4(gradA, aw); - var b = new float4(gradB, bw); - - var lower = math.smin_grad(a, b, k); - Assert.Equal(aw, lower.w, precision: 4); - Assert.Equal(gradA.x, lower.x, precision: 4); - Assert.Equal(gradA.y, lower.y, precision: 4); - Assert.Equal(gradA.z, lower.z, precision: 4); - - // Argument order must not matter: b below a is symmetric. - var higher = math.smin_grad(b, a, k); - Assert.Equal(aw, higher.w, precision: 4); - Assert.Equal(gradA.x, higher.x, precision: 4); - Assert.Equal(gradA.y, higher.y, precision: 4); - Assert.Equal(gradA.z, higher.z, precision: 4); - } - } - - [Fact] - public void SminGradientMatchesCentralDifferencesOfItsOwnValue() - { - var random = new System.Random(0xACE3); - var tested = 0; - while (tested < 200) - { - var cf = RandomUnit(random); - var cg = RandomUnit(random); - var offF = random.NextSingle() * 4.0f - 2.0f; - var offG = random.NextSingle() * 4.0f - 2.0f; - var k = 0.2f + random.NextSingle() * 2.0f; - var p = new float3(random.NextSingle() * 4.0f - 2.0f, random.NextSingle() * 4.0f - 2.0f, random.NextSingle() * 4.0f - 2.0f); - - float F(float3 q) => math.dot(q, cf) + offF; - float G(float3 q) => math.dot(q, cg) + offG; - float Smin(float3 q) => math.smin_grad(new float4(cf, F(q)), new float4(cg, G(q)), k).w; - - // Stay clear of the |a.w - b.w| = k seam: central differences straddling a kink would - // disagree with either side's analytic gradient (the same reason cellular excludes a - // band around its own F1 = F2 set). The band has an absolute floor, not just a - // fraction of k: eps grew to 0.01f below (SminGradientTolerance's comment), and a - // fraction-of-k-only band could be narrower than the diff can shift by eps*|cf-cg| - // (up to about 0.035 here) when k is small, letting p +/- eps step across the seam even - // though p itself was clear of it. - var diff = F(p) - G(p); - if (Math.Abs(Math.Abs(diff) - k) < Math.Max(0.05f * k, 0.05f)) - continue; - - tested++; - var analytic = math.smin_grad(new float4(cf, F(p)), new float4(cg, G(p)), k); - - // eps=0.01f, not 0.001f: smin is exactly quadratic within a band (no truncation error - // from curvature; see SminGradientTolerance's comment), so the larger eps only dilutes - // float32 rounding noise, at no accuracy cost, while still comfortably inside the band - // exclusion above. - const float eps = 0.01f; - var numeric = new float3( - (Smin(p + new float3(eps, 0.0f, 0.0f)) - Smin(p - new float3(eps, 0.0f, 0.0f))) / (2.0f * eps), - (Smin(p + new float3(0.0f, eps, 0.0f)) - Smin(p - new float3(0.0f, eps, 0.0f))) / (2.0f * eps), - (Smin(p + new float3(0.0f, 0.0f, eps)) - Smin(p - new float3(0.0f, 0.0f, eps))) / (2.0f * eps)); - - AssertWithinTolerance(numeric.x, analytic.x, SminGradientTolerance); - AssertWithinTolerance(numeric.y, analytic.y, SminGradientTolerance); - AssertWithinTolerance(numeric.z, analytic.z, SminGradientTolerance); - } - } - - [Fact] - public void SminGradientIsNotAConstantBlendOrTheSwappedFactor() - { - // Falsifies two named mutants directly: blending the gradient with a constant 0.5 instead of - // h, or with (1 - h) instead of h. Here a is comfortably the minimum (h saturates to 1 in the - // correct formula), so the correct gradient is exactly a's, unmixed. A constant-0.5 blend or a - // swapped (1 - h) blend both pull in b's gradient, which differs from a's here. - var a = new float4(1.0f, 0.0f, 0.0f, -3.0f); - var b = new float4(0.0f, 1.0f, 0.0f, 5.0f); - - var result = math.smin_grad(a, b, 0.25f); - - Assert.Equal(1.0f, result.x, precision: 5); - Assert.Equal(0.0f, result.y, precision: 5); - Assert.Equal(0.0f, result.z, precision: 5); - Assert.Equal(-3.0f, result.w, precision: 5); - } - - // ---- cellular ---- - - [Fact] - public void F1NeverExceedsF2() - { - var random = new System.Random(0xCE11); - for (var i = 0; i < 256; i++) - { - var p = new float3(random.NextSingle() * 20.0f - 10.0f, random.NextSingle() * 20.0f - 10.0f, random.NextSingle() * 20.0f - 10.0f); - var result = math.cellular(p); - Assert.True(result.edge.w >= -1.0e-4f, $"F2 - F1 = {result.edge.w} at {p.x},{p.y},{p.z}"); - - // Both returned .w values, checked against the independent 7x7x7 brute-force oracle (F1, - // F6), not a value the test derived some other way. - var (f1, f2) = FindF1F2(p); - Assert.Equal(f1, result.nearest.w, precision: 3); - Assert.Equal(f2 - f1, result.edge.w, precision: 3); - } - } - - [Fact] - public void NearestFeatureWinsEvenWhenItIsInTheLastVisitedNeighbourCell() - { - // math.cellular's own search order visits neighbour offsets with dz outermost and dx - // innermost over a 5x5x5 block (F6), and it also prunes cells whose position-only lower - // bound already exceeds the running F2. Offsets with any |component| = 2 are not provably - // unreachable as F1: their minimum possible distance is 1 (e.g. (2, 0, 0), whose lower bound - // per the exactness proof in math.cs is max(0, 2-1) = 1), well inside the query's own cell's - // sqrt(3) ceiling, so a |component| = 2 offset winning F1 is rare rather than impossible - // (Soul, cut 2a-i: measured 3 F1 wins in 2,000,000 samples). It is still rare enough that the - // 3x3x3 corners remain the practically-reachable "last" cases for this test. (dx, dy, dz) - // = (1, 1, 1) is the last of those the fixed iteration order visits, and if pruning or search - // order were broken, this is the offset most likely to be skipped. Rather than hand-picking a - // hash value, search random points for one whose true nearest feature (by independent brute - // force over the same 5x5x5 block) happens to live in exactly that cell, at a comfortable - // margin from the F1 = F2 seam, then check math.cellular agrees. - var random = new System.Random(0xCE14); - var lastOffset = new float3(1.0f, 1.0f, 1.0f); - for (var attempt = 0; attempt < 5000; attempt++) - { - var p = new float3(random.NextSingle() * 8.0f - 4.0f, random.NextSingle() * 8.0f - 4.0f, random.NextSingle() * 8.0f - 4.0f); - var (offset, f1, f2) = FindNearestOffset(p); - if (!offset.Equals(lastOffset) || f1 < 0.05f || f2 - f1 < 0.05f) - continue; - - var cell = new float3(MathF.Floor(p.x), MathF.Floor(p.y), MathF.Floor(p.z)); - var feature = FeaturePoint(cell + lastOffset); - var expectedGradient = (p - feature) / f1; - - var result = math.cellular(p); - Assert.Equal(f1, result.nearest.w, precision: 4); - Assert.Equal(expectedGradient.x, result.nearest.x, precision: 4); - Assert.Equal(expectedGradient.y, result.nearest.y, precision: 4); - Assert.Equal(expectedGradient.z, result.nearest.z, precision: 4); - return; - } - - Assert.Fail("did not find a random point whose nearest feature lives in the last-visited neighbour cell"); - } - - [Fact] - public void NearestGradientIsFiniteZeroAtAFeaturePointInsteadOfNaN() - { - var feature = FeaturePoint(new float3(4.0f, -2.0f, 9.0f)); - var result = math.cellular(feature); - - Assert.Equal(0.0f, result.nearest.w, precision: 4); - Assert.True(float.IsFinite(result.nearest.x) && float.IsFinite(result.nearest.y) && float.IsFinite(result.nearest.z)); - Assert.Equal(0.0f, result.nearest.x, precision: 5); - Assert.Equal(0.0f, result.nearest.y, precision: 5); - Assert.Equal(0.0f, result.nearest.z, precision: 5); - } - - [Fact] - public void GradientMatchesCentralDifferencesAwayFromTheF1EqualsF2Seam() - { - var random = new System.Random(0xCE12); - var tested = 0; - var attempts = 0; - while (tested < 120 && attempts < 20000) - { - attempts++; - var p = new float3(random.NextSingle() * 6.0f - 3.0f, random.NextSingle() * 6.0f - 3.0f, random.NextSingle() * 6.0f - 3.0f); - var (f1, f2, f3) = FindF1F2F3(p); - - // Exclude a band around the F1 = F2 set: the identity of the nearest and second-nearest - // feature swap there, a genuine kink in F1 and F2 alike, not something central differences - // can approximate. Also exclude a band around F2 = F3: edge (F2 - F1) kinks there too, - // since the second-nearest feature's identity swaps (Soul, cut 2a-i: an unfiltered sample - // hit a 0.78 edge error this way). Also skip the exact feature point itself (F1 = 0), - // covered separately. - if (f2 - f1 < 0.05f || f1 < 0.01f || f3 - f2 < 0.05f) - continue; - - tested++; - var result = math.cellular(p); - - // eps=0.0005f: unlike smin_grad, cellular's F1/F2 are Euclidean distance fields with real - // curvature, so central differences carry O(eps^2) truncation error and a larger eps is - // worse, not better (see CellularGradientTolerance's comment for the measured sweep). - const float eps = 0.0005f; - - // Differentiate cellular's own returned .w values (F1): calling cellular again at p +/- - // eps and reading result.nearest.w / result.edge.w, not a duplicate FindF1F2 search. A - // mutant that corrupts only the returned gradient, leaving the returned value alone, - // would survive a check built on an independent value oracle; it cannot survive this one. - float NearestValueAt(float3 q) => math.cellular(q).nearest.w; - float EdgeValueAt(float3 q) => math.cellular(q).edge.w; - - AssertGradientMatchesCentralDifference(NearestValueAt, p, eps, result.nearest, CellularGradientTolerance); - AssertGradientMatchesCentralDifference(EdgeValueAt, p, eps, result.edge, CellularGradientTolerance); - } - - Assert.True(tested >= 100, $"only {tested} of {attempts} random points landed away from the F1=F2 band"); - } - - [Fact] - public void IdIsConstantAcrossAFeaturesWholeRegionAndVariesBetweenCells() - { - var random = new System.Random(0xCE13); - var baseCell = new float3(2.0f, 2.0f, 2.0f); - var feature = FeaturePoint(baseCell); - var idAtFeature = math.cellular(feature).id; - - for (var i = 0; i < 32; i++) - { - // Small offsets that stay inside the same Voronoi region (well short of any neighbour). - var jitter = new float3(random.NextSingle() * 0.2f - 0.1f, random.NextSingle() * 0.2f - 0.1f, random.NextSingle() * 0.2f - 0.1f); - var nearby = feature + jitter; - if (!FindNearestCell(nearby).Equals(baseCell)) - continue; - - Assert.Equal(idAtFeature, math.cellular(nearby).id, precision: 6); - } - - var otherCellId = math.cellular(FeaturePoint(baseCell + new float3(5.0f, 0.0f, 0.0f))).id; - Assert.NotEqual(idAtFeature, otherCellId); - } - - [Fact] - public void IdIsNotCorrelatedWithAnyJitterComponent() - { - // F4: id must come from hash bits the jitter never uses. A mutant that takes id from - // hash.x (or hash.y) makes id literally equal to that jitter component, which this catches - // as a correlation near 1.0, far past the 0.05 threshold. n = 20000 keeps sampling noise - // (measured under 0.01 at this n) well clear of that threshold. - const int n = 20000; - var random = new System.Random(0xCE15); - var ids = new double[n]; - var jitterX = new double[n]; - var jitterY = new double[n]; - var jitterZ = new double[n]; - - for (var i = 0; i < n; i++) - { - var cell = new float3(random.Next(-2000, 2000), random.Next(-2000, 2000), random.Next(-2000, 2000)); - var hash = math.pcg3d(math.int3(cell)); - var jitter = new float3(Unit(hash.x), Unit(hash.y), Unit(hash.z)); - - ids[i] = math.cellular(cell + jitter).id; - jitterX[i] = jitter.x; - jitterY[i] = jitter.y; - jitterZ[i] = jitter.z; - } - - Assert.True(Math.Abs(Correlation(ids, jitterX)) < 0.05, "id correlates with jitter.x"); - Assert.True(Math.Abs(Correlation(ids, jitterY)) < 0.05, "id correlates with jitter.y"); - Assert.True(Math.Abs(Correlation(ids, jitterZ)) < 0.05, "id correlates with jitter.z"); - } - - [Fact] - public void JitterAndIdPassAChiSquareUniformityTest() - { - // F4/jitter uniformity: cellular hashes the winning cell's INTEGER coordinate (design.md, - // "Integer hashing"), never that coordinate's float32 bit pattern. Hashing the bit pattern of - // an integer-valued float shares more structure between adjacent cells (same exponent, a - // mantissa one increment apart) than the integers themselves do going into pcg3d's own - // mixing, and that structure survived into the jitter: Soul measured jitter.x's marginal - // chi-square statistic at 203.8 over 1,000,000 cells this way (15 degrees of freedom; - // critical value 37.7 at alpha = 0.001), soundly rejecting uniformity. 16 equal-width bins - // over [0, 1) give those same 15 degrees of freedom for jitter.x/y/z and id alike. - const int n = 200_000; - const int bins = 16; - const double criticalValue = 37.7; // chi-square, 15 df, alpha = 0.001 - - var jitterXCounts = new int[bins]; - var jitterYCounts = new int[bins]; - var jitterZCounts = new int[bins]; - var idCounts = new int[bins]; - var random = new System.Random(0xCE19); - - for (var i = 0; i < n; i++) - { - var cell = new float3(random.Next(-100000, 100000), random.Next(-100000, 100000), random.Next(-100000, 100000)); - var hash = math.pcg3d(math.int3(cell)); - jitterXCounts[Bin(Unit(hash.x), bins)]++; - jitterYCounts[Bin(Unit(hash.y), bins)]++; - jitterZCounts[Bin(Unit(hash.z), bins)]++; - - var idHash = math.pcg4d(new int4(math.int3(cell), 0)); - idCounts[Bin(Unit(idHash.w), bins)]++; - } - - var expected = (double)n / bins; - AssertUniform(jitterXCounts, expected, criticalValue, "jitter.x"); - AssertUniform(jitterYCounts, expected, criticalValue, "jitter.y"); - AssertUniform(jitterZCounts, expected, criticalValue, "jitter.z"); - AssertUniform(idCounts, expected, criticalValue, "id"); - } - - private static int Bin(float value, int binCount) => Math.Clamp((int)(value * binCount), 0, binCount - 1); - - private static void AssertUniform(int[] counts, double expected, double criticalValue, string name) - { - var chiSquare = 0.0; - foreach (var count in counts) - { - var delta = count - expected; - chiSquare += delta * delta / expected; - } - - Assert.True(chiSquare < criticalValue, $"{name} chi-square {chiSquare:G6} exceeds critical value {criticalValue} ({counts.Length - 1} df)"); - } - - [Fact] - public void NoNaNAtIntegerPointsNear2To24() - { - // F3: past |p| of about 2^23, distinct cells' jittered features can round to the same - // float32 value, making F2 = 0 reachable. Soul measured 4096 of 4096 integer points at 2^24 - // producing NaN before the guard; this pins the fix over the same sample. - const float scale = 16777216.0f; // 2^24 - for (var ix = 0; ix < 16; ix++) - for (var iy = 0; iy < 16; iy++) - for (var iz = 0; iz < 16; iz++) - { - var p = new float3(scale + ix, scale + iy, scale + iz); - var result = math.cellular(p); - - Assert.True( - float.IsFinite(result.nearest.x) && float.IsFinite(result.nearest.y) && - float.IsFinite(result.nearest.z) && float.IsFinite(result.nearest.w), - $"nearest not finite at {p.x},{p.y},{p.z}: {result.nearest.x},{result.nearest.y},{result.nearest.z},{result.nearest.w}"); - Assert.True( - float.IsFinite(result.edge.x) && float.IsFinite(result.edge.y) && - float.IsFinite(result.edge.z) && float.IsFinite(result.edge.w), - $"edge not finite at {p.x},{p.y},{p.z}: {result.edge.x},{result.edge.y},{result.edge.z},{result.edge.w}"); - Assert.True(float.IsFinite(result.id), $"id not finite at {p.x},{p.y},{p.z}"); - } - } - - [Fact] - public void CellularUnitMapsAllOnesBitsBelowOne() - { - // F7: cellular_unit(0xFFFFFFFF) must land in [0, 1), not round up to exactly 1.0f. - var mapped = math.cellular_unit(-1); // -1's bit pattern is 0xFFFFFFFF. - Assert.True(mapped >= 0.0f && mapped < 1.0f, $"cellular_unit(0xFFFFFFFF) = {mapped}"); - } - - [Fact] - public void ProductionSearchMatchesTheBruteForceOracleExactly() - { - // F6, part 2: the production 5x5x5 search must equal the independent 7x7x7 oracle exactly - // (to the oracle's own float32 precision), over a large seeded sweep in the domain Soul - // measured ([-50, 50]^3, where 3x3x3 missed 9 of 400,000 points). - var random = new System.Random(0xCE16); - for (var i = 0; i < 20000; i++) - { - var p = new float3(random.NextSingle() * 100.0f - 50.0f, random.NextSingle() * 100.0f - 50.0f, random.NextSingle() * 100.0f - 50.0f); - var result = math.cellular(p); - var (f1, f2) = FindF1F2(p); - - Assert.Equal(f1, result.nearest.w, precision: 4); - Assert.Equal(f2 - f1, result.edge.w, precision: 4); - } - } - - [Fact] - public void SearchReachesEveryAxisAlignedRadiusTwoSlice() - { - // F6, part 2 (Soul finding): a single-slice loop-bound mutant on one axis (e.g. dz < 2 - // instead of dz <= 2, silently dropping the +2 layer on that axis alone) still passed the - // random 20,000-point sweeps above. That is not those sweeps being weak; it is the - // exactness proof's own margin (F1, F2 always < sqrt(3), a radius-2 cell's lower bound is - // max(0, 2-1) = 1, not "exactly 2" as an earlier version of this comment claimed) making a - // genuine win from any single |offset component| = 2 cell rare over uniformly random points - // (Soul, cut 2a-i: measured about 1 hit in 28,000 samples, not the "zero in 3,000,000" an - // earlier version of this comment claimed). - // Hunting for one by choosing candidate query POINTS is the wrong end of the search; this - // instead scans candidate integer CELLS and, for each, builds the query point most likely to - // realize that cell's neighbour as the winner: the closest point of the query cell's own unit - // cube to the target neighbour's actual jittered feature. That construction finds a real - // example for every one of the six axis-aligned radius-2 offsets within a couple thousand - // candidate cells, deterministically (pcg3d has no randomness to get lucky or unlucky with). - foreach (var offset in AxisAlignedRadiusTwoOffsets) - { - var p = FindPointRealizingOffset(offset); - var (f1, f2) = FindF1F2(p); // independent 7x7x7 oracle - var result = math.cellular(p); - Assert.Equal(f1, result.nearest.w, precision: 3); - Assert.Equal(f2 - f1, result.edge.w, precision: 3); - } - } - - // The six axis-aligned radius-2 offsets (F6, part 2): shared by - // SearchReachesEveryAxisAlignedRadiusTwoSlice and, via FindPointRealizingOffset, by - // HlslSourceCompatibilityTests' mirror-comparison inputs, so both pin the same construction. - internal static readonly float3[] AxisAlignedRadiusTwoOffsets = - { - new(2.0f, 0.0f, 0.0f), new(-2.0f, 0.0f, 0.0f), - new(0.0f, 2.0f, 0.0f), new(0.0f, -2.0f, 0.0f), - new(0.0f, 0.0f, 2.0f), new(0.0f, 0.0f, -2.0f), - }; - - // Hunting for a query point where a radius-2 offset wins F1 or F2 by choosing candidate query - // POINTS is the wrong end of the search (Soul, cut 2a-i: about 1 hit in 28,000 uniformly random - // points); this instead scans candidate integer CELLS and, for each, builds the query point most - // likely to realize that cell's neighbour as the winner: the closest point of the query cell's - // own unit cube to the target neighbour's actual jittered feature. That construction finds a - // real example for every one of the six axis-aligned radius-2 offsets within a couple thousand - // candidate cells, deterministically (pcg3d has no randomness to get lucky or unlucky with). - internal static float3 FindPointRealizingOffset(float3 offset) - { - for (var qz = -80; qz <= 80; qz++) - for (var qy = -80; qy <= 80; qy++) - for (var qx = -80; qx <= 80; qx++) - { - var q = new float3(qx, qy, qz); - var targetFeature = FeaturePoint(q + offset); - var p = new float3( - Math.Clamp(targetFeature.x, q.x, q.x + 0.999f), - Math.Clamp(targetFeature.y, q.y, q.y + 0.999f), - Math.Clamp(targetFeature.z, q.z, q.z + 0.999f)); - - var (f1, f2) = FindF1F2(p); // independent 7x7x7 oracle - var d = math.distance(p, targetFeature); - if (MathF.Abs(d - f1) > 1.0e-4f && MathF.Abs(d - f2) > 1.0e-4f) - continue; // this cell didn't realize the target offset as F1 or F2; try another. - - return p; - } - - throw new InvalidOperationException($"could not construct a query point where offset {offset.x},{offset.y},{offset.z} ever wins F1 or F2"); - } - - [Fact] - public void PrunedSearchIsBitIdenticalToTheUnprunedFiveCubedSearchBelowTwoToTheTwentyFifth() - { - // F6, part 2: the lower-bound prune inside math.cellular's loop must never change the - // result. Compares against an independent, unpruned 5x5x5 reference that visits every cell - // in the same fixed order, over a large seeded sweep in [-50, 50]^3. - // - // This bit-identity has a domain: it holds for |p| < 2^25 (design.md, "Precision domain"). - // Above that, cell + offset starts to round in float32, so the pruned loop's recomputed - // neighbour and the unpruned loop's neighbour at the same nominal (dx, dy, dz) can land on - // different actual cells, and the two searches diverge (Soul, cut 2a-i: 180 of 1,100,000 - // points differ at |p| ~= 3.4e7). [-50, 50]^3 is comfortably inside the proven domain. - var random = new System.Random(0xCE18); - for (var i = 0; i < 20000; i++) - { - var p = new float3(random.NextSingle() * 100.0f - 50.0f, random.NextSingle() * 100.0f - 50.0f, random.NextSingle() * 100.0f - 50.0f); - var pruned = math.cellular(p); - var unpruned = UnprunedCellular5x5x5(p); - - Assert.Equal(unpruned.nearest.x, pruned.nearest.x); - Assert.Equal(unpruned.nearest.y, pruned.nearest.y); - Assert.Equal(unpruned.nearest.z, pruned.nearest.z); - Assert.Equal(unpruned.nearest.w, pruned.nearest.w); - Assert.Equal(unpruned.edge.x, pruned.edge.x); - Assert.Equal(unpruned.edge.y, pruned.edge.y); - Assert.Equal(unpruned.edge.z, pruned.edge.z); - Assert.Equal(unpruned.edge.w, pruned.edge.w); - Assert.Equal(unpruned.id, pruned.id); - } - } - - // Independent of math.cellular's own pruning: visits every cell of the 5x5x5 block, in the same - // fixed order, unconditionally. - private static CultCellular UnprunedCellular5x5x5(float3 p) - { - var cell = new float3(MathF.Floor(p.x), MathF.Floor(p.y), MathF.Floor(p.z)); - var f1 = 1.0e30f; - var f2 = 1.0e30f; - var c1 = float3.zero; - var c2 = float3.zero; - var cellId = float3.zero; - - for (var dz = -2; dz <= 2; dz++) - for (var dy = -2; dy <= 2; dy++) - for (var dx = -2; dx <= 2; dx++) - { - var neighbor = cell + new float3(dx, dy, dz); - var feature = FeaturePoint(neighbor); - var d = math.distance(p, feature); - if (d < f1) { f2 = f1; c2 = c1; f1 = d; c1 = feature; cellId = neighbor; } - else if (d < f2) { f2 = d; c2 = feature; } - } - - var grad1 = f1 > 0.0f ? (p - c1) / f1 : float3.zero; - var grad2 = f2 > 0.0f ? (p - c2) / f2 : float3.zero; - var idHash = math.pcg4d(new int4(math.int3(cellId), 0)); - return new CultCellular(new float4(grad1, f1), new float4(grad2 - grad1, f2 - f1), Unit(idHash.w)); - } - - private static double Correlation(double[] a, double[] b) - { - var n = a.Length; - var meanA = 0.0; - var meanB = 0.0; - for (var i = 0; i < n; i++) { meanA += a[i]; meanB += b[i]; } - meanA /= n; - meanB /= n; - - var cov = 0.0; - var varA = 0.0; - var varB = 0.0; - for (var i = 0; i < n; i++) - { - var da = a[i] - meanA; - var db = b[i] - meanB; - cov += da * db; - varA += da * da; - varB += db * db; - } - - return cov / Math.Sqrt(varA * varB); - } - - // ---- shared fixtures: an independent brute-force Worley search over the same public pcg3d - // hash cellular itself is documented to use (design.md, "Integer hashing"), so these tests do - // not anchor to cellular's own control flow or iteration order. FindF1F2 searches 7x7x7 (radius - // 3), strictly wider than production's own proven-sufficient 5x5x5 (F6), so it is a true - // independent oracle rather than a second copy of the algorithm under test. ---- - - internal static float3 FeaturePoint(float3 cell) - { - var hash = math.pcg3d(math.int3(cell)); - var jitter = new float3(Unit(hash.x), Unit(hash.y), Unit(hash.z)); - return cell + jitter; - } - - // Mirrors math.cellular_unit's top-24-bit [0, 1) mapping (F7) exactly, so this fixture never - // drifts from production's own jitter/id mapping. - private static float Unit(int bits) => ((uint)bits >> 8) * (1.0f / 16777216.0f); - - private static (float f1, float f2) FindF1F2(float3 p) - { - var (f1, f2, _) = FindF1F2F3(p); - return (f1, f2); - } - - // Same independent 7x7x7 oracle as FindF1F2, plus the third-nearest distance. Central-difference - // tests need this: excluding only the F1=F2 gap is not enough, because a small F2=F3 gap is also - // a kink (the identity of the second-nearest feature swaps there), and hitting one produces a - // large, curvature-driven finite-difference error unrelated to eps or float noise (Soul, cut - // 2a-i: an unfiltered large sample hit an edge error of 0.78 this way, versus ~2e-4 once F2=F3 - // is also excluded). - private static (float f1, float f2, float f3) FindF1F2F3(float3 p) - { - var cell = new float3(MathF.Floor(p.x), MathF.Floor(p.y), MathF.Floor(p.z)); - var f1 = float.PositiveInfinity; - var f2 = float.PositiveInfinity; - var f3 = float.PositiveInfinity; - for (var dz = -3; dz <= 3; dz++) - for (var dy = -3; dy <= 3; dy++) - for (var dx = -3; dx <= 3; dx++) - { - var d = math.distance(p, FeaturePoint(cell + new float3(dx, dy, dz))); - if (d < f1) { f3 = f2; f2 = f1; f1 = d; } - else if (d < f2) { f3 = f2; f2 = d; } - else if (d < f3) { f3 = d; } - } - - return (f1, f2, f3); - } - - private static (float3 offset, float f1, float f2) FindNearestOffset(float3 p) - { - var cell = new float3(MathF.Floor(p.x), MathF.Floor(p.y), MathF.Floor(p.z)); - var f1 = float.PositiveInfinity; - var f2 = float.PositiveInfinity; - var bestOffset = float3.zero; - for (var dz = -2; dz <= 2; dz++) - for (var dy = -2; dy <= 2; dy++) - for (var dx = -2; dx <= 2; dx++) - { - var offset = new float3(dx, dy, dz); - var d = math.distance(p, FeaturePoint(cell + offset)); - if (d < f1) { f2 = f1; f1 = d; bestOffset = offset; } - else if (d < f2) { f2 = d; } - } - - return (bestOffset, f1, f2); - } - - private static float3 FindNearestCell(float3 p) - { - var cell = new float3(MathF.Floor(p.x), MathF.Floor(p.y), MathF.Floor(p.z)); - var best = float.PositiveInfinity; - var bestCell = cell; - for (var dz = -1; dz <= 1; dz++) - for (var dy = -1; dy <= 1; dy++) - for (var dx = -1; dx <= 1; dx++) - { - var neighbour = cell + new float3(dx, dy, dz); - var d = math.distance(p, FeaturePoint(neighbour)); - if (d < best) { best = d; bestCell = neighbour; } - } - - return bestCell; - } - - private static void AssertGradientMatchesCentralDifference(Func scalarField, float3 p, float eps, float4 valueAndGradient, float tolerance) - { - var numeric = new float3( - (scalarField(p + new float3(eps, 0.0f, 0.0f)) - scalarField(p - new float3(eps, 0.0f, 0.0f))) / (2.0f * eps), - (scalarField(p + new float3(0.0f, eps, 0.0f)) - scalarField(p - new float3(0.0f, eps, 0.0f))) / (2.0f * eps), - (scalarField(p + new float3(0.0f, 0.0f, eps)) - scalarField(p - new float3(0.0f, 0.0f, eps))) / (2.0f * eps)); - - AssertWithinTolerance(numeric.x, valueAndGradient.x, tolerance); - AssertWithinTolerance(numeric.y, valueAndGradient.y, tolerance); - AssertWithinTolerance(numeric.z, valueAndGradient.z, tolerance); - } - - // A fixed decimal-place Assert.Equal snaps two numbers that agree to within a few times 1e-5 to - // different rounded digits whenever their true difference straddles a rounding boundary (seen: - // 0.824987829 vs 0.825006127, agreeing to 2e-5, rounding to 0.82 and 0.83). An absolute-tolerance - // check states the actual claim (central differences agree with the analytic gradient within the - // finite-difference step's own truncation error) without that artifact. - private static void AssertWithinTolerance(float expected, float actual, float tolerance) => - Assert.True(MathF.Abs(expected - actual) <= tolerance, $"expected {expected}, actual {actual}, tolerance {tolerance}"); - - private static float3 RandomUnit(System.Random random) => - new(random.NextSingle() * 2.0f - 1.0f, random.NextSingle() * 2.0f - 1.0f, random.NextSingle() * 2.0f - 1.0f); - - private static float RandomScalar(System.Random random) => random.NextSingle() * 20.0f - 10.0f; -} diff --git a/packages/cultmath/tests/CultMath.Tests/FirstOrderInterceptTests.cs b/packages/cultmath/tests/CultMath.Tests/FirstOrderInterceptTests.cs deleted file mode 100644 index a211d266..00000000 --- a/packages/cultmath/tests/CultMath.Tests/FirstOrderInterceptTests.cs +++ /dev/null @@ -1,182 +0,0 @@ -using CultMath; -using Xunit; - -namespace CultMath.Tests; - -// math.first_order_intercept_time / first_order_intercept had one happy-path test before this -// (MathTests.FirstOrderInterceptSolvesSimpleClosingTarget). Stryker's baseline on -// hands/adopt-stryker found L264 (zero relative velocity), L270 (the a-approx-zero linear -// fallback), L280/L283 (the quadratic determinant and its roots) and L285 (the t1>0 branch) -// all undefended by fixtures that never varied relative velocity, never went linear, never -// lacked a solution, and never used off-axis/off-origin shooter and target state. -// -// For the closing cases (a genuine positive root exists), the assertion is behavioural: the -// returned aim point, fired at shotSpeed from a moving shooter, and the target's own motion -// must put the shot and the target at the same place at the returned time. That is verified by -// computing both positions independently in the test from math.first_order_intercept's own -// contract (targetPosition + time * targetRelativeVelocity is the aim point; the shot's actual -// world position folds in the shooter's velocity), not by re-deriving a second copy of the -// production formula. -// -// For the no-solution branches, there is no physical coincidence to check (the fallback is not -// a root of the intercept equation), so the assertion instead pins the contract read directly -// from the source at math.cs:264-298: velocitySquared < 0.001 => 0; determinant < 0 => 0; -// determinant > 0 with both roots <= 0 => 0 (via max(t2, 0)). -public sealed class FirstOrderInterceptTests -{ - private const int Precision = 4; - - [Fact] - public void ZeroRelativeVelocityReturnsZeroTime() - { - // Target and shooter share a velocity: targetRelativeVelocity is exactly zero, well - // under the 0.001 lengthsq guard, regardless of either party's absolute speed. - var time = math.first_order_intercept_time( - shotSpeed: 50.0f, - targetRelativePosition: new float3(12.0f, -7.0f, 3.0f), - targetRelativeVelocity: float3.zero); - - Assert.Equal(0.0f, time); - - var aimPoint = math.first_order_intercept( - shooterPosition: new float3(1.0f, 2.0f, 3.0f), - shooterVelocity: new float3(4.0f, 4.0f, 4.0f), - shotSpeed: 50.0f, - targetPosition: new float3(13.0f, -5.0f, 6.0f), - targetVelocity: new float3(4.0f, 4.0f, 4.0f)); - - // Contract: with time 0, the aim point is exactly the target's current position. - Assert.Equal(13.0f, aimPoint.x, Precision); - Assert.Equal(-5.0f, aimPoint.y, Precision); - Assert.Equal(6.0f, aimPoint.z, Precision); - } - - [Fact] - public void LinearFallbackWhenRelativeSpeedMatchesShotSpeedProducesPhysicalIntercept() - { - // velocitySquared == shotSpeed*shotSpeed makes a == 0 (|a| < 0.001), forcing the - // linear branch: time = -lengthsq(pos) / (2 * dot(vel, pos)), clamped to >= 0. - var shooterPosition = new float3(2.0f, -3.0f, 1.0f); - var shooterVelocity = new float3(1.0f, 0.5f, -0.5f); - var targetRelativePosition = new float3(10.0f, 0.0f, 0.0f); - var targetRelativeVelocity = new float3(-2.0f, 0.0f, 0.0f); - const float shotSpeed = 2.0f; // |targetRelativeVelocity| == 2 == shotSpeed => a == 0 - - var time = math.first_order_intercept_time(shotSpeed, targetRelativePosition, targetRelativeVelocity); - - // -100 / (2 * -20) = 2.5, computed independently of the production formula's code path. - Assert.Equal(2.5f, time, Precision); - - var targetPosition = shooterPosition + targetRelativePosition; - var targetVelocity = shooterVelocity + targetRelativeVelocity; - - var aimPoint = math.first_order_intercept(shooterPosition, shooterVelocity, shotSpeed, targetPosition, targetVelocity); - AssertShotAndTargetCoincideAtTime(shooterPosition, shooterVelocity, shotSpeed, aimPoint, targetPosition, targetVelocity, time); - } - - [Fact] - public void NegativeDeterminantReturnsZeroWhenTargetOutrunsTheProjectile() - { - // Target moving fast and mostly sideways, with a slow shot, so no real root of the - // intercept quadratic exists. b = 2*dot(vel, pos) is deliberately nonzero (-40): if it - // were 0, a mutant that drops the "determinant < 0" branch entirely and falls through to - // `max(-b / (2a), 0)` would coincidentally also compute 0 and this test would not - // distinguish the two. - var time = math.first_order_intercept_time( - shotSpeed: 1.0f, - targetRelativePosition: new float3(10.0f, 0.0f, 0.0f), - targetRelativeVelocity: new float3(-2.0f, 30.0f, 0.0f)); - - Assert.Equal(0.0f, time); - } - - [Fact] - public void BothQuadraticRootsPositiveReturnsTheEarlierOne() - { - // velocitySquared > shotSpeed*shotSpeed (a > 0): the target is relatively faster than - // the shot, but closing almost head-on, so both quadratic roots are real and positive - // (lengthsq(pos) >= 0 forces same-signed roots whenever a > 0). This is the only shape - // that reaches the `t1 > 0` branch's `t2 > 0 ? min(t1, t2) : t1` line at all; every other - // fixture in this file keeps t1 <= 0. - var shooterPosition = new float3(4.0f, -2.0f, 1.0f); - var shooterVelocity = new float3(0.5f, -0.5f, 0.0f); - var shotSpeed = 5.0f; - var targetRelativePosition = new float3(10.0f, 0.0f, 0.0f); - var targetRelativeVelocity = new float3(-20.0f, 0.0f, 0.0f); - - var time = math.first_order_intercept_time(shotSpeed, targetRelativePosition, targetRelativeVelocity); - - // a = 400 - 25 = 375, b = -400, c = 100, determinant = 10000, root = 100: - // t1 = (400 + 100) / 750 = 0.6667, t2 = (400 - 100) / 750 = 0.4. Both positive; the - // earlier one (0.4) is the physically correct intercept time. - Assert.Equal(0.4f, time, Precision); - - var targetPosition = shooterPosition + targetRelativePosition; - var targetVelocity = shooterVelocity + targetRelativeVelocity; - var aimPoint = math.first_order_intercept(shooterPosition, shooterVelocity, shotSpeed, targetPosition, targetVelocity); - AssertShotAndTargetCoincideAtTime(shooterPosition, shooterVelocity, shotSpeed, aimPoint, targetPosition, targetVelocity, time); - } - - [Fact] - public void RecedingTargetWithBothRootsNonPositiveReturnsZero() - { - // Target moving directly away from the shooter, faster than a slow shot: a real - // determinant exists but both quadratic roots are negative, so t1 > 0 is false and the - // function falls through to max(t2, 0) == 0. - var time = math.first_order_intercept_time( - shotSpeed: 1.0f, - targetRelativePosition: new float3(10.0f, 0.0f, 0.0f), - targetRelativeVelocity: new float3(5.0f, 0.0f, 0.0f)); - - Assert.Equal(0.0f, time); - } - - [Fact] - public void ClosingCaseWithOffOriginOffAxisPositionsProducesPhysicallyConsistentIntercept() - { - // Neither shooter nor target sits at the origin, no vector is axis-aligned, and both - // parties are moving. This is the fixture shape ("degenerate fixtures are how these - // survived") the earlier single test never exercised. - var shooterPosition = new float3(2.0f, 3.0f, -1.0f); - var shooterVelocity = new float3(1.0f, -1.0f, 0.5f); - var shotSpeed = 10.0f; - var targetPosition = new float3(15.0f, 8.0f, 4.0f); - var targetVelocity = new float3(-3.0f, 2.0f, 1.0f); - - var targetRelativePosition = targetPosition - shooterPosition; - var targetRelativeVelocity = targetVelocity - shooterVelocity; - var time = math.first_order_intercept_time(shotSpeed, targetRelativePosition, targetRelativeVelocity); - - Assert.True(time > 0.0f, "fixture must produce a genuine closing solution"); - - var aimPoint = math.first_order_intercept(shooterPosition, shooterVelocity, shotSpeed, targetPosition, targetVelocity); - AssertShotAndTargetCoincideAtTime(shooterPosition, shooterVelocity, shotSpeed, aimPoint, targetPosition, targetVelocity, time); - } - - /// - /// Fires from , inheriting - /// and adding toward , and asserts the - /// shot's world position at coincides with the target's own actual - /// motion ( + time * ). - /// This is the behavioural contract first_order_intercept exists to serve: aim here, and the - /// target will be there when the shot arrives. - /// - private static void AssertShotAndTargetCoincideAtTime( - float3 shooterPosition, - float3 shooterVelocity, - float shotSpeed, - float3 aimPoint, - float3 targetPosition, - float3 targetVelocity, - float time) - { - var direction = math.normalize(aimPoint - shooterPosition); - var shotVelocity = shooterVelocity + shotSpeed * direction; - var shotPositionAtTime = shooterPosition + time * shotVelocity; - var targetPositionAtTime = targetPosition + time * targetVelocity; - - Assert.Equal(targetPositionAtTime.x, shotPositionAtTime.x, Precision); - Assert.Equal(targetPositionAtTime.y, shotPositionAtTime.y, Precision); - Assert.Equal(targetPositionAtTime.z, shotPositionAtTime.z, Precision); - } -} diff --git a/packages/cultmath/tests/CultMath.Tests/HlslMirrorTests.cs b/packages/cultmath/tests/CultMath.Tests/HlslMirrorTests.cs index 6b69a908..445ad255 100644 --- a/packages/cultmath/tests/CultMath.Tests/HlslMirrorTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/HlslMirrorTests.cs @@ -7,7 +7,7 @@ public sealed class HlslMirrorTests [Fact] public void HlslMirrorDoesNotPublishGeometryKernels() { - var source = HlslSourceCompatibilityTests.ReadShaderSource(GetCultMathRoot()); + var source = File.ReadAllText(Path.Combine(GetCultMathRoot(), "shaders", "CultMath.hlsl")); Assert.DoesNotContain("AdvancedErosionFilter.hlsl", source); Assert.DoesNotContain("Planetary.hlsl", source); Assert.DoesNotContain("spherical_erosion", source); @@ -17,7 +17,7 @@ public sealed class HlslMirrorTests [Fact] public void HlslMirrorPublishesCultMathPrimitives() { - var include = HlslSourceCompatibilityTests.ReadShaderSource(GetCultMathRoot()); + var include = File.ReadAllText(Path.Combine(GetCultMathRoot(), "shaders", "CultMath.hlsl")); var requiredSymbols = new[] { "cultmath_radians", @@ -31,6 +31,7 @@ public sealed class HlslMirrorTests "cultmath_smootherstep", "cultmath_lengthsq", "cultmath_distance", + "cultmath_normalize", "cultmath_reflect", "cultmath_rotate", "cultmath_csum", @@ -40,17 +41,12 @@ 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", "cultmath_value_noise_bicubic", "cultmath_value_noise_texture", "cultmath_value_noise_texture_bicubic", - "cultmath_smin_grad", - "cultmath_cellular", }; foreach (var symbol in requiredSymbols) @@ -58,19 +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); - } - - [Theory] - [InlineData("CultMath.hlsl")] - [InlineData("CultMath.Phacelle.hlsl")] - public void UnityPackageShaderIsIdenticalToTheCanonicalShader(string name) - { - var root = GetCultMathRoot(); - Assert.Equal( - File.ReadAllText(Path.Combine(root, "shaders", name)).ReplaceLineEndings("\n"), - File.ReadAllText(Path.Combine(root, "unity", "org.gamecult.cultmath", "Shaders", name)).ReplaceLineEndings("\n")); + Assert.Contains("CULTMATH_NORMALIZE_EPSILON", include); + Assert.Contains("max(length(value), CULTMATH_NORMALIZE_EPSILON)", include); } private static string GetCultMathRoot() diff --git a/packages/cultmath/tests/CultMath.Tests/HlslSemanticsTests.cs b/packages/cultmath/tests/CultMath.Tests/HlslSemanticsTests.cs index c6bac266..7b845ea7 100644 --- a/packages/cultmath/tests/CultMath.Tests/HlslSemanticsTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/HlslSemanticsTests.cs @@ -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() { @@ -444,11 +380,11 @@ public sealed class HlslSemanticsTests } [Theory] - [InlineData(0.0f, 0.0f, 0.0f, -0.435872972f)] - [InlineData(0.25f, -0.5f, 1.75f, 0.0492948629f)] - [InlineData(12.25f, -4.5f, 3.125f, 0.443114728f)] - [InlineData(-7.3f, 2.9f, 101.4f, -0.10221792f)] - public void Snoise3MatchesGoldenValues(float x, float y, float z, float expected) + [InlineData(0.0f, 0.0f, 0.0f, -0.41219875f)] + [InlineData(0.25f, -0.5f, 1.75f, 0.5250269f)] + [InlineData(12.25f, -4.5f, 3.125f, 0.008141451f)] + [InlineData(-7.3f, 2.9f, 101.4f, 0.578015f)] + public void Snoise3MatchesUnityMathematicsReference(float x, float y, float z, float expected) { Assert.Equal(expected, math.snoise(new float3(x, y, z)), precision: 5); } diff --git a/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs b/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs index 2cb8ae49..ca4584c7 100644 --- a/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs @@ -25,12 +25,6 @@ public sealed class HlslSourceCompatibilityTests Assert.NotNull(ShaderFunction(shader, "cultmath_snoise", typeof(float3))); Assert.NotNull(ShaderFunction(shader, "cultmath_snoise", typeof(float2))); Assert.NotNull(ShaderFunction(shader, "cultmath_value_noise_bicubic", typeof(float2))); - Assert.NotNull(ShaderFunction(shader, "cultmath_smin_grad", typeof(float4), typeof(float4), typeof(float))); - Assert.NotNull(ShaderFunction(shader, "cultmath_cellular", typeof(float3))); - Assert.NotNull(ShaderFunction(shader, "cultmath_snoise_grad", typeof(float3))); - Assert.NotNull(ShaderFunction(shader, "cultmath_fbm_grad", typeof(float3), typeof(int), typeof(float), typeof(float))); - Assert.NotNull(ShaderFunction(shader, "cultmath_ridged_grad", typeof(float3), typeof(int), typeof(float), typeof(float))); - Assert.NotNull(ShaderFunction(shader, "cultmath_phacelle", typeof(float3), typeof(float3), typeof(float), typeof(float))); } // HLSL functions carry no access modifier, so they compile as private instance methods. @@ -68,41 +62,6 @@ public sealed class HlslSourceCompatibilityTests for (var k = 0; k < 64; k++) { var values = Enumerable.Range(0, 32).Select(_ => Next()).ToArray(); cases.Add(i => values[i]); } for (var k = 0; k < 16; k++) { var values = Enumerable.Range(0, 32).Select(_ => Next()).ToArray(); cases.Add(i => values[i & ~1]); } - // Points aimed squarely at cellular's HLSL mirror (F6/F3 Soul findings): the generic random - // and special-value cases above pass ~92 inputs through cultmath_cellular and never happen to - // hit a point where a radius-2 cell wins (about 3.5e-5 of points, Soul measured) or an - // integer point at or past 2^23, so an HLSL mutant of the search radius, the prune, or the - // removed F2=0 guard can survive this test even though it changes cellular's real output. - void AddPointCase(float3 point) - { - var values = new[] { point.x, point.y, point.z }; - cases.Add(i => i < values.Length ? values[i] : 0.0f); - } - - // The six constructed points from CellularAndSminGradTests.SearchReachesEveryAxisAlignedRadiusTwoSlice: - // each one realizes a different axis-aligned radius-2 offset as F1 or F2, which is exactly - // the case an `if (lowerBound >= f2)` prune written as `>= f1`, or a search radius narrowed on - // one axis (`dz < 2`, `dx` starting at -1), changes. - foreach (var offset in CellularAndSminGradTests.AxisAlignedRadiusTwoOffsets) - AddPointCase(CellularAndSminGradTests.FindPointRealizingOffset(offset)); - - // Integer points at 2^24, several, both signs: past |p| ~= 2^23 distinct cells' jittered - // features can round onto the same float32 value (design.md, "Precision domain"), making - // F2 = 0 reachable and exercising the grad2 = f2 > 0 ? ... : zero guard this test would - // otherwise never touch. - const float twoTo24 = 16777216.0f; - AddPointCase(new float3(twoTo24, twoTo24, twoTo24)); - AddPointCase(new float3(-twoTo24, -twoTo24, -twoTo24)); - AddPointCase(new float3(twoTo24 + 3.0f, -twoTo24 + 5.0f, twoTo24 - 7.0f)); - AddPointCase(new float3(-twoTo24 + 11.0f, twoTo24 - 13.0f, -twoTo24 + 17.0f)); - - // One exact F1 = F2 tie point: the midpoint of two adjacent cells' feature points is - // bit-exact-equidistant from both by construction (negating a vector does not change its - // length, and p - a = -(p - b) here by symmetry), without needing to search for one. - var tieMidpoint = (CellularAndSminGradTests.FeaturePoint(new float3(0.0f, 0.0f, 0.0f)) - + CellularAndSminGradTests.FeaturePoint(new float3(1.0f, 0.0f, 0.0f))) * 0.5f; - AddPointCase(tieMidpoint); - var mismatches = new List(); var compared = new HashSet(); foreach (var mirror in shaderType.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic).Where(m => m.Name.StartsWith("cultmath_"))) @@ -119,208 +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 (!BitwiseEqual(expected, actual)) + 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).Select(v => v.Value))))}): C# {string.Join(" ", expected.Select(v => v.Value))}, mirror {string.Join(" ", actual.Select(v => v.Value))}"); + 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(34, compared.Count); + Assert.Equal(26, compared.Count); } - /// - /// The generic comparison above passes about a hundred inputs through cultmath_phacelle, and - /// its prune only changes an output for the rare cell whose weight is nonzero but whose lower - /// bound sits just under the 2.25 cut (Soul's F6 lesson for cellular): a mirror whose cut is 2.0 - /// survives a hundred inputs. A dense sweep of realistic arguments closes that gap. - /// - [Fact] - public void PhacelleMirrorMatchesCSharpBitForBitAcrossADenseSweep() - { - var (assembly, errors) = CompileShaderMirror(); - Assert.True(assembly is not null, string.Join(Environment.NewLine, errors)); - var shaderType = assembly!.GetType("CultMathHlsl.HlslShader")!; - var shader = Activator.CreateInstance(shaderType); - var mirror = ShaderFunction(shaderType, "cultmath_phacelle", typeof(float3), typeof(float3), typeof(float), typeof(float))!; + private static int Components(Type t) => t.GetFields().Count(f => f.FieldType == typeof(float) && !f.IsStatic); - var random = new System.Random(0x5EED2); - float Next(float extent) => (random.NextSingle() * 2.0f - 1.0f) * extent; - for (var i = 0; i < 20000; i++) - { - var p = new float3(Next(50.0f), Next(50.0f), Next(50.0f)); - var side = new float3(Next(9.0f), Next(9.0f), Next(9.0f)); - var offset = random.NextSingle(); - var normalization = random.NextSingle(); - var expected = Values(phacelle(p, side, offset, normalization)); - var actual = Values(mirror.Invoke(shader, new object[] { p, side, offset, normalization })!); - Assert.True(BitwiseEqual(expected, actual), $"mirror differs from C# at p = {p}, side = {side}"); - } - } - - /// - /// The dense sweep almost never meets a cell whose box bound is just under the 2.25 cut and whose - /// weight is still nonzero: a mirror cut of 2.245, 2.24 or 2.2 survives it. These points sit next - /// to such cells (), so the mirror must match C# bit for - /// bit, and must match the unpruned C# sum (the shader has no prune switch, so that is the - /// independent statement that the cut is exact). - /// - [Fact] - public void PhacelleMirrorMatchesCSharpBitForBitNextToCellsJustInsideTheCut() - { - var (assembly, errors) = CompileShaderMirror(); - Assert.True(assembly is not null, string.Join(Environment.NewLine, errors)); - var shaderType = assembly!.GetType("CultMathHlsl.HlslShader")!; - var shader = Activator.CreateInstance(shaderType); - var mirror = ShaderFunction(shaderType, "cultmath_phacelle", typeof(float3), typeof(float3), typeof(float), typeof(float))!; - - foreach (var p in PhacelleTests.PruneCutPoints) - { - Assert.True(PhacelleTests.HasCellJustInsideTheCut(p), $"no cell just inside the cut at {p}"); - var actual = Values(mirror.Invoke(shader, new object[] { p, PhacelleTests.PruneCutSide, PhacelleTests.PruneCutOffset, PhacelleTests.PruneCutNormalization })!); - var pruned = Values(phacelle(p, PhacelleTests.PruneCutSide, PhacelleTests.PruneCutOffset, PhacelleTests.PruneCutNormalization)); - var unpruned = Values(phacelle(p, PhacelleTests.PruneCutSide, PhacelleTests.PruneCutOffset, PhacelleTests.PruneCutNormalization, false)); - Assert.True(BitwiseEqual(pruned, actual), $"mirror differs from C# at p = {p}"); - Assert.True(BitwiseEqual(unpruned, actual), $"mirror differs from the unpruned C# sum at p = {p}"); - } - } - - /// - /// A hand-built pair of structs standing in for a mirror struct return and its C# counterpart - /// (invariant 8's one struct return shape, e.g. ): same field shape, - /// different concrete type, exactly like the real mirror comparison. Proves the field-by-field, - /// bit-for-bit walk actually rejects a struct whose field differs; without this, the walk could - /// have been comparing lengths only, or skipping the nested vector's own components. - /// - private struct StructMirrorProbeExpected { public float4 first; public float second; } - private struct StructMirrorProbeActual { public float4 first; public float second; } - - [Fact] - public void StructReturnComparisonRejectsAMismatchedField() - { - var expected = new StructMirrorProbeExpected { first = new float4(1.0f, 2.0f, 3.0f, 4.0f), second = 5.0f }; - var matching = new StructMirrorProbeActual { first = new float4(1.0f, 2.0f, 3.0f, 4.0f), second = 5.0f }; - var mismatchedLeaf = new StructMirrorProbeActual { first = new float4(1.0f, 2.0f, 3.0f, 4.0f), second = 5.5f }; - var mismatchedNestedComponent = new StructMirrorProbeActual { first = new float4(1.0f, 2.0f, -3.0f, 4.0f), second = 5.0f }; - - Assert.True(BitwiseEqual(Values(expected), Values(matching))); - Assert.False(BitwiseEqual(Values(expected), Values(mismatchedLeaf))); - Assert.False(BitwiseEqual(Values(expected), Values(mismatchedNestedComponent))); - } - - /// - /// Struct shapes standing in for CultCellular's own field order (nearest; edge; id), used - /// to falsify two named mutants (F2): H2 declares the mirror struct with its first two fields - /// swapped (edge; nearest; id) and swaps the assignments to match, so every leaf VALUE - /// still lands in the same POSITION as the correct struct; only the field NAME at each position - /// differs. H3 renames and retypes every field (id becomes an int carrying the same bits) while - /// keeping the original position order, so only NAME and TYPE differ, never position. - /// - private struct NearestEdgeIdExpected { public float4 nearest; public float4 edge; public float id; } - private struct EdgeNearestIdSwappedNames { public float4 edge; public float4 nearest; public float id; } - private struct RenamedAndRetypedFields { public float4 a; public float4 b; public int c; } - - [Fact] - public void StructReturnComparisonRejectsFieldsSwappedByNameEvenWhenValuesAlign() - { - // H2: the mutant struct's field ORDER is (edge, nearest, id), and its assignments are - // swapped to match, so declaration-position values are identical to the correct struct's. - // Only checking names (not just position) can tell these apart. - var expected = new NearestEdgeIdExpected - { - nearest = new float4(1.0f, 2.0f, 3.0f, 4.0f), - edge = new float4(5.0f, 6.0f, 7.0f, 8.0f), - id = 9.0f, - }; - var swapped = new EdgeNearestIdSwappedNames - { - edge = new float4(1.0f, 2.0f, 3.0f, 4.0f), // holds what should be "nearest"'s value. - nearest = new float4(5.0f, 6.0f, 7.0f, 8.0f), // holds what should be "edge"'s value. - id = 9.0f, - }; - - Assert.False(BitwiseEqual(Values(expected), Values(swapped))); - } - - [Fact] - public void StructReturnComparisonRejectsRenamedAndRetypedFields() - { - // H3: field order matches the correct struct exactly, but every field is renamed, and id is - // retyped from float to int carrying the same bit pattern the correct id would round to. - var expected = new NearestEdgeIdExpected - { - nearest = new float4(1.0f, 2.0f, 3.0f, 4.0f), - edge = new float4(5.0f, 6.0f, 7.0f, 8.0f), - id = 9.0f, - }; - var renamed = new RenamedAndRetypedFields - { - a = new float4(1.0f, 2.0f, 3.0f, 4.0f), - b = new float4(5.0f, 6.0f, 7.0f, 8.0f), - c = BitConverter.SingleToInt32Bits(9.0f), - }; - - Assert.False(BitwiseEqual(Values(expected), Values(renamed))); - } - - // 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 object Scalar(Type t, float f) => t == typeof(float) ? f - : t == typeof(int) ? BitConverter.SingleToInt32Bits(f) : (object)(uint)BitConverter.SingleToInt32Bits(f); - - // Scoped to scalar-typed fields: this only ever builds function ARGUMENTS (float2/3/4, int2/3/4, ...), - // never a struct-of-struct return, so it must not recurse. - private static FieldInfo[] Components(Type t) => t.GetFields().Where(f => !f.IsStatic && Scalars.Contains(f.FieldType)).ToArray(); - - /// - /// One flattened scalar leaf of a (possibly nested) return value: its declaration path (field - /// names joined by '.'), its declared type, and its value. Carrying path and type, not just the - /// value, is what lets the struct-return comparison reject a field that was renamed or retyped - /// but landed in the same position (F2): a positional-only comparison cannot tell "edge" holding - /// nearest's value apart from "nearest" holding it. - /// - private readonly record struct Leaf(string Path, Type Type, object Value); - - /// - /// Flattens a return value to its scalar leaves, recursing through struct fields of any type - /// (invariant 8's one struct return shape, e.g. 's float4/float4/float - /// fields) so a struct return is compared field by field, down to bit-for-bit scalars, exactly - /// like a bare vector return already is. Each leaf keeps the field name (and its ancestors') and - /// declared type it was read from. - /// - private static Leaf[] Values(object value, string path = "") => Scalars.Contains(value.GetType()) - ? new[] { new Leaf(path, value.GetType(), value) } - : value.GetType().GetFields().Where(f => !f.IsStatic) - .SelectMany(f => Values(f.GetValue(value)!, path.Length == 0 ? f.Name : path + "." + f.Name)) - .ToArray(); - - // Compares leaves by NAME and TYPE as well as value, in declaration order, so a mirror struct - // whose fields are reordered, renamed, or retyped relative to its C# counterpart fails here even - // when the flattened values happen to line up positionally (F2's H2/H3 mutants). - private static bool BitwiseEqual(Leaf[] expected, Leaf[] actual) => - expected.Length == actual.Length && expected.Zip(actual).All(p => - p.First.Path == p.Second.Path && p.First.Type == p.Second.Type && - (p.First.Value is float a && p.Second.Value is float b - ? (float.IsNaN(a) && float.IsNaN(b)) || BitConverter.SingleToInt32Bits(a) == BitConverter.SingleToInt32Bits(b) - : p.First.Value.Equals(p.Second.Value))); - - /// - /// The text of CultMath.hlsl with each #include "name" replaced in place by the - /// included file from the same directory, as the shader compiler would resolve it. Only the - /// package's own includes exist, so a missing file is an error rather than a skip. - /// - internal static string ReadShaderSource(string cultMathRoot) - { - var shaders = Path.Combine(cultMathRoot, "shaders"); - return Regex.Replace(File.ReadAllText(Path.Combine(shaders, "CultMath.hlsl")), @"(?m)^#include ""([^""]+)""[ \t]*\r?$", - match => File.ReadAllText(Path.Combine(shaders, match.Groups[1].Value))); - } + 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(); /// The documented HLSL-to-C# transformations, and nothing else. internal static string TransformHlslToCSharp(string hlsl) @@ -338,19 +115,13 @@ public sealed class HlslSourceCompatibilityTests // 4. Floating literals gain the `f` suffix; an unsuffixed C# literal is a double. source = Regex.Replace(source, @"(? - m.Groups[1].Value + Regex.Replace(m.Groups[2].Value, @"(?m)^(\s*)(\w+\s+\w+;)", "$1public $2") + m.Groups[3].Value); - - // 6. The file body is wrapped in a class; C# has no free functions. + // 5. The file body is wrapped in a class; C# has no free functions. return "using CultMath;\nusing static CultMath.math;\nnamespace CultMathHlsl;\npublic class HlslShader\n{\n" + source + "\n}\n"; } private static (Assembly? Assembly, IReadOnlyList Errors) CompileShaderMirror() { - var hlsl = ReadShaderSource(FindCultMathRoot()); + var hlsl = File.ReadAllText(Path.Combine(FindCultMathRoot(), "shaders", "CultMath.hlsl")); var tree = CSharpSyntaxTree.ParseText(TransformHlslToCSharp(hlsl)); var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) .Split(Path.PathSeparator) diff --git a/packages/cultmath/tests/CultMath.Tests/MathTests.cs b/packages/cultmath/tests/CultMath.Tests/MathTests.cs index 3dc374bf..de1bf996 100644 --- a/packages/cultmath/tests/CultMath.Tests/MathTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/MathTests.cs @@ -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(); - 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() { @@ -307,10 +272,10 @@ public sealed class MathTests [Theory] [InlineData(0.0f, 0.0f, 0.0f)] - [InlineData(0.25f, -0.5f, 0.225074813f)] - [InlineData(12.25f, -4.5f, -0.388599217f)] - [InlineData(536.5106f, 536.5106f, 0.538390875f)] - public void SimplexNoiseMatchesGoldenValues(float x, float y, float expected) + [InlineData(0.25f, -0.5f, -0.425866783f)] + [InlineData(12.25f, -4.5f, 0.258312f)] + [InlineData(536.5106f, 536.5106f, -0.6901103f)] + public void SimplexNoiseMatchesUnityMathematics(float x, float y, float expected) { Assert.Equal(expected, math.snoise(new float2(x, y)), precision: 6); } @@ -367,160 +332,4 @@ public sealed class MathTests Assert.Equal(0.0f, px[1], precision: 5); Assert.Equal(0.0f, py[1], precision: 5); } - - // Tabulated to 16 significant figures (Abramowitz & Stegun table 7.1 lineage / standard erf tables). - [Theory] - [InlineData(0.0f, 0.0)] - [InlineData(0.5f, 0.5204998778130465)] - [InlineData(-0.5f, -0.5204998778130465)] - [InlineData(1.0f, 0.8427007929497149)] - [InlineData(-1.0f, -0.8427007929497149)] - [InlineData(2.0f, 0.9953222650189527)] - [InlineData(-2.0f, -0.9953222650189527)] - [InlineData(3.0f, 0.9999779095030014)] - [InlineData(-3.0f, -0.9999779095030014)] - public void ErfMatchesReferenceValues(float x, double expected) - { - Assert.True( - Math.Abs(math.erf(x) - expected) <= 2e-6, - $"erf({x}) = {math.erf(x)}, expected {expected}"); - } - - [Fact] - public void ErfIsOdd() - { - for (var i = -30; i <= 30; i++) - { - var x = i / 10.0f; - Assert.Equal(-math.erf(x), math.erf(-x)); - } - } - - [Fact] - public void ErfApproachesItsLimitsOfPlusMinusOne() - { - Assert.Equal(1.0f, math.erf(6.0f)); - Assert.Equal(1.0f, math.erf(20.0f)); - Assert.Equal(-1.0f, math.erf(-6.0f)); - Assert.Equal(-1.0f, math.erf(-20.0f)); - } - - [Fact] - public void ErfinvInvertsErf() - { - for (var i = -999; i <= 999; i++) - { - var y = i / 1000.0f; - var x = math.erfinv(y); - var roundTrip = math.erf(x); - Assert.True( - MathF.Abs(roundTrip - y) < 1e-5f, - $"erf(erfinv({y})) = {roundTrip}"); - } - } - - // Reference values from bisecting the platform's double-precision erf (Python's math.erf, - // glibc-backed), not this package's own erf, to 200 iterations over [-6, 6] -- effectively - // exact at float precision. This is what ErfinvInvertsErf cannot be: a check against this - // package's own erf only proves the two functions agree with each other, not that either is - // numerically correct. Soul measured the round-trip test alone to be blind to a coefficient - // change that degraded erfinv's worst error 300-400x (5.0e-7 to 2.0e-4). - [Theory] - // Expected values are computed for the actual float32-rounded input (e.g. the literal - // 0.999f is really 0.9990000128746033 as a double), not the decimal literal, because - // erfinv's derivative diverges near +-1: at y = 0.999f that rounding alone moves the - // true answer by ~2.6e-6, larger than the test's own error bar, if ignored. - [InlineData(0.0f, 0.0)] - [InlineData(0.1f, 0.08885599182530649)] - [InlineData(-0.1f, -0.08885599182530649)] - [InlineData(0.3f, 0.2724627261055238)] - [InlineData(-0.3f, -0.2724627261055238)] - [InlineData(0.5f, 0.4769362762044699)] - [InlineData(-0.5f, -0.4769362762044699)] - [InlineData(0.7f, 0.7328690598827587)] - [InlineData(-0.7f, -0.732869059882759)] - [InlineData(0.9f, 1.1630870719457724)] - [InlineData(-0.9f, -1.1630870719457729)] - [InlineData(0.95f, 1.3859037522360764)] - [InlineData(-0.95f, -1.3859037522360773)] - [InlineData(0.99f, 1.8213866009002766)] - [InlineData(-0.99f, -1.8213866009002793)] - [InlineData(0.999f, 2.3267563267961435)] - [InlineData(-0.999f, -2.3267563267961657)] - [InlineData(0.9999f, 2.751035437903192)] - [InlineData(-0.9999f, -2.751035437903383)] - public void ErfinvMatchesReferenceValues(float y, double expected) - { - Assert.True( - Math.Abs(math.erfinv(y) - expected) <= 2e-6, - $"erfinv({y}) = {math.erfinv(y)}, expected {expected}"); - } - - [Fact] - public void ErfinvAtDomainEdgesAndBeyond() - { - // Domain is [-1, 1]. The edges are the correctly-signed infinite limits; anything - // outside, including +-infinity itself, has no real inverse and is NaN. - Assert.Equal(float.PositiveInfinity, math.erfinv(1.0f)); - Assert.Equal(float.NegativeInfinity, math.erfinv(-1.0f)); - Assert.True(float.IsNaN(math.erfinv(1.0001f))); - Assert.True(float.IsNaN(math.erfinv(-1.0001f))); - Assert.True(float.IsNaN(math.erfinv(2.0f))); - Assert.True(float.IsNaN(math.erfinv(-2.0f))); - Assert.True(float.IsNaN(math.erfinv(float.PositiveInfinity))); - Assert.True(float.IsNaN(math.erfinv(float.NegativeInfinity))); - Assert.True(float.IsNaN(math.erfinv(float.NaN))); - } - - // The deleted ErfinvIsMonotonicallyIncreasing asserted a property erfinv does not have: - // Soul measured 9662 backward steps scanning every representable float32 in [0, 0.999999], - // first at y = 0.00022214651, worst drop 5.960464e-8 (one ULP at that scale) -- invisible - // only because that fixture stepped by 0.001. erf has the same shape: 100 backward steps - // over [0, 4.5], worst drop 1.192093e-7. Pin what is actually true: monotone to within a - // tolerance comfortably above that measured noise floor. - [Fact] - public void ErfinvIsMonotonicWithinFloatingPointTolerance() - { - const float tolerance = 1e-6f; - var previous = float.NegativeInfinity; - for (var i = -999; i <= 999; i++) - { - var y = i / 1000.0f; - var x = math.erfinv(y); - Assert.True( - x >= previous - tolerance, - $"erfinv({y}) = {x} dropped more than {tolerance} below previous value {previous}"); - previous = x; - } - } - - // One property the consumer needs, not the whole of correctness: fire control turns - // adjacent erf values into a cell's probability mass, so a backward step in erf would - // show up as a negative mass. An erf that returned a constant would pass this and is - // caught by ErfMatchesReferenceValues instead. - // Soul checked these geometries clean: 16/64/256/1024/4096 cells over - // +-4 sigma, plus a tight 1 sigma spread and a very tight 0.25 sigma spread. - [Theory] - [InlineData(16, 4f)] - [InlineData(64, 4f)] - [InlineData(256, 4f)] - [InlineData(1024, 4f)] - [InlineData(4096, 4f)] - [InlineData(256, 1f)] - [InlineData(64, 0.25f)] - public void ErfCellMassesStayNonNegativeAcrossRealisticGeometries(int cells, float sigmaRange) - { - var lo = -sigmaRange; - var hi = sigmaRange; - var step = (hi - lo) / cells; - var previous = math.erf(lo / MathF.Sqrt(2f)); - for (var i = 1; i <= cells; i++) - { - var edge = lo + step * i; - var current = math.erf(edge / MathF.Sqrt(2f)); - var mass = current - previous; - Assert.True(mass >= 0f, $"cell {i} of {cells} over +-{sigmaRange} sigma has negative mass {mass}"); - previous = current; - } - } } diff --git a/packages/cultmath/tests/CultMath.Tests/NoiseGradTests.cs b/packages/cultmath/tests/CultMath.Tests/NoiseGradTests.cs deleted file mode 100644 index 77ec4819..00000000 --- a/packages/cultmath/tests/CultMath.Tests/NoiseGradTests.cs +++ /dev/null @@ -1,380 +0,0 @@ -using System; -using CultMath; -using Xunit; - -namespace CultMath.Tests; - -public sealed class NoiseGradTests -{ - // ---- Invariant-8 property harness (shared by snoise_grad, fbm_grad, ridged_grad) ---- - // - // Each test compares a function's analytic gradient with central differences of its own .w at - // random points in [-3, 3]^3. eps trades curvature error (eps too large) against float32 - // rounding noise (eps too small). snoise is continuous across simplex-cell boundaries (the - // continuity test below), so no point needs excluding for a seam; ridged_grad excludes a band - // around its own deliberate crease. Each tolerance is at least 5x the largest error the harness - // itself produces at its seed. - private const float PropertyEps = 0.0001f; - private const float SnoiseGradTolerance = 0.03f; - private const float FbmGradTolerance = 0.05f; - private const float RidgedGradTolerance = 0.05f; - - [Fact] - public void SnoiseGradGradientMatchesCentralDifferencesOfItsOwnValue() - { - var random = new System.Random(0xA11CE); - var tested = 0; - var attempts = 0; - while (tested < 200 && attempts < 20000) - { - attempts++; - var p = RandomPoint(random); - var g = math.snoise_grad(p); - float F(float3 q) => math.snoise_grad(q).w; - var fine = CentralDifference(F, p, PropertyEps); - - tested++; - AssertGradientWithinTolerance(fine, g, SnoiseGradTolerance); - } - - Assert.True(tested >= 150, $"only {tested} of {attempts} points were usable"); - } - - [Fact] - public void FbmGradGradientMatchesCentralDifferencesOfItsOwnValue() - { - const int octaves = 4; - const float lacunarity = 2.0f; - const float gain = 0.5f; - var random = new System.Random(0xA11CF); - var tested = 0; - var attempts = 0; - while (tested < 200 && attempts < 40000) - { - attempts++; - var p = RandomPoint(random); - var g = math.fbm_grad(p, octaves, lacunarity, gain); - float F(float3 q) => math.fbm_grad(q, octaves, lacunarity, gain).w; - var fine = CentralDifference(F, p, PropertyEps); - - tested++; - AssertGradientWithinTolerance(fine, g, FbmGradTolerance); - } - - Assert.True(tested >= 150, $"only {tested} of {attempts} points were usable"); - } - - [Fact] - public void RidgedGradGradientMatchesCentralDifferencesExcludingAnyOctavesZeroBand() - { - // ridged_grad's own crease (any octave's n_i = 0) is a real, deliberate discontinuity - // (design.md, "the operator's dune term"), not a numerical artifact: exclude a band around it - // for every octave, the same way cellular excludes its F1 = F2 seam. - const float zeroBand = 0.1f; - const int octaves = 3; - const float lacunarity = 2.0f; - const float gain = 0.5f; - var random = new System.Random(0xA11D0); - var tested = 0; - var attempts = 0; - while (tested < 200 && attempts < 80000) - { - attempts++; - var p = RandomPoint(random); - if (AnyOctaveNearZero(p, octaves, lacunarity, zeroBand)) - continue; - - var g = math.ridged_grad(p, octaves, lacunarity, gain); - float F(float3 q) => math.ridged_grad(q, octaves, lacunarity, gain).w; - var fine = CentralDifference(F, p, PropertyEps); - - tested++; - AssertGradientWithinTolerance(fine, g, RidgedGradTolerance); - } - - Assert.True(tested >= 150, $"only {tested} of {attempts} points were outside the zero band"); - } - - private static bool AnyOctaveNearZero(float3 p, int octaves, float lacunarity, float band) - { - var frequency = 1.0f; - for (var i = 0; i < octaves; i++) - { - if (MathF.Abs(math.snoise_grad(p * frequency).w) < band) - return true; - frequency *= lacunarity; - } - - return false; - } - - private static float3 RandomPoint(System.Random random) => new( - random.NextSingle() * 6.0f - 3.0f, random.NextSingle() * 6.0f - 3.0f, random.NextSingle() * 6.0f - 3.0f); - - private static float3 CentralDifference(Func f, float3 p, float eps) => new( - (f(p + new float3(eps, 0.0f, 0.0f)) - f(p - new float3(eps, 0.0f, 0.0f))) / (2.0f * eps), - (f(p + new float3(0.0f, eps, 0.0f)) - f(p - new float3(0.0f, eps, 0.0f))) / (2.0f * eps), - (f(p + new float3(0.0f, 0.0f, eps)) - f(p - new float3(0.0f, 0.0f, eps))) / (2.0f * eps)); - - private static void AssertGradientWithinTolerance(float3 numeric, float4 analytic, float tolerance) - { - AssertWithinTolerance(numeric.x, analytic.x, tolerance); - AssertWithinTolerance(numeric.y, analytic.y, tolerance); - AssertWithinTolerance(numeric.z, analytic.z, tolerance); - } - - private static void AssertWithinTolerance(float expected, float actual, float tolerance) => - Assert.True(MathF.Abs(expected - actual) <= tolerance, $"expected {expected}, actual {actual}, tolerance {tolerance}"); - - // ---- snoise is continuous across simplex-cell boundaries ---- - - // The simplex corner set (i, i1, i2) summed by snoise(float3), derived the same way snoise does. - private static (float3 i, float3 i1, float3 i2) SimplexCell(float3 p) - { - var i = math.floor(p + math.dot(p, new float3(1.0f / 3.0f, 1.0f / 3.0f, 1.0f / 3.0f))); - var x0 = p - i + math.dot(i, new float3(1.0f / 6.0f, 1.0f / 6.0f, 1.0f / 6.0f)); - var g = math.step(new float3(x0.y, x0.z, x0.x), x0); - var l = 1.0f - g; - var lzxy = new float3(l.z, l.x, l.y); - return (i, math.min(g, lzxy), math.max(g, lzxy)); - } - - [Fact] - public void SnoiseAndItsGradientAreContinuousAcrossSimplexCellBoundaries() - { - // March random lines in steps of 1e-3 until the corner set changes, bisect the crossing to - // float precision, and compare the two sides. With a kernel radius^2 above 0.5 a lattice - // vertex outside the four summed corners can lie inside the kernel, which leaves a value - // jump at the boundary (stegu/webgl-noise fixed it in 21d9fe23d7); at 0.5 the jump is float - // noise. A value jump below 1e-5 and a gradient jump below 1e-4 is float noise on both sides. - const int lines = 400; - const int maxSteps = 20000; - const float dt = 1.0e-3f; - var random = new System.Random(0x5EA4); - var crossings = 0; - var maxValueJump = 0.0f; - var maxGradientJump = 0.0f; - for (var line = 0; line < lines; line++) - { - var origin = RandomPoint(random); - var direction = math.normalize(new float3( - random.NextSingle() - 0.5f, random.NextSingle() - 0.5f, random.NextSingle() - 0.5f)); - var start = SimplexCell(origin); - var t = 0.0f; - var found = false; - for (var step = 0; step < maxSteps && !found; step++) - { - t += dt; - found = !SimplexCell(origin + direction * t).Equals(start); - } - - Assert.True(found, $"line {line} never left its simplex cell in {maxSteps} steps"); - - double lo = t - dt; - double hi = t; - for (var bisect = 0; bisect < 60; bisect++) - { - var mid = 0.5 * (lo + hi); - if (SimplexCell(origin + direction * (float)mid).Equals(start)) lo = mid; else hi = mid; - } - - var a = math.snoise_grad(origin + direction * (float)lo); - var b = math.snoise_grad(origin + direction * (float)hi); - crossings++; - maxValueJump = MathF.Max(maxValueJump, MathF.Abs(a.w - b.w)); - maxGradientJump = MathF.Max(maxGradientJump, MathF.Max(MathF.Abs(a.x - b.x), MathF.Max(MathF.Abs(a.y - b.y), MathF.Abs(a.z - b.z)))); - } - - Assert.Equal(lines, crossings); - Assert.True(maxValueJump < 1.0e-5f, $"value jump {maxValueJump} across a simplex boundary"); - Assert.True(maxGradientJump < 1.0e-4f, $"gradient jump {maxGradientJump} across a simplex boundary"); - } - - // ---- snoise_grad.w vs snoise ---- - - [Fact] - public void SnoiseGradValueIsBitEqualToSnoise() - { - // Both compute 105 * dot(m4, px) via the exact same operation order (math.cs comment on - // snoise_grad), so this asserts bit equality over 5,000 random points in [-100, 100]^3. - var random = new System.Random(0x50F2); - for (var i = 0; i < 5000; i++) - { - var p = new float3( - random.NextSingle() * 200.0f - 100.0f, random.NextSingle() * 200.0f - 100.0f, random.NextSingle() * 200.0f - 100.0f); - var expected = math.snoise(p); - var actual = math.snoise_grad(p).w; - Assert.Equal(BitConverter.SingleToInt32Bits(expected), BitConverter.SingleToInt32Bits(actual)); - } - } - - // ---- fbm_grad / ridged_grad: per-octave gain and lacunarity ---- - - [Fact] - public void FbmGradWithOneOctaveEqualsSnoiseGrad() - { - var random = new System.Random(0xF6B10); - for (var i = 0; i < 64; i++) - { - var p = RandomPoint(random); - var lacunarity = 1.0f + random.NextSingle() * 3.0f; - var gain = 0.1f + random.NextSingle() * 0.8f; - - var expected = math.snoise_grad(p); - var actual = math.fbm_grad(p, 1, lacunarity, gain); - - Assert.Equal(expected.x, actual.x); - Assert.Equal(expected.y, actual.y); - Assert.Equal(expected.z, actual.z); - Assert.Equal(expected.w, actual.w); - } - } - - [Fact] - public void FbmGradCompoundsGainAndLacunarityPerOctaveNotOnce() - { - // Falsifies two mutant shapes at once: applying gain/lacunarity once for the whole sum - // instead of compounding per octave, and swapping which argument drives amplitude versus - // frequency. lacunarity != gain and both != 1 so a swap changes the result, and 3 octaves - // gives amplitude/frequency three distinct compounded values (1, gain, gain^2 and - // 1, lacunarity, lacunarity^2) so "applied once" mutants cannot coincidentally match. - // The expected value is built by the same incremental multiply chain fbm_grad itself uses - // (frequency *= lacunarity, amplitude *= gain, one step at a time, not lacunarity*lacunarity - // or MathF.Pow), so this asserts bit-exact equality rather than needing a tolerance. - const float lacunarity = 2.5f; - const float gain = 0.6f; - var random = new System.Random(0xF6B11); - for (var i = 0; i < 32; i++) - { - var p = RandomPoint(random); - - var frequency = 1.0f; - var amplitude = 1.0f; - var n0 = math.snoise_grad(p * frequency); - var expectedValue = amplitude * n0.w; - var expectedGradient = amplitude * frequency * new float3(n0.x, n0.y, n0.z); - frequency *= lacunarity; - amplitude *= gain; - - var n1 = math.snoise_grad(p * frequency); - expectedValue += amplitude * n1.w; - expectedGradient += amplitude * frequency * new float3(n1.x, n1.y, n1.z); - frequency *= lacunarity; - amplitude *= gain; - - var n2 = math.snoise_grad(p * frequency); - expectedValue += amplitude * n2.w; - expectedGradient += amplitude * frequency * new float3(n2.x, n2.y, n2.z); - - var actual = math.fbm_grad(p, 3, lacunarity, gain); - - Assert.Equal(expectedValue, actual.w); - Assert.Equal(expectedGradient.x, actual.x); - Assert.Equal(expectedGradient.y, actual.y); - Assert.Equal(expectedGradient.z, actual.z); - } - } - - [Fact] - public void RidgedGradCompoundsGainAndLacunarityPerOctaveNotOnce() - { - // Same construction as FbmGradCompoundsGainAndLacunarityPerOctaveNotOnce, folded: value is - // Sum a_i*(1-|n_i|), gradient is -Sum a_i*f_i*sign(n_i)*grad(n_i) (math.cs comment on - // ridged_grad). p is offset away from the origin and re-tried on a near-zero octave so no - // octave's n_i lands in the fold's own zero band, which would make the closed form's sign() - // ambiguous between runs. - const float lacunarity = 2.5f; - const float gain = 0.6f; - var random = new System.Random(0xF6B12); - var tested = 0; - var attempts = 0; - while (tested < 32 && attempts < 4000) - { - attempts++; - var p = RandomPoint(random); - if (AnyOctaveNearZero(p, 3, lacunarity, 0.1f)) - continue; - tested++; - - var frequency = 1.0f; - var amplitude = 1.0f; - var n0 = math.snoise_grad(p * frequency); - var s0 = (float)MathF.Sign(n0.w); - var expectedValue = amplitude * (1.0f - MathF.Abs(n0.w)); - var expectedGradient = -amplitude * frequency * s0 * new float3(n0.x, n0.y, n0.z); - frequency *= lacunarity; - amplitude *= gain; - - var n1 = math.snoise_grad(p * frequency); - var s1 = (float)MathF.Sign(n1.w); - expectedValue += amplitude * (1.0f - MathF.Abs(n1.w)); - expectedGradient += -amplitude * frequency * s1 * new float3(n1.x, n1.y, n1.z); - frequency *= lacunarity; - amplitude *= gain; - - var n2 = math.snoise_grad(p * frequency); - var s2 = (float)MathF.Sign(n2.w); - expectedValue += amplitude * (1.0f - MathF.Abs(n2.w)); - expectedGradient += -amplitude * frequency * s2 * new float3(n2.x, n2.y, n2.z); - - var actual = math.ridged_grad(p, 3, lacunarity, gain); - - Assert.Equal(expectedValue, actual.w); - Assert.Equal(expectedGradient.x, actual.x); - Assert.Equal(expectedGradient.y, actual.y); - Assert.Equal(expectedGradient.z, actual.z); - } - - Assert.True(tested == 32, $"only {tested} of {attempts} points were outside the zero band"); - } - - // ---- ridged_grad's deliberate crease at n = 0 ---- - - [Fact] - public void RidgedGradOneSidedGradientsAcrossAZeroCrossingDifferInSign() - { - // 1 octave: ridged_grad(p) = 1 - |snoise(p)|, gradient = -sign(snoise(p)) * grad(snoise)(p). - // Bisect along a fixed direction from a random start point to find a tight straddle of a - // snoise zero crossing, then check the two ridged_grad gradients just either side of it point - // in near-opposite directions (their dot product is negative): the crease is real, not a - // numerical accident (design.md, "the operator's dune term"). - var random = new System.Random(0xC2E55); - for (var attempt = 0; attempt < 200; attempt++) - { - var origin = RandomPoint(random); - var direction = new float3(random.NextSingle() - 0.5f, random.NextSingle() - 0.5f, random.NextSingle() - 0.5f); - if (math.length(direction) < 1.0e-3f) - continue; - - float NoiseAt(float t) => math.snoise(origin + direction * t); - - var lo = -3.0f; - var hi = 3.0f; - if (Math.Sign(NoiseAt(lo)) == Math.Sign(NoiseAt(hi))) - continue; - - for (var step = 0; step < 40; step++) - { - var mid = (lo + hi) * 0.5f; - if (Math.Sign(NoiseAt(lo)) == Math.Sign(NoiseAt(mid))) lo = mid; else hi = mid; - } - - // lo and hi now straddle the crossing within about 3 * 2^-40 of parameter t; step off by - // a small margin either side so each sample is cleanly on one side, not exactly on it. - const float margin = 1.0e-4f; - var minusSide = origin + direction * (lo - margin); - var plusSide = origin + direction * (hi + margin); - if (MathF.Sign(NoiseAt(lo - margin)) == MathF.Sign(NoiseAt(hi + margin))) - continue; // margin stepped back across the crossing; try another direction. - - var gradMinus = math.ridged_grad(minusSide, 1, 2.0f, 0.5f); - var gradPlus = math.ridged_grad(plusSide, 1, 2.0f, 0.5f); - var dot = gradMinus.x * gradPlus.x + gradMinus.y * gradPlus.y + gradMinus.z * gradPlus.z; - - Assert.True(dot < 0.0f, $"one-sided gradients did not differ in sign: minus=({gradMinus.x},{gradMinus.y},{gradMinus.z}) plus=({gradPlus.x},{gradPlus.y},{gradPlus.z}) dot={dot}"); - return; - } - - Assert.Fail("did not find a snoise zero crossing to straddle"); - } -} diff --git a/packages/cultmath/tests/CultMath.Tests/PhacelleTests.cs b/packages/cultmath/tests/CultMath.Tests/PhacelleTests.cs deleted file mode 100644 index b64f70b1..00000000 --- a/packages/cultmath/tests/CultMath.Tests/PhacelleTests.cs +++ /dev/null @@ -1,340 +0,0 @@ -using System; -using CultMath; -using Xunit; - -namespace CultMath.Tests; - -public sealed class PhacelleTests -{ - // Every tolerance below is at least 5x the largest error the same test measures at its seed. - private const float GradientEps = 1.0e-3f; - private const float GradientTolerance = 5.0e-3f; - private const double ValueTolerance = 1.6e-5; - private const double ThresholdBand = 0.02; - - private static float3 RandomPoint(System.Random random, float extent = 3.0f) => new( - (random.NextSingle() * 2.0f - 1.0f) * extent, (random.NextSingle() * 2.0f - 1.0f) * extent, (random.NextSingle() * 2.0f - 1.0f) * extent); - - // A wave vector of length 2*pi times a stripe frequency in [0.5, 1.5], in a random direction. - private static float3 RandomSide(System.Random random) - { - float3 direction; - do - { - direction = new float3(random.NextSingle() - 0.5f, random.NextSingle() - 0.5f, random.NextSingle() - 0.5f); - } - while (math.length(direction) < 0.1f); - - return math.normalize(direction) * (math.TAU * (0.5f + random.NextSingle())); - } - - // ---- An independent double-precision evaluation: all 64 cells, no prune, no gradient. ---- - // - // It shares only the hash (pcg3d over the integer cell coordinate, top 24 bits) and the constants - // the function is specified by: weight max(0, exp(-2 d^2) - 0.01111), jitter in [-0.5, 0.5). - - private readonly record struct Reference(double Cos, double Sin, double RawLength, double SupportEdgeGap); - - private static Reference Evaluate(float3 p, float3 side, float offset, float normalization) - { - double fx = Math.Floor(p.x), fy = Math.Floor(p.y), fz = Math.Floor(p.z); - double lx = p.x - fx, ly = p.y - fy, lz = p.z - fz; - double sumW = 0.0, sumCos = 0.0, sumSin = 0.0, edgeGap = double.MaxValue; - var supportRadius = Math.Sqrt(Math.Log(1.0 / 0.01111) / 2.0); - for (var dz = -1; dz <= 2; dz++) - for (var dy = -1; dy <= 2; dy++) - for (var dx = -1; dx <= 2; dx++) - { - var hash = math.pcg3d(new int3((int)fx + dx, (int)fy + dy, (int)fz + dz)); - double vx = lx - dx - (math.cellular_unit(hash.x) - 0.5); - double vy = ly - dy - (math.cellular_unit(hash.y) - 0.5); - double vz = lz - dz - (math.cellular_unit(hash.z) - 0.5); - var d2 = vx * vx + vy * vy + vz * vz; - edgeGap = Math.Min(edgeGap, Math.Abs(Math.Sqrt(d2) - supportRadius)); - var w = Math.Max(0.0, Math.Exp(-2.0 * d2) - 0.01111); - var phase = vx * side.x + vy * side.y + vz * side.z + (double)offset * math.TAU; - sumW += w; - sumCos += w * Math.Cos(phase); - sumSin += w * Math.Sin(phase); - } - - double rawCos = sumCos / sumW, rawSin = sumSin / sumW; - var raw = Math.Sqrt(rawCos * rawCos + rawSin * rawSin); - var length = Math.Max(1.0 - normalization, raw); - return new Reference(rawCos / length, rawSin / length, raw, edgeGap); - } - - private static float3 CentralDifference(Func f, float3 p, float eps) => new( - (f(p + new float3(eps, 0.0f, 0.0f)) - f(p - new float3(eps, 0.0f, 0.0f))) / (2.0f * eps), - (f(p + new float3(0.0f, eps, 0.0f)) - f(p - new float3(0.0f, eps, 0.0f))) / (2.0f * eps), - (f(p + new float3(0.0f, 0.0f, eps)) - f(p - new float3(0.0f, 0.0f, eps))) / (2.0f * eps)); - - private static float MaxComponentError(float3 numeric, float4 analytic) => MathF.Max( - MathF.Abs(numeric.x - analytic.x), MathF.Max(MathF.Abs(numeric.y - analytic.y), MathF.Abs(numeric.z - analytic.z))); - - // ---- Value: the function is Johansen's, on 3D cells ---- - - [Theory] - [InlineData(0.0f)] - [InlineData(0.5f)] - [InlineData(0.9f)] - [InlineData(1.0f)] - public void ValueMatchesTheIndependentReferenceAndNormalizesAboveTheThreshold(float normalization) - { - var random = new System.Random(0x7ACE1 + (int)(normalization * 10.0f)); - var maxError = 0.0; - var above = 0; - var below = 0; - for (var i = 0; i < 3000; i++) - { - var p = RandomPoint(random, 40.0f); - var side = RandomSide(random); - var offset = random.NextSingle(); - var actual = math.phacelle(p, side, offset, normalization); - var expected = Evaluate(p, side, offset, normalization); - - maxError = Math.Max(maxError, Math.Max(Math.Abs(actual.cos.w - expected.Cos), Math.Abs(actual.sin.w - expected.Sin))); - if (expected.RawLength >= 1.0 - normalization + ThresholdBand) - { - // The output magnitude is 1 wherever the raw magnitude reaches 1 - normalization. - above++; - var magnitude = Math.Sqrt((double)actual.cos.w * actual.cos.w + (double)actual.sin.w * actual.sin.w); - Assert.True(Math.Abs(magnitude - 1.0) < ValueTolerance, $"magnitude {magnitude} above the threshold at {p}"); - } - else if (expected.RawLength <= 1.0 - normalization - ThresholdBand) - { - below++; - } - } - - Assert.True(maxError < ValueTolerance, $"max value error against the reference {maxError}"); - if (normalization > 0.0f) - Assert.True(above >= 500, $"only {above} points above the threshold"); - if (normalization is > 0.0f and < 1.0f) - Assert.True(below >= 50, $"only {below} points below the threshold"); - } - - [Fact] - public void ValueIsPeriodicInOffsetAndAQuarterCycleRotatesCosIntoSin() - { - // offset is in cycles: a quarter turn maps cos to -sin and sin to cos, and a whole turn is the - // identity. The weights and the normalization do not depend on the phase, so this is exact - // up to float rounding. - var random = new System.Random(0x7ACE2); - for (var i = 0; i < 200; i++) - { - var p = RandomPoint(random); - var side = RandomSide(random); - var offset = random.NextSingle(); - var basePhasor = math.phacelle(p, side, offset, 0.5f); - var quarter = math.phacelle(p, side, offset + 0.25f, 0.5f); - var whole = math.phacelle(p, side, offset + 1.0f, 0.5f); - - Assert.True(MathF.Abs(quarter.cos.w + basePhasor.sin.w) < 2.0e-5f); - Assert.True(MathF.Abs(quarter.sin.w - basePhasor.cos.w) < 2.0e-5f); - Assert.True(MathF.Abs(whole.cos.w - basePhasor.cos.w) < 2.0e-5f); - Assert.True(MathF.Abs(whole.sin.w - basePhasor.sin.w) < 2.0e-5f); - } - } - - // ---- Invariant 8: the gradient is exact ---- - - [Fact] - public void GradientOfCosAndSinMatchesCentralDifferencesOnBothSidesOfTheNormalizationThreshold() - { - // Above the threshold the output is I / |I|; below it, I / (1 - normalization). The gradient - // is discontinuous where |I| crosses the threshold, and at the edge of each cell's weight support - // (distance 1.49998, where max(0, ...) starts: a jump of 4 d 0.01111 in grad w), so a band - // around each is excluded (by the - // reference's raw length, since the output alone cannot say how close it is). - const float normalization = 0.5f; - var random = new System.Random(0x7ACE3); - var above = 0; - var below = 0; - var maxError = 0.0f; - for (var attempts = 0; attempts < 20000 && (above < 150 || below < 150); attempts++) - { - var p = RandomPoint(random); - var side = RandomSide(random); - var offset = random.NextSingle(); - var reference = Evaluate(p, side, offset, normalization); - var distance = reference.RawLength - (1.0 - normalization); - if (Math.Abs(distance) < ThresholdBand || reference.SupportEdgeGap < 3.0 * GradientEps) - continue; - if (distance > 0.0 ? above >= 150 : below >= 150) - continue; - if (distance > 0.0) above++; else below++; - - var analytic = math.phacelle(p, side, offset, normalization); - var numericCos = CentralDifference(q => math.phacelle(q, side, offset, normalization).cos.w, p, GradientEps); - var numericSin = CentralDifference(q => math.phacelle(q, side, offset, normalization).sin.w, p, GradientEps); - maxError = MathF.Max(maxError, MathF.Max(MaxComponentError(numericCos, analytic.cos), MaxComponentError(numericSin, analytic.sin))); - } - - Assert.True(above >= 150 && below >= 150, $"{above} points above and {below} below the threshold"); - Assert.True(maxError < GradientTolerance, $"max gradient error against central differences {maxError}"); - } - - // ---- Stripes run along the flow ---- - - [Fact] - public void OnTheUnitSphereStripesRunAlongTheFlow() - { - // side = cross(p_hat, flow) * 2*pi is tangent to the sphere and perpendicular to the flow, so the - // cosine should change across the stripes (along side) and hardly at all along them (along - // flow). Asura's probe measured 0.212 for this ratio, against 0.202 for the planar upstream. - const float radiusInCells = 10.0f; - var random = new System.Random(0x7ACE4); - var reference = math.normalize(new float3(0.3f, 0.5f, 0.8f)); - var alongFlow = 0.0; - var acrossFlow = 0.0; - var used = 0; - while (used < 2000) - { - var hat = math.normalize(new float3(random.NextSingle() - 0.5f, random.NextSingle() - 0.5f, random.NextSingle() - 0.5f)); - var tangent = reference - hat * math.dot(hat, reference); - if (math.length(tangent) < 0.2f) - continue; - - var flow = math.normalize(tangent); - var sideDirection = math.cross(hat, flow); - var phasor = math.phacelle(hat * radiusInCells, sideDirection * math.TAU, 0.25f, 0.5f); - var gradient = new float3(phasor.cos.x, phasor.cos.y, phasor.cos.z); - alongFlow += Math.Abs(math.dot(gradient, flow)); - acrossFlow += Math.Abs(math.dot(gradient, sideDirection)); - used++; - } - - var ratio = alongFlow / acrossFlow; - Assert.True(ratio <= 0.25, $"mean|d cos/d flow| / mean|d cos/d side| = {ratio}"); - } - - // ---- Continuity ---- - - [Fact] - public void ValueStepDifferenceShrinksTenfoldPerTenfoldStepIncludingAcrossCellPlanes() - { - // Pairs straddle an integer plane on each axis (where the 4x4x4 window moves by one cell and a - // weight that did not vanish at the window edge would jump) and also start at random points. - // Lipschitz continuity means the largest difference over a step scales with the step. - var random = new System.Random(0x7ACE5); - var steps = new[] { 1.0e-3f, 1.0e-4f, 1.0e-5f }; - var maxDifference = new float[steps.Length]; - for (var i = 0; i < 3000; i++) - { - var p = RandomPoint(random); - var side = RandomSide(random); - var offset = random.NextSingle(); - var axis = i % 3; - if (i % 2 == 0) - p = SetAxis(p, axis, MathF.Round(GetAxis(p, axis))); - for (var k = 0; k < steps.Length; k++) - { - var low = math.phacelle(SetAxis(p, axis, GetAxis(p, axis) - steps[k] * 0.5f), side, offset, 0.5f); - var high = math.phacelle(SetAxis(p, axis, GetAxis(p, axis) + steps[k] * 0.5f), side, offset, 0.5f); - maxDifference[k] = MathF.Max(maxDifference[k], MathF.Max(MathF.Abs(low.cos.w - high.cos.w), MathF.Abs(low.sin.w - high.sin.w))); - } - } - - Assert.True(maxDifference[1] * 8.0f <= maxDifference[0], $"1e-3: {maxDifference[0]}, 1e-4: {maxDifference[1]}"); - Assert.True(maxDifference[2] * 8.0f <= maxDifference[1], $"1e-4: {maxDifference[1]}, 1e-5: {maxDifference[2]}"); - } - - private static float GetAxis(float3 v, int axis) => axis == 0 ? v.x : axis == 1 ? v.y : v.z; - - private static float3 SetAxis(float3 v, int axis, float value) => - axis == 0 ? new float3(value, v.y, v.z) : axis == 1 ? new float3(v.x, value, v.z) : new float3(v.x, v.y, value); - - // ---- Exact pruning ---- - - [Fact] - public void PrunedOutputIsBitEqualToTheUnprunedSum() - { - var random = new System.Random(0x7ACE6); - for (var i = 0; i < 20000; i++) - { - var p = RandomPoint(random, 50.0f); - var side = RandomSide(random); - var offset = random.NextSingle(); - var normalization = random.NextSingle(); - var pruned = math.phacelle(p, side, offset, normalization); - var unpruned = math.phacelle(p, side, offset, normalization, false); - - AssertBitEqual(unpruned.cos, pruned.cos); - AssertBitEqual(unpruned.sin, pruned.sin); - } - } - - // Points next to a cell whose jitter sits at the far corner of its cube: the cell's box bound is - // just under the 2.25 cut (2.2451 to 2.25) while its real distance is still under the support - // radius (d^2 < 2.24995), so its weight is small but nonzero and a prune that cuts even slightly - // low (2.245, 2.24, 2.2) drops a contribution and changes output bits. Random points meet such - // a cell about once in 10^4 evaluations, which is why the random sweep above let those cuts - // through. Each was found once by an offline search (cells K in [-12, 12]^3 whose jitter on one - // axis lies within 0.0008 of the cube face; p is the feature moved 1.4996 out along that axis, - // so |p - feature| = 1.4996 and the bound is about 1.4988). The witness check below re-derives - // that property from the hash, so a change to pcg3d fails loudly instead of degenerating. - // Shared with the HLSL mirror test. - internal static readonly float3[] PruneCutPoints = - { - new(1.77850008f, -2.16020727f, -0.9991166f), - new(1.09346282f, -2.37882328f, -3.00082445f), - new(-6.39144945f, -3.99953747f, -0.610023975f), - new(-0.99940908f, 4.16302729f, 4.07277536f), - new(3.99912024f, 7.21890545f, 1.09053993f), - new(5.43959713f, 1.99937367f, -6.14929914f), - }; - - internal static readonly float3 PruneCutSide = new(2.1f, -3.7f, 1.3f); - internal const float PruneCutOffset = 0.3f; - internal const float PruneCutNormalization = 0.5f; - - // True when some cell of the 4x4x4 window has a box bound in [2.2451, 2.25) and a positive weight. - internal static bool HasCellJustInsideTheCut(float3 p) - { - double fx = Math.Floor(p.x), fy = Math.Floor(p.y), fz = Math.Floor(p.z); - double lx = p.x - fx, ly = p.y - fy, lz = p.z - fz; - for (var dz = -1; dz <= 2; dz++) - for (var dy = -1; dy <= 2; dy++) - for (var dx = -1; dx <= 2; dx++) - { - var gx = Math.Max(0.0, Math.Abs(lx - dx) - 0.5); - var gy = Math.Max(0.0, Math.Abs(ly - dy) - 0.5); - var gz = Math.Max(0.0, Math.Abs(lz - dz) - 0.5); - var bound = gx * gx + gy * gy + gz * gz; - if (bound < 2.2451 || bound >= 2.25) - continue; - - var hash = math.pcg3d(new int3((int)fx + dx, (int)fy + dy, (int)fz + dz)); - double vx = lx - dx - (math.cellular_unit(hash.x) - 0.5); - double vy = ly - dy - (math.cellular_unit(hash.y) - 0.5); - double vz = lz - dz - (math.cellular_unit(hash.z) - 0.5); - if (Math.Exp(-2.0 * (vx * vx + vy * vy + vz * vz)) > 0.01111) - return true; - } - - return false; - } - - [Fact] - public void PrunedOutputIsBitEqualToTheUnprunedSumNextToCellsJustInsideTheCut() - { - foreach (var p in PruneCutPoints) - { - Assert.True(HasCellJustInsideTheCut(p), $"no cell just inside the cut at {p}: the fixture no longer exercises the prune"); - var pruned = math.phacelle(p, PruneCutSide, PruneCutOffset, PruneCutNormalization); - var unpruned = math.phacelle(p, PruneCutSide, PruneCutOffset, PruneCutNormalization, false); - - AssertBitEqual(unpruned.cos, pruned.cos); - AssertBitEqual(unpruned.sin, pruned.sin); - } - } - - private static void AssertBitEqual(float4 expected, float4 actual) - { - Assert.Equal(BitConverter.SingleToInt32Bits(expected.x), BitConverter.SingleToInt32Bits(actual.x)); - Assert.Equal(BitConverter.SingleToInt32Bits(expected.y), BitConverter.SingleToInt32Bits(actual.y)); - Assert.Equal(BitConverter.SingleToInt32Bits(expected.z), BitConverter.SingleToInt32Bits(actual.z)); - Assert.Equal(BitConverter.SingleToInt32Bits(expected.w), BitConverter.SingleToInt32Bits(actual.w)); - } -} diff --git a/packages/cultmath/tests/CultMath.Tests/VectorEqualityTests.cs b/packages/cultmath/tests/CultMath.Tests/VectorEqualityTests.cs deleted file mode 100644 index 9335784d..00000000 --- a/packages/cultmath/tests/CultMath.Tests/VectorEqualityTests.cs +++ /dev/null @@ -1,583 +0,0 @@ -using System; -using CultMath; -using Xunit; - -namespace CultMath.Tests; - -// Hand-verified on hands/adopt-stryker: weakening float3.Equals from `&&` to `||` passes the -// whole existing suite. The same shape of Equals/GetHashCode, ==/!=, and /<=/>= exists (with -// no shared base type) across float2/3/4, double2/3, int2/3/4, bool2/3/4, quaternion, -// float2x2 and float3x3. These tests are data-driven per component position rather than one -// method per type: each type supplies a base value and, per component index, a value that -// differs at exactly that index and agrees everywhere else, plus (for the four ordered numeric -// families) a left/right pair whose relational result differs per component. bool2/3/4 and -// quaternion have no ordering operators and are not included in the comparison-operator cases. -// float2x2/float3x3 have no == / != operators (Equals and GetHashCode only) and are covered -// separately, at row granularity. -public sealed class VectorEqualityTests -{ - // ---- shared, type-agnostic assertions ------------------------------------------------- - - private static void AssertEqualsAndHashCodeContract(T a, T b, bool expectedEqual, string label) - where T : IEquatable - { - Assert.True(a.Equals(b) == expectedEqual, $"{label}: a.Equals(b) expected {expectedEqual}"); - Assert.True(b.Equals(a) == expectedEqual, $"{label}: b.Equals(a) expected {expectedEqual} (symmetry)"); - if (expectedEqual) - { - Assert.True(a.GetHashCode() == b.GetHashCode(), $"{label}: equal values must share a hash code"); - } - } - - private static void AssertElementwiseEqualityOperators( - TLeft a, - TRight b, - int count, - Func expectedEqualAt, - Func eq, - Func neq, - Func at, - string label) - { - var eqResult = eq(a, b); - var neqResult = neq(a, b); - for (var i = 0; i < count; i++) - { - var expectedEqual = expectedEqualAt(i); - Assert.True(at(eqResult, i) == expectedEqual, $"{label}: == component {i} expected {expectedEqual}"); - Assert.True(at(neqResult, i) == !expectedEqual, $"{label}: != component {i} expected {!expectedEqual}"); - } - } - - private static void AssertElementwiseComparisonOperators( - TLeft left, - TRight right, - double[] leftVals, - double[] rightVals, - Func lt, - Func gt, - Func le, - Func ge, - Func at, - string label) - { - var ltResult = lt(left, right); - var gtResult = gt(left, right); - var leResult = le(left, right); - var geResult = ge(left, right); - for (var i = 0; i < leftVals.Length; i++) - { - var less = leftVals[i] < rightVals[i]; - var greater = leftVals[i] > rightVals[i]; - Assert.True(at(ltResult, i) == less, $"{label}: < component {i} (left={leftVals[i]}, right={rightVals[i]})"); - Assert.True(at(gtResult, i) == greater, $"{label}: > component {i} (left={leftVals[i]}, right={rightVals[i]})"); - Assert.True(at(leResult, i) == !greater, $"{label}: <= component {i} (left={leftVals[i]}, right={rightVals[i]})"); - Assert.True(at(geResult, i) == !less, $"{label}: >= component {i} (left={leftVals[i]}, right={rightVals[i]})"); - } - } - - // ---- float2 ----------------------------------------------------------------------------- - - [Theory] - [InlineData(0)] - [InlineData(1)] - public void Float2EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index) - { - var a = new float2(3.0f, -7.0f); - var b = a; - b[index] += 5.0f; - - AssertEqualsAndHashCodeContract(a, a, true, "float2 self"); - AssertEqualsAndHashCodeContract(a, b, false, $"float2 differing at {index}"); - AssertElementwiseEqualityOperators( - a, b, 2, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float2 vector==vector"); - - // Scalar overloads: a scalar equal to exactly one component, on both operand sides. - var scalar = a[index]; - AssertElementwiseEqualityOperators( - a, scalar, 2, i => a[i] == scalar, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float2 vector==scalar"); - AssertElementwiseEqualityOperators( - scalar, a, 2, i => a[i] == scalar, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float2 scalar==vector"); - } - - [Fact] - public void Float2ComparisonOperatorsDifferPerComponent() - { - var left = new float2(1.0f, 5.0f); - var right = new float2(2.0f, 3.0f); - var leftVals = new double[] { 1.0, 5.0 }; - var rightVals = new double[] { 2.0, 3.0 }; - - AssertElementwiseComparisonOperators( - left, right, leftVals, rightVals, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float2 vector-vector"); - - const float scalar = 2.0f; - AssertElementwiseComparisonOperators( - left, scalar, leftVals, new double[] { scalar, scalar }, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float2 vector-scalar"); - AssertElementwiseComparisonOperators( - scalar, left, new double[] { scalar, scalar }, leftVals, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float2 scalar-vector"); - - // A value compared against an exact copy of itself: every component ties. This is the - // complementary fixture to the no-tie one above. A tie is where < and <= (or > and >=) - // actually disagree, so it catches a strict/non-strict boundary swap (e.g. < mutated to - // <=) that a fixture with no equal components cannot: unlike a threshold compared against - // an arbitrary float, an exact self-tie is neither rare nor equivalent here, since these - // are general-purpose comparison operators (bounds/containment checks compare against - // literal endpoints routinely, as CultMath's own touching-edges rect test does). - AssertElementwiseComparisonOperators( - left, left, leftVals, leftVals, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float2 tied vector-vector"); - - // The scalar overloads need their own tie: a uniform vector against a scalar equal to - // every component, so every component of the vector-scalar and scalar-vector overloads - // ties too (the vector-vector tie above does not cover them). - var uniform = new float2(4.0f, 4.0f); - var uniformVals = new double[] { 4.0, 4.0 }; - AssertElementwiseComparisonOperators( - uniform, 4.0f, uniformVals, uniformVals, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float2 tied vector-scalar"); - AssertElementwiseComparisonOperators( - 4.0f, uniform, uniformVals, uniformVals, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float2 tied scalar-vector"); - } - - // ---- float3 ----------------------------------------------------------------------------- - - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(2)] - public void Float3EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index) - { - var a = new float3(3.0f, -7.0f, 4.5f); - var b = a; - b[index] += 5.0f; - - AssertEqualsAndHashCodeContract(a, a, true, "float3 self"); - AssertEqualsAndHashCodeContract(a, b, false, $"float3 differing at {index}"); - AssertElementwiseEqualityOperators( - a, b, 3, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float3 vector==vector"); - - var scalar = a[index]; - AssertElementwiseEqualityOperators( - a, scalar, 3, i => a[i] == scalar, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float3 vector==scalar"); - AssertElementwiseEqualityOperators( - scalar, a, 3, i => a[i] == scalar, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float3 scalar==vector"); - } - - [Fact] - public void Float3ComparisonOperatorsDifferPerComponent() - { - // No component is tied: a tie makes strict < and strict > agree (both false), which - // would hide a direction-flip mutation (< swapped for >) at that component. - var left = new float3(1.0f, 5.0f, 7.0f); - var right = new float3(2.0f, 3.0f, 4.0f); - var leftVals = new double[] { 1.0, 5.0, 7.0 }; - var rightVals = new double[] { 2.0, 3.0, 4.0 }; - - AssertElementwiseComparisonOperators( - left, right, leftVals, rightVals, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float3 vector-vector"); - - const float scalar = 4.0f; - AssertElementwiseComparisonOperators( - left, scalar, leftVals, new double[] { scalar, scalar, scalar }, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float3 vector-scalar"); - AssertElementwiseComparisonOperators( - scalar, left, new double[] { scalar, scalar, scalar }, leftVals, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float3 scalar-vector"); - - // Tied against itself (see the float2 tied-fixture comment above). - AssertElementwiseComparisonOperators( - left, left, leftVals, leftVals, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float3 tied vector-vector"); - - // The scalar overloads need their own tie (see the float2 uniform-vector comment above). - var uniform = new float3(4.0f, 4.0f, 4.0f); - var uniformVals = new double[] { 4.0, 4.0, 4.0 }; - AssertElementwiseComparisonOperators( - uniform, 4.0f, uniformVals, uniformVals, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float3 tied vector-scalar"); - AssertElementwiseComparisonOperators( - 4.0f, uniform, uniformVals, uniformVals, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float3 tied scalar-vector"); - } - - // ---- float4 ----------------------------------------------------------------------------- - - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(2)] - [InlineData(3)] - public void Float4EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index) - { - var a = new float4(3.0f, -7.0f, 4.5f, 9.0f); - var b = a; - b[index] += 5.0f; - - AssertEqualsAndHashCodeContract(a, a, true, "float4 self"); - AssertEqualsAndHashCodeContract(a, b, false, $"float4 differing at {index}"); - AssertElementwiseEqualityOperators( - a, b, 4, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float4 vector==vector"); - - var scalar = a[index]; - AssertElementwiseEqualityOperators( - a, scalar, 4, i => a[i] == scalar, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float4 vector==scalar"); - AssertElementwiseEqualityOperators( - scalar, a, 4, i => a[i] == scalar, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float4 scalar==vector"); - } - - [Fact] - public void Float4ComparisonOperatorsDifferPerComponent() - { - // No component is tied (see the float3 comment above for why a tie hides a - // direction-flip mutation at that component). - var left = new float4(1.0f, 5.0f, 7.0f, 2.0f); - var right = new float4(2.0f, 3.0f, 4.0f, 9.0f); - var leftVals = new double[] { 1.0, 5.0, 7.0, 2.0 }; - var rightVals = new double[] { 2.0, 3.0, 4.0, 9.0 }; - - AssertElementwiseComparisonOperators( - left, right, leftVals, rightVals, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float4 vector-vector"); - - const float scalar = 4.0f; - AssertElementwiseComparisonOperators( - left, scalar, leftVals, new double[] { scalar, scalar, scalar, scalar }, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float4 vector-scalar"); - AssertElementwiseComparisonOperators( - scalar, left, new double[] { scalar, scalar, scalar, scalar }, leftVals, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float4 scalar-vector"); - - // Tied against itself (see the float2 tied-fixture comment above). - AssertElementwiseComparisonOperators( - left, left, leftVals, leftVals, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float4 tied vector-vector"); - - // The scalar overloads need their own tie (see the float2 uniform-vector comment above). - var uniform = new float4(4.0f, 4.0f, 4.0f, 4.0f); - var uniformVals = new double[] { 4.0, 4.0, 4.0, 4.0 }; - AssertElementwiseComparisonOperators( - uniform, 4.0f, uniformVals, uniformVals, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float4 tied vector-scalar"); - AssertElementwiseComparisonOperators( - 4.0f, uniform, uniformVals, uniformVals, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float4 tied scalar-vector"); - } - - // ---- double2 ---------------------------------------------------------------------------- - - [Theory] - [InlineData(0)] - [InlineData(1)] - public void Double2EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index) - { - var a = new double2(3.0, -7.0); - var b = a; - b[index] += 5.0; - - AssertEqualsAndHashCodeContract(a, a, true, "double2 self"); - AssertEqualsAndHashCodeContract(a, b, false, $"double2 differing at {index}"); - AssertElementwiseEqualityOperators( - a, b, 2, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "double2 =="); - } - - [Fact] - public void Double2ComparisonOperatorsDifferPerComponent() - { - var left = new double2(1.0, 5.0); - var right = new double2(2.0, 3.0); - var leftVals = new double[] { 1.0, 5.0 }; - var rightVals = new double[] { 2.0, 3.0 }; - - AssertElementwiseComparisonOperators( - left, right, leftVals, rightVals, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "double2 vector-vector"); - - // Tied against itself (see the float2 tied-fixture comment above). - AssertElementwiseComparisonOperators( - left, left, leftVals, leftVals, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "double2 tied vector-vector"); - } - - // ---- double3 ---------------------------------------------------------------------------- - - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(2)] - public void Double3EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index) - { - var a = new double3(3.0, -7.0, 4.5); - var b = a; - b[index] += 5.0; - - AssertEqualsAndHashCodeContract(a, a, true, "double3 self"); - AssertEqualsAndHashCodeContract(a, b, false, $"double3 differing at {index}"); - AssertElementwiseEqualityOperators( - a, b, 3, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "double3 =="); - } - - [Fact] - public void Double3ComparisonOperatorsDifferPerComponent() - { - // No component is tied (see the float3 comment above). - var left = new double3(1.0, 5.0, 7.0); - var right = new double3(2.0, 3.0, 4.0); - var leftVals = new double[] { 1.0, 5.0, 7.0 }; - var rightVals = new double[] { 2.0, 3.0, 4.0 }; - - AssertElementwiseComparisonOperators( - left, right, leftVals, rightVals, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "double3 vector-vector"); - - // Tied against itself (see the float2 tied-fixture comment above). - AssertElementwiseComparisonOperators( - left, left, leftVals, leftVals, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "double3 tied vector-vector"); - } - - // ---- int2 ------------------------------------------------------------------------------- - - [Theory] - [InlineData(0)] - [InlineData(1)] - public void Int2EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index) - { - var a = new int2(3, -7); - var b = a; - b[index] += 5; - - AssertEqualsAndHashCodeContract(a, a, true, "int2 self"); - AssertEqualsAndHashCodeContract(a, b, false, $"int2 differing at {index}"); - AssertElementwiseEqualityOperators( - a, b, 2, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "int2 =="); - } - - [Fact] - public void Int2ComparisonOperatorsDifferPerComponent() - { - var left = new int2(1, 5); - var right = new int2(2, 3); - var leftVals = new double[] { 1, 5 }; - var rightVals = new double[] { 2, 3 }; - - AssertElementwiseComparisonOperators( - left, right, leftVals, rightVals, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "int2 vector-vector"); - - // Tied against itself (see the float2 tied-fixture comment above); integers make an - // exact tie the routine case, not the rare one. - AssertElementwiseComparisonOperators( - left, left, leftVals, leftVals, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "int2 tied vector-vector"); - } - - // ---- int3 ------------------------------------------------------------------------------- - - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(2)] - public void Int3EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index) - { - var a = new int3(3, -7, 4); - var b = a; - b[index] += 5; - - AssertEqualsAndHashCodeContract(a, a, true, "int3 self"); - AssertEqualsAndHashCodeContract(a, b, false, $"int3 differing at {index}"); - AssertElementwiseEqualityOperators( - a, b, 3, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "int3 =="); - } - - [Fact] - public void Int3ComparisonOperatorsDifferPerComponent() - { - // No component is tied (see the float3 comment above). - var left = new int3(1, 5, 7); - var right = new int3(2, 3, 4); - var leftVals = new double[] { 1, 5, 7 }; - var rightVals = new double[] { 2, 3, 4 }; - - AssertElementwiseComparisonOperators( - left, right, leftVals, rightVals, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "int3 vector-vector"); - - // Tied against itself (see the int2 tied-fixture comment above). - AssertElementwiseComparisonOperators( - left, left, leftVals, leftVals, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "int3 tied vector-vector"); - } - - // ---- int4 ------------------------------------------------------------------------------- - - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(2)] - [InlineData(3)] - public void Int4EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index) - { - var a = new int4(3, -7, 4, 9); - var b = a; - b[index] += 5; - - AssertEqualsAndHashCodeContract(a, a, true, "int4 self"); - AssertEqualsAndHashCodeContract(a, b, false, $"int4 differing at {index}"); - AssertElementwiseEqualityOperators( - a, b, 4, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "int4 =="); - } - - [Fact] - public void Int4ComparisonOperatorsDifferPerComponent() - { - // No component is tied (see the float3 comment above). - var left = new int4(1, 5, 7, 2); - var right = new int4(2, 3, 4, 9); - var leftVals = new double[] { 1, 5, 7, 2 }; - var rightVals = new double[] { 2, 3, 4, 9 }; - - AssertElementwiseComparisonOperators( - left, right, leftVals, rightVals, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "int4 vector-vector"); - - // Tied against itself (see the int2 tied-fixture comment above). - AssertElementwiseComparisonOperators( - left, left, leftVals, leftVals, - (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "int4 tied vector-vector"); - } - - // ---- bool2/3/4 (no ordering operators) --------------------------------------------------- - - [Theory] - [InlineData(0)] - [InlineData(1)] - public void Bool2EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index) - { - var a = new bool2(true, false); - var b = a; - b[index] = !b[index]; - - AssertEqualsAndHashCodeContract(a, a, true, "bool2 self"); - AssertEqualsAndHashCodeContract(a, b, false, $"bool2 differing at {index}"); - AssertElementwiseEqualityOperators( - a, b, 2, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "bool2 =="); - } - - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(2)] - public void Bool3EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index) - { - var a = new bool3(true, false, true); - var b = a; - b[index] = !b[index]; - - AssertEqualsAndHashCodeContract(a, a, true, "bool3 self"); - AssertEqualsAndHashCodeContract(a, b, false, $"bool3 differing at {index}"); - AssertElementwiseEqualityOperators( - a, b, 3, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "bool3 =="); - } - - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(2)] - [InlineData(3)] - public void Bool4EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index) - { - var a = new bool4(true, false, true, false); - var b = a; - b[index] = !b[index]; - - AssertEqualsAndHashCodeContract(a, a, true, "bool4 self"); - AssertEqualsAndHashCodeContract(a, b, false, $"bool4 differing at {index}"); - AssertElementwiseEqualityOperators( - a, b, 4, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "bool4 =="); - } - - [Fact] - public void BoolVectorFalseAndTrueConstantsHaveEveryComponentSet() - { - // bool2.@false/@true and bool4.@false/bool3.@true have no other consumer in this suite - // (bool3.@false and bool4.@true are read by HlslSemanticsTests), so a wrong component in - // their field initializers would otherwise go unnoticed. - Assert.Equal(new bool2(false, false), bool2.@false); - Assert.Equal(new bool2(true, true), bool2.@true); - Assert.Equal(new bool3(false, false, false), bool3.@false); - Assert.Equal(new bool3(true, true, true), bool3.@true); - Assert.Equal(new bool4(false, false, false, false), bool4.@false); - Assert.Equal(new bool4(true, true, true, true), bool4.@true); - } - - // ---- quaternion (== and != return a plain bool, not a component vector) ----------------- - - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(2)] - [InlineData(3)] - public void QuaternionEqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index) - { - var a = new quaternion(1.0f, 2.0f, 3.0f, 4.0f); - float4 asFloat4 = a; - asFloat4[index] += 5.0f; - quaternion b = asFloat4; - - AssertEqualsAndHashCodeContract(a, a, true, "quaternion self"); - AssertEqualsAndHashCodeContract(a, b, false, $"quaternion differing at {index}"); - - // quaternion's == / != are scalar bool, defined in terms of Equals: confirm they - // actually delegate rather than reimplementing (and weakening) the comparison. - var aCopy = new quaternion(a.x, a.y, a.z, a.w); - Assert.Equal(a.Equals(aCopy), a == aCopy); - Assert.Equal(!a.Equals(aCopy), a != aCopy); - Assert.Equal(a.Equals(b), a == b); - Assert.Equal(!a.Equals(b), a != b); - } - - // ---- float2x2 / float3x3 (Equals/GetHashCode only, row granularity) --------------------- - - [Theory] - [InlineData(0)] - [InlineData(1)] - public void Float2x2EqualsAndHashCodeDetectSingleRowDifference(int row) - { - var row0 = new float2(1.0f, 2.0f); - var row1 = new float2(3.0f, 4.0f); - var a = new float2x2(row0, row1); - - var differingRow0 = row == 0 ? row0 + new float2(10.0f, 0.0f) : row0; - var differingRow1 = row == 1 ? row1 + new float2(0.0f, 10.0f) : row1; - var b = new float2x2(differingRow0, differingRow1); - - AssertEqualsAndHashCodeContract(a, a, true, "float2x2 self"); - AssertEqualsAndHashCodeContract(a, b, false, $"float2x2 differing at row {row}"); - } - - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(2)] - public void Float3x3EqualsAndHashCodeDetectSingleRowDifference(int row) - { - var row0 = new float3(1.0f, 2.0f, 3.0f); - var row1 = new float3(4.0f, 5.0f, 6.0f); - var row2 = new float3(7.0f, 8.0f, 9.0f); - var a = new float3x3(row0, row1, row2); - - var differingRow0 = row == 0 ? row0 + new float3(10.0f, 0.0f, 0.0f) : row0; - var differingRow1 = row == 1 ? row1 + new float3(0.0f, 10.0f, 0.0f) : row1; - var differingRow2 = row == 2 ? row2 + new float3(0.0f, 0.0f, 10.0f) : row2; - var b = new float3x3(differingRow0, differingRow1, differingRow2); - - AssertEqualsAndHashCodeContract(a, a, true, "float3x3 self"); - AssertEqualsAndHashCodeContract(a, b, false, $"float3x3 differing at row {row}"); - } -} diff --git a/packages/cultmath/tests/CultMath.Tests/stryker-config.json b/packages/cultmath/tests/CultMath.Tests/stryker-config.json deleted file mode 100644 index 82c6d79c..00000000 --- a/packages/cultmath/tests/CultMath.Tests/stryker-config.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "stryker-config": { - "project": "CultMath.csproj", - "mutate": [ - "**/packages/cultmath/src/CultMath/**/*.cs", - "!**/packages/cultmath/src/CultMath/Swizzles.g.cs", - "!**/packages/cultmath/src/CultMath/*.meta" - ], - "mutation-level": "Standard", - "reporters": [ "progress", "html", "json" ], - "thresholds": { "high": 80, "low": 60, "break": 0 }, - "test-runner": "mtp" - } -} diff --git a/packages/cultmath/tools/generate-swizzles.ps1 b/packages/cultmath/tools/generate-swizzles.ps1 index e5f79011..9030f2be 100644 --- a/packages/cultmath/tools/generate-swizzles.ps1 +++ b/packages/cultmath/tools/generate-swizzles.ps1 @@ -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++) { diff --git a/packages/cultmath/unity/org.gamecult.cultmath/CHANGELOG.md b/packages/cultmath/unity/org.gamecult.cultmath/CHANGELOG.md deleted file mode 100644 index 871022f1..00000000 --- a/packages/cultmath/unity/org.gamecult.cultmath/CHANGELOG.md +++ /dev/null @@ -1,19 +0,0 @@ -# Changelog - -All notable changes to this package are documented in this file. - -Earlier releases (0.2.0-0.2.3) predate this package's changelog and are not -backfilled here; see `docs/semver-policy.md` for why. - -## [0.2.4] - -### Added - -- `math.erf` and `math.erfinv`: single-precision error function and its - inverse. `erf` is the Abramowitz & Stegun 7.1.26 rational/exponential fit - (max absolute error 1.5e-7 by that source, 6.621e-7 measured here against - an independent reference). `erfinv` is Giles' minimax polynomial - ("Approximating the erfinv function", GPU Computing Gems, 2010), guarded - explicitly at the domain edges (`erfinv(1) = +Infinity`, - `erfinv(-1) = -Infinity`, outside `[-1, 1]` is `NaN`); measured worst - absolute error 5.066e-7, worst `erf(erfinv(y))` round trip 6.109e-7. diff --git a/packages/cultmath/unity/org.gamecult.cultmath/CHANGELOG.md.meta b/packages/cultmath/unity/org.gamecult.cultmath/CHANGELOG.md.meta deleted file mode 100644 index 8a5177b2..00000000 --- a/packages/cultmath/unity/org.gamecult.cultmath/CHANGELOG.md.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 0a6d25c5f7894c0d8e83f2b9d69358a4 \ No newline at end of file diff --git a/packages/cultmath/unity/org.gamecult.cultmath/LICENSE.md b/packages/cultmath/unity/org.gamecult.cultmath/LICENSE.md index 7e18d22d..5e50f2d6 100644 --- a/packages/cultmath/unity/org.gamecult.cultmath/LICENSE.md +++ b/packages/cultmath/unity/org.gamecult.cultmath/LICENSE.md @@ -4,7 +4,7 @@ CultMath is licensed under the Mozilla Public License 2.0 (`MPL-2.0`). The complete license text is available from the Mozilla Foundation at . Source and modification history for the -precompiled assembly in this package are available in the GameCult/CultLib -repository (`packages/cultmath`) at the corresponding package tag. +precompiled assembly in this package are available in the GameCult/CultMath +repository at the corresponding package tag. Third-party code retains its original license. See `THIRD-PARTY-NOTICES.md`. diff --git a/packages/cultmath/unity/org.gamecult.cultmath/README.md b/packages/cultmath/unity/org.gamecult.cultmath/README.md index 4981c54c..20e1840c 100644 --- a/packages/cultmath/unity/org.gamecult.cultmath/README.md +++ b/packages/cultmath/unity/org.gamecult.cultmath/README.md @@ -2,7 +2,7 @@ This is CultMath's Unity 2021.3-compatible Git-UPM surface. It contains the precompiled `CultMath.dll`, portable symbols, the numeric -`Shaders/CultMath.hlsl` mirror (with its `CultMath.Phacelle.hlsl` include), and the `CultMath.UnityBridge` assembly. +`Shaders/CultMath.hlsl` mirror, and the `CultMath.UnityBridge` assembly. `CultMath.UnityBridge` is the only C# source Unity compiles here. It holds the UnityEngine conversions the engine-free core cannot declare: `ToCultMath()` on diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll index 2962aa2a..bf712012 100644 Binary files a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll and b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll differ diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb index c71111b1..1eaadd23 100644 Binary files a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb and b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb differ diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.Phacelle.hlsl b/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.Phacelle.hlsl deleted file mode 100644 index 1db0a2fc..00000000 --- a/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.Phacelle.hlsl +++ /dev/null @@ -1,99 +0,0 @@ -#ifndef CULTMATH_PHACELLE_HLSL -#define CULTMATH_PHACELLE_HLSL - -// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of -// the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. -// -// Phacelle noise. Copyright (c) 2025 Rune Skovbo Johansen (Shadertoy t3dyWl; MPL-2.0), read from the -// C# port PhacelleNoise in lpmitchell/AdvancedTerrainErosion, converted to burstable C# by Luke -// Mitchell (2026). Modified by GameCult: 3D cells, a caller-supplied stripe wave vector, an exact -// gradient and exact pruning. This file is an MPL-2.0 file; CultMath.hlsl, which includes it after -// its own definitions of CULTMATH_TAU and cultmath_pcg3d, is MIT (see THIRD-PARTY-NOTICES.md). - -// Invariant 8's struct return shape for cultmath_phacelle, compared field by field and bit for bit -// against CultMath.CultPhasor, same field order. -struct CultPhasor -{ - float4 cos; - float4 sin; -}; - -// Johansen's Phacelle noise on 3D cells. See math.phacelle's comment (math.Phacelle.cs) for the stripe wave -// vector, the weight, the normalization, the exact gradient and why the prune is exact. Mirrors -// the C# body operation for operation; the C#-only internal overload that switches the prune off -// has no shader twin. -CultPhasor cultmath_phacelle(float3 p, float3 side, float offset, float normalization) -{ - float3 cell = floor(p); - float3 local = p - cell; - float phaseOffset = offset * CULTMATH_TAU; - float sumW = 0.0; - float sumCos = 0.0; - float sumSin = 0.0; - float3 gradW = float3(0.0, 0.0, 0.0); - float3 gradCos = float3(0.0, 0.0, 0.0); - float3 gradSin = float3(0.0, 0.0, 0.0); - - for (int dz = -1; dz <= 2; dz++) - { - float gz = max(0.0, abs(local.z - (float)dz) - 0.5); - for (int dy = -1; dy <= 2; dy++) - { - float gy = max(0.0, abs(local.y - (float)dy) - 0.5); - for (int dx = -1; dx <= 2; dx++) - { - float gx = max(0.0, abs(local.x - (float)dx) - 0.5); - if (gx * gx + gy * gy + gz * gz >= 2.25) - continue; - - float3 gridStep = float3(dx, dy, dz); - int3 hash = cultmath_pcg3d(int3(cell + gridStep)); - float3 jitter = float3( - ((uint)hash.x >> 8) * (1.0 / 16777216.0) - 0.5, - ((uint)hash.y >> 8) * (1.0 / 16777216.0) - 0.5, - ((uint)hash.z >> 8) * (1.0 / 16777216.0) - 0.5); - float3 v = local - gridStep - jitter; - float falloff = exp(-2.0 * dot(v, v)); - float w = max(0.0, falloff - 0.01111); - float3 dw = w > 0.0 ? (-4.0 * falloff) * v : float3(0.0, 0.0, 0.0); - float phase = dot(v, side) + phaseOffset; - float c = cos(phase); - float s = sin(phase); - - sumW += w; - sumCos += w * c; - sumSin += w * s; - gradW += dw; - gradCos += dw * c - (w * s) * side; - gradSin += dw * s + (w * c) * side; - } - } - } - - float invW = 1.0 / sumW; - float rawCos = sumCos * invW; - float rawSin = sumSin * invW; - float3 gradRawCos = (gradCos - rawCos * gradW) * invW; - float3 gradRawSin = (gradSin - rawSin * gradW) * invW; - float rawLength = sqrt(rawCos * rawCos + rawSin * rawSin); - float floorLength = 1.0 - normalization; - - CultPhasor result; - if (rawLength > floorLength) - { - float outCos = rawCos / rawLength; - float outSin = rawSin / rawLength; - float3 gradLength = (rawCos * gradRawCos + rawSin * gradRawSin) / rawLength; - result.cos = float4((gradRawCos - outCos * gradLength) / rawLength, outCos); - result.sin = float4((gradRawSin - outSin * gradLength) / rawLength, outSin); - } - else - { - result.cos = float4(gradRawCos / floorLength, rawCos / floorLength); - result.sin = float4(gradRawSin / floorLength, rawSin / floorLength); - } - - return result; -} - -#endif diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.Phacelle.hlsl.meta b/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.Phacelle.hlsl.meta deleted file mode 100644 index 9c074cb5..00000000 --- a/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.Phacelle.hlsl.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 5b1c7e0a94d24f6c8a3e2d17f60b9c48 -ShaderIncludeImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl b/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl index af2d3741..365705f5 100644 --- a/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl +++ b/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl @@ -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); } @@ -76,8 +77,8 @@ float cultmath_smootherstep(float value) float2 cultmath_snoise_mod289(float2 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } float3 cultmath_snoise_mod289(float3 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } float4 cultmath_snoise_mod289(float4 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } -float3 cultmath_snoise_permute(float3 value) { return cultmath_snoise_mod289(((value * 34.0) + 10.0) * value); } -float4 cultmath_snoise_permute(float4 value) { return cultmath_snoise_mod289(((value * 34.0) + 10.0) * value); } +float3 cultmath_snoise_permute(float3 value) { return cultmath_snoise_mod289(((value * 34.0) + 1.0) * value); } +float4 cultmath_snoise_permute(float4 value) { return cultmath_snoise_mod289(((value * 34.0) + 1.0) * value); } // Ashima Arts / Ian McEwan 3D simplex noise (MIT), same float32 evaluation // order as the C# math.snoise(float3) mirror. @@ -125,9 +126,9 @@ float cultmath_snoise(float3 value) p2 *= norm.z; p3 *= norm.w; - float4 m = max(0.5 - float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); + float4 m = max(0.6 - float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); m = m * m; - return 105.0 * dot(m * m, float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3))); + return 42.0 * dot(m * m, float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3))); } float cultmath_snoise(float2 value) @@ -162,114 +163,6 @@ float cultmath_snoise(float2 value) return 130.0 * dot(m, g); } -// Analytic gradient of cultmath_snoise(float3) (invariant 8), following webgl-noise -// src/noise3Dgrad.glsl (Ashima Arts / McEwan, MIT); same kernel, permutation and scale as -// cultmath_snoise(float3) (stegu/webgl-noise 22434e04d7). See the C# math.snoise_grad comment for the -// derivation. Every local up through p0..p3 and x0..x3 is cultmath_snoise(float3)'s own derivation -// verbatim. -float4 cultmath_snoise_grad(float3 value) -{ - float2 c = float2(1.0 / 6.0, 1.0 / 3.0); - float3 i = floor(value + dot(value, c.yyy)); - float3 x0 = value - i + dot(i, c.xxx); - float3 g = step(x0.yzx, x0.xyz); - float3 l = 1.0 - g; - float3 i1 = min(g.xyz, l.zxy); - float3 i2 = max(g.xyz, l.zxy); - float3 x1 = x0 - i1 + c.xxx; - float3 x2 = x0 - i2 + c.yyy; - float3 x3 = x0 - 0.5; - - i = cultmath_snoise_mod289(i); - float4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute( - i.z + float4(0.0, i1.z, i2.z, 1.0)) + i.y + float4(0.0, i1.y, i2.y, 1.0)) + i.x + float4(0.0, i1.x, i2.x, 1.0)); - - const float n_ = 0.142857142857; - float3 ns = float3(2.0 * n_, 0.5 * n_ - 1.0, n_); - float4 j = p - 49.0 * floor(p * ns.z * ns.z); - float4 x_ = floor(j * ns.z); - float4 y_ = floor(j - 7.0 * x_); - float4 x = x_ * ns.x + ns.yyyy; - float4 y = y_ * ns.x + ns.yyyy; - float4 h = 1.0 - abs(x) - abs(y); - - float4 b0 = float4(x.xy, y.xy); - float4 b1 = float4(x.zw, y.zw); - float4 s0 = floor(b0) * 2.0 + 1.0; - float4 s1 = floor(b1) * 2.0 + 1.0; - float4 sh = -step(h, 0.0); - float4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; - float4 a1 = b1.xzyw + s1.xzyw * sh.zzww; - - float3 p0 = float3(a0.xy, h.x); - float3 p1 = float3(a0.zw, h.y); - float3 p2 = float3(a1.xy, h.z); - float3 p3 = float3(a1.zw, h.w); - float4 norm = 1.79284291400159 - 0.85373472095314 * float4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)); - p0 *= norm.x; - p1 *= norm.y; - p2 *= norm.z; - p3 *= norm.w; - - float4 m0 = max(0.5 - float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); - float4 m2 = m0 * m0; - float4 m3 = m2 * m0; - float4 m4 = m2 * m2; - - float4 px = float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)); - float value2 = 105.0 * dot(m4, px); - float3 grad = -8.0 * m3.x * x0 * px.x + m4.x * p0 - + -8.0 * m3.y * x1 * px.y + m4.y * p1 - + -8.0 * m3.z * x2 * px.z + m4.z * p2 - + -8.0 * m3.w * x3 * px.w + m4.w * p3; - grad *= 105.0; - - return float4(grad, value2); -} - -// Octave sum of cultmath_snoise_grad (invariant 8); see the C# math.fbm_grad comment for the -// per-octave frequency/amplitude derivation and why octaves is clamped. -float4 cultmath_fbm_grad(float3 p, int octaves, float lacunarity, float gain) -{ - octaves = clamp(octaves, 0, 16); - float amplitude = 1.0; - float frequency = 1.0; - float value = 0.0; - float3 gradient = float3(0.0, 0.0, 0.0); - for (int i = 0; i < octaves; i++) - { - float4 n = cultmath_snoise_grad(p * frequency); - value += amplitude * n.w; - gradient += amplitude * frequency * n.xyz; - frequency *= lacunarity; - amplitude *= gain; - } - - return float4(gradient, value); -} - -// Ridged multifractal noise (Musgrave, Texturing and Modeling, ch. 16); see the C# math.ridged_grad -// comment for the fold/sign derivation, the deliberate crease at n = 0, and why octaves is clamped. -float4 cultmath_ridged_grad(float3 p, int octaves, float lacunarity, float gain) -{ - octaves = clamp(octaves, 0, 16); - float amplitude = 1.0; - float frequency = 1.0; - float value = 0.0; - float3 gradient = float3(0.0, 0.0, 0.0); - for (int i = 0; i < octaves; i++) - { - float4 n = cultmath_snoise_grad(p * frequency); - float s = sign(n.w); - value += amplitude * (1.0 - abs(n.w)); - gradient += -amplitude * frequency * s * n.xyz; - frequency *= lacunarity; - amplitude *= gain; - } - - return float4(gradient, value); -} - float cultmath_lengthsq(float2 value) { return dot(value, value); } float cultmath_lengthsq(float3 value) { return dot(value, value); } float cultmath_lengthsq(float4 value) { return dot(value, value); } @@ -278,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; @@ -389,122 +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))); } - -// Inigo Quilez, "smooth minimum": quadratic-polynomial smin carried to value-and-gradient form. See -// math.smin_grad's comment (math.cs) for why the gradient blend is exact, not an approximation. -float4 cultmath_smin_grad(float4 a, float4 b, float k) -{ - float h = saturate(0.5 + 0.5 * (b.w - a.w) / k); - float value = lerp(b.w, a.w, h) - k * h * (1.0 - h); - float3 gradient = lerp(b.xyz, a.xyz, h); - return float4(gradient, value); -} - -// Invariant 8's one struct return shape (design.md, "HLSL Target: Source Transformations"): -// compared field by field and bit for bit against CultMath.CultCellular, same field order. -struct CultCellular -{ - float4 nearest; - float4 edge; - float id; -}; - -// Worley, "A Cellular Texture Basis Function" (SIGGRAPH 1996). See math.cellular's comment -// (math.cs) for the hashing, gradient, search-radius exactness proof, id source and -// degenerate-point conventions this mirrors exactly, including the 5x5x5 lower-bound prune. -CultCellular cultmath_cellular(float3 p) -{ - float3 cell = floor(p); - float f1 = 1.0e30; - float f2 = 1.0e30; - float3 c1 = float3(0.0, 0.0, 0.0); - float3 c2 = float3(0.0, 0.0, 0.0); - float3 cellId = float3(0.0, 0.0, 0.0); - - for (int dz = -2; dz <= 2; dz++) - { - float lz = max(0.0, abs((float)dz) - 1.0); - for (int dy = -2; dy <= 2; dy++) - { - float ly = max(0.0, abs((float)dy) - 1.0); - for (int dx = -2; dx <= 2; dx++) - { - float lx = max(0.0, abs((float)dx) - 1.0); - float lowerBound = sqrt(lx * lx + ly * ly + lz * lz); - if (lowerBound >= f2) - continue; - - float3 neighbor = cell + float3(dx, dy, dz); - int3 hash = cultmath_pcg3d(int3(neighbor)); - float3 jitter = float3( - ((uint)hash.x >> 8) * (1.0 / 16777216.0), - ((uint)hash.y >> 8) * (1.0 / 16777216.0), - ((uint)hash.z >> 8) * (1.0 / 16777216.0)); - float3 feature = neighbor + jitter; - float d = cultmath_distance(p, feature); - - if (d < f1) - { - f2 = f1; c2 = c1; - f1 = d; c1 = feature; cellId = neighbor; - } - else if (d < f2) - { - f2 = d; c2 = feature; - } - } - } - } - - // F1 = 0 and F2 = 0 share the same degenerate-gradient guard; see math.cellular's comment - // (math.cs) for why F2 = 0 is reachable past roughly |p| = 2^23. - float3 grad1 = f1 > 0.0 ? (p - c1) / f1 : float3(0.0, 0.0, 0.0); - float3 grad2 = f2 > 0.0 ? (p - c2) / f2 : float3(0.0, 0.0, 0.0); - - // id comes from pcg4d over the winning cell's integer coordinate alone (same int-hashing rule - // as the jitter above), never from the pcg3d hash that produced its jitter (F4). - int4 idHash = cultmath_pcg4d(int4(int3(cellId), 0)); - - CultCellular result; - result.nearest = float4(grad1, f1); - result.edge = float4(grad2 - grad1, f2 - f1); - result.id = ((uint)idHash.w >> 8) * (1.0 / 16777216.0); - return result; -} - -#include "CultMath.Phacelle.hlsl" - float cultmath_value_noise(float2 position) { float2 cell = floor(position); diff --git a/packages/cultmath/unity/org.gamecult.cultmath/THIRD-PARTY-NOTICES.md b/packages/cultmath/unity/org.gamecult.cultmath/THIRD-PARTY-NOTICES.md index 625d63a9..06ebb6c3 100644 --- a/packages/cultmath/unity/org.gamecult.cultmath/THIRD-PARTY-NOTICES.md +++ b/packages/cultmath/unity/org.gamecult.cultmath/THIRD-PARTY-NOTICES.md @@ -6,36 +6,6 @@ simplex-noise functions by Ian McEwan and Ashima Arts, as distributed by Stefan Gustavson's `webgl-noise` project and Unity Mathematics. -`math.snoise(float3)` and `cultmath_snoise(float3)` follow `src/noise3D.glsl` and -`math.snoise(float2)` follows `src/noise2D.glsl` of stegu/webgl-noise at commit -`22434e04d7753f7e949e8d724ab3da2864c17a0f` (the simplex-boundary fix is -`21d9fe23d7`, scale `ff3b5d34ea`, permutation `a3e6d57095`). - -`math.snoise_grad`/`cultmath_snoise_grad` (and the `fbm_grad`/`ridged_grad` -octave sums built on them) follow the analytic-gradient differentiation from -the same project's `src/noise3Dgrad.glsl`, with the same kernel radius, -permutation and scale as `snoise(float3)`. - - Copyright 2011 Ashima Arts. - Upstream: - License: MIT. - -## Phacelle Noise - -`math.phacelle` and `cultmath_phacelle` follow Rune Skovbo Johansen's Phacelle -noise (Shadertoy `t3dyWl`; erosion filter `wXcfWn`; blog, March 2026), read -from the C# port `PhacelleNoise` in `lpmitchell/AdvancedTerrainErosion` -(`AdvancedTerrainErosion.cs`, converted to burstable C# by Luke Mitchell, -2026). The cell weight, the phase blend and the normalization follow it. The -3D-cell generalisation, the caller-supplied stripe wave vector, the exact -gradient and the exact pruning are new here. - -- Copyright (c) 2025 Rune Skovbo Johansen. -- Upstream: -- License: MPL-2.0 (). MPL-2.0 is file-level, so it - covers exactly the files that carry the port and nothing else: - `src/CultMath/math.Phacelle.cs`, `src/CultMath/CultPhasor.cs`, - `shaders/CultMath.Phacelle.hlsl` and the Unity copy of that include. Each - carries the MPL-2.0 header and this attribution. The rest of CultMath is MIT, - so the `GameCult.Math` NuGet package declares `MIT AND MPL-2.0` and ships this - file. The Unity package as a whole is MPL-2.0. diff --git a/packages/cultmath/unity/org.gamecult.cultmath/package.json b/packages/cultmath/unity/org.gamecult.cultmath/package.json index fc0e64c7..c09bf6bb 100644 --- a/packages/cultmath/unity/org.gamecult.cultmath/package.json +++ b/packages/cultmath/unity/org.gamecult.cultmath/package.json @@ -1,7 +1,7 @@ { "name": "org.gamecult.cultmath", "displayName": "CultMath", - "version": "0.2.4", + "version": "0.2.0", "unity": "2021.3", "description": "Precompiled CultMath numeric primitives and matching HLSL semantics for GameCult Unity runtimes.", "license": "MPL-2.0", diff --git a/packages/cultmesh-browser/package.json b/packages/cultmesh-browser/package.json index 186ab630..c49062b0 100644 --- a/packages/cultmesh-browser/package.json +++ b/packages/cultmesh-browser/package.json @@ -37,7 +37,7 @@ "directory": "packages/cultmesh-browser" }, "engines": { - "node": ">=20" + "node": ">=18" }, "dependencies": { "@msgpack/msgpack": "^3.1.3", diff --git a/packages/cultmesh-browser/src/index.ts b/packages/cultmesh-browser/src/index.ts index ea37c17e..4dddfa7d 100644 --- a/packages/cultmesh-browser/src/index.ts +++ b/packages/cultmesh-browser/src/index.ts @@ -15,28 +15,42 @@ import { type CultMeshSessionAcceptedMessage, type CultMeshSessionOpenMessage, } from "cultnet-ts/contracts"; -import { - base64ToBytes, - bytesToBase64, - isLoopbackEndpoint, - isUnsignedCertificate, - routeCertificateView, - verifyAuthorityRoute, - verifyProviderSessionProof, - type CultMeshAuthorityIdentity, - type CultMeshAuthorityRouteCertificate, - type CultMeshAuthorityRouteView, - type CultMeshAuthorityTrustMode, - type CultMeshAuthorityTrustPolicy, - type CultMeshP256PublicKey, -} from "cultnet-ts/authority"; -export type CultMeshBrowserIdentity = CultMeshAuthorityIdentity; -export type CultMeshBrowserRoute = CultMeshAuthorityRouteView; -export type CultMeshBrowserP256PublicKey = CultMeshP256PublicKey; -export type CultMeshBrowserRouteCertificate = CultMeshAuthorityRouteCertificate; -export type CultMeshBrowserAuthorityTrustMode = CultMeshAuthorityTrustMode; -export type CultMeshBrowserAuthorityTrustPolicy = CultMeshAuthorityTrustPolicy; +export interface CultMeshBrowserIdentity { + verseId: string; + authorityRuntimeId: string; +} + +export interface CultMeshBrowserRoute extends CultMeshBrowserIdentity { + endpoint: string; + protocolId?: string; + protocolIds?: readonly string[]; + priority?: number; + generation: string; + certificate?: CultMeshBrowserRouteCertificate; +} + +export interface CultMeshBrowserP256PublicKey { + keyId: string; + x: string; + y: string; +} + +export interface CultMeshBrowserRouteCertificate { + providerKey: CultMeshBrowserP256PublicKey; + odinKeyId: string; + issuedAtUnixMilliseconds: number; + expiresAtUnixMilliseconds: number; + signature: string; +} + +export type CultMeshBrowserAuthorityTrustMode = "authenticated-remote" | "local-development"; + +export interface CultMeshBrowserAuthorityTrustPolicy { + mode: CultMeshBrowserAuthorityTrustMode; + odinRoots?: readonly CultMeshBrowserP256PublicKey[]; + now?: () => number; +} export interface CultMeshBrowserRendezvous { resolve( @@ -780,6 +794,19 @@ export function decodeCultNetOperationPayload(response: CultNetOperationRespo return decode(base64ToBytes(response.payload)) as T; } +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); + } + return btoa(binary); +} + +function base64ToBytes(value: string): Uint8Array { + const binary = atob(value); + return Uint8Array.from(binary, character => character.charCodeAt(0)); +} + function requireWebSocketEndpoint(value: string, field: string): URL { let endpoint: URL; try { @@ -871,7 +898,17 @@ async function selectOdinRoute( protocolIds: route.protocolIds, priority: route.priority, generation: route.generation, - ...(route.certificate ? { certificate: routeCertificateView(route.certificate) } : {}), + ...(route.certificate ? { certificate: { + providerKey: { + keyId: route.certificate.providerKeyId, + x: route.certificate.providerPublicKeyX, + y: route.certificate.providerPublicKeyY, + }, + odinKeyId: route.certificate.odinKeyId, + issuedAtUnixMilliseconds: route.certificate.issuedAtUnixMilliseconds, + expiresAtUnixMilliseconds: route.certificate.expiresAtUnixMilliseconds, + signature: route.certificate.signature, + } } : {}), }; try { await verifyAuthorityRoute(candidate, trust); @@ -904,26 +941,136 @@ async function validateSessionAcceptance( `expected '${request.verseId}/${request.authorityRuntimeId}/${protocolId}/${route.generation}'.`, ); } - // `CultMeshVerifiedAuthorityRoute.IsLocalDevelopment` in C#: an unsigned - // loopback route under local-development needs no provider session proof. - if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint) && isUnsignedCertificate(route.certificate)) { - return undefined; - } + if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint) && !route.certificate) return undefined; const providerKey = route.certificate?.providerKey; if (!providerKey || message.providerKeyId !== providerKey.keyId || !message.providerSignature) { return new Error("CultMesh authority did not prove possession of the Odin-certified provider key."); } - if (!await verifyProviderSessionProof(request, route.endpoint, providerKey, message.providerSignature)) { + if (!await verifyP256(providerKey, canonicalSession(request, route.endpoint), message.providerSignature)) { return new Error("CultMesh provider session proof is invalid."); } return undefined; } +async function verifyAuthorityRoute( + route: CultMeshBrowserRoute, + trust: CultMeshBrowserAuthorityTrustPolicy, +): Promise { + 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 { + 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.`); } diff --git a/packages/cultmesh-browser/test/cultmesh-browser.test.ts b/packages/cultmesh-browser/test/cultmesh-browser.test.ts index 59f9fac1..6c169d93 100644 --- a/packages/cultmesh-browser/test/cultmesh-browser.test.ts +++ b/packages/cultmesh-browser/test/cultmesh-browser.test.ts @@ -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; diff --git a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt index 368b5338..104bd1b9 100644 --- a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt +++ b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt @@ -11,7 +11,6 @@ import java.io.InputStream import java.io.OutputStream import java.net.DatagramPacket import java.net.DatagramSocket -import java.net.Inet6Address import java.net.InetAddress import java.net.InetSocketAddress import java.net.ServerSocket @@ -582,7 +581,7 @@ object CultMesh { connectionId: Long, remoteHost: String, remotePort: Int, - bindHost: String? = null, + bindHost: String = "127.0.0.1", bindPort: Int = 0, tuning: CultNetRudpSocketTuning = CultNetRudpSocketTuning(), ): CultNetRudpSocketTransportConnection = cultNetRudpClient( @@ -601,7 +600,7 @@ object CultMesh { runtimeId: String, connectionId: Long, endpoint: CultNetRudpEndpoint, - bindHost: String? = null, + bindHost: String = "127.0.0.1", bindPort: Int = 0, tuning: CultNetRudpSocketTuning = CultNetRudpSocketTuning(), ): CultNetRudpSocketTransportConnection = createRudpClient( @@ -618,7 +617,7 @@ object CultMesh { runtimeId: String, connectionId: Long, endpoint: String, - bindHost: String? = null, + bindHost: String = "127.0.0.1", bindPort: Int = 0, tuning: CultNetRudpSocketTuning = CultNetRudpSocketTuning(), ): CultNetRudpSocketTransportConnection = createRudpClient( @@ -634,7 +633,7 @@ object CultMesh { runtimeId: String, connectionId: Long, peer: CultMeshPeerCard, - bindHost: String? = null, + bindHost: String = "127.0.0.1", bindPort: Int = 0, tuning: CultNetRudpSocketTuning = CultNetRudpSocketTuning(), ): CultNetRudpSocketTransportConnection { @@ -652,7 +651,7 @@ object CultMesh { role: String, shardId: String? = null, at: Instant = Instant.now(), - bindHost: String? = null, + bindHost: String = "127.0.0.1", bindPort: Int = 0, tuning: CultNetRudpSocketTuning = CultNetRudpSocketTuning(), ): CultNetRudpSocketTransportConnection { @@ -665,7 +664,7 @@ object CultMesh { runtimeId: String, connectionId: Long, endpoint: CultNetRudpEndpoint, - bindHost: String? = null, + bindHost: String = "127.0.0.1", bindPort: Int = 0, tuning: CultNetRudpSocketTuning = CultNetRudpSocketTuning(), connectPayload: ByteArray = ByteArray(0), @@ -684,7 +683,7 @@ object CultMesh { runtimeId: String, connectionId: Long, endpoint: String, - bindHost: String? = null, + bindHost: String = "127.0.0.1", bindPort: Int = 0, tuning: CultNetRudpSocketTuning = CultNetRudpSocketTuning(), connectPayload: ByteArray = ByteArray(0), @@ -706,7 +705,7 @@ object CultMesh { runtimeId: String, connectionId: Long, peer: CultMeshPeerCard, - bindHost: String? = null, + bindHost: String = "127.0.0.1", bindPort: Int = 0, tuning: CultNetRudpSocketTuning = CultNetRudpSocketTuning(), connectPayload: ByteArray = ByteArray(0), @@ -737,7 +736,7 @@ object CultMesh { role: String, shardId: String? = null, at: Instant = Instant.now(), - bindHost: String? = null, + bindHost: String = "127.0.0.1", bindPort: Int = 0, tuning: CultNetRudpSocketTuning = CultNetRudpSocketTuning(), connectPayload: ByteArray = ByteArray(0), @@ -1360,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, @@ -1378,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", @@ -1469,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", ), ) @@ -3029,36 +3028,22 @@ fun cultNetRudpServer( ) } -/** - * The address an RUDP client binds when its caller names none: loopback for a - * loopback endpoint, otherwise the unspecified address of the endpoint's family. - * A socket bound to loopback cannot send off the host on Windows. - */ -fun cultNetRudpClientBindAddress(remote: InetAddress): InetAddress = when { - remote is Inet6Address && remote.isLoopbackAddress -> InetAddress.getByName("::1") - remote is Inet6Address -> InetAddress.getByName("::") - remote.isLoopbackAddress -> InetAddress.getByName("127.0.0.1") - else -> InetAddress.getByName("0.0.0.0") -} - fun cultNetRudpClient( runtimeId: String, connectionId: Long, remoteHost: String, remotePort: Int, - bindHost: String? = null, + bindHost: String = "127.0.0.1", bindPort: Int = 0, tuning: CultNetRudpSocketTuning = CultNetRudpSocketTuning(), ): CultNetRudpSocketTransportConnection { - val remote = InetAddress.getByName(remoteHost) - val bindAddress = bindHost?.let(InetAddress::getByName) ?: cultNetRudpClientBindAddress(remote) - val socket = DatagramSocket(bindPort, bindAddress).also { it.soTimeout = 20 } + val socket = DatagramSocket(bindPort, InetAddress.getByName(bindHost)).also { it.soTimeout = 20 } return CultNetRudpSocketTransportConnection( socket = socket, mode = CultNetRudpSocketMode.Client, runtimeId = runtimeId, connectionId = connectionId, - remoteAddress = InetSocketAddress(remote, remotePort), + remoteAddress = InetSocketAddress(InetAddress.getByName(remoteHost), remotePort), initialSequence = tuning.initialSequence, resendDelayMs = tuning.resendDelayMs, maxFragmentBytes = tuning.maxFragmentBytes, @@ -3383,7 +3368,6 @@ fun main(args: Array) { rudpSessionFragmentsAndReassemblesReliableOrderedPayloads() rudpSessionAdvancesLargeFragmentSetsThroughBoundedReliableWindow() rudpSocketTransportErgonomicFactoriesCarrySchemaFrames() - rudpClientBindsLoopbackOnlyForALoopbackEndpoint() rudpSocketTransportHandshakesAndCarriesReliableOrderedSchemaFrames() rudpSocketTransportCarriesFragmentedReliableOrderedSchemaFrames() return @@ -3733,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 @@ -4643,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) @@ -4660,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( @@ -5394,27 +5378,6 @@ private fun rudpSessionAdvancesLargeFragmentSetsThroughBoundedReliableWindow() { check(oldAck.ackMask == 0L) } -private fun rudpClientBoundHost(endpoint: String, bindHost: String? = null): InetAddress = - CultMesh.createRudpClient("kotlin-rudp-bind-client", 0x10203060L, endpoint, bindHost).use { client -> - InetAddress.getByName(client.profile.transports.single().host) - } - -// A client bound to loopback cannot send to another host on Windows, so a -// client for a remote endpoint binds the unspecified address. A JVM wildcard -// socket is dual-stack and reports one wildcard for either family, so the -// family is not observable here. -private fun rudpClientBindsLoopbackOnlyForALoopbackEndpoint() { - check(rudpClientBoundHost("rudp://10.77.0.1:17872").isAnyLocalAddress) - check(rudpClientBoundHost("rudp://[2001:db8::1]:17872").isAnyLocalAddress) - check(rudpClientBoundHost("rudp://127.0.0.1:17872") == InetAddress.getByName("127.0.0.1")) - check(rudpClientBoundHost("rudp://[::1]:17872") == InetAddress.getByName("::1")) - check(rudpClientBoundHost("rudp://10.77.0.1:17872", "127.0.0.1") == InetAddress.getByName("127.0.0.1")) - check(rudpClientBoundHost("rudp://127.0.0.1:17872", "0.0.0.0").isAnyLocalAddress) - CultMesh.createRudpServer("kotlin-rudp-bind-server", 0x10203061L).use { server -> - check(InetAddress.getByName(server.profile.transports.single().host) == InetAddress.getByName("127.0.0.1")) - } -} - private fun rudpSocketTransportErgonomicFactoriesCarrySchemaFrames() { val connectionId = 0x10203042L CultMesh.createRudpServer( diff --git a/packages/cultmesh-py/src/cultmesh_py/facade.py b/packages/cultmesh-py/src/cultmesh_py/facade.py index fea60610..53328aab 100644 --- a/packages/cultmesh-py/src/cultmesh_py/facade.py +++ b/packages/cultmesh-py/src/cultmesh_py/facade.py @@ -18,7 +18,6 @@ from cultnet_py import ( CultNetShardCatalog, CultNetSimulationObservationHub, create_rudp_schema_transport, - rudp_client_bind_host, wire_message_schema_catalog, ) @@ -158,7 +157,7 @@ class CultMesh: bind_host: str = "127.0.0.1", bind_port: int = 0, socket: socket_module.socket | None = None, - initial_sequence: int | None = None, + initial_sequence: int = 1, resend_delay_ms: int = 250, transport_id: str = "rudp", max_payload_bytes: int | None = None, @@ -189,10 +188,10 @@ class CultMesh: connection_id: int, endpoint: str | CultMeshRudpEndpoint, *, - bind_host: str | None = None, + bind_host: str = "127.0.0.1", bind_port: int = 0, socket: socket_module.socket | None = None, - initial_sequence: int | None = None, + initial_sequence: int = 1, resend_delay_ms: int = 250, transport_id: str = "rudp", max_payload_bytes: int | None = None, @@ -205,10 +204,7 @@ class CultMesh: if isinstance(endpoint, str) else endpoint ) - transport_socket = socket or _bind_rudp_socket( - bind_host if bind_host is not None else rudp_client_bind_host(parsed_endpoint.host), - bind_port, - ) + transport_socket = socket or _bind_rudp_socket(bind_host, bind_port) return CultNetRudpSocketTransportConnection( CultNetRudpSocketTransportOptions( runtime_id=runtime_id, @@ -433,7 +429,7 @@ class CultMesh: endpoint: str | CultMeshRudpEndpoint | None = None, connection_id: int = 0x43554C54, runtime_id: str = "cultmesh-python-rudp-client", - bind_host: str | None = None, + bind_host: str = "127.0.0.1", bind_port: int = 0, ) -> CultNetRawClient: if endpoint is None and host.lower().startswith("rudp://"): @@ -469,7 +465,7 @@ class CultMesh: endpoint: str | CultMeshRudpEndpoint | None = None, connection_id: int = 0x43554C54, runtime_id: str = "cultmesh-python-rudp-client", - bind_host: str | None = None, + bind_host: str = "127.0.0.1", bind_port: int = 0, ) -> CultNetRawClient: return CultMesh.create_client( @@ -485,10 +481,7 @@ class CultMesh: def _bind_rudp_socket(bind_host: str, bind_port: int) -> socket_module.socket: - transport_socket = socket_module.socket( - socket_module.AF_INET6 if ":" in bind_host else socket_module.AF_INET, - socket_module.SOCK_DGRAM, - ) + transport_socket = socket_module.socket(socket_module.AF_INET, socket_module.SOCK_DGRAM) transport_socket.bind((bind_host, bind_port)) transport_socket.settimeout(0.02) return transport_socket diff --git a/packages/cultmesh-py/src/cultmesh_py/server.py b/packages/cultmesh-py/src/cultmesh_py/server.py index 413441c3..e0c7fdd0 100644 --- a/packages/cultmesh-py/src/cultmesh_py/server.py +++ b/packages/cultmesh-py/src/cultmesh_py/server.py @@ -1,6 +1,5 @@ from __future__ import annotations -import logging import socket import threading import time @@ -21,7 +20,6 @@ from cultnet_py import ( create_tcp_framed_transport_profile, decode_rudp_packet, encode_rudp_packet, - is_permanent_send_error, wire_message_schema_descriptors, ) @@ -33,8 +31,6 @@ from cultnet_py.cultmesh_contracts import ( CultMeshVerseCatalog, ) -_LOGGER = logging.getLogger(__name__) - @dataclass(frozen=True) class _DatabaseSubscription: @@ -77,16 +73,6 @@ class CultMeshLocalServer: _stop: threading.Event = field(default_factory=threading.Event, init=False, repr=False) _thread: threading.Thread | None = field(default=None, init=False, repr=False) _rudp_thread: threading.Thread | None = field(default=None, init=False, repr=False) - _rudp_send_failures: int = field(default=0, init=False, repr=False) - - @property - def rudp_send_failures(self) -> int: - """Datagrams that could not be sent to a peer. Each is that peer's lost - datagram: a reliable packet stays pending and is resent, and no other - peer is affected. A datagram that can never be sent as built - (`is_permanent_send_error`) is not counted here: it ends that peer's - session, and the peer's goodbye names the error.""" - return self._rudp_send_failures def __post_init__(self) -> None: if self.max_snapshot_documents is not None and self.max_snapshot_documents < 0: @@ -222,10 +208,7 @@ class CultMeshLocalServer: while not self._stop.is_set(): try: wire, remote_addr = rudp_socket.recvfrom(65535) - except (TimeoutError, ConnectionResetError): - # Windows reports an earlier ICMP port-unreachable, from a peer that - # has gone away, as an error on the next receive. It names no datagram - # of this read, so it is idle, not the end of the thread. + except TimeoutError: self._poll_rudp_resends(rudp_socket, peers) continue except OSError: @@ -241,87 +224,55 @@ class CultMeshLocalServer: now_ms = _now_ms() peer = peers.get(remote_addr) if packet.packet_type == CultNetRudpPacketType.CONNECT: - # The session decides whether this Connect repeats the one it - # accepted. Any other Connect is a new client session, with new - # subscriptions. - if peer is None or not peer.session.connect_repeats(packet): - peer = _RudpPeerConnection( - CultNetRudpSession( - CultNetRudpSessionOptions( - connection_id=self.rudp_connection_id, - resend_delay_ms=self.rudp_resend_delay_ms, - ) + peer = _RudpPeerConnection( + CultNetRudpSession( + CultNetRudpSessionOptions( + connection_id=self.rudp_connection_id, + resend_delay_ms=self.rudp_resend_delay_ms, ) ) - peers[remote_addr] = peer - unsendable = self._send_rudp_packet( + ) + peers[remote_addr] = peer + self._send_rudp_packet( rudp_socket, remote_addr, peer.session.accept_connect(packet, now_ms, b"cultmesh-local-rudp"), ) - if unsendable is not None: - self._end_unsendable_peer(rudp_socket, peers, remote_addr, unsendable) continue if peer is None: continue - try: - result = peer.session.receive(packet, now_ms) - except ValueError: - # receive() has already recorded the packet's reliable sequence, so the - # session cannot be kept: a retransmit would be acknowledged and the - # frame silently lost. It ends, never the thread, and the peer is told. - self._end_refused_peer(rudp_socket, peers, remote_addr) - continue - unsendable: OSError | None = None - for outgoing in (*([result.reply] if result.reply is not None else []), *result.ready_to_send): - unsendable = self._send_rudp_packet(rudp_socket, remote_addr, outgoing) - if unsendable is not None: - break + result = peer.session.receive(packet, now_ms) + if result.reply is not None: + self._send_rudp_packet(rudp_socket, remote_addr, result.reply) + for ready in result.ready_to_send: + self._send_rudp_packet(rudp_socket, remote_addr, ready) if result.disconnected: peers.pop(remote_addr, None) continue - try: - for delivered in result.delivered: - if delivered.channel_id != "schema": - continue - try: - message = msgpack.unpackb(delivered.payload, raw=False) - except (msgpack.ExtraData, msgpack.FormatError, msgpack.StackError, ValueError): - continue - if not isinstance(message, dict): - continue - responses = self._handle_connection_message(message, peer.subscriptions) - for response in responses: - if unsendable is None: - unsendable = self._send_rudp_schema_frame( - rudp_socket, - remote_addr, - peer.session, - msgpack.packb(response, use_bin_type=True), - ) - except Exception as error: # noqa: BLE001 - one peer's message never ends the thread - # Whatever a peer's message makes the handling raise is that peer's failure. - # The session recorded the frame's sequence, so it cannot be kept: a retransmit - # would be acknowledged and never handled. The session ends, never the thread, - # and the peer is told. The log names the error type only, because the error's - # own message can quote what the peer sent. - _LOGGER.warning( - "CultMesh RUDP server could not handle a peer's message (%s); that peer's session ended.", - type(error).__name__, - ) - self._end_refused_peer(rudp_socket, peers, remote_addr) - continue - if unsendable is None and ( - packet.reliable or packet.packet_type == CultNetRudpPacketType.DATA or result.delivered - ): - unsendable = self._send_rudp_packet( + for delivered in result.delivered: + if delivered.channel_id != "schema": + continue + try: + message = msgpack.unpackb(delivered.payload, raw=False) + except (msgpack.ExtraData, msgpack.FormatError, msgpack.StackError, ValueError): + continue + if not isinstance(message, dict): + continue + responses = self._handle_connection_message(message, peer.subscriptions) + for response in responses: + self._send_rudp_schema_frame( + rudp_socket, + remote_addr, + peer.session, + msgpack.packb(response, use_bin_type=True), + ) + if packet.reliable or packet.packet_type == CultNetRudpPacketType.DATA or result.delivered: + self._send_rudp_packet( rudp_socket, remote_addr, peer.session.create_ack_for_received(packet.sequence), ) - if unsendable is not None: - self._end_unsendable_peer(rudp_socket, peers, remote_addr, unsendable) def _poll_rudp_resends( self, @@ -331,10 +282,7 @@ class CultMeshLocalServer: now_ms = _now_ms() for remote_addr, peer in list(peers.items()): for packet in peer.session.due_resends(now_ms): - unsendable = self._send_rudp_packet(rudp_socket, remote_addr, packet) - if unsendable is not None: - self._end_unsendable_peer(rudp_socket, peers, remote_addr, unsendable) - break + self._send_rudp_packet(rudp_socket, remote_addr, packet) def _send_rudp_schema_frame( self, @@ -342,62 +290,21 @@ class CultMeshLocalServer: remote_addr: tuple[str, int], session: CultNetRudpSession, payload: bytes, - ) -> OSError | None: + ) -> None: for packet in session.send_many( "schema", payload, CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=_now_ms()), ): - unsendable = self._send_rudp_packet(rudp_socket, remote_addr, packet) - if unsendable is not None: - return unsendable - return None + self._send_rudp_packet(rudp_socket, remote_addr, packet) + @staticmethod def _send_rudp_packet( - self, rudp_socket: socket.socket, remote_addr: tuple[str, int], packet: CultNetRudpPacket, - ) -> OSError | None: - # A transient send failure is that peer's lost datagram: counted, never - # raised (raising here ended the RUDP thread for every peer). A permanent - # one (`is_permanent_send_error`) is returned: the datagram can never be - # sent as built, and the caller ends that peer's session. - wire = encode_rudp_packet(packet) - try: - rudp_socket.sendto(wire, remote_addr) - except OSError as error: - if is_permanent_send_error(error): - return error - self._rudp_send_failures += 1 - return None - - def _end_refused_peer( - self, - rudp_socket: socket.socket, - peers: dict[tuple[str, int], _RudpPeerConnection], - remote_addr: tuple[str, int], ) -> None: - peer = peers.pop(remote_addr) - # The goodbye is built after the reset, or its ack field would acknowledge the - # very frame the session refused. - peer.session.reset_peer_state() - goodbye = peer.session.create_disconnect(b"session refused a packet") - self._send_rudp_packet(rudp_socket, remote_addr, goodbye) - - def _end_unsendable_peer( - self, - rudp_socket: socket.socket, - peers: dict[tuple[str, int], _RudpPeerConnection], - remote_addr: tuple[str, int], - error: OSError, - ) -> None: - """The session owes its peer a packet that can never be sent as built. It ends, - never the thread, and the peer's goodbye names the error.""" - peer = peers.pop(remote_addr, None) - if peer is None: - return - self._send_rudp_packet(rudp_socket, remote_addr, peer.session.end_unsendable_session(error)) + rudp_socket.sendto(encode_rudp_packet(packet), remote_addr) def _handle_connection_message( self, diff --git a/packages/cultmesh-py/tests/test_cultmesh.py b/packages/cultmesh-py/tests/test_cultmesh.py index 4c6afabc..b6a21bb8 100644 --- a/packages/cultmesh-py/tests/test_cultmesh.py +++ b/packages/cultmesh-py/tests/test_cultmesh.py @@ -1,6 +1,5 @@ from __future__ import annotations -import errno import json import os import socket @@ -48,12 +47,10 @@ from cultnet_py import ( CultNetSimulationConsensusCandidate, CultNetSimulationObservation, CultNetSimulationObservationHub, - CultNetRudpPacketType, CultNetRudpSocketMode, CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions, apply_shard_log_response, - decode_rudp_packet, document_delete, document_put_raw, hello, @@ -216,37 +213,6 @@ class CultMeshTests(unittest.TestCase): self.assertEqual(received_messages[0]["schemaVersion"], "cultnet.schema_catalog_request.v0") self.assertEqual(received_messages[0]["messageId"], "rudp-transport-factory") - def _rudp_client_bound_host(self, endpoint: str, bind_host: str | None = None) -> str: - client = CultMesh.create_rudp_client("python-cultmesh-bind-client", 0x10203060, endpoint, bind_host=bind_host) - try: - return client.socket.getsockname()[0] - finally: - client.socket.close() - - # A client bound to loopback cannot send to another host on Windows, so a - # client for a remote endpoint binds the unspecified address of its family. - def test_cultmesh_rudp_client_for_a_remote_endpoint_binds_the_unspecified_address(self) -> None: - self.assertEqual(self._rudp_client_bound_host("rudp://10.77.0.1:17872"), "0.0.0.0") - self.assertEqual(self._rudp_client_bound_host("rudp://[2001:db8::1]:17872"), "::") - - def test_cultmesh_rudp_client_for_a_loopback_endpoint_binds_loopback(self) -> None: - self.assertEqual(self._rudp_client_bound_host("rudp://127.0.0.1:17872"), "127.0.0.1") - self.assertEqual(self._rudp_client_bound_host("rudp://[::1]:17872"), "::1") - - def test_cultmesh_rudp_client_binds_an_explicit_bind_host_whatever_the_endpoint(self) -> None: - self.assertEqual(self._rudp_client_bound_host("rudp://10.77.0.1:17872", "127.0.0.1"), "127.0.0.1") - self.assertEqual(self._rudp_client_bound_host("rudp://127.0.0.1:17872", "0.0.0.0"), "0.0.0.0") - - def test_cultmesh_rudp_server_binds_loopback_unless_told_otherwise(self) -> None: - loopback = CultMesh.create_rudp_server("python-cultmesh-bind-server", 0x10203061) - anywhere = CultMesh.create_rudp_server("python-cultmesh-bind-server", 0x10203062, bind_host="0.0.0.0") - try: - self.assertEqual(loopback.socket.getsockname()[0], "127.0.0.1") - self.assertEqual(anywhere.socket.getsockname()[0], "0.0.0.0") - finally: - loopback.socket.close() - anywhere.socket.close() - def test_cultmesh_facade_creates_rudp_client_from_peer_endpoint(self) -> None: connection_id = 0x10203042 server = CultMesh.create_rudp_server( @@ -1793,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, @@ -4067,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") @@ -4351,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() @@ -4370,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( @@ -4532,459 +4487,5 @@ class CultMeshTests(unittest.TestCase): self.assertEqual(streams.latest_frame("mimir:kiyo-pro").sequence, 42) -class CultMeshRudpSendFailureTests(unittest.TestCase): - """One peer whose datagrams cannot be sent must not stop the RUDP thread - serving the others.""" - - def _client(self, server: Any, name: str) -> tuple[CultNetRudpSocketTransportConnection, tuple[str, int]]: - sock = bind_udp_socket() - client = CultNetRudpSocketTransportConnection( - CultNetRudpSocketTransportOptions( - runtime_id=name, - socket=sock, - mode=CultNetRudpSocketMode.CLIENT, - connection_id=server.rudp_connection_id, - remote_addr=("127.0.0.1", server.port), - resend_delay_ms=25, - ) - ) - client.connect(b"join") - for _ in range(200): - client.receive_once() - if client.connected: - break - self.assertTrue(client.connected) - return client, sock.getsockname()[:2] - - def test_a_failed_send_to_one_peer_does_not_kill_the_rudp_thread(self) -> None: - import msgpack # type: ignore - - server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server")) - original_sendto = socket.socket.sendto - try: - x, x_addr = self._client(server, "peer-x") - y, _ = self._client(server, "peer-y") - - def sendto(sock: socket.socket, data: bytes, *args: Any) -> int: - if args and tuple(args[-1]) == tuple(x_addr): - raise OSError("injected send failure") - return original_sendto(sock, data, *args) - - request = hello(runtime_id="probe").to_bytes() - with patch.object(socket.socket, "sendto", sendto): - # The server's reply and ack to X both fail to send. - x.send("schema", request) - self._wait_for(lambda: server.rudp_send_failures > 0) - self.assertTrue(server._rudp_thread.is_alive()) - - y.send("schema", request) - response = None - for _ in range(200): - response = y.receive_once() - if response is not None: - break - time.sleep(0.005) - - self.assertIsNotNone(response, "Y is still served after X's send failed") - self.assertEqual(msgpack.unpackb(response.payload, raw=False)["runtimeId"], "mesh-server") - self.assertTrue(server._rudp_thread.is_alive()) - self.assertGreater(server.rudp_send_failures, 0) - finally: - server.stop() - - def _served(self, y: CultNetRudpSocketTransportConnection) -> Any: - import msgpack # type: ignore - - y.send("schema", hello(runtime_id="probe").to_bytes()) - for _ in range(200): - response = y.receive_once() - if response is not None: - return msgpack.unpackb(response.payload, raw=False) - time.sleep(0.005) - return None - - def test_a_vanished_peer_does_not_kill_the_rudp_thread(self) -> None: - # On Windows the closed port of a peer that went away is reported as - # ConnectionResetError (WinError 10054) by the next receive. It can only fail there. - server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server")) - try: - x, _ = self._client(server, "peer-x") - y, _ = self._client(server, "peer-y") - x.send("schema", hello(runtime_id="probe").to_bytes()) - x.socket.close() - time.sleep(1.0) # the server's reply and resends to X draw ICMP port-unreachable - - self.assertTrue(server._rudp_thread.is_alive()) - served = self._served(y) - self.assertIsNotNone(served, "Y is still served after X vanished") - self.assertEqual(served["runtimeId"], "mesh-server") - finally: - server.stop() - - def test_a_reset_reported_by_a_receive_is_idle_not_the_end_of_the_thread(self) -> None: - server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server")) - original_recvfrom = socket.socket.recvfrom - resets = [3] - try: - y, _ = self._client(server, "peer-y") - - def recvfrom(sock: socket.socket, *args: Any) -> Any: - if sock is server._rudp_socket and resets[0] > 0: - resets[0] -= 1 - raise ConnectionResetError(errno.ECONNRESET, "injected reset") - return original_recvfrom(sock, *args) - - with patch.object(socket.socket, "recvfrom", recvfrom): - self._wait_for(lambda: resets[0] == 0) - self.assertTrue(server._rudp_thread.is_alive()) - self.assertIsNotNone(self._served(y)) - finally: - server.stop() - - def test_a_permanent_send_failure_ends_only_that_peers_session_and_the_goodbye_names_it(self) -> None: - server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server")) - original_sendto = socket.socket.sendto - try: - x, x_addr = self._client(server, "peer-x") - y, _ = self._client(server, "peer-y") - failures = [1] - - def sendto(sock: socket.socket, data: bytes, *args: Any) -> int: - if args and tuple(args[-1]) == tuple(x_addr) and failures[0] > 0: - failures[0] -= 1 - raise OSError(errno.EMSGSIZE, "Message too long") - return original_sendto(sock, data, *args) - - with patch.object(socket.socket, "sendto", sendto): - # The server's response to X can never be sent as built. - x.send("schema", hello(runtime_id="probe").to_bytes()) - for _ in range(200): - x.receive_once() - if x.disconnect_reason is not None: - break - time.sleep(0.005) - self.assertIsNotNone(x.disconnect_reason, "X is told its session ended") - self.assertTrue(x.disconnect_reason.startswith(b"packet could not be sent: ")) - self.assertGreater(len(x.disconnect_reason), len(b"packet could not be sent: ")) - self.assertTrue(server._rudp_thread.is_alive()) - - # Y is unaffected. - self.assertFalse(x.connected) - self.assertIsNotNone(self._served(y)) - self.assertEqual(server.rudp_send_failures, 0) - finally: - server.stop() - - def test_healthy_sends_are_not_failures(self) -> None: - server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server")) - try: - x, _ = self._client(server, "peer-x") - self.assertIsNotNone(self._served(x)) - self.assertEqual(server.rudp_send_failures, 0) - finally: - server.stop() - - def test_a_permanent_failure_in_a_resend_ends_only_that_peers_session(self) -> None: - server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server")) - original_sendto = socket.socket.sendto - try: - x, x_addr = self._client(server, "peer-x") - y, _ = self._client(server, "peer-y") - mode = ["transient"] - - def sendto(sock: socket.socket, data: bytes, *args: Any) -> int: - if args and tuple(args[-1]) == tuple(x_addr) and mode[0] == "transient": - raise OSError(errno.ENOBUFS, "injected transient failure") - if args and tuple(args[-1]) == tuple(x_addr) and mode[0] == "permanent": - mode[0] = "healthy" - raise OSError(errno.EMSGSIZE, "Message too long") - return original_sendto(sock, data, *args) - - with patch.object(socket.socket, "sendto", sendto): - # X's response is lost, so a resend is pending; then it can never be sent as built. - x.send("schema", hello(runtime_id="probe").to_bytes()) - self._wait_for(lambda: server.rudp_send_failures > 0) - self.assertFalse(x.disconnect_reason, "a transient failure ends no session") - mode[0] = "permanent" - for _ in range(200): - x.receive_once() - if x.disconnect_reason is not None: - break - time.sleep(0.005) - self.assertIsNotNone(x.disconnect_reason) - self.assertTrue(x.disconnect_reason.startswith(b"packet could not be sent: ")) - self.assertTrue(server._rudp_thread.is_alive()) - self.assertIsNotNone(self._served(y)) - finally: - server.stop() - - def test_a_permanent_failure_acknowledging_a_frame_ends_the_session(self) -> None: - import msgpack # type: ignore - - server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server")) - original_sendto = socket.socket.sendto - try: - x, x_addr = self._client(server, "peer-x") - failures = [1] - - def sendto(sock: socket.socket, data: bytes, *args: Any) -> int: - if args and tuple(args[-1]) == tuple(x_addr) and failures[0] > 0: - failures[0] -= 1 - raise OSError(errno.EMSGSIZE, "Message too long") - return original_sendto(sock, data, *args) - - with patch.object(socket.socket, "sendto", sendto): - # Not a message the server answers, so its ack is the only datagram owed. - x.send("schema", msgpack.packb(1)) - for _ in range(200): - x.receive_once() - if x.disconnect_reason is not None: - break - time.sleep(0.005) - self.assertIsNotNone(x.disconnect_reason) - self.assertTrue(x.disconnect_reason.startswith(b"packet could not be sent: ")) - self.assertTrue(server._rudp_thread.is_alive()) - finally: - server.stop() - - def test_a_connect_whose_accept_can_never_be_sent_starts_no_session(self) -> None: - server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server")) - original_sendto = socket.socket.sendto - try: - y, _ = self._client(server, "peer-y") - z_socket = bind_udp_socket() - z_addr = z_socket.getsockname()[:2] - failures = [1] - - def sendto(sock: socket.socket, data: bytes, *args: Any) -> int: - if args and tuple(args[-1]) == tuple(z_addr) and failures[0] > 0: - failures[0] -= 1 - raise OSError(errno.EMSGSIZE, "Message too long") - return original_sendto(sock, data, *args) - - with patch.object(socket.socket, "sendto", sendto): - z = CultNetRudpSocketTransportConnection( - CultNetRudpSocketTransportOptions( - runtime_id="peer-z", - socket=z_socket, - mode=CultNetRudpSocketMode.CLIENT, - connection_id=server.rudp_connection_id, - remote_addr=("127.0.0.1", server.port), - resend_delay_ms=25, - ) - ) - z.connect(b"join") - # A session that started would resend its Accept, and the resend would get through. - time.sleep(0.5) - z_socket.settimeout(0.05) - received = [] - while True: - try: - wire, _ = z_socket.recvfrom(65535) - except TimeoutError: - break - received.append(decode_rudp_packet(wire).packet_type) - self.assertEqual(received, [CultNetRudpPacketType.DISCONNECT]) - self.assertTrue(server._rudp_thread.is_alive()) - self.assertIsNotNone(self._served(y)) - finally: - server.stop() - - def test_a_packet_the_session_refuses_ends_only_that_peers_session(self) -> None: - from cultnet_py import CultNetRudpPacket, encode_rudp_packet - - server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server")) - try: - x, _ = self._client(server, "peer-x") - y, _ = self._client(server, "peer-y") - # A fragment whose metadata the session refuses: fragment id 0 names no set. - refused = CultNetRudpPacket( - packet_type=CultNetRudpPacketType.DATA, - connection_id=server.rudp_connection_id, - sequence=0, - ack=0, - ack_mask=0, - channel_id="schema", - reliable=False, - fragment_id=0, - fragment_index=0, - fragment_count=2, - payload=b"x", - ) - x.socket.sendto(encode_rudp_packet(refused), ("127.0.0.1", server.port)) - for _ in range(200): - x.receive_once() - if x.disconnect_reason is not None: - break - time.sleep(0.005) - self.assertEqual(x.disconnect_reason, b"session refused a packet") - self.assertTrue(server._rudp_thread.is_alive()) - self.assertIsNotNone(self._served(y), "Y is still served") - finally: - server.stop() - - def test_a_message_whose_handling_fails_ends_only_that_peers_session(self) -> None: - import msgpack # type: ignore - - canary = "canary-7f3e1c" - # Well-formed frames whose fields have the wrong type: handling raises TypeError. - frames = [ - {"schemaVersion": "cultnet.database_subscribe.v0", "subscriptionId": canary, "schemaIds": 5}, - {"schemaVersion": "cultnet.database_subscribe.v0", "subscriptionId": canary, "recordKeys": True}, - ] - server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server")) - try: - y, _ = self._client(server, "peer-y") - for frame in frames: - with self.subTest(frame=frame): - x, _ = self._client(server, "peer-x") - with self.assertLogs("cultmesh_py.server", level="WARNING") as logs: - x.send("schema", msgpack.packb(frame, use_bin_type=True)) - for _ in range(200): - x.receive_once() - if x.disconnect_reason is not None: - break - time.sleep(0.005) - self.assertEqual(x.disconnect_reason, b"session refused a packet") - self.assertTrue(server._rudp_thread.is_alive()) - self.assertIsNotNone(self._served(y), "Y is still served") - self.assertEqual(len(logs.output), 1) - self.assertIn("TypeError", logs.output[0]) - self.assertNotIn(canary, logs.output[0]) - finally: - server.stop() - - def _raw_peer(self, server: Any) -> tuple[socket.socket, Any]: - """A peer driven packet by packet, to deliver frames in an order a client would not.""" - from cultnet_py import CultNetRudpSession, CultNetRudpSessionOptions, encode_rudp_packet - - sock = bind_udp_socket() - sock.settimeout(0.05) - session = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=server.rudp_connection_id)) - sock.sendto(encode_rudp_packet(session.create_connect(0, b"join")), ("127.0.0.1", server.port)) - wire, _ = sock.recvfrom(65535) - accept = decode_rudp_packet(wire) - self.assertEqual(accept.packet_type, CultNetRudpPacketType.ACCEPT) - session.receive(accept, 0) - return sock, session - - def test_the_goodbye_for_a_refused_reliable_frame_does_not_acknowledge_it(self) -> None: - import dataclasses - - from cultnet_py import CultNetRudpSendOptions, encode_rudp_packet - - server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server")) - try: - sock, session = self._raw_peer(server) - # A reliable fragment whose metadata the session refuses: fragment id 0 names no set. - # The session records its sequence before it refuses it. - (fragment,) = session.send_many("schema", b"x", CultNetRudpSendOptions(reliable=True, ordered=True)) - refused = dataclasses.replace(fragment, fragment_id=0, fragment_index=0, fragment_count=2) - sock.sendto(encode_rudp_packet(refused), ("127.0.0.1", server.port)) - goodbye = None - for _ in range(40): - try: - wire, _ = sock.recvfrom(65535) - except TimeoutError: - continue - packet = decode_rudp_packet(wire) - if packet.packet_type == CultNetRudpPacketType.DISCONNECT: - goodbye = packet - break - self.assertIsNotNone(goodbye) - self.assertEqual(goodbye.payload, b"session refused a packet") - distance = goodbye.ack - refused.sequence - acknowledged = distance == 0 or (0 < distance <= 32 and goodbye.ack_mask & (1 << (distance - 1)) != 0) - self.assertFalse(acknowledged, "the goodbye acknowledged the refused frame") - finally: - server.stop() - - def test_responses_after_one_that_can_never_be_sent_are_not_sent(self) -> None: - from cultnet_py import CultNetRudpSendOptions, encode_rudp_packet - - server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server")) - original_sendto = socket.socket.sendto - try: - sock, session = self._raw_peer(server) - addr = sock.getsockname()[:2] - request = hello(runtime_id="probe").to_bytes() - options = CultNetRudpSendOptions(reliable=True, ordered=True) - first = session.send("schema", request, options) - second = session.send("schema", request, options) - attempts: list[CultNetRudpPacketType] = [] - failures = [1] - - def sendto(sock_: socket.socket, data: bytes, *args: Any) -> int: - if args and tuple(args[-1]) == tuple(addr): - packet_type = decode_rudp_packet(data).packet_type - attempts.append(packet_type) - if packet_type == CultNetRudpPacketType.DATA and failures[0] > 0: - failures[0] -= 1 - raise OSError(errno.EMSGSIZE, "Message too long") - return original_sendto(sock_, data, *args) - - with patch.object(socket.socket, "sendto", sendto): - # The second frame waits for the first, so one receive delivers both, and - # the first of the two responses can never be sent as built. - sock.sendto(encode_rudp_packet(second), ("127.0.0.1", server.port)) - time.sleep(0.1) - attempts.clear() - sock.sendto(encode_rudp_packet(first), ("127.0.0.1", server.port)) - self._wait_for(lambda: CultNetRudpPacketType.DISCONNECT in attempts) - time.sleep(0.1) - - # The unsendable response, then only the goodbye: no second response, no ack. - self.assertEqual(attempts, [CultNetRudpPacketType.DATA, CultNetRudpPacketType.DISCONNECT]) - self.assertTrue(server._rudp_thread.is_alive()) - finally: - server.stop() - - def test_a_peer_ended_in_the_resend_loop_is_sent_nothing_more_from_it(self) -> None: - server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server")) - original_sendto = socket.socket.sendto - try: - x, x_addr = self._client(server, "peer-x") - mode = ["transient"] - attempts: list[CultNetRudpPacketType] = [] - - def sendto(sock: socket.socket, data: bytes, *args: Any) -> int: - if args and tuple(args[-1]) == tuple(x_addr): - packet_type = decode_rudp_packet(data).packet_type - if mode[0] != "transient": - attempts.append(packet_type) - # Only responses fail; X's requests are acknowledged, so X goes quiet. - if packet_type == CultNetRudpPacketType.DATA and mode[0] == "transient": - raise OSError(errno.ENOBUFS, "injected transient failure") - if packet_type == CultNetRudpPacketType.DATA and mode[0] == "permanent": - mode[0] = "healthy" - raise OSError(errno.EMSGSIZE, "Message too long") - return original_sendto(sock, data, *args) - - with patch.object(socket.socket, "sendto", sendto): - x.send("schema", hello(runtime_id="one").to_bytes()) - x.send("schema", hello(runtime_id="two").to_bytes()) - for _ in range(40): - x.receive_once() - time.sleep(0.005) - self._wait_for(lambda: server.rudp_send_failures >= 2) - # Both responses are owed; the next resend of the first can never be sent. - mode[0] = "permanent" - self._wait_for(lambda: CultNetRudpPacketType.DISCONNECT in attempts) - time.sleep(0.2) - - self.assertEqual(attempts, [CultNetRudpPacketType.DATA, CultNetRudpPacketType.DISCONNECT]) - self.assertTrue(server._rudp_thread.is_alive()) - finally: - server.stop() - - def _wait_for(self, predicate: Callable[[], bool]) -> None: - deadline = time.monotonic() + 2.0 - while time.monotonic() < deadline: - if predicate(): - return - time.sleep(0.005) - self.fail("condition not reached") - - if __name__ == "__main__": unittest.main() diff --git a/packages/cultmesh-py/tests/test_rudp_server_generation.py b/packages/cultmesh-py/tests/test_rudp_server_generation.py deleted file mode 100644 index 967a03c2..00000000 --- a/packages/cultmesh-py/tests/test_rudp_server_generation.py +++ /dev/null @@ -1,159 +0,0 @@ -"""The CultMesh local server's RUDP loop asks each peer's session whether a Connect -repeats. A retransmitted Connect keeps the peer; a restarted client on the same -address is a new one, with none of the old one's subscriptions.""" -from __future__ import annotations - -import socket -import time -import unittest -from dataclasses import dataclass - -import msgpack -from cultcache_py import define_database_entry_type -from cultmesh_py import create_node -from cultmesh_py.server import CultMeshLocalServer -from cultnet_py import ( - CultNetRudpSendOptions, - CultNetRudpSession, - CultNetRudpSessionOptions, - document_put_raw, -) -from cultnet_py.transport import CultNetRudpPacketType, decode_rudp_packet, encode_rudp_packet - -CONNECTION_ID = 0x10203091 -SCHEMA_ID = "mesh.rudp_generation_note.v1" -RELIABLE = CultNetRudpSendOptions(reliable=True, ordered=True) - - -@dataclass -class Note: - body: str - - -NOTE = define_database_entry_type( - "mesh.rudp_generation_note", - [("body", 0)], - cls=Note, - schema_id=SCHEMA_ID, - schema_name="mesh.rudp_generation_note", - schema_version=SCHEMA_ID, -) - - -def wire(message: dict) -> bytes: - return msgpack.packb(message, use_bin_type=True) - - -class RudpServerGenerationTests(unittest.TestCase): - def setUp(self) -> None: - node = create_node(runtime_id="rudp-generation") - node.database.register_document(NOTE) - self.server = CultMeshLocalServer(node=node, rudp_connection_id=CONNECTION_ID) - self.server.start() - self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - self.sock.bind(("127.0.0.1", 0)) - self.sock.settimeout(0.02) - self.target = ("127.0.0.1", self.server.port) - # Every schema data packet the server sent, by sequence, whether or not the receiving - # session delivered it: a new server-side session numbers its packets afresh. - self.sent: dict[int, str] = {} - - def tearDown(self) -> None: - self.sock.close() - self.server.stop() - - def to_server(self, packet) -> None: - self.sock.sendto(encode_rudp_packet(packet), self.target) - - def pump(self, session: CultNetRudpSession, until, seconds: float = 2.0) -> bool: - deadline = time.monotonic() + seconds - while time.monotonic() < deadline: - if until(): - return True - try: - data, _ = self.sock.recvfrom(65535) - except TimeoutError: - continue - packet = decode_rudp_packet(data) - session.receive(packet, int(time.monotonic() * 1000)) - if packet.reliable or packet.packet_type == CultNetRudpPacketType.ACCEPT: - self.to_server(session.create_ack_for_received(packet.sequence)) - if packet.packet_type == CultNetRudpPacketType.DATA and packet.channel_id == "schema": - self.sent[packet.sequence] = msgpack.unpackb(packet.payload, raw=False).get("schemaVersion") - return until() - - def count(self, schema_version: str) -> int: - return sum(1 for value in self.sent.values() if value == schema_version) - - def new_session(self, initial_sequence: int) -> CultNetRudpSession: - return CultNetRudpSession(CultNetRudpSessionOptions(connection_id=CONNECTION_ID, initial_sequence=initial_sequence)) - - def test_a_retransmitted_connect_keeps_the_peer_and_a_restarted_client_is_a_new_one(self) -> None: - first = self.new_session(50_000) - connect = first.create_connect(0, b"same") - self.to_server(connect) - self.assertTrue(self.pump(first, lambda: first.connected), "the first Accept") - hello = first.send( - "schema", wire({"schemaVersion": "cultnet.hello.v0", "runtimeId": "probe", "runtimeKind": "test"}), RELIABLE - ) - self.to_server(hello) - self.assertTrue(self.pump(first, lambda: self.count("cultnet.hello.v0") == 1), "the first answer") - self.to_server( - first.send( - "schema", - wire({ - "schemaVersion": "cultnet.database_subscribe.v0", - "messageId": "subscribe", - "subscriptionId": "old-incarnation", - "schemaIds": [SCHEMA_ID], - "includeSnapshot": False, - }), - RELIABLE, - ) - ) - self.pump(first, lambda: False, 0.2) - first_put = document_put_raw( - message_id="first-put", - key="note:a", - schema_id=SCHEMA_ID, - stored_at="2026-09-30T00:00:00Z", - payload=NOTE.encode_payload(Note("first")), - ) - self.to_server(first.send("schema", first_put.to_bytes(), RELIABLE)) - self.assertTrue( - self.pump(first, lambda: self.count("cultnet.database_change_raw.v0") == 1), - "the subscription did not notify its own connection", - ) - - # A retransmitted Connect repeats: the data already delivered is not delivered, or answered, again. - self.to_server(connect) - self.to_server(hello) - self.pump(first, lambda: False, 0.4) - self.assertEqual(self.count("cultnet.hello.v0"), 1, "the retransmitted Connect reset the peer") - - # The same client restarted on the same address, connection id and payload: a new peer. - second = self.new_session(7) - self.to_server(second.create_connect(0, b"same")) - self.assertTrue(self.pump(second, lambda: second.connected), "the restarted client's Accept") - self.to_server( - second.send( - "schema", wire({"schemaVersion": "cultnet.hello.v0", "runtimeId": "probe", "runtimeKind": "test"}), RELIABLE - ) - ) - self.assertTrue(self.pump(second, lambda: self.count("cultnet.hello.v0") == 2), "the restarted client's answer") - - # It inherited none of the old incarnation's subscriptions. - put = document_put_raw( - message_id="second-put", - key="note:a", - schema_id=SCHEMA_ID, - stored_at="2026-09-30T00:00:01Z", - payload=NOTE.encode_payload(Note("second")), - ) - self.to_server(second.send("schema", put.to_bytes(), RELIABLE)) - self.pump(second, lambda: False, 0.4) - self.assertEqual(self.count("cultnet.database_change_raw.v0"), 1, "the new peer inherited a subscription") - - -if __name__ == "__main__": - unittest.main() diff --git a/packages/cultmesh-rs/src/lib.rs b/packages/cultmesh-rs/src/lib.rs index 9c3e4d90..8cfe8cec 100644 --- a/packages/cultmesh-rs/src/lib.rs +++ b/packages/cultmesh-rs/src/lib.rs @@ -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}"); } @@ -961,7 +961,7 @@ fn connect_rudp_client( mode: cultnet_rs::CultNetRudpSocketMode::Client, remote_addr: Some(target), connection_id: fresh_rudp_connection_epoch(), - initial_sequence: None, + initial_sequence: 1, resend_delay_ms, transport_id: Some(transport_id.to_string()), max_payload_bytes: None, @@ -1406,7 +1406,7 @@ mod tests { let mut session = cultnet_rs::CultNetRudpSession::new(cultnet_rs::CultNetRudpSessionOptions { connection_id: connect.connection_id, - initial_sequence: Some(1), + initial_sequence: 1, resend_delay_ms: 5, max_pending_reliable_packets: Some(64), }); diff --git a/packages/cultmesh-rs/src/rudp_document_server.rs b/packages/cultmesh-rs/src/rudp_document_server.rs index c0cd200f..bebeb6c3 100644 --- a/packages/cultmesh-rs/src/rudp_document_server.rs +++ b/packages/cultmesh-rs/src/rudp_document_server.rs @@ -3,11 +3,9 @@ use cultnet_rs::{ CultNetMessage, CultNetRawDocumentRecord, CultNetRudpPacket, CultNetRudpPacketType, CultNetRudpSendOptions, CultNetRudpSession, CultNetRudpSessionOptions, CultNetWireContract, decode_cultnet_message_from_slice, decode_rudp_packet, encode_cultnet_message_to_vec, - encode_rudp_packet, is_permanent_send_error, + encode_rudp_packet, }; use std::collections::BTreeMap; -#[cfg(test)] -use std::collections::BTreeSet; use std::io::ErrorKind; use std::net::{SocketAddr, UdpSocket}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -209,15 +207,6 @@ pub struct CultMeshRudpDocumentServer { snapshot_source: Q, clock: C, options: CultMeshRudpDocumentServerOptions, - packets_dropped: u64, - send_failures: u64, - /// Peers whose every datagram fails to send, standing in for an unroutable - /// or full path that a loopback peer cannot be made to have. - #[cfg(test)] - failing_peers: BTreeSet, - /// Peers whose next datagram after this many fails permanently, once. - #[cfg(test)] - unsendable_after: BTreeMap, } impl CultMeshRudpDocumentServer @@ -245,35 +234,9 @@ where snapshot_source, clock, options, - packets_dropped: 0, - send_failures: 0, - #[cfg(test)] - failing_peers: BTreeSet::new(), - #[cfg(test)] - unsendable_after: BTreeMap::new(), }) } - /// Datagrams read and discarded because they belong to no admissible - /// session: malformed frames, unknown sessions, a Connect the session cap - /// refuses, a packet that fails its session. Never an - /// error: a moved flow or a scanner must not end the daemon loop. - pub fn packets_dropped(&self) -> u64 { - self.packets_dropped - } - - /// Datagrams that could not be sent to a peer and may yet be: no route, full - /// buffers, a refused path. Each is that peer's lost datagram: a reliable - /// packet stays pending and is resent, and the peer's session ends by idle - /// timeout, lifetime, refusal or Disconnect, never by the failure. Never an - /// error: one unreachable peer must not stop the poll that serves the - /// others. A datagram that can never be sent as built - /// (`is_permanent_send_error`) is not counted here: it ends that peer's - /// session, and the peer's goodbye names the error. - pub fn send_failures(&self) -> u64 { - self.send_failures - } - pub fn local_addr(&self) -> Result { Ok(self.socket.local_addr()?) } @@ -292,14 +255,7 @@ where let mut wire = vec![0_u8; MAX_UDP_DATAGRAM_BYTES]; let (received, remote_addr) = match self.socket.recv_from(&mut wire) { Ok(value) => value, - // Windows reports an earlier ICMP port-unreachable on the next - // receive; it names no datagram of this poll. - Err(error) - if matches!( - error.kind(), - ErrorKind::WouldBlock | ErrorKind::ConnectionReset - ) => - { + Err(error) if error.kind() == ErrorKind::WouldBlock => { return Ok(CultMeshRudpPollOutcome::Idle); } Err(error) => return Err(error.into()), @@ -308,10 +264,7 @@ where let packet = match decode_rudp_packet(&wire) { Ok(packet) => packet, - Err(_) => { - self.packets_dropped += 1; - return Ok(CultMeshRudpPollOutcome::Handled); - } + Err(_) => return Ok(CultMeshRudpPollOutcome::Handled), }; let now_unix = self.clock.now_unix_millis(); let now_monotonic = self.clock.now_monotonic_millis(); @@ -321,14 +274,11 @@ where }; if packet.packet_type == CultNetRudpPacketType::Connect { - if !self.accept_connection(key, &packet, now_monotonic)? { - self.packets_dropped += 1; - } + self.accept_connection(key, &packet, now_monotonic)?; return Ok(CultMeshRudpPollOutcome::Handled); } if !self.sessions.contains_key(&key) { - self.packets_dropped += 1; return Ok(CultMeshRudpPollOutcome::Handled); } if packet.packet_type == CultNetRudpPacketType::Data @@ -359,16 +309,14 @@ where let result = match result { Ok(result) => result, Err(_) => { - self.end_refused_session(key)?; - self.packets_dropped += 1; + self.sessions.remove(&key); return Ok(CultMeshRudpPollOutcome::Handled); } }; - let mut unsendable = match result.reply { - Some(reply) => self.send_packet(key.remote_addr, &reply)?, - None => None, - }; + if let Some(reply) = result.reply { + self.send_packet(key.remote_addr, &reply)?; + } if result.disconnected { self.sessions.remove(&key); @@ -398,17 +346,14 @@ where } } - if packet.reliable && unsendable.is_none() { + if packet.reliable { let ack = self .sessions .get_mut(&key) .ok_or_else(|| anyhow!("CultMesh RUDP session disappeared before ACK"))? .session .create_ack(); - unsendable = self.send_packet(key.remote_addr, &ack)?; - } - if let Some(error) = unsendable { - self.end_unsendable_session(key, &error)?; + self.send_packet(key.remote_addr, &ack)?; } Ok(CultMeshRudpPollOutcome::Handled) } @@ -428,17 +373,11 @@ where let mut resends = Vec::new(); for (key, entry) in &mut self.sessions { for packet in entry.session.due_resends(now) { - resends.push((*key, packet)); + resends.push((key.remote_addr, packet)); } } - for (key, packet) in &resends { - // A session ended earlier in this loop has nothing left to resend. - if !self.sessions.contains_key(key) { - continue; - } - if let Some(error) = self.send_packet(key.remote_addr, packet)? { - self.end_unsendable_session(*key, &error)?; - } + for (remote_addr, packet) in &resends { + self.send_packet(*remote_addr, packet)?; } Ok(CultMeshRudpMaintenance { sessions_expired: expired, @@ -446,65 +385,45 @@ where }) } - /// `Ok(false)` when the Connect was refused because the session cap is full; - /// only a socket failure is an `Err`. fn accept_connection( &mut self, key: CultMeshRudpSessionKey, packet: &CultNetRudpPacket, now: u64, - ) -> Result { - // The session decides whether a Connect repeats the one it accepted. Any - // other Connect from this key is a new client session: the old one ends - // with its budget, and the new one starts fresh. - if self - .sessions - .get(&key) - .is_some_and(|entry| !entry.session.connect_repeats(packet)) - { - self.sessions.remove(&key); + ) -> Result<()> { + if !self.sessions.contains_key(&key) { + if self.sessions.len() >= self.options.max_sessions { + return Ok(()); + } + self.sessions.insert( + key, + SessionEntry { + session: CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: key.connection_id, + initial_sequence: 1, + resend_delay_ms: duration_millis(self.options.resend_delay), + max_pending_reliable_packets: Some( + self.options.max_pending_reliable_packets_per_session, + ), + }), + created_at_monotonic_millis: now, + last_activity_monotonic_millis: now, + admitted_payload_bytes: 0, + }, + ); } - let reply = match self.sessions.get_mut(&key) { - Some(entry) => { - entry.last_activity_monotonic_millis = now; - match entry.session.accept_connect(packet, now, Vec::new()) { - Ok(reply) => reply, - Err(_) => { - self.end_refused_session(key)?; - return Ok(false); - } - } - } - None => { - if self.sessions.len() >= self.options.max_sessions { - return Ok(false); - } - let mut session = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: key.connection_id, - initial_sequence: None, - resend_delay_ms: duration_millis(self.options.resend_delay), - max_pending_reliable_packets: Some( - self.options.max_pending_reliable_packets_per_session, - ), - }); - let accept = session.accept_connect(packet, now, Vec::new())?; - self.sessions.insert( - key, - SessionEntry { - session, - created_at_monotonic_millis: now, - last_activity_monotonic_millis: now, - admitted_payload_bytes: 0, - }, - ); - accept - } + + let accept = { + let entry = self + .sessions + .get_mut(&key) + .expect("accepted session must be present"); + // The same key is a retransmitted Connect for the existing epoch. + // A genuinely fresh client incarnation must choose a fresh id. + entry.last_activity_monotonic_millis = now; + entry.session.accept_connect(packet, now, Vec::new())? }; - if let Some(error) = self.send_packet(key.remote_addr, &reply)? { - self.end_unsendable_session(key, &error)?; - return Ok(false); - } - Ok(true) + self.send_packet(key.remote_addr, &accept) } fn deliver_application_message( @@ -639,14 +558,7 @@ where entry.admitted_payload_bytes = entry.admitted_payload_bytes.saturating_add(payload_bytes); for packet in &packets { - if let Some(error) = self.send_packet(key.remote_addr, packet)? { - return Ok(Some(CultMeshRudpApplicationRejection { - session: key, - operation: CultMeshRudpApplicationOperation::SnapshotRequest, - message_id, - reason: format!("snapshot response could not be sent: {error}"), - })); - } + self.send_packet(key.remote_addr, packet)?; } } _ => {} @@ -654,32 +566,6 @@ where Ok(None) } - /// The session refused a packet: it ends, and the client is told so it does - /// not wait on a session the server no longer holds. - fn end_refused_session(&mut self, key: CultMeshRudpSessionKey) -> Result<()> { - let Some(mut entry) = self.sessions.remove(&key) else { - return Ok(()); - }; - let goodbye = entry.session.end_refused_session(); - self.send_packet(key.remote_addr, &goodbye)?; - Ok(()) - } - - /// The session owes its peer a packet that can never be sent as built. The - /// session ends, never the poll, and the goodbye names the error. - fn end_unsendable_session( - &mut self, - key: CultMeshRudpSessionKey, - error: &std::io::Error, - ) -> Result<()> { - let Some(mut entry) = self.sessions.remove(&key) else { - return Ok(()); - }; - let goodbye = entry.session.end_unsendable_session(error); - self.send_packet(key.remote_addr, &goodbye)?; - Ok(()) - } - fn payload_budget_allows(&self, key: CultMeshRudpSessionKey, bytes: usize) -> bool { let Some(session_bytes) = self .sessions @@ -703,48 +589,13 @@ where .is_some_and(|total| total <= self.options.max_admitted_payload_bytes) } - /// A transient send failure is that peer's lost datagram: counted, never an - /// error. A permanent one (`is_permanent_send_error`) is returned as `Some` - /// for the caller to end the session over. Only an encode failure, the - /// server's own bug, is an error. - fn send_packet( - &mut self, - remote_addr: SocketAddr, - packet: &CultNetRudpPacket, - ) -> Result> { + fn send_packet(&mut self, remote_addr: SocketAddr, packet: &CultNetRudpPacket) -> Result<()> { let wire = encode_rudp_packet(packet)?; - match self.send_datagram(&wire, remote_addr) { - Ok(_) => {} - Err(error) if is_permanent_send_error(&error) => return Ok(Some(error)), - Err(_) => self.send_failures += 1, - } - Ok(None) - } - - fn send_datagram(&mut self, wire: &[u8], remote_addr: SocketAddr) -> std::io::Result { - #[cfg(test)] - { - match self.unsendable_after.get(&remote_addr).copied() { - Some(0) => { - self.unsendable_after.remove(&remote_addr); - return Err(std::io::Error::from(ErrorKind::InvalidInput)); - } - Some(remaining) => { - self.unsendable_after.insert(remote_addr, remaining - 1); - } - None => {} - } - if self.failing_peers.contains(&remote_addr) { - return Err(std::io::Error::other("injected send failure")); - } - } - self.socket.send_to(wire, remote_addr) + self.socket.send_to(&wire, remote_addr)?; + Ok(()) } } -#[cfg(test)] -mod send_failure_tests; - fn validate_options(options: &CultMeshRudpDocumentServerOptions) -> Result<()> { if options.max_sessions == 0 { return Err(anyhow!("max_sessions must be greater than zero")); diff --git a/packages/cultmesh-rs/src/rudp_document_server/send_failure_tests.rs b/packages/cultmesh-rs/src/rudp_document_server/send_failure_tests.rs deleted file mode 100644 index 68351b35..00000000 --- a/packages/cultmesh-rs/src/rudp_document_server/send_failure_tests.rs +++ /dev/null @@ -1,446 +0,0 @@ -//! One peer whose datagrams cannot be sent must not stop the server serving -//! the others. The failure is injected in `send_packet` through -//! `failing_peers`; a loopback peer cannot be made unroutable on demand. - -use super::*; -use cultnet_rs::{ - CultNetRawPayloadEncoding, CultNetRudpSocketMode, CultNetRudpSocketTransportConnection, - CultNetRudpSocketTransportOptions, -}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; -use std::thread; - -#[derive(Clone)] -struct Clock(Arc); - -impl CultMeshRudpServerClock for Clock { - fn now_unix_millis(&self) -> u64 { - self.0.load(Ordering::SeqCst) - } - fn now_monotonic_millis(&self) -> u64 { - self.0.load(Ordering::SeqCst) - } -} - -type Received = Arc>>; -type Server = CultMeshRudpDocumentServer< - Box Result<()>>, - Box Result>>, - Clock, ->; -type Client = CultNetRudpSocketTransportConnection; - -fn document(key: &str) -> CultNetRawDocumentRecord { - CultNetRawDocumentRecord { - schema_id: "test.raw.v1".into(), - record_key: key.into(), - stored_at: "2026-09-03T00:00:00Z".into(), - payload_encoding: CultNetRawPayloadEncoding::Messagepack, - payload: vec![0x81, 0xa1, 0x78, 0x2a], - source_runtime_id: None, - source_agent_id: None, - source_role: None, - tags: None, - } -} - -fn client(target: SocketAddr, id: u32) -> (Client, SocketAddr) { - let socket = UdpSocket::bind("127.0.0.1:0").unwrap(); - socket.set_nonblocking(true).unwrap(); - let addr = socket.local_addr().unwrap(); - let client = CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { - media_delivery: None, - runtime_id: "test-client".into(), - socket, - mode: CultNetRudpSocketMode::Client, - remote_addr: Some(target), - connection_id: id, - initial_sequence: Some(1), - resend_delay_ms: 10, - transport_id: None, - max_payload_bytes: None, - max_fragment_bytes: Some(1200), - max_pending_reliable_packets: Some(64), - media_reliable_expire_after_ms: None, - reconnect_policy: None, - }) - .unwrap(); - (client, addr) -} - -fn send(client: &mut Client, message: &CultNetMessage) { - client - .send( - "schema", - encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0).unwrap(), - ) - .unwrap(); -} - -/// A server with a 1 s idle timeout and two connected peers, the first -/// returned being the one whose address sorts first. -fn two_peers() -> ( - Server, - Clock, - Received, - (Client, SocketAddr), - (Client, SocketAddr), -) { - let clock = Clock(Arc::new(AtomicU64::new(1_000))); - let received: Received = Arc::default(); - let sink_received = received.clone(); - let sink: Box Result<()>> = - Box::new(move |receipt| { - sink_received.lock().unwrap().push(receipt.message_id); - Ok(()) - }); - let source: Box< - dyn FnMut(&CultMeshRudpSnapshotQuery) -> Result>, - > = Box::new(|_| Ok(vec![document("catalog")])); - let mut server = CultMeshRudpDocumentServer::new( - UdpSocket::bind("127.0.0.1:0").unwrap(), - sink, - source, - clock.clone(), - CultMeshRudpDocumentServerOptions { - session_idle_timeout: Duration::from_secs(1), - resend_delay: Duration::from_millis(10), - ..Default::default() - }, - ) - .unwrap(); - let target = server.local_addr().unwrap(); - let (mut a, a_addr) = client(target, 7); - let (mut b, b_addr) = client(target, 7); - a.connect(Vec::new()).unwrap(); - b.connect(Vec::new()).unwrap(); - for _ in 0..500 { - server.poll_once().unwrap(); - a.receive_once().unwrap(); - b.receive_once().unwrap(); - if a.connected() && b.connected() { - break; - } - thread::sleep(Duration::from_millis(1)); - } - assert!(a.connected() && b.connected()); - for _ in 0..4 { - server.poll_once().unwrap(); - } - assert_eq!(server.session_count(), 2); - if a_addr < b_addr { - (server, clock, received, (a, a_addr), (b, b_addr)) - } else { - (server, clock, received, (b, b_addr), (a, a_addr)) - } -} - -#[test] -fn one_peer_whose_sends_fail_does_not_stop_the_server_serving_the_others() { - let (mut server, clock, received, (mut x, x_addr), (mut y, _)) = two_peers(); - server.failing_peers.insert(x_addr); - - // X's reply, ack and snapshot response all fail to send. X is visited first. - send( - &mut x, - &CultNetMessage::SnapshotRequest { - message_id: "x-snapshot".into(), - schema_ids: None, - record_keys: None, - }, - ); - send( - &mut y, - &CultNetMessage::DocumentPutRaw { - message_id: "y-put".into(), - document: document("y"), - }, - ); - send( - &mut y, - &CultNetMessage::SnapshotRequest { - message_id: "y-snapshot".into(), - schema_ids: None, - record_keys: None, - }, - ); - let mut y_response = None; - for step in 0..200 { - // The clock moves past X's resend delay so `maintain` has X's resend due. - clock.0.store(1_000 + step * 15, Ordering::SeqCst); - // Never `Err`: X's failed sends are its own lost datagrams. - server.poll_once().unwrap(); - if let Some(frame) = y.receive_once().unwrap() { - y_response = Some(frame); - break; - } - thread::sleep(Duration::from_millis(1)); - } - let frame = y_response.expect("Y receives its snapshot response"); - let response = - decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0) - .unwrap(); - assert!(matches!( - response, - CultNetMessage::SnapshotResponseRaw { ref message_id, .. } if message_id == "y-snapshot" - )); - assert!(server.send_failures() > 0); - assert_eq!(received.lock().unwrap().as_slice(), ["y-put"]); - assert!(server.maintain().is_ok()); - - // X's session ends by the idle rule that already ends sessions, not by the - // failure: Y stays active, X does not. - clock.0.store(1_900, Ordering::SeqCst); - send( - &mut y, - &CultNetMessage::DocumentPutRaw { - message_id: "y-put-2".into(), - document: document("y2"), - }, - ); - for _ in 0..20 { - server.poll_once().unwrap(); - thread::sleep(Duration::from_millis(1)); - } - assert_eq!(server.session_count(), 2, "the failure ended no session"); - clock.0.store(2_500, Ordering::SeqCst); - let maintenance = server.maintain().unwrap(); - assert_eq!(maintenance.sessions_expired, 1); - assert_eq!(server.session_count(), 1); -} - -// A permanent send failure is one that can never succeed for the datagram as -// built (`is_permanent_send_error`). It ends that peer's session, never the -// poll, and the peer's goodbye names the error. - -fn put(id: &str) -> CultNetMessage { - CultNetMessage::DocumentPutRaw { - message_id: id.into(), - document: document(id), - } -} - -fn snapshot(id: &str) -> CultNetMessage { - CultNetMessage::SnapshotRequest { - message_id: id.into(), - schema_ids: None, - record_keys: None, - } -} - -fn goodbye_reason(client: &mut Client) -> Vec { - for _ in 0..50 { - client.receive_once().unwrap(); - if let Some(reason) = client.disconnect_reason() { - return reason.to_vec(); - } - thread::sleep(Duration::from_millis(2)); - } - panic!("the client was never told the session ended"); -} - -fn is_unsendable_reason(reason: &[u8]) -> bool { - reason.starts_with(b"packet could not be sent: ") - && reason.len() > b"packet could not be sent: ".len() -} - -#[test] -fn a_permanent_failure_acknowledging_a_document_ends_the_session_after_the_document_is_kept() { - let (mut server, _clock, received, (mut x, x_addr), (mut y, _)) = two_peers(); - server.unsendable_after.insert(x_addr, 0); - send(&mut x, &put("x-put")); - send(&mut y, &put("y-put")); - - for _ in 0..10 { - // Never `Err`: the failure ends X's session, not the poll. - server.poll_once().unwrap(); - } - let mut kept = received.lock().unwrap().clone(); - kept.sort(); - assert_eq!(kept, ["x-put", "y-put"]); - assert_eq!(server.session_count(), 1); - assert_eq!(server.send_failures(), 0); - assert!(is_unsendable_reason(&goodbye_reason(&mut x))); -} - -#[test] -fn a_permanent_failure_sending_a_snapshot_response_rejects_it_naming_the_error() { - let (mut server, _clock, _received, (mut x, x_addr), (_y, _)) = two_peers(); - server.unsendable_after.insert(x_addr, 0); - send(&mut x, &snapshot("x-snapshot")); - - let mut rejection = None; - for _ in 0..10 { - if let CultMeshRudpPollOutcome::ApplicationRejected(found) = server.poll_once().unwrap() { - rejection = Some(found); - break; - } - } - let rejection = rejection.expect("the response can never be sent"); - assert_eq!(rejection.operation, CultMeshRudpApplicationOperation::SnapshotRequest); - assert_eq!(rejection.message_id, "x-snapshot"); - assert!( - rejection - .reason - .starts_with("snapshot response could not be sent: ") - && rejection.reason.len() > "snapshot response could not be sent: ".len(), - "{}", - rejection.reason - ); - assert_eq!(server.session_count(), 1); -} - -#[test] -fn a_permanent_failure_in_resends_ends_only_that_peers_session() { - let (mut server, clock, received, (mut x, x_addr), (mut y, _)) = two_peers(); - // X's snapshot response is lost, so a resend is due; then the datagram can - // never be sent as built. - server.failing_peers.insert(x_addr); - send(&mut x, &snapshot("x-snapshot")); - for _ in 0..5 { - server.poll_once().unwrap(); - } - server.failing_peers.clear(); - server.unsendable_after.insert(x_addr, 0); - clock.0.store(1_200, Ordering::SeqCst); - - let maintenance = server.maintain().unwrap(); - assert!(maintenance.packets_resent > 0); - assert_eq!(server.session_count(), 1); - assert!(is_unsendable_reason(&goodbye_reason(&mut x))); - - send(&mut y, &put("y-put")); - for _ in 0..5 { - server.poll_once().unwrap(); - } - assert_eq!(received.lock().unwrap().as_slice(), ["y-put"]); -} - -#[test] -fn a_connect_whose_accept_can_never_be_sent_starts_no_session() { - let (mut server, _clock, _received, _x, _y) = two_peers(); - let (mut z, z_addr) = client(server.local_addr().unwrap(), 9); - server.unsendable_after.insert(z_addr, 0); - z.connect(Vec::new()).unwrap(); - - for _ in 0..10 { - server.poll_once().unwrap(); - thread::sleep(Duration::from_millis(1)); - } - assert_eq!(server.session_count(), 2); - assert_eq!(server.packets_dropped(), 1); -} - -#[test] -fn healthy_sends_are_not_failures() { - let (mut server, _clock, _received, (mut x, _), (_y, _)) = two_peers(); - send(&mut x, &put("x-put")); - send(&mut x, &snapshot("x-snapshot")); - for _ in 0..10 { - server.poll_once().unwrap(); - thread::sleep(Duration::from_millis(1)); - } - assert_eq!(server.send_failures(), 0); - assert_eq!(server.session_count(), 2); -} - -#[test] -fn a_session_ended_in_the_resend_loop_is_sent_nothing_more_from_it() { - let (mut server, clock, _received, (mut x, x_addr), (_y, _)) = two_peers(); - server.failing_peers.insert(x_addr); - send(&mut x, &snapshot("one")); - send(&mut x, &snapshot("two")); - for _ in 0..10 { - server.poll_once().unwrap(); - } - let before = server.send_failures(); - - server.unsendable_after.insert(x_addr, 0); - clock.0.store(1_200, Ordering::SeqCst); - let maintenance = server.maintain().unwrap(); - - // Both responses are due. The first can never be sent and ends the session; - // the goodbye is the only other datagram attempted at X. - assert_eq!(maintenance.packets_resent, 2); - assert_eq!(server.send_failures() - before, 1); - assert_eq!(server.session_count(), 1); -} - -#[test] -fn no_ack_follows_a_reply_that_can_never_be_sent() { - let (mut server, _clock, _received, _x, _y) = two_peers(); - let target = server.local_addr().unwrap(); - // A peer driven packet by packet: a client never sends a reliable Ping, but a - // peer may, and the server both answers it and acknowledges it. - let socket = UdpSocket::bind("127.0.0.1:0").unwrap(); - socket - .set_read_timeout(Some(Duration::from_millis(20))) - .unwrap(); - let addr = socket.local_addr().unwrap(); - let mut session = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: 7, - ..Default::default() - }); - let connect = session.create_connect(0, Vec::new()).unwrap(); - socket - .send_to(&encode_rudp_packet(&connect).unwrap(), target) - .unwrap(); - let mut wire = vec![0_u8; MAX_UDP_DATAGRAM_BYTES]; - for _ in 0..10 { - server.poll_once().unwrap(); - } - let (read, _) = socket.recv_from(&mut wire).unwrap(); - let accept = decode_rudp_packet(&wire[..read]).unwrap(); - assert_eq!(accept.packet_type, CultNetRudpPacketType::Accept); - session.receive(&accept, 0).unwrap(); - assert_eq!(server.session_count(), 3); - - let mut ping = session.create_ping(b"p".to_vec()); - ping.reliable = true; - ping.sequence = connect.sequence.wrapping_add(1); - // The Pong can never be sent; the ack after it could. - server.unsendable_after.insert(addr, 0); - socket - .send_to(&encode_rudp_packet(&ping).unwrap(), target) - .unwrap(); - for _ in 0..10 { - server.poll_once().unwrap(); - } - - // The session ends over the Pong: the peer gets its goodbye and nothing else. - assert_eq!(server.session_count(), 2); - let mut arrived = Vec::new(); - while let Ok((read, _)) = socket.recv_from(&mut wire) { - arrived.push(decode_rudp_packet(&wire[..read]).unwrap()); - } - assert_eq!(arrived.len(), 1, "{arrived:?}"); - assert_eq!(arrived[0].packet_type, CultNetRudpPacketType::Disconnect); - assert!(is_unsendable_reason(&arrived[0].payload)); -} - -/// Windows reports an ICMP port-unreachable for an earlier datagram as -/// `ConnectionReset` on the next receive. It names no datagram of this poll, so -/// the poll is idle and the server goes on serving. Only Windows reports it, so -/// only a Windows run can see the rule break. -#[cfg(windows)] -#[test] -fn a_reset_reported_by_a_receive_is_idle_not_an_error() { - let (mut server, _clock, received, (mut x, _), (mut y, _)) = two_peers(); - // X asks for a snapshot and goes away before the answer. - send(&mut x, &snapshot("x-snapshot")); - drop(x); - for _ in 0..20 { - server - .poll_once() - .expect("a reset from X's closed port is not an error"); - thread::sleep(Duration::from_millis(1)); - } - - send(&mut y, &put("y-put")); - for _ in 0..20 { - server.poll_once().unwrap(); - thread::sleep(Duration::from_millis(1)); - } - assert_eq!(received.lock().unwrap().as_slice(), ["y-put"]); -} diff --git a/packages/cultmesh-rs/tests/rudp_document_server.rs b/packages/cultmesh-rs/tests/rudp_document_server.rs index e43e4e59..df14b4ea 100644 --- a/packages/cultmesh-rs/tests/rudp_document_server.rs +++ b/packages/cultmesh-rs/tests/rudp_document_server.rs @@ -160,15 +160,7 @@ fn server( } fn client(target: SocketAddr, id: u32) -> Result { - client_on(UdpSocket::bind("127.0.0.1:0")?, target, id, 1) -} - -fn client_on( - socket: UdpSocket, - target: SocketAddr, - id: u32, - initial_sequence: u32, -) -> Result { + let socket = UdpSocket::bind("127.0.0.1:0")?; socket.set_nonblocking(true)?; CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { media_delivery: None, @@ -177,7 +169,7 @@ fn client_on( mode: CultNetRudpSocketMode::Client, remote_addr: Some(target), connection_id: id, - initial_sequence: Some(initial_sequence), + initial_sequence: 1, resend_delay_ms: 10, transport_id: None, max_payload_bytes: None, @@ -299,7 +291,7 @@ fn same_connection_id_is_peer_scoped_and_raw_bytes_are_untouched() -> Result<()> } #[test] -fn a_second_connect_from_a_client_replaces_its_session_and_a_fresh_epoch_is_separate() -> Result<()> { +fn duplicate_connect_preserves_epoch_and_fresh_epoch_is_separate() -> Result<()> { let clock = Clock::new(45_000); let sink = Sink::default(); let mut server = server(Default::default(), clock, sink.clone(), Source::default())?; @@ -333,97 +325,6 @@ fn a_second_connect_from_a_client_replaces_its_session_and_a_fresh_epoch_is_sepa Ok(()) } -/// A client that restarts on the same address and connection id starts a new -/// session: its Connect carries a sequence the accepted one did not, so the -/// server does not mistake it for a retransmit, and what it sends is delivered. -#[test] -fn a_restarted_client_on_the_same_address_and_connection_id_is_admitted() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(46_000), sink.clone(), Source::default())?; - let target = server.local_addr()?; - let shared = UdpSocket::bind("127.0.0.1:0")?; - let twin = shared.try_clone()?; - let mut first = client_on(shared, target, 93, 50_000)?; - connect(&mut server, &mut [&mut first])?; - for index in 0..3 { - send( - &mut first, - &CultNetMessage::DocumentPutRaw { - message_id: format!("before-{index}"), - document: document("before", vec![index]), - }, - )?; - } - for _ in 0..40 { - server.poll_once()?; - first.receive_once()?; - } - assert_eq!(sink.0.lock().unwrap().receipts.len(), 3); - - let mut restarted = client_on(twin, target, 93, 7)?; - connect(&mut server, &mut [&mut restarted])?; - assert_eq!(server.session_count(), 1); - send( - &mut restarted, - &CultNetMessage::DocumentPutRaw { - message_id: "after-restart".into(), - document: document("after", vec![9]), - }, - )?; - for _ in 0..40 { - server.poll_once()?; - restarted.receive_once()?; - } - let receipts = sink.0.lock().unwrap(); - assert!( - receipts.receipts.iter().any(|r| r.message_id == "after-restart"), - "the restarted client's frame was not delivered" - ); - Ok(()) -} - -/// A restarted client is a new session, and starts with a new payload budget: -/// what the old one spent is not charged to it. -#[test] -fn a_restarted_client_starts_with_a_fresh_payload_budget() -> Result<()> { - let put = |id: &str| CultNetMessage::DocumentPutRaw { - message_id: id.into(), - document: document("budget", vec![7; 64]), - }; - let encoded = encode_cultnet_message_to_vec(&put("put-a"), CultNetWireContract::CultNetSchemaV0)?; - let options = CultMeshRudpDocumentServerOptions { - max_admitted_payload_bytes: encoded.len() * 2, - max_admitted_payload_bytes_per_session: encoded.len(), - max_snapshot_response_bytes: encoded.len(), - ..Default::default() - }; - let sink = Sink::default(); - let mut server = server(options, Clock::new(47_000), sink.clone(), Source::default())?; - let target = server.local_addr()?; - let shared = UdpSocket::bind("127.0.0.1:0")?; - let twin = shared.try_clone()?; - let mut first = client_on(shared, target, 94, 50_000)?; - connect(&mut server, &mut [&mut first])?; - send(&mut first, &put("put-a"))?; - for _ in 0..20 { - server.poll_once()?; - } - assert_eq!(sink.0.lock().unwrap().receipts.len(), 1); - - let mut restarted = client_on(twin, target, 94, 7)?; - connect(&mut server, &mut [&mut restarted])?; - send(&mut restarted, &put("put-b"))?; - for _ in 0..20 { - server.poll_once()?; - } - assert_eq!( - sink.0.lock().unwrap().receipts.len(), - 2, - "the restarted client was charged what the old session spent" - ); - Ok(()) -} - #[test] fn application_rejection_is_nonfatal_peer_scoped_and_unacknowledged() -> Result<()> { let clock = Clock::new(48_000); @@ -652,103 +553,3 @@ fn session_cap_and_expiry_use_monotonic_time() -> Result<()> { assert_eq!(server.session_count(), 0); Ok(()) } - -#[test] -fn a_connect_storm_and_stray_frames_are_dropped_not_fatal() -> Result<()> { - use cultnet_rs::{ - CultNetRudpSendOptions, CultNetRudpSession, CultNetRudpSessionOptions, - encode_rudp_packet, - }; - let clock = Clock::new(60_000); - let sink = Sink::default(); - let options = CultMeshRudpDocumentServerOptions { - max_pending_reliable_packets_per_session: 4, - ..Default::default() - }; - let mut server = server(options, clock.clone(), sink.clone(), Source::default())?; - let target = server.local_addr()?; - let mut real = client(target, 101)?; - connect(&mut server, &mut [&mut real])?; - assert_eq!(server.packets_dropped(), 0); - - let raw = UdpSocket::bind("127.0.0.1:0")?; - let session = |id| { - CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: id, - initial_sequence: Some(1), - resend_delay_ms: 10, - max_pending_reliable_packets: None, - }) - }; - let send_raw = |server: &mut Server, packet: &cultnet_rs::CultNetRudpPacket| -> Result<()> { - raw.send_to(&encode_rudp_packet(packet)?, target)?; - server.poll_once()?; - Ok(()) - }; - - // A malformed frame. - raw.send_to(b"not a rudp packet", target)?; - server.poll_once()?; - assert_eq!(server.packets_dropped(), 1); - - // Data for a session no Connect admitted. - let mut unadmitted = session(300); - unadmitted.assume_connected(0); - let reliable = CultNetRudpSendOptions { - reliable: true, - ..Default::default() - }; - send_raw(&mut server, &unadmitted.send("schema", vec![1], reliable.clone())?)?; - assert_eq!(server.packets_dropped(), 2); - - // A moved flow re-sends Connect from a socket that never hears the Accept. - // Each repeat re-sends the Accept still awaiting acknowledgement and queues - // nothing, so the storm neither errors nor grows the session's queue. - let mut stormer = session(102); - let connect_packet = stormer.create_connect(0, Vec::new())?; - for _ in 0..200 { - send_raw(&mut server, &connect_packet)?; - } - assert_eq!(server.packets_dropped(), 2); - assert_eq!(server.session_count(), 2); - clock.set(60_000 + 1_000); - assert_eq!( - server.maintain()?.packets_resent, - 1, - "one Accept awaits acknowledgement, however many Connects repeated" - ); - - // A packet its own admitted session refuses ends that session, not the loop. - stormer.assume_connected(0); - let mut poison = stormer.send("schema", vec![1], reliable)?; - poison.fragment_count = 2; - poison.fragment_id = 0; - send_raw(&mut server, &poison)?; - assert_eq!(server.packets_dropped(), 3); - assert_eq!(server.session_count(), 1); - // ...and the refused client is told, after the Accepts its storm drew. - raw.set_read_timeout(Some(Duration::from_millis(200)))?; - let mut buffer = vec![0_u8; 65_535]; - let mut told = false; - while let Ok(received) = raw.recv(&mut buffer) { - told |= cultnet_rs::decode_rudp_packet(&buffer[..received])?.packet_type - == cultnet_rs::CultNetRudpPacketType::Disconnect; - } - assert!(told, "the refused client must be sent a goodbye"); - - send( - &mut real, - &CultNetMessage::DocumentPutRaw { - message_id: "after-storm".into(), - document: document("real", vec![9]), - }, - )?; - for _ in 0..20 { - server.poll_once()?; - if sink.0.lock().unwrap().receipts.len() == 1 { - break; - } - } - assert_eq!(sink.0.lock().unwrap().receipts.len(), 1); - Ok(()) -} diff --git a/packages/cultmesh-ts/native/MANIFEST.txt b/packages/cultmesh-ts/native/MANIFEST.txt deleted file mode 100644 index 074975f0..00000000 --- a/packages/cultmesh-ts/native/MANIFEST.txt +++ /dev/null @@ -1,9 +0,0 @@ -msquic.version=2.5.9 -msquic.windows.source=https://www.nuget.org/api/v2/package/Microsoft.Native.Quic.MsQuic.OpenSSL/2.5.9 sha256=FEE9A664C0052DEEE66D40CED2E19BFE20BC423DE5AE5234CFC0CEBC22CA3F5E -msquic.linux.source=https://packages.microsoft.com/debian/13/prod/pool/main/libm/libmsquic/libmsquic_2.5.9_amd64.deb sha256=1baa61ade0b7b4a99f6dcb6b00d9aedb12b5566d00918a325be7425e878e51ba -bridge.commit=e5bf1c9243acb6b76de72c7864e3b79214177199 -build.workflow_run=https://github.com/GameCult/CultLib/actions/runs/35912386052 -build.release=none (TEMPORARY push-to-quic-cut6 trigger, no tag; see quic-native.yml history for the tagged release path) -build.win32-x64.host=windows-2025-vs2026, cl.exe (MSVC) 19.51.36256.0 -build.linux-x64.host=ubuntu-24.04, container debian:13@sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9ffc4b1, g++ (Debian 14.2.0-19) 14.2.0 -date=2026-09-23T19:54:08Z diff --git a/packages/cultmesh-ts/native/MSQUIC-LICENSE.txt b/packages/cultmesh-ts/native/MSQUIC-LICENSE.txt deleted file mode 100644 index 63447fd8..00000000 --- a/packages/cultmesh-ts/native/MSQUIC-LICENSE.txt +++ /dev/null @@ -1,21 +0,0 @@ -Copyright (c) Microsoft Corporation. - -MIT License - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file diff --git a/packages/cultmesh-ts/native/SHA256SUMS b/packages/cultmesh-ts/native/SHA256SUMS deleted file mode 100644 index a1233d52..00000000 --- a/packages/cultmesh-ts/native/SHA256SUMS +++ /dev/null @@ -1,4 +0,0 @@ -9dae391a44d59f12e19baa08a44d86c7e8b4916ca5d40007a42d65913ce0896f *win32-x64/gamecult_mesh_quic_native.dll -c17c65813fe007eb486a5270d244f3659261355795b27abb99bd19ff77539263 *win32-x64/msquic.dll -0ee3d28324c79a5b0135368187e7bd75c1969ad94fc6985755e81de4ec96e430 *linux-x64/libgamecult_mesh_quic_native.so -c09a72f9b3bfd9119044b305c6064731c1e1879411e6f54276474fb58b524b68 *linux-x64/libmsquic.so.2 diff --git a/packages/cultmesh-ts/native/linux-x64/libgamecult_mesh_quic_native.so b/packages/cultmesh-ts/native/linux-x64/libgamecult_mesh_quic_native.so deleted file mode 100644 index 7363d1cb..00000000 Binary files a/packages/cultmesh-ts/native/linux-x64/libgamecult_mesh_quic_native.so and /dev/null differ diff --git a/packages/cultmesh-ts/native/linux-x64/libmsquic.so.2 b/packages/cultmesh-ts/native/linux-x64/libmsquic.so.2 deleted file mode 100644 index e14af169..00000000 Binary files a/packages/cultmesh-ts/native/linux-x64/libmsquic.so.2 and /dev/null differ diff --git a/packages/cultmesh-ts/native/win32-x64/gamecult_mesh_quic_native.dll b/packages/cultmesh-ts/native/win32-x64/gamecult_mesh_quic_native.dll deleted file mode 100644 index da7be3c0..00000000 Binary files a/packages/cultmesh-ts/native/win32-x64/gamecult_mesh_quic_native.dll and /dev/null differ diff --git a/packages/cultmesh-ts/native/win32-x64/msquic.dll b/packages/cultmesh-ts/native/win32-x64/msquic.dll deleted file mode 100644 index 71107c2c..00000000 Binary files a/packages/cultmesh-ts/native/win32-x64/msquic.dll and /dev/null differ diff --git a/packages/cultmesh-ts/package.json b/packages/cultmesh-ts/package.json index 4d59133a..0ca8afb3 100644 --- a/packages/cultmesh-ts/package.json +++ b/packages/cultmesh-ts/package.json @@ -12,14 +12,13 @@ } }, "files": [ - "dist", - "native" + "dist" ], "scripts": { "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json", "prepack": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json", "typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --pretty false", - "test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --expose-gc --test dist-test/test/*.test.js" + "test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js" }, "keywords": [ "cultmesh", @@ -40,13 +39,12 @@ }, "homepage": "https://github.com/GameCult/CultLib/tree/main/packages/cultmesh-ts#readme", "engines": { - "node": ">=20" + "node": ">=18" }, "dependencies": { "@msgpack/msgpack": "^3.1.2", "@gamecult/cultcache-ts": "^0.14.0", - "cultnet-ts": "^0.2.0", - "koffi": "3.3.0" + "cultnet-ts": "^0.2.0" }, "devDependencies": { "@types/node": "^24.0.0", diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index 2e487c77..e9fbb8be 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -14,7 +14,6 @@ import { CultNetRudpSession, CultNetPeer, CultNetRudpSocketTransportConnection, - rudpClientBindHost, CultNetSchemaCatalog, CultNetShardCatalog, cultNetBuiltinSchemaRegistry, @@ -22,9 +21,7 @@ import { defineCultNetDocumentBinding, encodeCultNetMessageForWire, encodeRudpPacket, - isPermanentSendError, parseCultNetMessage, - sendRudpDatagram, type CultNetErrorMessage, type CultNetDocumentBinding, type CultNetMessage, @@ -32,24 +29,11 @@ import { type CultNetOperationResponseMessage, type CultNetRawDocumentRecord, type CultNetReconnectPolicy, - type CultNetRudpPacket, type CultNetSchemaCatalogOptions, type CultNetSnapshotResponseRawMessage, type CultNetWireContract, } from "cultnet-ts"; -import { - CultMeshQuicRealtimeConnector, - CultMeshQuicRealtimeProvider, - CultMeshQuicRealtimeSessionManager, - parseQuicRealtimeEndpoint, - type CultMeshQuicRealtimeConnectorOptions, - type CultMeshQuicRealtimeProviderOptions, - type CultMeshQuicRealtimeSessionManagerOptions, -} from "./realtime-quic"; - -export * from "./realtime-wire"; -export * from "./realtime-quic"; export * from "./provider-session"; export * from "./provider-session-wire"; export * from "./provider-rudp-transport"; @@ -4310,12 +4294,6 @@ export interface CultMeshRudpServerSession { export interface CultMeshRudpDocumentServer { readonly bind: { host: string; port: number }; - /** - * Datagrams that could not be sent to a peer. Each is that peer's lost datagram: a - * reliable packet stays pending and is resent, and no other peer is affected. A datagram - * that can never be sent as built is not counted here: it ends that peer's session. - */ - readonly sendFailures: number; start(): Promise; close(): void; } @@ -4340,11 +4318,6 @@ export interface CultMeshRudpEndpoint { } export interface CultMeshRudpSocketOptions { - /** - * Local address to bind. Unset, a server binds loopback, and a client binds - * loopback for a loopback endpoint and the unspecified address of the - * endpoint's family otherwise, so it can reach a remote host. - */ bindHost?: string; bindPort?: number; socket?: Socket; @@ -5776,19 +5749,15 @@ export class CultMesh { const port = options.bindPort ?? 0; const bind = { host, port }; const socket = options.socket ?? createSocket(host.includes(":") ? "udp6" : "udp4"); - type SessionRecord = { + const sessions = new Map; - }; - const sessions = new Map(); + }>(); let sessionSequence = 0; - let sendFailures = 0; const resendPollMs = Math.max(10, options.resendPollMs ?? 25); const sessionTimeoutMs = Math.max(1_000, options.sessionTimeoutMs ?? 30_000); const wireContract = options.wireContract ?? "cultnet.schema.v0"; @@ -5812,16 +5781,20 @@ export class CultMesh { if (packet.packetType === "connect") { const connectPayload = Uint8Array.from(packet.payload ?? []); - if (record?.session.connectRepeats(packet)) { - sendPacket(record, record.session.acceptConnect(packet, nowMs)); + if (record && byteArraysEqual(record.connectPayload, connectPayload)) { + socket.send( + encodeRudpPacket(record.session.acceptConnect(packet, nowMs)), + remote.port, + remote.address, + ); return; } - const next: SessionRecord = { + if (record) closeSession(record); + record = { sessionId: `${runtimeId}:${++sessionSequence}`, remote: { address: remote.address, family: remote.family, port: remote.port }, connectPayload, closed: false, - unsendable: false, session: new CultNetRudpSession({ connectionId, initialSequence: options.initialSequence, @@ -5830,12 +5803,8 @@ export class CultMesh { }), work: Promise.resolve(), }; - sendPacket(next, next.session.acceptConnect(packet, nowMs)); - // A peer whose Accept can never be sent cannot be answered: no session - // starts, and one already there is left as it was. - if (next.unsendable) return; - if (record) closeSession(record); - sessions.set(key, next); + sessions.set(key, record); + socket.send(encodeRudpPacket(record.session.acceptConnect(packet, nowMs)), remote.port, remote.address); return; } @@ -5843,22 +5812,12 @@ export class CultMesh { return; } - let result: ReturnType; - try { - result = record.session.receive(packet, nowMs); - } catch { - // receive() has already recorded the packet's reliable sequence, so the - // session cannot be kept: a retransmit would be acknowledged and the frame - // silently lost. It ends, never the server, and the peer is told. What a - // datagram carries is the sender's business, so nothing is reported. - endRefusedSession(key, record); - return; - } + const result = record.session.receive(packet, nowMs); if (result.reply) { - sendPacket(record, result.reply); + socket.send(encodeRudpPacket(result.reply), record.remote.port, record.remote.address); } for (const ready of result.readyToSend ?? []) { - sendPacket(record, ready); + socket.send(encodeRudpPacket(ready), record.remote.port, record.remote.address); } for (const frame of result.delivered) { if (frame.channelId !== "schema") { @@ -5874,7 +5833,11 @@ export class CultMesh { return; } if (packet.reliable) { - sendPacket(record, record.session.createAckForReceived(packet.sequence)); + socket.send( + encodeRudpPacket(record.session.createAckFor(packet.sequence)), + record.remote.port, + record.remote.address, + ); } } catch (error) { reportError(error); @@ -5883,7 +5846,7 @@ export class CultMesh { socket.on("error", reportError); async function handleDocumentServerFrame( - record: SessionRecord, + record: { sessionId: string; remote: { address: string; family: string; port: number }; session: CultNetRudpSession; connectPayload: Uint8Array; closed: boolean }, payload: Uint8Array, ): Promise { if (record.closed) return; @@ -5945,7 +5908,13 @@ export class CultMesh { } } - function publicSession(record: SessionRecord): CultMeshRudpServerSession { + function publicSession(record: { + sessionId: string; + remote: { address: string; family: string; port: number }; + session: CultNetRudpSession; + connectPayload: Uint8Array; + closed: boolean; + }): CultMeshRudpServerSession { return { sessionId: record.sessionId, remote: { ...record.remote }, @@ -5957,58 +5926,31 @@ export class CultMesh { }; } - function notifySessionClosed(record: SessionRecord): void { + function notifySessionClosed(record: { + sessionId: string; + remote: { address: string; family: string; port: number }; + session: CultNetRudpSession; + connectPayload: Uint8Array; + closed: boolean; + }): void { if (!options.onSessionClosed) return; void Promise.resolve(options.onSessionClosed(publicSession(record))).catch(reportError); } - function closeSession(record: SessionRecord): void { + function closeSession(record: { + sessionId: string; + remote: { address: string; family: string; port: number }; + session: CultNetRudpSession; + connectPayload: Uint8Array; + closed: boolean; + }): void { if (record.closed) return; record.closed = true; notifySessionClosed(record); } - /** - * Sends one packet to a session's peer. A failure is that peer's alone: one that may - * pass (no route, full buffers) is a lost datagram, counted, and resent if it was - * reliable; one that can never pass as built ends that peer's session. Neither ends - * the server, a timer or another peer's session. - */ - function sendPacket(record: SessionRecord, packet: CultNetRudpPacket): void { - sendRudpDatagram(socket, encodeRudpPacket(packet), record.remote.port, record.remote.address, (error) => { - if (isPermanentSendError(error)) { - endUnsendableSession(record, error); - } else { - sendFailures += 1; - } - }); - } - - function endRefusedSession(key: string, record: SessionRecord): void { - sessions.delete(key); - closeSession(record); - // The goodbye is built after the reset, or its ack field would acknowledge the - // very frame the session refused. - record.session.resetPeerState(); - const goodbye = encodeRudpPacket(record.session.createDisconnect(Buffer.from("session refused a packet", "utf8"))); - sendRudpDatagram(socket, goodbye, record.remote.port, record.remote.address, () => {}); - } - - /** The session owes its peer a packet that can never be sent as built. */ - function endUnsendableSession(record: SessionRecord, error: Error): void { - if (record.unsendable) return; - record.unsendable = true; - const key = `${record.remote.address}:${record.remote.port}`; - // A session that never started, or was already replaced, has nothing to end. - if (sessions.get(key) !== record) return; - sessions.delete(key); - closeSession(record); - const goodbye = encodeRudpPacket(record.session.endUnsendableSession(error)); - sendRudpDatagram(socket, goodbye, record.remote.port, record.remote.address, () => {}); - } - function sendSchemaMessage( - record: SessionRecord, + record: { sessionId: string; remote: { address: string; port: number }; session: CultNetRudpSession; closed: boolean }, message: CultNetMessage, ): void { if (record.closed) throw new Error(`CultMesh RUDP session ${record.sessionId} is closed.`); @@ -6018,15 +5960,12 @@ export class CultMesh { ordered: true, nowMs: Date.now(), })) { - sendPacket(record, packet); + socket.send(encodeRudpPacket(packet), record.remote.port, record.remote.address); } } return { bind, - get sendFailures() { - return sendFailures; - }, async start(): Promise { await new Promise((resolve, reject) => { socket.once("error", reject); @@ -6048,7 +5987,7 @@ export class CultMesh { continue; } for (const packet of record.session.dueResends(nowMs)) { - sendPacket(record, packet); + socket.send(encodeRudpPacket(packet), record.remote.port, record.remote.address); } } }, resendPollMs); @@ -6184,7 +6123,7 @@ export class CultMesh { options: CultMeshRudpSocketOptions = {}, ): Promise { requireNonEmpty(runtimeId, "runtimeId"); - const socket = options.socket ?? (await bindRudpSocket(options.bindHost ?? "127.0.0.1", options)); + const socket = options.socket ?? (await bindRudpSocket(options)); return new CultNetRudpSocketTransportConnection({ runtimeId, socket, @@ -6210,10 +6149,7 @@ export class CultMesh { requireNonEmpty(runtimeId, "runtimeId"); const parsedEndpoint = typeof endpoint === "string" ? CultMesh.parseRudpEndpoint(endpoint) : endpoint; - const socket = options.socket ?? (await bindRudpSocket( - options.bindHost ?? (await rudpClientBindHost(parsedEndpoint.host)), - options, - )); + const socket = options.socket ?? (await bindRudpSocket(options)); return new CultNetRudpSocketTransportConnection({ runtimeId, socket, @@ -6332,28 +6268,6 @@ export class CultMesh { } return CultMesh.createRudpPeerForPeer(runtimeId, connectionId, peer, options); } - - public static parseQuicRealtimeEndpoint(endpoint: string): ReturnType { - return parseQuicRealtimeEndpoint(endpoint); - } - - public static createQuicRealtimeConnector( - options: CultMeshQuicRealtimeConnectorOptions = {}, - ): CultMeshQuicRealtimeConnector { - return new CultMeshQuicRealtimeConnector(options); - } - - public static createQuicRealtimeSessionManager( - options: CultMeshQuicRealtimeSessionManagerOptions, - ): CultMeshQuicRealtimeSessionManager { - return new CultMeshQuicRealtimeSessionManager(options); - } - - public static createQuicRealtimeProvider( - options: CultMeshQuicRealtimeProviderOptions, - ): Promise { - return CultMeshQuicRealtimeProvider.listen(options); - } } function cultMeshSchemaFromDefinition( @@ -6638,8 +6552,9 @@ function toUint8Array(value: unknown): Uint8Array { throw new Error("CultNet raw document payload was not binary."); } -async function bindRudpSocket(host: string, options: CultMeshRudpSocketOptions): Promise { - const socket = createSocket(host.includes(":") ? "udp6" : "udp4"); +async function bindRudpSocket(options: CultMeshRudpSocketOptions): Promise { + const socket = createSocket("udp4"); + const host = options.bindHost ?? "127.0.0.1"; const port = options.bindPort ?? 0; await new Promise((resolve, reject) => { socket.once("error", reject); @@ -6722,6 +6637,14 @@ function nonBlankOr(value?: string, fallback = ""): string { return value && value.trim() ? value : fallback; } +function byteArraysEqual(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) return false; + for (let index = 0; index < left.byteLength; index += 1) { + if (left[index] !== right[index]) return false; + } + return true; +} + function parseCultMeshLocalityKind( value: string | undefined, fallback: CultMeshLocalityKind, diff --git a/packages/cultmesh-ts/src/realtime-quic-native.ts b/packages/cultmesh-ts/src/realtime-quic-native.ts deleted file mode 100644 index 52b0ce01..00000000 --- a/packages/cultmesh-ts/src/realtime-quic-native.ts +++ /dev/null @@ -1,583 +0,0 @@ -// The koffi binding for `native/GameCult.Mesh.Quic.Native`'s C ABI v2 -// (`include/cultmesh_quic_native.h`). This file is the only place `koffi` is -// imported, and it is imported lazily, inside `loadBindings`, so a consumer -// that never touches QUIC (AetheriaEve Electron, Odin, Stonks, weksa) loads no -// native module at all. -// -// No koffi callbacks. `cultmesh_quic_next_event` is the single blocking -// export; `CultMeshQuicNativeRuntime` runs it on koffi's async thread pool in -// one loop per process-wide runtime and dispatches decoded events to -// registered listener/connection handlers by id. Every other export is a -// plain synchronous call. -// -// Binary resolution: `CULTMESH_QUIC_NATIVE_DIR` when set (the development -// loop and CI point it at `artifacts/quic-native/` straight from a -// build), otherwise `path.join(__dirname, "..", "native", "-")` -// (the committed tree a later cut lands; `__dirname` is `dist/` at runtime, so -// this path holds in the repo, an `npm pack` tarball and a pnpm `file:` copy -// alike). No `node_modules` lookup, no platform package, no registry. -// -// Two koffi quirks this file works around, both probed against the pinned -// 3.3.0 build: `_Out_` does not auto-allocate storage, so every out -// parameter is `koffi.alloc(type, 1)` written by the call and read back with -// `koffi.decode(buffer, type)`; and a decoded 64-bit value (`void *`, -// `uint64_t`) comes back as a plain `number` when it fits a safe integer, -// not a `bigint`. `toBigInt64` below is the one place that coerces either -// into the `bigint` every exported type in this module promises. - -import { existsSync } from "node:fs"; -import { join } from "node:path"; - -/** One decoded native event, payload already sliced to `payload_length`. */ -export interface CultMeshQuicNativeEvent { - readonly type: number; - readonly streamKind: number; - readonly listenerId: bigint; - readonly connectionId: bigint; - readonly streamId: bigint; - readonly code: bigint; - readonly status: number; - readonly payload: Uint8Array; -} - -export const CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION = 1; -export const CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED = 2; -export const CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED = 3; -export const CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN = 4; -export const CULTMESH_QUIC_EVENT_STREAM_STARTED = 5; -export const CULTMESH_QUIC_EVENT_STREAM_FRAME = 6; -export const CULTMESH_QUIC_EVENT_STREAM_SEND_COMPLETE = 7; -export const CULTMESH_QUIC_EVENT_STREAM_SHUTDOWN = 8; -export const CULTMESH_QUIC_EVENT_LISTENER_STOPPED = 9; - -export const CULTMESH_QUIC_SEND_COMPLETED = 0; -export const CULTMESH_QUIC_SEND_CANCELED = 1; - -export const CULTMESH_QUIC_STREAM_RELIABLE = 1; -export const CULTMESH_QUIC_STREAM_LATEST_ONLY = 2; - -export const CULTMESH_QUIC_RESULT_BAD_CALL = -1; -export const CULTMESH_QUIC_RESULT_BAD_ARGUMENT = -2; - -type NativeEventListener = (event: CultMeshQuicNativeEvent) => void; -/** Invoked once when a connection is faulted: a throwing listener, a malformed - * frame, or any other error raised while dispatching an event for it. */ -type NativeFaultHandler = (error: Error) => void; - -/** koffi decodes a 64-bit value as `number` when it fits a safe integer and as - * `bigint` otherwise (probed on the pinned 3.3.0 build); this is the single - * place that coerces either into the `bigint` every exported type promises. */ -function toBigInt64(value: number | bigint): bigint { - return typeof value === "bigint" ? value : BigInt(value); -} - -/** The opaque native runtime handle: a `void *`, decoded by koffi as a `bigint`. */ -export type CultMeshQuicNativeHandle = bigint; - -interface NativeEventStruct { - type: number; - stream_kind: number; - listener_id: bigint; - connection_id: bigint; - stream_id: bigint; - code: bigint; - status: number; - payload_length: number; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type KoffiLib = any; - -/** `nextEventAsync`'s signature, typed explicitly because `promisify` cannot - * infer one from koffi's untyped (`any`) async export. */ -type NextEventAsyncFn = ( - runtime: CultMeshQuicNativeHandle, - timeoutMs: number, - outEvent: unknown, - payload: Uint8Array | null, - payloadCapacity: number, - outRequired: unknown, -) => Promise; - -/** - * The loaded native library: koffi itself, the declared struct, and the - * twelve raw v2 exports. Untyped (koffi is `any`); the runtime class methods - * below are the type surface for these calls, so wrapping them a second time - * in a typed interface would only restate what the methods already say. The - * type is inferred from `buildLibrary`'s return value instead of restated. - */ -// eslint-disable-next-line @typescript-eslint/no-use-before-define -type LoadedNativeLibrary = ReturnType; - -let libraryCache: LoadedNativeLibrary | undefined; - -function platformArchDir(): string { - return `${process.platform}-${process.arch}`; -} - -function platformFileNames(): { bridge: string; dependency: string } { - if (process.platform === "win32") { - return { bridge: "gamecult_mesh_quic_native.dll", dependency: "msquic.dll" }; - } - if (process.platform === "linux") { - return { bridge: "libgamecult_mesh_quic_native.so", dependency: "libmsquic.so.2" }; - } - throw new Error(`CultMesh QUIC native binding does not support platform '${process.platform}'.`); -} - -function resolveNativeDir(): string { - const configured = process.env.CULTMESH_QUIC_NATIVE_DIR; - if (configured && configured.length > 0) return configured; - return join(__dirname, "..", "native", platformArchDir()); -} - -/** Loads koffi, resolves the binary directory, and declares the ABI. */ -function buildLibrary() { - // Untyped on purpose: koffi's own type declarations are ESM-flavored and - // fight a CommonJS `typeof import(...)` reference under Node16 resolution. - // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-explicit-any - const koffi: any = require("koffi"); - const { bridge, dependency } = platformFileNames(); - const dir = resolveNativeDir(); - const bridgePath = join(dir, bridge); - const dependencyPath = join(dir, dependency); - if (!existsSync(bridgePath) || !existsSync(dependencyPath)) { - const envDir = process.env.CULTMESH_QUIC_NATIVE_DIR; - throw new Error( - "CultMesh QUIC native binaries were not found. Tried " + - `'${dir}' (${envDir ? "CULTMESH_QUIC_NATIVE_DIR" : "the package's committed native/ tree"}). ` + - "Set CULTMESH_QUIC_NATIVE_DIR to a directory built by scripts/build-quic-native.ps1 or " + - "scripts/build-quic-native.sh, or ship the committed native/ tree.", - ); - } - - // The dependent library must be resolvable before the bridge loads. - koffi.load(dependencyPath); - const bridgeLib = koffi.load(bridgePath); - - const event = koffi.struct("cultmesh_quic_event", { - type: "uint32_t", - stream_kind: "uint32_t", - listener_id: "uint64_t", - connection_id: "uint64_t", - stream_id: "uint64_t", - code: "uint64_t", - status: "int32_t", - payload_length: "int32_t", - reserved: koffi.array("uint8_t", 16), - }); - - const runtimeOpenFn = bridgeLib.func( - "int32_t cultmesh_quic_runtime_open(str app_name, void **out_runtime)", - ); - const runtimeCloseFn = bridgeLib.func("void cultmesh_quic_runtime_close(void *runtime)"); - const listenerOpenFn = bridgeLib.func( - "int32_t cultmesh_quic_listener_open(void *runtime, str host, uint16_t port, " + - "uint8_t *pkcs12, int32_t pkcs12_length, str password, " + - "uint64_t *out_listener_id, uint16_t *out_bound_port)", - ); - const listenerCloseFn = bridgeLib.func( - "void cultmesh_quic_listener_close(void *runtime, uint64_t listener_id)", - ); - const connectionOpenFn = bridgeLib.func( - "int32_t cultmesh_quic_connection_open(void *runtime, str host, uint16_t port, " + - "uint64_t *out_connection_id)", - ); - const connectionCertificateCompleteFn = bridgeLib.func( - "int32_t cultmesh_quic_connection_certificate_complete(void *runtime, uint64_t connection_id, int32_t accept)", - ); - const connectionShutdownFn = bridgeLib.func( - "void cultmesh_quic_connection_shutdown(void *runtime, uint64_t connection_id, uint64_t code)", - ); - const streamOpenFn = bridgeLib.func( - "int32_t cultmesh_quic_stream_open(void *runtime, uint64_t connection_id, uint8_t kind, " + - "uint64_t *out_stream_id)", - ); - const streamSendFrameFn = bridgeLib.func( - "int32_t cultmesh_quic_stream_send_frame(void *runtime, uint64_t stream_id, " + - "uint8_t *encoded_frame, int32_t length, int32_t fin)", - ); - const streamShutdownFn = bridgeLib.func( - "void cultmesh_quic_stream_shutdown(void *runtime, uint64_t stream_id, uint64_t code)", - ); - const nextEventFn = bridgeLib.func( - "int32_t cultmesh_quic_next_event(void *runtime, int32_t timeout_ms, " + - `${event.name} *out_event, uint8_t *payload, int32_t payload_capacity, ` + - "int32_t *out_required)", - ); - const lastErrorFn = bridgeLib.func( - "int32_t cultmesh_quic_last_error(void *runtime, uint8_t *destination, int32_t capacity)", - ); - - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { promisify } = require("node:util") as typeof import("node:util"); - const nextEventAsync = promisify(nextEventFn.async) as NextEventAsyncFn; - - return { - koffi, - event, - runtimeOpenFn, - runtimeCloseFn, - listenerOpenFn, - listenerCloseFn, - connectionOpenFn, - connectionCertificateCompleteFn, - connectionShutdownFn, - streamOpenFn, - streamSendFrameFn, - streamShutdownFn, - nextEventAsync, - lastErrorFn, - }; -} - -/** Lazily builds and caches the loaded native library. */ -function loadBindings(): LoadedNativeLibrary { - if (libraryCache) return libraryCache; - libraryCache = buildLibrary(); - return libraryCache; -} - -function readLastError(lib: LoadedNativeLibrary, runtime: CultMeshQuicNativeHandle): string { - const buffer = Buffer.alloc(1024); - const written = lib.lastErrorFn(runtime, buffer, buffer.length); - return written > 0 ? buffer.subarray(0, written).toString("utf8") : "CultMesh native QUIC call failed."; -} - -/** - * One MsQuic registration and one event queue, shared process-wide. - * `open`/`release` are reference-counted: the underlying runtime opens on the - * first `open` and closes on the last matching `release`. `open`/`release` - * calls do not need to be paired 1:1 by the same caller as long as the counts - * balance overall. - */ -export class CultMeshQuicNativeRuntime { - private static shared: CultMeshQuicNativeRuntime | undefined; - private static refCountValue = 0; - - /** Outstanding `open()` references not yet matched by `release()`. Test-visible: the UAF and eviction regression tests assert it returns to its baseline. */ - static get refCount(): number { - return CultMeshQuicNativeRuntime.refCountValue; - } - - private readonly handle: CultMeshQuicNativeHandle; - private readonly lib: LoadedNativeLibrary; - private readonly listenerListeners = new Map(); - private readonly connectionListeners = new Map< - bigint, - { listener: NativeEventListener; onFault?: NativeFaultHandler } - >(); - private closed = false; - private pumpLoop: Promise; - - /** - * `nextEvent`'s out-parameter buffers, allocated once and reused for every - * call instead of per call: the pump is single-threaded with at most one - * `nextEvent` in flight (`nextEventAsync` is only ever awaited, never - * fired concurrently), so decode-then-reuse is safe. Freed once in - * `release()` after the pump loop has exited. - */ - private readonly outEventBuffer: unknown; - private readonly outRequiredBuffer: unknown; - - private constructor(handle: CultMeshQuicNativeHandle, lib: LoadedNativeLibrary) { - this.handle = handle; - this.lib = lib; - this.outEventBuffer = lib.koffi.alloc(lib.event, 1); - this.outRequiredBuffer = lib.koffi.alloc("int32_t", 1); - this.pumpLoop = this.pump(); - } - - static async open(): Promise { - if (!CultMeshQuicNativeRuntime.shared) { - const lib = loadBindings(); - const out = lib.koffi.alloc("void *", 1); - const status = lib.runtimeOpenFn(null, out); - if (status !== 0) { - lib.koffi.free(out); - throw new Error(`cultmesh_quic_runtime_open failed with status ${status}.`); - } - const handle = toBigInt64(lib.koffi.decode(out, "void *")); - lib.koffi.free(out); - CultMeshQuicNativeRuntime.shared = new CultMeshQuicNativeRuntime(handle, lib); - } - CultMeshQuicNativeRuntime.refCountValue += 1; - return CultMeshQuicNativeRuntime.shared; - } - - /** - * Takes one more reference on this already-open runtime, synchronously — - * unlike `open()`, this never awaits a runtime-open call, so a caller - * already holding a live instance (a provider handing a reference to a - * newly accepted peer) can attach it in the same synchronous turn instead - * of crossing a microtask boundary that would leave the event a caller is - * mid-dispatching unowned until the next tick. - */ - retain(): CultMeshQuicNativeRuntime { - CultMeshQuicNativeRuntime.refCountValue += 1; - return this; - } - - /** - * Drops one reference; closes the shared runtime once none remain. Refuses - * a release when no reference is outstanding. The bridge header (section 4) - * forbids any host call beginning once `cultmesh_quic_runtime_close` has - * started, so this marks the runtime closed and waits for the pump to exit - * (it wakes within its 250 ms `nextEvent` timeout) before making that call: - * a queued, not-yet-started `nextEvent` call must never begin after close - * and run against freed memory. - */ - async release(): Promise { - if (CultMeshQuicNativeRuntime.refCountValue === 0) { - throw new Error("CultMeshQuicNativeRuntime.release called with no outstanding reference."); - } - CultMeshQuicNativeRuntime.refCountValue -= 1; - if (CultMeshQuicNativeRuntime.refCountValue > 0) return; - this.closed = true; - CultMeshQuicNativeRuntime.shared = undefined; - await this.pumpLoop; - this.lib.runtimeCloseFn(this.handle); - this.lib.koffi.free(this.outEventBuffer); - this.lib.koffi.free(this.outRequiredBuffer); - } - - onListenerEvent(listenerId: bigint, listener: NativeEventListener): void { - this.listenerListeners.set(listenerId, listener); - } - - offListenerEvent(listenerId: bigint): void { - this.listenerListeners.delete(listenerId); - } - - /** - * Registers the event listener for one connection. `onFault`, if given, is - * invoked exactly once if `listener` (or anything it calls synchronously, - * such as a caller-supplied certificate validator) throws while dispatching - * an event for this connection: the pump isolates the fault to this - * connection, shuts it down at the native level, removes the listener, and - * keeps running for every other connection. - */ - onConnectionEvent(connectionId: bigint, listener: NativeEventListener, onFault?: NativeFaultHandler): void { - this.connectionListeners.set(connectionId, { listener, onFault }); - } - - offConnectionEvent(connectionId: bigint): void { - this.connectionListeners.delete(connectionId); - } - - listenerOpen( - host: string | null, - port: number, - pkcs12: Uint8Array, - password: string | null, - ): { listenerId: bigint; boundPort: number } { - const outListenerId = this.lib.koffi.alloc("uint64_t", 1); - const outBoundPort = this.lib.koffi.alloc("uint16_t", 1); - try { - const status = this.lib.listenerOpenFn( - this.handle, - host, - port, - pkcs12, - pkcs12.length, - password, - outListenerId, - outBoundPort, - ); - if (status !== 0) { - throw new Error(`cultmesh_quic_listener_open failed: ${readLastError(this.lib, this.handle)}`); - } - return { - listenerId: toBigInt64(this.lib.koffi.decode(outListenerId, "uint64_t")), - boundPort: this.lib.koffi.decode(outBoundPort, "uint16_t") as number, - }; - } finally { - this.lib.koffi.free(outListenerId); - this.lib.koffi.free(outBoundPort); - } - } - - listenerClose(listenerId: bigint): void { - this.lib.listenerCloseFn(this.handle, listenerId); - this.offListenerEvent(listenerId); - } - - connectionOpen(host: string, port: number): bigint { - const out = this.lib.koffi.alloc("uint64_t", 1); - try { - const status = this.lib.connectionOpenFn(this.handle, host, port, out); - if (status !== 0) { - throw new Error(`cultmesh_quic_connection_open failed: ${readLastError(this.lib, this.handle)}`); - } - return toBigInt64(this.lib.koffi.decode(out, "uint64_t")); - } finally { - this.lib.koffi.free(out); - } - } - - connectionCertificateComplete(connectionId: bigint, accept: boolean): void { - const status = this.lib.connectionCertificateCompleteFn(this.handle, connectionId, accept ? 1 : 0); - if (status !== 0) { - throw new Error( - `cultmesh_quic_connection_certificate_complete failed: ${readLastError(this.lib, this.handle)}`, - ); - } - } - - connectionShutdown(connectionId: bigint, code: bigint): void { - this.lib.connectionShutdownFn(this.handle, connectionId, code); - } - - streamOpen(connectionId: bigint, kind: number): bigint { - const out = this.lib.koffi.alloc("uint64_t", 1); - try { - const status = this.lib.streamOpenFn(this.handle, connectionId, kind, out); - if (status !== 0) { - throw new Error(`cultmesh_quic_stream_open failed: ${readLastError(this.lib, this.handle)}`); - } - return toBigInt64(this.lib.koffi.decode(out, "uint64_t")); - } finally { - this.lib.koffi.free(out); - } - } - - streamSendFrame(streamId: bigint, encodedFrame: Uint8Array, fin: boolean): void { - const status = this.lib.streamSendFrameFn(this.handle, streamId, encodedFrame, encodedFrame.length, fin ? 1 : 0); - if (status !== 0) { - throw new Error(`cultmesh_quic_stream_send_frame failed: ${readLastError(this.lib, this.handle)}`); - } - } - - streamShutdown(streamId: bigint, code: bigint): void { - this.lib.streamShutdownFn(this.handle, streamId, code); - } - - /** - * Isolates the faulting connection so one bad event never brings down the - * pump: a malformed frame, a stream-kind/delivery mismatch, or a throwing - * caller-supplied listener (certificate validator, waiter) faults only its - * own connection. The listener is removed, the connection is shut down at - * the native level, and `onFault` (if registered) is told, so pending and - * future work on that connection can reject with the error. Every other - * connection, and the pump itself, keep running. - */ - private dispatch(event: NativeEventStruct, payload: Uint8Array): void { - const decoded: CultMeshQuicNativeEvent = { - type: event.type, - streamKind: event.stream_kind, - listenerId: event.listener_id, - connectionId: event.connection_id, - streamId: event.stream_id, - code: event.code, - status: event.status, - payload, - }; - if (decoded.type === CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION || decoded.type === CULTMESH_QUIC_EVENT_LISTENER_STOPPED) { - try { - this.listenerListeners.get(decoded.listenerId)?.(decoded); - } catch (error) { - // No fault contract for listener-level events today; do not let a - // throwing handler crash the pump. - this.reportUnhandledDispatchError(error); - } - return; - } - const entry = this.connectionListeners.get(decoded.connectionId); - if (!entry) return; - try { - entry.listener(decoded); - } catch (error) { - this.faultConnection(decoded.connectionId, error instanceof Error ? error : new Error(String(error)), entry.onFault); - } - } - - private faultConnection(connectionId: bigint, error: Error, onFault: NativeFaultHandler | undefined): void { - this.connectionListeners.delete(connectionId); - try { - this.lib.connectionShutdownFn(this.handle, connectionId, 0n); - } catch { - // Best-effort: the connection may already be gone. - } - if (onFault) { - try { - onFault(error); - } catch (handlerError) { - // The fault handler itself is caller-supplied code; a throw here - // must not escape into the pump any more than the original fault did. - this.reportUnhandledDispatchError(handlerError); - } - } else { - this.reportUnhandledDispatchError(error); - } - } - - private reportUnhandledDispatchError(error: unknown): void { - // eslint-disable-next-line no-console - console.error("CultMesh QUIC native event dispatch failed with no fault handler registered:", error); - } - - private async nextEvent( - timeoutMs: number, - payload: Uint8Array | null, - payloadCapacity: number, - ): Promise<{ status: number; event: NativeEventStruct | null; required: number }> { - // Reuses the buffers allocated once in the constructor instead of - // allocating fresh ones per call: the pump awaits each `nextEventAsync` - // before issuing the next one, so at most one call is ever in flight - // against them, and both are fully decoded here before the buffers can - // be reused by the pump's next call. - const status = await this.lib.nextEventAsync( - this.handle, - timeoutMs, - this.outEventBuffer, - payload, - payloadCapacity, - this.outRequiredBuffer, - ); - const decoded = - status === 1 ? (this.lib.koffi.decode(this.outEventBuffer, this.lib.event) as NativeEventStruct) : null; - return { - status, - event: decoded && { - ...decoded, - listener_id: toBigInt64(decoded.listener_id), - connection_id: toBigInt64(decoded.connection_id), - stream_id: toBigInt64(decoded.stream_id), - code: toBigInt64(decoded.code), - }, - required: this.lib.koffi.decode(this.outRequiredBuffer, "int32_t") as number, - }; - } - - private async pump(): Promise { - while (!this.closed) { - let first: Awaited>; - try { - first = await this.nextEvent(250, null, 0); - } catch { - if (this.closed) return; - continue; - } - if (this.closed) return; - if (first.status === 0 || first.status === CULTMESH_QUIC_RESULT_BAD_CALL) continue; - if (first.status === 1 && first.event) { - this.dispatch(first.event, new Uint8Array(0)); - continue; - } - if (first.status === 2 && first.required > 0) { - const buffer = Buffer.alloc(first.required); - let second: Awaited>; - try { - second = await this.nextEvent(0, buffer, buffer.length); - } catch { - if (this.closed) return; - continue; - } - if (second.status === 1 && second.event) { - this.dispatch(second.event, buffer.subarray(0, second.event.payload_length)); - } - continue; - } - } - } -} diff --git a/packages/cultmesh-ts/src/realtime-quic.ts b/packages/cultmesh-ts/src/realtime-quic.ts deleted file mode 100644 index c368c96f..00000000 --- a/packages/cultmesh-ts/src/realtime-quic.ts +++ /dev/null @@ -1,1329 +0,0 @@ -// The Node QUIC realtime plane: a consumer transport and connector over the -// native v2 bridge, and the session manager that binds an advertised route to -// a connected, authority-verified transport. -// -// Provider and consumer semantics (which stream kind carries which delivery -// mode, the generation filter, the certificate pin decision) are pinned rule -// for rule against the C# reference, `src/GameCult.Mesh.Quic/CultMeshQuicRealtimeTransport.cs` -// (the connector) and `src/GameCult.Mesh/CultMeshSessions.cs` (the session -// manager's binding, ordering and racing). -// -// This module never touches `koffi` directly; every native call goes through -// `CultMeshQuicNativeRuntime` in `realtime-quic-native.ts`, which loads it -// lazily. No Odin WebSocket rendezvous lives here (Q7, ruled A): a lookup -// source is a port this module takes, not a transport it owns. - -import { createHash } from "node:crypto"; -import { createSecureContext } from "node:tls"; - -import { - routeCertificateView, - trimCSharp, - verifyAuthorityRoute, - type CultMeshAuthorityTrustPolicy, -} from "cultnet-ts"; -import type { CultMeshVerseDescriptorMessage } from "cultnet-ts/contracts"; - -import { - decodeRealtimeFrame, - encodeRealtimeFrame, - CULTMESH_REALTIME_CONNECTION_CLOSE_CODE, - type CultMeshRealtimeFrame, -} from "./realtime-wire"; -import { - CultMeshQuicNativeRuntime, - CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED, - CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED, - CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN, - CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION, - CULTMESH_QUIC_EVENT_STREAM_FRAME, - CULTMESH_QUIC_EVENT_STREAM_SEND_COMPLETE, - CULTMESH_QUIC_EVENT_STREAM_SHUTDOWN, - CULTMESH_QUIC_EVENT_STREAM_STARTED, - CULTMESH_QUIC_SEND_CANCELED, - CULTMESH_QUIC_STREAM_LATEST_ONLY, - CULTMESH_QUIC_STREAM_RELIABLE, - type CultMeshQuicNativeEvent, -} from "./realtime-quic-native"; - -/** `CultMeshProtocols.RealtimeState.Value` in the C# reference. */ -export const CULTMESH_REALTIME_STATE_PROTOCOL_ID = "cultmesh.realtime_state.v1"; - -/** The QUIC realtime connector's discovery scheme; `CultMeshQuicRealtimeTransportConnector.Scheme`. */ -export const CULTMESH_QUIC_REALTIME_SCHEME = "cultmesh-state+quic"; - -/** Stable provider identity inside one Verse; `CultMeshSessionTarget` in the C# reference. */ -export interface CultMeshRealtimeTarget { - readonly verseId: string; - readonly authorityRuntimeId: string; -} - -/** A physical route candidate; the TS analogue of `CultMeshTransportCandidate`. */ -export interface CultMeshRealtimeCandidate { - readonly endpoint: string; - readonly authorityRuntimeId: string; - readonly priority: number; - readonly generation: string; -} - -/** Transport-neutral realtime plane; the TS analogue of `ICultMeshRealtimeTransport`. */ -export interface CultMeshRealtimeTransport { - readonly transportId: string; - readonly endpoint: string; - sendFrame(frame: CultMeshRealtimeFrame): Promise; - receiveFrame(signal?: AbortSignal): Promise; - dispose(): void; - isVerifiedFor( - verseId: string, - authorityRuntimeId: string, - protocolId: string, - routeGeneration: string, - ): boolean; - /** - * Registers a handler invoked once this transport becomes unusable for any - * reason (explicit `dispose()`, a peer-initiated shutdown, or an internal - * fault). A session manager uses this to evict a dead cached session so the - * next `connect()` redials, mirroring `CultMeshSessions.InvalidateRealtimeSession`. - */ - onDisposed?(handler: () => void): void; -} - -/** Creates realtime transports; the TS analogue of `ICultMeshRealtimeTransportConnector`. */ -export interface CultMeshRealtimeTransportConnector { - readonly connectorId: string; - readonly priority: number; - canConnect(candidate: CultMeshRealtimeCandidate): boolean; - connect( - candidate: CultMeshRealtimeCandidate, - target: CultMeshRealtimeTarget, - signal?: AbortSignal, - ): Promise; -} - -/** A route source the session manager resolves against; Q7's lookup-source port. */ -export interface ICultMeshRealtimeLookupSource { - resolve(target: CultMeshRealtimeTarget): Promise; -} - -/** `CultMeshQuicRealtimeTransportConnector.TryParseEndpoint` (`:98-109`). */ -export function parseQuicRealtimeEndpoint( - endpoint: string, -): { host: string; port: number; certificateSha256?: string } { - let parsed: URL; - try { - parsed = new URL(endpoint); - } catch { - throw new Error(`QUIC realtime connector does not support '${endpoint}'.`); - } - if (parsed.protocol.toLowerCase() !== `${CULTMESH_QUIC_REALTIME_SCHEME}:`) { - throw new Error(`QUIC realtime connector does not support '${endpoint}'.`); - } - if (!parsed.hostname) { - throw new Error(`QUIC realtime connector does not support '${endpoint}'.`); - } - const port = parsed.port ? Number.parseInt(parsed.port, 10) : Number.NaN; - if (!Number.isInteger(port) || port <= 0) { - throw new Error(`QUIC realtime connector does not support '${endpoint}'.`); - } - const pin = parsed.searchParams.get("cert-sha256") ?? undefined; - return { host: parsed.hostname, port, certificateSha256: pin ?? undefined }; -} - -function certificateSha256Hex(der: Uint8Array): string { - return createHash("sha256").update(der).digest("hex"); -} - -/** Shared by `CultMeshQuicRealtimeTransport.sendFrame` and `CultMeshQuicRealtimeProvider.broadcast`: one message, not two copies that can drift. */ -const CULTMESH_QUIC_UNRELIABLE_UNSUPPORTED_MESSAGE = - "The native MsQuic connector exposes streams but not QUIC datagrams; unreliable delivery is not supported."; - -interface PendingSend { - resolve(): void; - reject(error: Error): void; -} - -interface LatestGeneration { - producerEpoch: bigint; - sequence: bigint; -} - -function compareGeneration(a: LatestGeneration, b: LatestGeneration): number { - if (a.producerEpoch !== b.producerEpoch) return a.producerEpoch < b.producerEpoch ? -1 : 1; - if (a.sequence === b.sequence) return 0; - return a.sequence < b.sequence ? -1 : 1; -} - -/** A direct entry, or a coalesced `key` to resolve against `pending` at delivery time. */ -type InboxToken = { readonly value: T } | { readonly key: string }; - -/** - * Mirrors the C# reference's `CultMeshRealtimeInbox` - * (`CultMeshQuicRealtimeTransport.cs:598-644`): publishing under `key` - * coalesces to at most one pending entry per key, keeping that key's - * first-arrival queue position while a later publish overwrites its value; - * publishing with no `key` always queues, unbounded. `receive` drains - * whatever is queued before a `complete(error)` rejects further calls. - * Shared by the transport's inbound queue, the provider's fan-in, and (via - * a pump reading it instead of `receive` callers) each peer's outbox. - */ -class CultMeshRealtimeInbox { - private readonly pending = new Map(); - private readonly ready: InboxToken[] = []; - private readonly waiters: { resolve(value: T): void; reject(error: Error): void }[] = []; - private completed = false; - private terminalError: Error | undefined; - - /** Entries queued but not yet delivered: one per ready key, or one per unkeyed entry. Test-visible. */ - get size(): number { - return this.ready.length; - } - - /** Publishes `value`, coalescing on `key` when given. Returns `false` once `complete()` has run. */ - publish(value: T, key?: string): boolean { - if (this.completed) return false; - if (key === undefined) { - this.deliver({ value }); - return true; - } - const alreadyPending = this.pending.has(key); - this.pending.set(key, value); - if (!alreadyPending) this.deliver({ key }); - return true; - } - - /** Resolves the oldest ready entry, or waits for one; rejects once `complete(error)` has drained the backlog. */ - async receive(signal?: AbortSignal): Promise { - if (this.ready.length > 0) return this.take(this.ready.shift()!); - if (this.completed) throw this.terminalError ?? new Error("CultMesh realtime inbox is completed."); - if (signal?.aborted) throw new Error("CultMesh realtime inbox receive aborted."); - return await new Promise((resolve, reject) => { - const onAbort = (): void => { - const index = this.waiters.indexOf(waiter); - if (index >= 0) this.waiters.splice(index, 1); - reject(new Error("CultMesh realtime inbox receive aborted.")); - }; - const waiter = { - resolve: (value: T) => { - signal?.removeEventListener("abort", onAbort); - resolve(value); - }, - reject: (error: Error) => { - signal?.removeEventListener("abort", onAbort); - reject(error); - }, - }; - signal?.addEventListener("abort", onAbort, { once: true }); - this.waiters.push(waiter); - }); - } - - /** Completes the inbox: entries already queued still drain via `receive`; every call after that rejects with `error`. */ - complete(error?: Error): void { - if (this.completed) return; - this.completed = true; - this.terminalError = error; - for (const waiter of this.waiters.splice(0, this.waiters.length)) { - waiter.reject(error ?? new Error("CultMesh realtime inbox is completed.")); - } - } - - private deliver(token: InboxToken): void { - const waiter = this.waiters.shift(); - if (waiter) { - waiter.resolve(this.take(token)); - return; - } - this.ready.push(token); - } - - private take(token: InboxToken): T { - if ("value" in token) return token.value; - const value = this.pending.get(token.key)!; - this.pending.delete(token.key); - return value; - } -} - -/** Configures certificate acceptance for an outbound QUIC realtime connection. */ -export interface CultMeshQuicRealtimeConnectorOptions { - readonly handshakeTimeoutMs?: number; - readonly validateProviderCertificate?: ( - target: CultMeshRealtimeTarget, - der: Uint8Array, - ) => boolean; -} - -/** Creates Node-native MsQuic realtime connections. `ConnectorId` is `"msquic-realtime"`. */ -export class CultMeshQuicRealtimeConnector implements CultMeshRealtimeTransportConnector { - private readonly options: CultMeshQuicRealtimeConnectorOptions; - - constructor(options: CultMeshQuicRealtimeConnectorOptions = {}) { - this.options = options; - } - - readonly connectorId = "msquic-realtime"; - readonly priority = 0; - - canConnect(candidate: CultMeshRealtimeCandidate): boolean { - try { - parseQuicRealtimeEndpoint(candidate.endpoint); - return true; - } catch { - return false; - } - } - - async connect( - candidate: CultMeshRealtimeCandidate, - target: CultMeshRealtimeTarget, - signal?: AbortSignal, - ): Promise { - if (signal?.aborted) { - throw new Error("CultMesh QUIC connect aborted."); - } - const { host, port, certificateSha256 } = parseQuicRealtimeEndpoint(candidate.endpoint); - const runtime = await CultMeshQuicNativeRuntime.open(); - let connectionId: bigint; - try { - connectionId = runtime.connectionOpen(host, port); - } catch (error) { - await runtime.release(); - throw error; - } - - const handshakeTimeoutMs = this.options.handshakeTimeoutMs ?? 10_000; - const validator = this.options.validateProviderCertificate; - - return await new Promise((resolve, reject) => { - let settled = false; - - // Every failure path below — an already-aborted signal, the handshake - // timeout, a later abort, and any handshake-time error — settles here - // exactly once: shut the native connection down, drop the runtime - // reference, and reject. - const fail = (error: Error): void => { - if (settled) return; - settled = true; - clearTimeout(timer); - signal?.removeEventListener("abort", onAbort); - runtime.offConnectionEvent(connectionId); - runtime.connectionShutdown(connectionId, CULTMESH_REALTIME_CONNECTION_CLOSE_CODE); - void runtime.release(); - reject(error); - }; - - const succeed = (transport: CultMeshRealtimeTransport): void => { - if (settled) return; - settled = true; - clearTimeout(timer); - signal?.removeEventListener("abort", onAbort); - resolve(transport); - }; - - // Honour a signal already aborted by the time we reach here (it fired - // before `addEventListener` could observe it, so it never would). - if (signal?.aborted) { - fail(new Error("CultMesh QUIC connect aborted.")); - return; - } - - const timer = setTimeout( - () => fail(new Error(`CultMesh QUIC handshake with '${candidate.endpoint}' timed out.`)), - handshakeTimeoutMs, - ); - const onAbort = (): void => fail(new Error("CultMesh QUIC connect aborted.")); - signal?.addEventListener("abort", onAbort, { once: true }); - - // Told by the runtime when this connection faults mid-dispatch (a - // throwing validator during the handshake, or a malformed frame / - // stream-kind mismatch afterwards): reject the still-pending connect, - // or hand the fault to the connected transport. - const onFault = (error: Error): void => { - if (!settled) { - fail(error); - return; - } - transport.fault(error); - }; - - const transport = new CultMeshQuicRealtimeTransport( - candidate.endpoint, - runtime, - connectionId, - target, - candidate.generation, - ); - - runtime.onConnectionEvent( - connectionId, - (event) => { - if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED) { - const der = event.payload; - const accepted = validator - ? validator(target, der) - : certificateSha256 !== undefined && - certificateSha256Hex(der).toLowerCase() === certificateSha256.toLowerCase(); - try { - runtime.connectionCertificateComplete(connectionId, accepted); - } catch (error) { - fail(error instanceof Error ? error : new Error(String(error))); - return; - } - if (!accepted) { - fail(new Error(`CultMesh QUIC provider certificate for '${candidate.endpoint}' was rejected.`)); - } - return; - } - if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN && !settled) { - const reason = Buffer.from(event.payload).toString("utf8"); - fail(new Error(`CultMesh QUIC connection closed during handshake: ${reason || event.code}`)); - return; - } - transport.handleConnectionEvent(event); - if (!settled && event.type === CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED) { - succeed(transport); - } - }, - onFault, - ); - }); - } -} - -/** - * `CultMeshQuicRealtimeTransport`, over the native v2 bridge. `TransportId` - * is `"msquic-realtime"`. - */ -export class CultMeshQuicRealtimeTransport implements CultMeshRealtimeTransport { - readonly transportId = "msquic-realtime"; - readonly endpoint: string; - - private readonly runtime: CultMeshQuicNativeRuntime; - private readonly connectionId: bigint; - private readonly target: CultMeshRealtimeTarget; - private readonly routeGeneration: string; - /** Serializes writes to the reliable-ordered outbound stream: each send chains onto this tail. */ - private reliableSendTail: Promise = Promise.resolve(); - private readonly pendingSends = new Map(); - /** Peer-opened stream id to its declared kind, pruned on `STREAM_SHUTDOWN`; see `trackedStreamCount`. */ - private readonly streamKinds = new Map(); - /** Generation staleness filter: the newest `(channel, body)` generation delivered so far. */ - private readonly latestGenerations = new Map(); - /** Inbound frame queue: bounded to one pending latest-only frame per key. */ - private readonly inbox = new CultMeshRealtimeInbox(); - private reliableOutboundStreamId: bigint | undefined; - private disposed = false; - private terminalError: Error | undefined; - private readonly disposalHandlers = new Set<() => void>(); - - constructor( - endpoint: string, - runtime: CultMeshQuicNativeRuntime, - connectionId: bigint, - target: CultMeshRealtimeTarget, - routeGeneration: string, - ) { - this.endpoint = endpoint; - this.runtime = runtime; - this.connectionId = connectionId; - this.target = target; - this.routeGeneration = routeGeneration ?? ""; - } - - isVerifiedFor( - verseId: string, - authorityRuntimeId: string, - protocolId: string, - routeGeneration: string, - ): boolean { - return ( - this.target.verseId === verseId && - this.target.authorityRuntimeId === authorityRuntimeId && - CULTMESH_REALTIME_STATE_PROTOCOL_ID === protocolId && - this.routeGeneration === routeGeneration - ); - } - - /** Number of peer-opened streams still tracked for their kind. Test-visible: proves `STREAM_SHUTDOWN` prunes `streamKinds` instead of leaking one entry per stream for the life of the connection. */ - get trackedStreamCount(): number { - return this.streamKinds.size; - } - - async sendFrame(frame: CultMeshRealtimeFrame): Promise { - if (this.disposed) throw new Error("CultMesh QUIC realtime transport is disposed."); - if (frame.delivery === "unreliable") { - throw new Error(CULTMESH_QUIC_UNRELIABLE_UNSUPPORTED_MESSAGE); - } - return this.sendEncodedFrame(frame.delivery, encodeRealtimeFrame(frame)); - } - - /** - * @internal The primitive `sendFrame` itself calls, after encoding. Exposed - * so a caller that already holds the encoded bytes for many peers at once - * — `CultMeshQuicRealtimeProvider.broadcast` fanning a frame out to every - * peer, and each peer's `CultMeshQuicRealtimeProviderOutbox` pump — sends - * them without re-encoding (and re-validating) per peer. - */ - async sendEncodedFrame(delivery: "reliable-ordered" | "latest-only", encoded: Uint8Array): Promise { - if (this.disposed) throw new Error("CultMesh QUIC realtime transport is disposed."); - if (delivery === "reliable-ordered") { - const send = this.reliableSendTail.then(async () => { - if (this.reliableOutboundStreamId === undefined) { - this.reliableOutboundStreamId = this.runtime.streamOpen( - this.connectionId, - CULTMESH_QUIC_STREAM_RELIABLE, - ); - } - await this.sendOnStream(this.reliableOutboundStreamId, encoded, false); - }); - // The tail must advance even if this send rejects, so the next queued - // send is not blocked forever on a settled promise; the caller still - // observes the rejection through `send` itself. - this.reliableSendTail = send.catch(() => {}); - return send; - } - // latest-only: a fresh stream per send, mirroring the C# reference. - const streamId = this.runtime.streamOpen(this.connectionId, CULTMESH_QUIC_STREAM_LATEST_ONLY); - await this.sendOnStream(streamId, encoded, true); - } - - /** - * A pending send registered after `cleanup()` already ran (a `sendFrame` - * queued behind `reliableSendTail` whose microtask lands after `dispose()` - * or a fault settles this transport) would otherwise sit in `pendingSends` - * forever: `cleanup()`'s own reject sweep already ran once and is guarded - * against running again, and `offConnectionEvent` means no later native - * event can resolve it either. Checking `this.disposed` here closes that - * window: any send that loses the race against disposal rejects - * immediately instead of registering an entry nothing will ever settle. - */ - private sendOnStream(streamId: bigint, encoded: Uint8Array, fin: boolean): Promise { - return new Promise((resolve, reject) => { - if (this.disposed) { - reject(this.terminalError ?? new Error("CultMesh QUIC realtime transport is disposed.")); - return; - } - this.pendingSends.set(streamId, { resolve, reject }); - try { - this.runtime.streamSendFrame(streamId, encoded, fin); - } catch (error) { - this.pendingSends.delete(streamId); - reject(error instanceof Error ? error : new Error(String(error))); - } - }); - } - - async receiveFrame(signal?: AbortSignal): Promise { - return this.inbox.receive(signal); - } - - /** Registers a handler invoked once, when this transport becomes unusable. */ - onDisposed(handler: () => void): void { - if (this.disposed) { - handler(); - return; - } - this.disposalHandlers.add(handler); - } - - dispose(): void { - if (this.disposed) return; - try { - this.runtime.connectionShutdown(this.connectionId, CULTMESH_REALTIME_CONNECTION_CLOSE_CODE); - } catch { - // Best-effort: the connection may already be gone. - } - this.cleanup(); - } - - /** - * @internal Called by the connector when the native runtime faults this - * connection mid-dispatch (a malformed frame, a stream-kind mismatch, or a - * throwing caller callback). Mirrors the C# reference's - * `_received.Complete(error)` / `_shutdown.Cancel()`: pending and future - * `receiveFrame` calls reject with `error`, and the connection is torn - * down. The native side has already shut the connection down and removed - * its listener by the time this runs. - */ - fault(error: Error): void { - this.cleanup(error); - } - - /** - * Runs exactly once, however the transport becomes unusable: explicit - * `dispose()`, a peer-initiated `CONNECTION_SHUTDOWN`, or `fault()`. - * Releases the runtime reference before running any `onDisposed` handler, - * so a handler that throws (or blocks) can never delay or skip the - * release; every handler runs in its own try/catch, so one throwing - * handler cannot stop the rest from running. Also rejects every pending - * and future receive/send, so none of those paths can leak the reference - * or hang a waiter forever. - */ - private cleanup(error?: Error): void { - if (this.disposed) return; - this.disposed = true; - this.terminalError = error; - this.runtime.offConnectionEvent(this.connectionId); - const rejection = error ?? new Error("CultMesh QUIC realtime transport is disposed."); - for (const [, pending] of this.pendingSends) pending.reject(rejection); - this.pendingSends.clear(); - this.inbox.complete(rejection); - void this.runtime.release(); - const handlers = [...this.disposalHandlers]; - this.disposalHandlers.clear(); - for (const handler of handlers) { - try { - handler(); - } catch (handlerError) { - // eslint-disable-next-line no-console - console.error("CultMesh QUIC realtime transport onDisposed handler threw:", handlerError); - } - } - } - - /** @internal Routed here by the connector's connection-event registration. */ - handleConnectionEvent(event: CultMeshQuicNativeEvent): void { - switch (event.type) { - case CULTMESH_QUIC_EVENT_STREAM_STARTED: - this.streamKinds.set(event.streamId, event.streamKind); - return; - case CULTMESH_QUIC_EVENT_STREAM_FRAME: - this.onStreamFrame(event); - return; - case CULTMESH_QUIC_EVENT_STREAM_SHUTDOWN: - this.streamKinds.delete(event.streamId); - return; - case CULTMESH_QUIC_EVENT_STREAM_SEND_COMPLETE: { - const pending = this.pendingSends.get(event.streamId); - if (!pending) return; - this.pendingSends.delete(event.streamId); - if (event.code === BigInt(CULTMESH_QUIC_SEND_CANCELED)) { - pending.reject(new Error("CultMesh QUIC send was canceled.")); - } else { - pending.resolve(); - } - return; - } - case CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN: - this.cleanup(new Error("CultMesh QUIC connection was closed by the remote peer.")); - return; - default: - return; - } - } - - /** - * `ReadStreamAsync`'s stream-kind/delivery consistency check (`:519-524`). - * Throwing here is intentional: the native runtime's dispatch loop (fix 1) - * catches it, faults only this connection, and keeps the pump and every - * other connection running. - */ - private onStreamFrame(event: CultMeshQuicNativeEvent): void { - const kind = this.streamKinds.get(event.streamId); - const frame = decodeRealtimeFrame(event.payload); - if (kind === CULTMESH_QUIC_STREAM_RELIABLE && frame.delivery !== "reliable-ordered") { - throw new Error("Reliable QUIC stream carried incompatible delivery semantics."); - } - if (kind === CULTMESH_QUIC_STREAM_LATEST_ONLY && frame.delivery !== "latest-only") { - throw new Error("Latest-only QUIC stream carried incompatible delivery semantics."); - } - this.publishReceived(frame); - } - - /** - * `PublishReceived`'s per-`(channel, body)` generation filter (`:538-560`), - * plus the inbox's own coalescing: a latest-only frame overwrites the - * still-pending frame for its key rather than queuing beside it, so an - * unread backlog holds at most one frame per `(channel, body)`. - * Reliable-ordered frames queue directly, unbounded, exactly as the C# - * reference's unbounded channel does. - */ - private publishReceived(frame: CultMeshRealtimeFrame): void { - if (frame.delivery === "latest-only") { - const key = frame.channelId + "\u001f" + frame.bodyId; - const candidate: LatestGeneration = { producerEpoch: frame.producerEpoch, sequence: frame.sequence }; - const current = this.latestGenerations.get(key); - if (current && compareGeneration(candidate, current) <= 0) return; - this.latestGenerations.set(key, candidate); - this.inbox.publish(frame, key); - return; - } - this.inbox.publish(frame); - } -} - -/** - * `TryPublishLatest` plus `SendPublishedFramesAsync` (`:363-367`, `:421-439`), - * over a coalescing outbox (`:572-618`). Owns one peer's outbound - * `latest-only` traffic: `publish` never blocks (it just replaces whatever is - * still pending for the frame's `(channel, body)` key and, if a pump is not - * already draining, starts one), and the pump opens a fresh kind-2 stream per - * frame through the peer's own `sendFrame` (unchanged from the consumer path - * above), sending with `fin` and waiting for `send_complete` before it takes - * the next ready key. A stalled peer therefore accumulates at most one - * pending frame per key and never blocks a caller's `broadcast`. A send - * failure evicts the peer once, the same way a reliable-ordered send failure - * does in `CultMeshQuicRealtimeProvider.broadcast`. - */ -interface OutboxEntry { - readonly frame: CultMeshRealtimeFrame; - readonly encoded: Uint8Array; -} - -class CultMeshQuicRealtimeProviderOutbox { - private readonly inbox = new CultMeshRealtimeInbox(); - private readonly transport: CultMeshQuicRealtimeTransport; - private readonly onSendFailure: (error: Error) => void; - private pumping = false; - private disposed = false; - - constructor(transport: CultMeshQuicRealtimeTransport, onSendFailure: (error: Error) => void) { - this.transport = transport; - this.onSendFailure = onSendFailure; - } - - /** - * Enqueues an already-encoded `frame`, coalescing on `(channelId, - * bodyId)`. Never blocks. Takes the encoded bytes rather than encoding its - * own copy so every peer's outbox reuses the one encode `broadcast` (or a - * retained-frame seed) already did for this frame. - */ - publish(frame: CultMeshRealtimeFrame, encoded: Uint8Array): void { - if (this.disposed) return; - const key = frame.channelId + "\u001f" + frame.bodyId; - this.inbox.publish({ frame, encoded }, key); - if (!this.pumping) void this.pump(); - } - - dispose(): void { - this.disposed = true; - this.inbox.complete(); - } - - /** One in flight per peer: the next ready key is taken only after `send_complete`, mirroring `SendPublishedFramesAsync`. */ - private async pump(): Promise { - this.pumping = true; - try { - for (;;) { - if (this.disposed) return; - let entry: OutboxEntry; - try { - entry = await this.inbox.receive(); - } catch { - return; - } - try { - await this.transport.sendEncodedFrame("latest-only", entry.encoded); - } catch (error) { - this.dispose(); - this.onSendFailure(error instanceof Error ? error : new Error(String(error))); - return; - } - } - } finally { - this.pumping = false; - } - } -} - -/** Stable placeholder target for a provider's accepted peer connections: `isVerifiedFor` is a consumer-side concept the provider never calls. */ -const CULTMESH_PROVIDER_PEER_TARGET: CultMeshRealtimeTarget = { verseId: "", authorityRuntimeId: "" }; - -/** Configures a `CultMeshQuicRealtimeProvider` listener. */ -export interface CultMeshQuicRealtimeProviderOptions { - readonly host: string; - readonly port: number; - readonly serverCertificate: { readonly pkcs12: Uint8Array; readonly password?: string }; - readonly handshakeTimeoutMs?: number; - /** - * The host advertised in `advertisedEndpoint`, when it differs from the - * bind address `host` — the usual case for a public service: `host` is - * typically `0.0.0.0`, an empty string, or an IPv6 wildcard (`::`), none of - * which describe a route a client can dial, and the native listener (which - * `host` also configures) only accepts IP addresses in the first place, not - * a DNS name. Defaults to `host`. - */ - readonly advertisedHost?: string; - /** - * When `false` (the default: every existing caller, test, and interop - * peer calls `receive()`), the provider fans client-originated streams - * into `receive()` as before. When `true`, the provider is publish-only: - * any stream a peer opens faults that connection at `STREAM_STARTED`, - * before a frame can be decoded or queued, so no inbox, generation-map, - * or fan-in entry is ever created for client data; `receive()` itself - * rejects. StreamPixels sets this explicitly — it closes the unbounded - * per-client memory growth Soul's probe found. - */ - readonly acceptClientFrames?: boolean; - /** - * Refuses a new connection at accept once the number of connections this - * provider is tracking (including mid-handshake ones) has reached this - * many: shut down immediately, before any transport, outbox, or - * retained-frame seeding is allocated for it. Existing peers are - * unaffected. Defaults to 1024 — far more than any single StreamPixels - * fan-out needs today, while still bounding per-connection overhead - * against a public UDP port whose pin authenticates only the server, so - * any internet client can dial in. StreamPixels sets its own value. - */ - readonly maxConnections?: number; -} - -/** - * Brackets an IPv6 literal for use in a URL authority (`[::1]:443`), the way - * `URL` itself renders one; a hostname or IPv4 literal passes through - * unchanged, and an already-bracketed literal is left alone. - */ -function formatEndpointHost(host: string): string { - if (host.startsWith("[")) return host; - return host.includes(":") ? `[${host}]` : host; -} - -/** - * Recovers the leaf certificate's DER bytes from a PKCS12 credential, to - * compute the pin a provider advertises. Node has no public PKCS12 decoder; - * `tls.createSecureContext` loads the PKCS12 through OpenSSL, and its - * internal `context.getCertificate()` is the only way to read back the DER - * bytes OpenSSL parsed out of it. Probed against the pinned Node 24 build and - * this package's own test fixture pair (`test/fixtures/quic-test.p12` and - * `quic-test-cert.der`, generated independently by `openssl pkcs12 -export`): - * the returned buffer is byte-identical to the `.der` file and its SHA-256 - * matches the pin the fixture's tests already assert. - */ -function extractLeafCertificateDer(serverCertificate: CultMeshQuicRealtimeProviderOptions["serverCertificate"]): Uint8Array { - const context = createSecureContext({ - pfx: Buffer.from(serverCertificate.pkcs12), - passphrase: serverCertificate.password ?? "", - }) as unknown as { context: { getCertificate(): Buffer | undefined } }; - const der = context.context.getCertificate(); - if (!der || der.length === 0) { - throw new Error("CultMesh QUIC provider certificate has no parseable leaf certificate."); - } - return new Uint8Array(der); -} - -/** - * The StreamPixels role: a TypeScript listener that broadcasts with an - * explicit delivery mode and cannot be backpressured by a slow consumer. - * Rule for rule, this is `CultMeshQuicRealtimeServer` (`CultMeshQuicRealtimeTransport.cs:151-310`): - * `listen` opens a listener and computes the advertised endpoint; - * `broadcast` mirrors `BroadcastAsync` (`:216-235`); `receive` mirrors - * `ReceiveAsync` fanned in from every accepted peer's inbox - * (`CultMeshRealtimeInbox.ReceiveAsync`, `:597-609`); peers are evicted on - * send failure or shutdown, and there is no reconnect logic here, matching - * the C# reference's `AcceptLoopAsync` (`:276-309`). - */ -export class CultMeshQuicRealtimeProvider { - /** `cultmesh-state+quic://:?cert-sha256=`, uppercase — the Unity connector requires the pin. */ - readonly advertisedEndpoint: string; - - private readonly runtime: CultMeshQuicNativeRuntime; - private readonly listenerId: bigint; - private readonly handshakeTimeoutMs: number; - private readonly peers = new Set(); - private readonly outboxes = new Map(); - /** The newest `latest-only` frame broadcast so far per `(channel, body)` key, already - * encoded, seeded into every peer attached after that broadcast. Reliable-ordered frames - * are events, not state, and are never retained. */ - private readonly retained = new Map(); - /** Client-to-provider fan-in, bounded the same way as the transport's inbound queue. */ - private readonly received = new CultMeshRealtimeInbox(); - /** - * Every transport `acceptConnection` has created for a still-live native - * connection, whether or not its handshake has reached CONNECTED and - * joined `peers` yet. A connection whose handshake never reaches CONNECTED - * (a rejected certificate, a stalled client) would otherwise sit on its - * own handshake timer — and hold a runtime reference — for up to - * `handshakeTimeoutMs` after the provider itself was disposed, keeping the - * process alive over a connection nothing can still reach; `dispose()` - * sweeps this set instead of only `peers` to close that gap. - */ - private readonly accepted = new Set(); - private readonly acceptClientFrames: boolean; - private readonly maxConnections: number; - private disposed = false; - - private constructor( - runtime: CultMeshQuicNativeRuntime, - listenerId: bigint, - advertisedEndpoint: string, - handshakeTimeoutMs: number, - acceptClientFrames: boolean, - maxConnections: number, - ) { - this.runtime = runtime; - this.listenerId = listenerId; - this.advertisedEndpoint = advertisedEndpoint; - this.handshakeTimeoutMs = handshakeTimeoutMs; - this.acceptClientFrames = acceptClientFrames; - this.maxConnections = maxConnections; - } - - /** Number of currently accepted peer connections. */ - get connectionCount(): number { - return this.peers.size; - } - - /** Frames fanned in from peers but not yet drained by `receive()`. Test-visible: proves the fan-in queue is bounded. */ - get receiveQueueSize(): number { - return this.received.size; - } - - static async listen(options: CultMeshQuicRealtimeProviderOptions): Promise { - const handshakeTimeoutMs = options.handshakeTimeoutMs ?? 10_000; - const der = extractLeafCertificateDer(options.serverCertificate); - const pinHex = certificateSha256Hex(der).toUpperCase(); - const bindHost = options.host && options.host.length > 0 ? options.host : "127.0.0.1"; - const advertisedHost = options.advertisedHost && options.advertisedHost.length > 0 ? options.advertisedHost : bindHost; - - const runtime = await CultMeshQuicNativeRuntime.open(); - let listenerId: bigint; - let boundPort: number; - try { - ({ listenerId, boundPort } = runtime.listenerOpen( - options.host || null, - options.port, - options.serverCertificate.pkcs12, - options.serverCertificate.password ?? null, - )); - } catch (error) { - await runtime.release(); - throw error; - } - - const advertisedEndpoint = `${CULTMESH_QUIC_REALTIME_SCHEME}://${formatEndpointHost(advertisedHost)}:${boundPort}?cert-sha256=${pinHex}`; - const provider = new CultMeshQuicRealtimeProvider( - runtime, - listenerId, - advertisedEndpoint, - handshakeTimeoutMs, - options.acceptClientFrames ?? true, - options.maxConnections ?? 1024, - ); - runtime.onListenerEvent(listenerId, (event) => { - if (event.type === CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION) provider.acceptConnection(event.connectionId); - }); - return provider; - } - - /** - * `BroadcastAsync` (`:216-235`). `latest-only` enqueues into each peer's - * outbox and returns as soon as every peer has accepted the frame into its - * outbox — never once any peer's I/O. `reliable-ordered` awaits every - * peer's `send_complete` (or eviction) before resolving, matching - * `Task.WhenAll` over `SendToConnectedClientAsync` (`:233-255`). - */ - async broadcast(frame: CultMeshRealtimeFrame): Promise { - if (this.disposed) throw new Error("CultMesh QUIC realtime provider is disposed."); - if (frame.delivery === "unreliable") { - throw new Error(CULTMESH_QUIC_UNRELIABLE_UNSUPPORTED_MESSAGE); - } - // Encode (and validate) exactly once, up front: an invalid frame throws - // here, to the caller, before touching any peer. The old per-peer encode - // inside `sendFrame` meant an invalid frame failed identically for every - // peer in turn, evicting all of them while `broadcast` itself still - // reported success. - const encoded = encodeRealtimeFrame(frame); - if (frame.delivery === "latest-only") { - const key = frame.channelId + "\u001f" + frame.bodyId; - const current = this.retained.get(key); - const candidate: LatestGeneration = { producerEpoch: frame.producerEpoch, sequence: frame.sequence }; - if (!current || compareGeneration(candidate, current.generation) > 0) { - this.retained.set(key, { frame, encoded, generation: candidate }); - } - for (const outbox of this.outboxes.values()) outbox.publish(frame, encoded); - return; - } - const sends = [...this.peers].map(async (transport) => { - try { - await transport.sendEncodedFrame("reliable-ordered", encoded); - } catch { - transport.dispose(); - } - }); - if (sends.length > 0) await Promise.all(sends); - } - - /** The next client-originated frame, fanned in from every accepted peer. */ - async receive(signal?: AbortSignal): Promise { - if (!this.acceptClientFrames) { - throw new Error( - "CultMesh QUIC realtime provider is publish-only (acceptClientFrames: false); it never accepts client frames.", - ); - } - return this.received.receive(signal); - } - - dispose(): void { - if (this.disposed) return; - this.disposed = true; - try { - this.runtime.listenerClose(this.listenerId); - } catch { - // Best-effort: the listener may already be gone. - } - // `accepted` is every transport this provider ever created for a native - // connection, from the moment `acceptConnection` synchronously builds - // it — whether or not its handshake ever reached CONNECTED and joined - // `peers`. A still-handshaking connection has no other owner to sweep it. - for (const transport of [...this.accepted]) transport.dispose(); - this.received.complete(new Error("CultMesh QUIC realtime provider is disposed.")); - void this.runtime.release(); - } - - /** Coalesces client-sent `latest-only` frames by `(channel, body)`, so a publish-only `receive()` caller (StreamPixels) backs up to at most one pending frame per key. */ - private async pumpReceivedFrom(transport: CultMeshQuicRealtimeTransport): Promise { - for (;;) { - let frame: CultMeshRealtimeFrame; - try { - frame = await transport.receiveFrame(); - } catch { - return; // The transport is disposed/faulted; nothing more to read. - } - if (frame.delivery === "latest-only") { - this.received.publish(frame, frame.channelId + "\u001f" + frame.bodyId); - } else { - this.received.publish(frame); - } - } - } - - /** - * Handles `LISTENER_NEW_CONNECTION`, fully synchronously: one handler owns - * this connection from this point on, so there is no async window in which - * a NEW_CONNECTION-then-CONNECTED pair (the bridge's own event order, - * `native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp:731-757,627`) - * can be split across a temporary buffering handler and a later real one. - * `this.runtime.retain()` takes a reference on the provider's own - * already-open runtime instead of `await`ing a fresh - * `CultMeshQuicNativeRuntime.open()`, which is what used to force that - * split: the previous async gap left `CONNECTION_CONNECTED` dispatched - * straight to `transport.handleConnectionEvent` (which has no case for - * it) with nothing to clear the handshake timer, so a healthy peer was - * evicted at `handshakeTimeoutMs` regardless of how well-behaved the - * client was. - */ - private acceptConnection(connectionId: bigint): void { - if (this.disposed) { - this.runtime.connectionShutdown(connectionId, CULTMESH_REALTIME_CONNECTION_CLOSE_CODE); - return; - } - if (this.accepted.size >= this.maxConnections) { - // Refused before any transport, timer, or outbox exists for it: - // existing accepted connections are untouched. - this.runtime.connectionShutdown(connectionId, CULTMESH_REALTIME_CONNECTION_CLOSE_CODE); - return; - } - - const transport = new CultMeshQuicRealtimeTransport( - `quic-peer-${connectionId}`, - this.runtime.retain(), - connectionId, - CULTMESH_PROVIDER_PEER_TARGET, - "", - ); - this.accepted.add(transport); - - const handshakeTimer = setTimeout(() => transport.dispose(), this.handshakeTimeoutMs); - transport.onDisposed(() => { - clearTimeout(handshakeTimer); - this.accepted.delete(transport); - this.peers.delete(transport); - this.outboxes.get(transport)?.dispose(); - this.outboxes.delete(transport); - }); - - // CONNECTED fires at most once per connection; `attached` guards against - // ever running the join-`peers` bookkeeping twice. - let attached = false; - this.runtime.onConnectionEvent( - connectionId, - (event) => { - // Publish-only: a peer-opened stream faults this connection alone, - // before `handleConnectionEvent` can record its stream kind or - // decode a frame off it. Throwing here drives the dispatch loop's - // own fault path (`CultMeshQuicNativeRuntime.dispatch`), which - // shuts the connection down and evicts it. - if (!this.acceptClientFrames && event.type === CULTMESH_QUIC_EVENT_STREAM_STARTED) { - throw new Error( - "CultMesh QUIC provider is publish-only (acceptClientFrames: false); it does not accept inbound streams from peers.", - ); - } - transport.handleConnectionEvent(event); - if (attached || event.type !== CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED) return; - attached = true; - clearTimeout(handshakeTimer); - // Cut C's retained-frame seeding happens atomically here, before the - // peer joins `peers`: nothing can observe this transport as an - // attached peer with a not-yet-seeded outbox. - const outbox = new CultMeshQuicRealtimeProviderOutbox(transport, () => transport.dispose()); - for (const { frame, encoded } of this.retained.values()) outbox.publish(frame, encoded); - this.outboxes.set(transport, outbox); - this.peers.add(transport); - if (this.acceptClientFrames) void this.pumpReceivedFrom(transport); - }, - (error) => transport.fault(error), - ); - } -} - -/** `CultMeshStaticRealtimeLookupSource`: an array of pre-resolved Verse descriptors. */ -export class CultMeshStaticRealtimeLookupSource implements ICultMeshRealtimeLookupSource { - private readonly verses: readonly CultMeshVerseDescriptorMessage[]; - - constructor(verses: readonly CultMeshVerseDescriptorMessage[]) { - this.verses = verses; - } - - async resolve(target: CultMeshRealtimeTarget): Promise { - return this.verses.filter((verse) => verse.verseId === target.verseId); - } -} - -/** Options for `CultMeshQuicRealtimeSessionManager`. */ -export interface CultMeshQuicRealtimeSessionManagerOptions { - readonly lookupSource: ICultMeshRealtimeLookupSource; - readonly trust: CultMeshAuthorityTrustPolicy; - readonly connectors: readonly CultMeshRealtimeTransportConnector[]; - readonly maxRacedCandidates?: number; -} - -/** - * Binds an advertised route to a connected, authority-verified realtime - * transport. Rule for rule, this is `CultMeshSessionManager`'s realtime path - * in the C# reference (`ConnectRealtimeAsync`, `:833-896`). - */ -export class CultMeshQuicRealtimeSessionManager { - private readonly options: CultMeshQuicRealtimeSessionManagerOptions; - private readonly sessions = new Map(); - /** Single-flight: concurrent `connect()` calls for the same key share one dial. */ - private readonly connecting = new Map>(); - /** One controller per in-flight dial, so `dispose()` and `disconnect()` can cancel it. */ - private readonly dialAborts = new Map(); - private disposed = false; - - constructor(options: CultMeshQuicRealtimeSessionManagerOptions) { - if (options.connectors.length === 0) { - throw new Error("CultMeshQuicRealtimeSessionManager requires at least one realtime connector."); - } - if ((options.maxRacedCandidates ?? 2) <= 0) { - throw new Error("maxRacedCandidates must be positive."); - } - this.options = options; - } - - private sessionKey(target: CultMeshRealtimeTarget): string { - return target.verseId + "\u001f" + target.authorityRuntimeId; - } - - /** - * Resolves, verifies, races and connects; disposes losing candidates. - * Returns the cached session for `target` when one is live. Concurrent - * calls for the same target share one dial (mirrors C#'s `Lazy`); a - * transport that dies later (peer shutdown, an internal fault, or explicit - * disposal) evicts itself from the cache so the next call redials, mirroring - * `CultMeshSessions.InvalidateRealtimeSession` (`:898-913`). - */ - async connect(target: CultMeshRealtimeTarget): Promise { - if (this.disposed) throw new Error("CultMeshQuicRealtimeSessionManager is disposed."); - const key = this.sessionKey(target); - const existing = this.sessions.get(key); - if (existing) return existing; - - const inFlight = this.connecting.get(key); - if (inFlight) return inFlight; - - const controller = new AbortController(); - this.dialAborts.set(key, controller); - const attempt = this.connectOwnedAsync(key, target, controller.signal).finally(() => { - this.connecting.delete(key); - if (this.dialAborts.get(key) === controller) this.dialAborts.delete(key); - }); - this.connecting.set(key, attempt); - return attempt; - } - - private async connectOwnedAsync( - key: string, - target: CultMeshRealtimeTarget, - signal: AbortSignal, - ): Promise { - const transport = await this.dialAsync(key, target, signal); - if (this.disposed || signal.aborted) { - transport.dispose(); - throw new Error( - this.disposed - ? "CultMeshQuicRealtimeSessionManager was disposed while connecting." - : `Dial for '${key}' was cancelled while connecting.`, - ); - } - this.sessions.set(key, transport); - transport.onDisposed?.(() => { - if (this.sessions.get(key) === transport) this.sessions.delete(key); - }); - return transport; - } - - private async dialAsync( - key: string, - target: CultMeshRealtimeTarget, - signal: AbortSignal, - ): Promise { - const verses = await this.options.lookupSource.resolve(target); - const routes = verses - .flatMap((verse) => verse.authorityRoutes ?? []) - .filter((route) => route.authorityRuntimeId === target.authorityRuntimeId) - .filter((route) => route.protocolIds.includes(CULTMESH_REALTIME_STATE_PROTOCOL_ID)); - if (routes.length === 0) { - throw new Error(`No realtime state route was advertised for '${key}'.`); - } - - const verifiedCandidates: CultMeshRealtimeCandidate[] = []; - const verificationFailures: Error[] = []; - for (const route of routes) { - const candidate: CultMeshRealtimeCandidate = { - endpoint: route.endpoint, - authorityRuntimeId: route.authorityRuntimeId, - priority: route.priority, - generation: trimCSharp(route.generation), - }; - try { - await verifyAuthorityRoute( - { - verseId: target.verseId, - authorityRuntimeId: route.authorityRuntimeId, - endpoint: route.endpoint, - protocolIds: route.protocolIds, - priority: route.priority, - generation: route.generation, - certificate: routeCertificateView(route.certificate), - }, - this.options.trust, - ); - verifiedCandidates.push(candidate); - } catch (error) { - verificationFailures.push(error instanceof Error ? error : new Error(String(error))); - } - } - if (verifiedCandidates.length === 0) { - throw new Error( - `No trusted realtime route remained for '${key}': ${verificationFailures.map((error) => error.message).join("; ")}`, - ); - } - - const tiers = new Map>(); - for (const candidate of verifiedCandidates) { - for (const connector of this.options.connectors) { - if (!connector.canConnect(candidate)) continue; - const tier = connector.priority; - const list = tiers.get(tier) ?? []; - list.push({ candidate, connector }); - tiers.set(tier, list); - } - } - if (tiers.size === 0) { - throw new Error(`No realtime connector supports an advertised route for '${key}'.`); - } - - const maxRaced = this.options.maxRacedCandidates ?? 2; - const failures: Error[] = []; - for (const tier of [...tiers.keys()].sort((a, b) => a - b)) { - if (signal.aborted) break; - const entries = [...(tiers.get(tier) ?? [])] - .sort((a, b) => a.candidate.priority - b.candidate.priority) - .slice(0, maxRaced); - const winner = await this.raceFirstSuccessAsync(key, target, entries, failures, signal); - if (winner) return winner; - } - throw new Error( - `No realtime state path connected for '${key}': ${failures.map((error) => error.message).join("; ")}`, - ); - } - - /** - * Races one tier's candidates and returns the first verified success, - * mirroring C#'s `Task.WhenAny` loop (`:851-866`): as soon as one candidate - * both connects and proves `isVerifiedFor`, the rest are aborted (an - * `AbortSignal` is threaded into `connector.connect`) and disposed once - * they settle, without blocking the return. `Promise.allSettled` here would - * instead wait out every candidate, including a dead route stalled on its - * own handshake timeout. - */ - private async raceFirstSuccessAsync( - key: string, - target: CultMeshRealtimeTarget, - entries: ReadonlyArray<{ candidate: CultMeshRealtimeCandidate; connector: CultMeshRealtimeTransportConnector }>, - failures: Error[], - outerSignal: AbortSignal, - ): Promise { - const controller = new AbortController(); - if (outerSignal.aborted) controller.abort(); - else outerSignal.addEventListener("abort", () => controller.abort(), { once: true }); - const pending = new Set( - entries.map((entry) => this.connectCandidateAsync(key, target, entry, controller.signal)), - ); - let winner: CultMeshRealtimeTransport | undefined; - while (pending.size > 0 && !winner) { - const settled = await Promise.race( - [...pending].map((attempt) => - attempt.then( - (value) => ({ attempt, ok: true as const, value }), - (error) => ({ attempt, ok: false as const, error }), - ), - ), - ); - pending.delete(settled.attempt); - if (settled.ok) { - winner = settled.value; - } else { - failures.push(settled.error instanceof Error ? settled.error : new Error(String(settled.error))); - } - } - if (!winner) return undefined; - - controller.abort(); - // The remaining candidates may already be in flight past the point an - // abort signal helps; dispose whatever they eventually produce, without - // blocking the winner's return. - for (const loser of pending) { - loser.then((transport) => transport.dispose()).catch(() => {}); - } - return winner; - } - - private async connectCandidateAsync( - key: string, - target: CultMeshRealtimeTarget, - entry: { candidate: CultMeshRealtimeCandidate; connector: CultMeshRealtimeTransportConnector }, - signal: AbortSignal, - ): Promise { - const transport = await entry.connector.connect(entry.candidate, target, signal); - if (!transport.isVerifiedFor(target.verseId, target.authorityRuntimeId, CULTMESH_REALTIME_STATE_PROTOCOL_ID, entry.candidate.generation)) { - transport.dispose(); - throw new Error(`Transport did not prove CultMesh authority for '${key}'.`); - } - return transport; - } - - /** - * Marks the session for `target` offline, disposing its transport. Also - * cancels a dial still in flight for `target`, so a connect racing this - * call cannot cache a transport this call meant to discard. - */ - disconnect(target: CultMeshRealtimeTarget): void { - const key = this.sessionKey(target); - const transport = this.sessions.get(key); - if (transport) { - this.sessions.delete(key); - transport.dispose(); - } - this.dialAborts.get(key)?.abort(); - } - - dispose(): void { - this.disposed = true; - for (const controller of this.dialAborts.values()) controller.abort(); - this.dialAborts.clear(); - for (const transport of this.sessions.values()) transport.dispose(); - this.sessions.clear(); - } -} diff --git a/packages/cultmesh-ts/src/realtime-wire.ts b/packages/cultmesh-ts/src/realtime-wire.ts deleted file mode 100644 index fa647133..00000000 --- a/packages/cultmesh-ts/src/realtime-wire.ts +++ /dev/null @@ -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)), - }; -} diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 7d0c43c4..16d51e1e 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -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", ); @@ -2547,44 +2543,6 @@ test("CultMesh TS branded facade exposes schema and shard catalogs", () => { ); }); -async function rudpClientBoundHost(endpoint: string, bindHost?: string): Promise { - const client = await CultMesh.createRudpClient("cultmesh-ts-bind-client", 0x10203060, endpoint, { bindHost }); - try { - return client.profile.transports[0]?.host; - } finally { - client.close(); - } -} - -// A client bound to loopback cannot send to another host on Windows, so a -// client for a remote endpoint binds the unspecified address of its family. -test("CultMesh TS RUDP client for a remote endpoint binds the unspecified address", async () => { - assert.equal(await rudpClientBoundHost("rudp://10.77.0.1:17872"), "0.0.0.0"); - assert.equal(await rudpClientBoundHost("rudp://[2001:db8::1]:17872"), "::"); -}); - -test("CultMesh TS RUDP client for a loopback endpoint binds loopback", async () => { - assert.equal(await rudpClientBoundHost("rudp://127.0.0.1:17872"), "127.0.0.1"); - assert.equal(await rudpClientBoundHost("rudp://[::1]:17872"), "::1"); -}); - -test("CultMesh TS RUDP client binds an explicit bindHost whatever the endpoint", async () => { - assert.equal(await rudpClientBoundHost("rudp://10.77.0.1:17872", "127.0.0.1"), "127.0.0.1"); - assert.equal(await rudpClientBoundHost("rudp://127.0.0.1:17872", "0.0.0.0"), "0.0.0.0"); -}); - -test("CultMesh TS RUDP server binds loopback unless told otherwise", async () => { - const loopback = await CultMesh.createRudpServer("cultmesh-ts-bind-server", 0x10203061); - const any = await CultMesh.createRudpServer("cultmesh-ts-bind-server", 0x10203062, { bindHost: "0.0.0.0" }); - try { - assert.equal(loopback.profile.transports[0]?.host, "127.0.0.1"); - assert.equal(any.profile.transports[0]?.host, "0.0.0.0"); - } finally { - loopback.close(); - any.close(); - } -}); - test("CultMesh TS branded facade creates RUDP clients from peer endpoints", async () => { const connectionId = 0x10203044; const server = await CultMesh.createRudpServer( diff --git a/packages/cultmesh-ts/test/fixtures/README.md b/packages/cultmesh-ts/test/fixtures/README.md deleted file mode 100644 index 70573ff2..00000000 --- a/packages/cultmesh-ts/test/fixtures/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# QUIC test fixtures - -`quic-test.p12` and `quic-test-cert.der` are a self-signed P-256 credential -used only to open a loopback MsQuic listener in tests -(`realtime-quic-native.test.ts`, `realtime-quic-consumer.test.ts`). Not a -production credential; no private key outside this pair signs anything real. - -`quic-test-cert.der`'s subject is `CN=cultmesh-quic-native-tests`, and its -SHA-256 is what the tests pin as the expected certificate pin -(`cert-sha256`). - -Generated with (mirrors the recipe the cut map records at P13): - -```sh -openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \ - -keyout quic-test-key.pem -out quic-test-cert.pem \ - -subj "/CN=cultmesh-quic-native-tests" -days 3650 - -openssl x509 -in quic-test-cert.pem -outform der -out quic-test-cert.der - -openssl pkcs12 -export -inkey quic-test-key.pem -in quic-test-cert.pem \ - -out quic-test.p12 -passout pass: -``` - -Regenerate the same way if the pair ever needs replacing; nothing here -depends on today's exact bytes past what the tests read back out (the DER -cert and its SHA-256, and a PKCS12 the native bridge can load). diff --git a/packages/cultmesh-ts/test/fixtures/quic-test-cert.der b/packages/cultmesh-ts/test/fixtures/quic-test-cert.der deleted file mode 100644 index 5cce6f94..00000000 Binary files a/packages/cultmesh-ts/test/fixtures/quic-test-cert.der and /dev/null differ diff --git a/packages/cultmesh-ts/test/fixtures/quic-test.p12 b/packages/cultmesh-ts/test/fixtures/quic-test.p12 deleted file mode 100644 index e9d03ce6..00000000 Binary files a/packages/cultmesh-ts/test/fixtures/quic-test.p12 and /dev/null differ diff --git a/packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts b/packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts deleted file mode 100644 index aadd1265..00000000 --- a/packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts +++ /dev/null @@ -1,234 +0,0 @@ -// The TypeScript half of the QUIC realtime interop lane. Two modes: -// -// `dial` (Cut 4): a one-shot process that dials an advertised -// `cultmesh-state+quic://` endpoint with the native connector, receives -// `--expect` frames from a peer (normally the C# managed provider started by -// `cultnet-interop.test.ts`'s "CultMesh QUIC realtime" test), and prints them -// as one JSON line. -// -// `serve` (Cut 5): the StreamPixels role. Opens a `CultMeshQuicRealtimeProvider` -// listener, prints `{status: "ready", endpoint}` (the same shape the C# -// `quic-realtime-serve` peer prints), waits for a first peer before -// broadcasting (mirroring `QuicRealtimeServeAsync`'s -// `WaitForFirstConnectionAsync`, since a broadcast to nobody would otherwise -// silently no-op), then broadcasts `--frames` frames at `--delivery` with -// `--interval-ms` between them, watching stdin for EOF as its own shutdown -// signal like the C# peer's `WatchStdinCloseAsync`. `--linger-ms` (default 0) -// keeps the listener open that long after broadcasting finishes, so the -// harness can dial a peer late and exercise Cut C's retained-state seed -// instead of the provider always tearing down the moment it is done sending. -// -// Neither mode imports `cultmesh-ts` itself (the harness drives processes, -// and the dependency direction is `cultmesh-ts` -> `cultnet-ts`, never the -// reverse); this script reaches the provider/connector through its own -// package's `src/`. - -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import process from "node:process"; - -import { - CultMeshQuicRealtimeConnector, - CultMeshQuicRealtimeProvider, - type CultMeshRealtimeTarget, -} from "../../src/realtime-quic"; -import { encodeRealtimeFrame, type CultMeshRealtimeDelivery, type CultMeshRealtimeFrame } from "../../src/realtime-wire"; - -// `__dirname` at runtime is `dist-test/test/interop`; fixtures are binary and -// are never compiled/copied there, so they are read from their source -// location, three levels up. The same self-signed test credential the unit -// tests use (`test/fixtures/README.md`): not a production credential. -const FIXTURE_P12 = join(__dirname, "..", "..", "..", "test", "fixtures", "quic-test.p12"); - -function parseArgs(argv: readonly string[]): Map { - const args = new Map(); - for (let index = 0; index < argv.length; index += 1) { - const token = argv[index]; - if (!token.startsWith("--")) continue; - const name = token.slice(2); - const value = argv[index + 1]; - if (!value || value.startsWith("--")) throw new Error(`Missing value for --${name}`); - args.set(name, value); - index += 1; - } - return args; -} - -function requireArg(args: Map, name: string): string { - const value = args.get(name); - if (!value) throw new Error(`Missing required argument --${name}`); - return value; -} - -function optionalIntArg(args: Map, name: string, fallback: number): number { - const raw = args.get(name); - if (!raw) return fallback; - const parsed = Number.parseInt(raw, 10); - if (!Number.isInteger(parsed)) throw new Error(`Argument --${name} must be an integer.`); - return parsed; -} - -function writeJsonLine(value: unknown): void { - process.stdout.write(`${JSON.stringify(value)}\n`); -} - -function writeLog(event: string, payload: Record): void { - process.stderr.write(`${JSON.stringify({ event, ...payload })}\n`); -} - -function watchStdinCloseAsync(): Promise { - return new Promise((resolve) => { - process.stdin.resume(); - process.stdin.once("end", resolve); - }); -} - -async function serveAsync(args: Map): Promise { - const frames = optionalIntArg(args, "frames", 5); - const deliveryArg = args.get("delivery") ?? "latest-only"; - if (deliveryArg !== "latest-only" && deliveryArg !== "reliable-ordered") { - throw new Error(`Unsupported --delivery '${deliveryArg}'.`); - } - const delivery = deliveryArg as CultMeshRealtimeDelivery; - const intervalMs = optionalIntArg(args, "interval-ms", 50); - const lingerMs = optionalIntArg(args, "linger-ms", 0); - const port = optionalIntArg(args, "port", 0); - - const provider = await CultMeshQuicRealtimeProvider.listen({ - host: "127.0.0.1", - port, - serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, - }); - writeJsonLine({ status: "ready", endpoint: provider.advertisedEndpoint }); - - try { - // A broadcast to zero peers is not an error, but it sends nothing: - // matching the C# `quic-realtime-serve` peer, wait for a first - // connection, bounded, so a peer that never shows up still exits. - const connectDeadline = Date.now() + 30_000; - while (provider.connectionCount === 0) { - if (Date.now() >= connectDeadline) throw new Error("quic-realtime-serve: no client connected within 30000ms."); - await new Promise((resolve) => setTimeout(resolve, 20)); - } - - const stdinClosed = watchStdinCloseAsync(); - let stopped = false; - void stdinClosed.then(() => { - stopped = true; - }); - for (let sequence = 1; sequence <= frames && !stopped; sequence += 1) { - const frame: CultMeshRealtimeFrame = { - channelId: "interop.quic-realtime", - schemaId: "gamecult.interop.quic_realtime_frame.v0", - bodyId: "interop:quic-realtime:frame", - producerEpoch: 1n, - sequence: BigInt(sequence), - delivery, - payload: Buffer.from(`frame-${sequence}`, "utf8"), - }; - await provider.broadcast(frame); - // Printed so the harness can compare this exact frame's TypeScript - // encoding against the same frame's C#-side re-encoding (golden bytes - // across processes), without the harness importing the codec itself. - writeLog("quic-realtime-serve", { sent: sequence, hex: Buffer.from(encodeRealtimeFrame(frame)).toString("hex") }); - if (intervalMs > 0) await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } - if (lingerMs > 0 && !stopped) await new Promise((resolve) => setTimeout(resolve, lingerMs)); - } finally { - provider.dispose(); - // Mirrors the C# peer returning from `QuicRealtimeServeAsync`: once - // broadcasting is done (or stdin closed early), let the process exit on - // its own instead of keeping the event loop alive on a stdin that may - // never close. The harness still ends/kills this process explicitly in - // its own cleanup; this only lets a plain manual run exit cleanly too. - process.stdin.pause(); - } -} - -async function main(): Promise { - const [mode, ...rest] = process.argv.slice(2); - if (mode === "serve") { - await serveAsync(parseArgs(rest)); - return; - } - if (mode !== "dial") throw new Error("Expected mode: dial | serve"); - const args = parseArgs(rest); - const endpoint = requireArg(args, "endpoint"); - const expect = optionalIntArg(args, "expect", 1); - // Alternative stop condition to --expect: keep receiving (through whatever - // the consumer's own coalescing inbox delivers) until a frame with this - // sequence arrives, then stop. Lets a caller prove convergence under - // latest-only coalescing, where the frame *count* a stalled reader sees is - // not the interesting number, but the final value it converges on is. - const expectSequenceRaw = args.get("expect-sequence"); - const expectSequence = expectSequenceRaw !== undefined ? BigInt(expectSequenceRaw) : undefined; - const timeoutMs = optionalIntArg(args, "timeout-ms", 15_000); - // Simulates "a test hook on the runtime that stops calling next_event" - // (spec section 10, LatestOnly-under-a-stalled-consumer lane) at the level - // this subprocess can reach: after receiving --pause-after frames, stop - // calling receiveFrame for --pause-ms. The shared native pump keeps - // dispatching and coalescing into this transport's own inbox regardless - // (Cut 4), so not reading is externally equivalent to not polling. - const pauseAfter = optionalIntArg(args, "pause-after", -1); - const pauseMs = optionalIntArg(args, "pause-ms", 0); - - const target: CultMeshRealtimeTarget = { verseId: "interop", authorityRuntimeId: "interop.csharp-provider" }; - const connector = new CultMeshQuicRealtimeConnector(); - const transport = await connector.connect( - { endpoint, authorityRuntimeId: target.authorityRuntimeId, priority: 0, generation: "interop" }, - target, - ); - writeLog("connected", { endpoint, transportId: transport.transportId }); - - const frames: Array<{ - channelId: string; - schemaId: string; - bodyId: string; - producerEpoch: string; - sequence: string; - delivery: string; - payloadHex: string; - }> = []; - try { - const deadline = Date.now() + timeoutMs; - let lastSequence: bigint | undefined; - while ( - (expectSequence === undefined && frames.length < expect) || - (expectSequence !== undefined && lastSequence !== expectSequence) - ) { - if (pauseAfter >= 0 && frames.length === pauseAfter) { - writeLog("pausing", { afterFrames: frames.length, pauseMs }); - await new Promise((resolve) => setTimeout(resolve, pauseMs)); - writeLog("resumed", { afterFrames: frames.length }); - } - const remaining = deadline - Date.now(); - if (remaining <= 0) throw new Error(`Timed out after receiving ${frames.length} frames (last sequence ${lastSequence}).`); - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), remaining); - try { - const frame = await transport.receiveFrame(controller.signal); - lastSequence = frame.sequence; - frames.push({ - channelId: frame.channelId, - schemaId: frame.schemaId, - bodyId: frame.bodyId, - producerEpoch: frame.producerEpoch.toString(), - sequence: frame.sequence.toString(), - delivery: frame.delivery, - payloadHex: Buffer.from(frame.payload).toString("hex"), - }); - } finally { - clearTimeout(timer); - } - } - } finally { - transport.dispose(); - } - - writeJsonLine({ endpoint, transportId: transport.transportId, frames }); -} - -main().catch((error) => { - writeLog("fatal", { error: error instanceof Error ? (error.stack ?? error.message) : String(error) }); - process.exitCode = 1; -}); diff --git a/packages/cultmesh-ts/test/provider-rudp-transport.test.ts b/packages/cultmesh-ts/test/provider-rudp-transport.test.ts index f1dd5d2d..5b44c84b 100644 --- a/packages/cultmesh-ts/test/provider-rudp-transport.test.ts +++ b/packages/cultmesh-ts/test/provider-rudp-transport.test.ts @@ -8,7 +8,6 @@ import { decodeRudpPacket, encodeRudpPacket, type CultNetOperationRequestMessage, - type CultNetRudpPacket, } from "cultnet-ts"; import { @@ -321,80 +320,6 @@ test("a replaced RUDP generation drops operations queued behind an active handle } }); -test("a client restarted on the same address with the same Connect payload replaces its RUDP session, and a retransmitted Connect does not", async () => { - const connectionId = 0x43554c54; - let closedSessions = 0; - const server = CultMesh.createRudpDocumentServer("odin-restart-test", connectionId, { - bindPort: 0, - documents: new CultNetDocumentRegistry(), - onSessionClosed: () => { closedSessions += 1; }, - onError: () => undefined, - }); - await server.start(); - const socket = createSocket("udp4"); - const received: CultNetRudpPacket[] = []; - socket.on("message", wire => received.push(decodeRudpPacket(wire))); - const toServer = (packet: CultNetRudpPacket) => - socket.send(encodeRudpPacket(packet), server.bind.port, "127.0.0.1"); - const evidence = Buffer.from("same connect payload"); - try { - const first = new CultNetRudpSession({ connectionId, initialSequence: 50_000 }); - const connect = first.createConnect(Date.now(), evidence); - toServer(connect); - await waitFor(() => received.some(packet => packet.packetType === "accept"), "the first Accept"); - - // A retransmitted Connect repeats: the session stays. - toServer(connect); - await new Promise(resolve => setTimeout(resolve, 100)); - assert.equal(closedSessions, 0, "a retransmitted Connect closed the session"); - - // The same client restarted: another sequence, the same payload. - received.length = 0; - const second = new CultNetRudpSession({ connectionId, initialSequence: 7 }); - toServer(second.createConnect(Date.now(), evidence)); - await waitFor(() => closedSessions === 1, "the old RUDP session to close"); - await waitFor(() => received.some(packet => packet.packetType === "accept"), "the restarted client's Accept"); - } finally { - socket.close(); - server.close(); - } -}); - -test("the RUDP document server does not acknowledge by name a reliable packet its session did not receive", async () => { - const connectionId = 0x43554c55; - const server = CultMesh.createRudpDocumentServer("odin-ack-test", connectionId, { - bindPort: 0, - documents: new CultNetDocumentRegistry(), - onError: () => undefined, - }); - await server.start(); - const socket = createSocket("udp4"); - const received: CultNetRudpPacket[] = []; - socket.on("message", wire => received.push(decodeRudpPacket(wire))); - const toServer = (packet: CultNetRudpPacket) => - socket.send(encodeRudpPacket(packet), server.bind.port, "127.0.0.1"); - try { - const client = new CultNetRudpSession({ connectionId, initialSequence: 50_000 }); - const connect = client.createConnect(Date.now()); - toServer(connect); - await waitFor(() => received.some(packet => packet.packetType === "accept"), "the Accept"); - received.length = 0; - - // A reliable packet the session ignores (a client does not send an Accept). - // The session never received it, so the Ack must not name it. - const ignored: CultNetRudpPacket = { ...client.createAck(), packetType: "accept", reliable: true, sequence: connect.sequence + 5 }; - toServer(ignored); - await waitFor(() => received.some(packet => packet.packetType === "ack"), "the acknowledgement"); - const ack = received.find(packet => packet.packetType === "ack")!; - const names = ack.ack === ignored.sequence - || Array.from({ length: 32 }, (_, bit) => bit).some(bit => (ack.ackMask & (1 << bit)) !== 0 && ack.ack - bit - 1 === ignored.sequence); - assert.equal(names, false, "the server acknowledged by name a packet its session refused"); - } finally { - socket.close(); - server.close(); - } -}); - function deferred(): { promise: Promise; resolve(value: T): void } { let resolve!: (value: T) => void; return { promise: new Promise(done => resolve = done), resolve }; diff --git a/packages/cultmesh-ts/test/realtime-quic-consumer.test.ts b/packages/cultmesh-ts/test/realtime-quic-consumer.test.ts deleted file mode 100644 index 8a2063fe..00000000 --- a/packages/cultmesh-ts/test/realtime-quic-consumer.test.ts +++ /dev/null @@ -1,1074 +0,0 @@ -// The TypeScript QUIC realtime consumer, proven two ways: directly against a -// raw listener opened through the native runtime (no provider semantics yet, -// mirrors `CultMeshNativeQuicRealtimeTransportTests.cs`), and through -// `CultMeshQuicRealtimeSessionManager` with a static lookup source and routes -// signed with `node:crypto`, mirroring `CultMeshQuicRealtimeTransportTests.cs` -// and `CultMeshSessions.cs`'s realtime binding. -// -// Skips instead of failing when no native bridge is available; see -// `realtime-quic-native.test.ts` for why. - -import assert from "node:assert/strict"; -import { createHash, generateKeyPairSync, sign as cryptoSign, type KeyObject } from "node:crypto"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import test from "node:test"; - -import { - canonicalRoute, - type CultMeshAuthorityRouteView, - type CultMeshAuthorityTrustPolicy, - type CultMeshP256PublicKey, -} from "cultnet-ts"; -import type { CultMeshAuthorityRouteMessage, CultMeshVerseDescriptorMessage } from "cultnet-ts/contracts"; - -import { - CultMeshQuicNativeRuntime, - CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION, - CULTMESH_QUIC_STREAM_LATEST_ONLY, - CULTMESH_QUIC_STREAM_RELIABLE, -} from "../src/realtime-quic-native"; -import { - CultMeshQuicRealtimeConnector, - CultMeshQuicRealtimeSessionManager, - CultMeshStaticRealtimeLookupSource, - CULTMESH_REALTIME_STATE_PROTOCOL_ID, - type CultMeshQuicRealtimeTransport, - type CultMeshRealtimeCandidate, - type CultMeshRealtimeTarget, - type CultMeshRealtimeTransport, - type CultMeshRealtimeTransportConnector, -} from "../src/realtime-quic"; -import { encodeRealtimeFrame, type CultMeshRealtimeFrame } from "../src/realtime-wire"; -import { nativeBridgeAvailable } from "./support/native-bridge"; - -// __dirname at runtime is dist-test/test; fixtures are binary and are never -// compiled/copied there, so they are read from their source location, two -// levels up. -const FIXTURE_P12 = join(__dirname, "..", "..", "test", "fixtures", "quic-test.p12"); -const FIXTURE_DER = join(__dirname, "..", "..", "test", "fixtures", "quic-test-cert.der"); - -function fixturePinHex(): string { - return createHash("sha256").update(readFileSync(FIXTURE_DER)).digest("hex"); -} - -/** A bare loopback listener over the raw native runtime, no provider semantics. */ -class RawQuicListener { - private constructor( - private readonly runtime: CultMeshQuicNativeRuntime, - private readonly listenerId: bigint, - readonly boundPort: number, - ) {} - - static async open(): Promise { - const runtime = await CultMeshQuicNativeRuntime.open(); - try { - const pkcs12 = readFileSync(FIXTURE_P12); - const { listenerId, boundPort } = runtime.listenerOpen("127.0.0.1", 0, pkcs12, ""); - return new RawQuicListener(runtime, listenerId, boundPort); - } catch (error) { - // A runtime opened above and never handed to a caller would otherwise - // leak: the shared runtime's refcount never reaches zero, its MsQuic - // registration and worker threads never tear down, and a process that - // hits this on every one of several tests never exits on its own. - await runtime.release(); - throw error; - } - } - - /** Waits for one inbound connection and returns its native connection id. */ - async acceptOnce(): Promise { - return await new Promise((resolve) => { - this.runtime.onListenerEvent(this.listenerId, (event) => { - if (event.type === CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION) resolve(event.connectionId); - }); - }); - } - - /** Sends one frame to an accepted connection on a fresh stream of `kind`. */ - sendFrame(connectionId: bigint, frame: CultMeshRealtimeFrame, kind: number): void { - const streamId = this.runtime.streamOpen(connectionId, kind); - this.runtime.streamSendFrame(streamId, encodeRealtimeFrame(frame), true); - } - - /** Sends arbitrary bytes (not necessarily a valid encoded frame) on a fresh stream of `kind`. */ - sendRaw(connectionId: bigint, bytes: Uint8Array, kind: number): void { - const streamId = this.runtime.streamOpen(connectionId, kind); - this.runtime.streamSendFrame(streamId, bytes, true); - } - - /** Shuts down one accepted connection from this side, as a peer disconnect. */ - shutdownConnection(connectionId: bigint, code = 0n): void { - this.runtime.connectionShutdown(connectionId, code); - } - - async close(): Promise { - this.runtime.listenerClose(this.listenerId); - await this.runtime.release(); - } -} - -/** - * `assert.rejects`, but safe against a mutant or a real regression that - * makes the promise resolve instead: a transport that unexpectedly connects - * is disposed before the assertion fails, rather than left live with an open - * native connection and an outstanding runtime reference. A bare - * `assert.rejects(connector.connect(...))` hung the whole file exactly this - * way under M1 (the certificate-accept mutation): three tests failed their - * assertion correctly, but each left a connected, undisposed transport - * behind, and Node's test runner refuses to exit while any promise or - * native handle is still outstanding. - */ -async function assertRejectsAndDisposes( - promise: Promise<{ dispose(): void }>, - match?: RegExp, -): Promise { - let resolved: { dispose(): void } | undefined; - try { - resolved = await promise; - } catch (error) { - if (match) assert.match(error instanceof Error ? error.message : String(error), match); - return; - } - resolved.dispose(); - assert.fail("expected the promise to reject, but it resolved (the resolved value has been disposed)"); -} - -/** - * `transport.receiveFrame()`, bounded: a fault-path regression that leaves a - * malformed frame undelivered-but-not-rejected would otherwise hang this - * call (and, through a bare `assert.rejects`, the whole test file) forever, - * rather than fail the one assertion it was supposed to fail. - */ -function receiveFrameOrTimeout(transport: { receiveFrame(signal?: AbortSignal): Promise }, ms = 3_000): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), ms); - return transport.receiveFrame(controller.signal).finally(() => clearTimeout(timer)); -} - -function testFrame(overrides: Partial = {}): CultMeshRealtimeFrame { - return { - channelId: "aetheria.entities", - schemaId: "eve.entity_soa.v1", - bodyId: "body:aetheria:entities", - producerEpoch: 7n, - sequence: 1n, - delivery: "latest-only", - payload: new Uint8Array([3, 4, 5]), - ...overrides, - }; -} - -test("connect with the correct pin succeeds and receives a frame", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - const accepted = listener.acceptOnce(); - const transport = await connector.connect(candidate, target); - try { - assert.equal(transport.transportId, "msquic-realtime"); - const connectionId = await accepted; - listener.sendFrame(connectionId, testFrame(), CULTMESH_QUIC_STREAM_LATEST_ONLY); - const received = await transport.receiveFrame(); - assert.equal(received.bodyId, "body:aetheria:entities"); - assert.equal(received.sequence, 1n); - } finally { - transport.dispose(); - } - } finally { - await listener.close(); - } -}); - -test("connect refuses a wrong advertised pin", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${"0".repeat(64)}`, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - await assertRejectsAndDisposes(connector.connect(candidate, target), /certificate|rejected/i); - } finally { - await listener.close(); - } -}); - -test("connect refuses a missing certificate pin when no validator is supplied", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}`, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - await assertRejectsAndDisposes(connector.connect(candidate, target), /certificate|rejected/i); - } finally { - await listener.close(); - } -}); - -test("a caller-supplied validator observes the target identity and the DER bytes", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}`, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - let observedTarget: CultMeshRealtimeTarget | undefined; - let observedDerLength = 0; - const connector = new CultMeshQuicRealtimeConnector({ - validateProviderCertificate: (validatorTarget, der) => { - observedTarget = validatorTarget; - observedDerLength = der.byteLength; - return true; - }, - }); - const transport = await connector.connect(candidate, target); - transport.dispose(); - assert.deepEqual(observedTarget, target); - assert.ok(observedDerLength > 0); - } finally { - await listener.close(); - } -}); - -test("a late older generation is dropped by the receive-side filter", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - const accepted = listener.acceptOnce(); - const transport = await connector.connect(candidate, target); - try { - const connectionId = await accepted; - listener.sendFrame(connectionId, testFrame({ producerEpoch: 8n, sequence: 0n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); - const latest = await transport.receiveFrame(); - assert.equal(latest.producerEpoch, 8n); - assert.equal(latest.sequence, 0n); - - listener.sendFrame(connectionId, testFrame({ producerEpoch: 7n, sequence: 999n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); - const staleTimeout = new AbortController(); - const timer = setTimeout(() => staleTimeout.abort(), 300); - await assert.rejects(transport.receiveFrame(staleTimeout.signal)); - clearTimeout(timer); - } finally { - transport.dispose(); - } - } finally { - await listener.close(); - } -}); - -// --------------------------------------------------------------------------- -// Trust negatives, through the session manager with a static lookup source. -// --------------------------------------------------------------------------- - -function base64UrlToBase64(value: string): string { - let base64 = value.replace(/-/g, "+").replace(/_/g, "/"); - while (base64.length % 4 !== 0) base64 += "="; - return base64; -} - -let odinRootSequence = 0; - -function generateOdinRoot(): { privateKey: KeyObject; publicKey: CultMeshP256PublicKey } { - const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); - const jwk = publicKey.export({ format: "jwk" }) as { x: string; y: string }; - odinRootSequence += 1; - return { - privateKey, - publicKey: { - keyId: `odin-root-${odinRootSequence}`, - x: base64UrlToBase64(jwk.x), - y: base64UrlToBase64(jwk.y), - }, - }; -} - -interface SignedRouteOptions { - readonly verseId: string; - readonly authorityRuntimeId: string; - readonly endpoint: string; - readonly protocolIds: readonly string[]; - readonly privateKey: KeyObject; - readonly odinKeyId: string; - readonly priority?: number; - readonly generation?: string; - readonly issuedAtUnixMilliseconds?: number; - readonly expiresAtUnixMilliseconds?: number; - readonly providerKey?: CultMeshP256PublicKey; - readonly corruptSignature?: boolean; -} - -function signRoute(options: SignedRouteOptions): CultMeshAuthorityRouteMessage { - const priority = options.priority ?? 0; - const generation = options.generation ?? ""; - const issuedAt = options.issuedAtUnixMilliseconds ?? Date.now() - 60_000; - const expiresAt = options.expiresAtUnixMilliseconds ?? Date.now() + 3_600_000; - const providerKey = options.providerKey ?? { keyId: "provider-1", x: "unused", y: "unused" }; - const view: CultMeshAuthorityRouteView = { - verseId: options.verseId, - authorityRuntimeId: options.authorityRuntimeId, - endpoint: options.endpoint, - protocolIds: options.protocolIds, - priority, - generation, - certificate: { - providerKey, - odinKeyId: options.odinKeyId, - issuedAtUnixMilliseconds: issuedAt, - expiresAtUnixMilliseconds: expiresAt, - signature: "", - }, - }; - const payload = canonicalRoute(view); - const signatureBytes = options.corruptSignature - ? Buffer.alloc(64, 0x41) - : (cryptoSign("sha256", Buffer.from(payload), { key: options.privateKey, dsaEncoding: "ieee-p1363" }) as Buffer); - return { - authorityRuntimeId: options.authorityRuntimeId, - endpoint: options.endpoint, - protocolIds: [...options.protocolIds], - priority, - generation, - certificate: { - providerKeyId: providerKey.keyId, - providerPublicKeyX: providerKey.x, - providerPublicKeyY: providerKey.y, - odinKeyId: options.odinKeyId, - issuedAtUnixMilliseconds: issuedAt, - expiresAtUnixMilliseconds: expiresAt, - signature: signatureBytes.toString("base64"), - }, - }; -} - -function verseWithRoute(verseId: string, route: CultMeshAuthorityRouteMessage): CultMeshVerseDescriptorMessage { - return { - verseId, - displayName: verseId, - authorityModel: "operator-cluster", - compatibility: { transportVersion: "cultmesh.v1", rulesHash: "test", compatibleVerseIds: [], requiredPluginIds: [], optionalPluginIds: [] }, - discoveryEndpoints: [route.endpoint], - authorityRuntimeIds: [route.authorityRuntimeId], - authorityRoutes: [route], - }; -} - -const unreachableConnector: CultMeshRealtimeTransportConnector = { - connectorId: "unreachable", - priority: 0, - canConnect: () => true, - connect: async () => { - throw new Error("a trust-refused route must never reach a connector"); - }, -}; - -test("trust negative (a): an expired certificate is refused", async () => { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const root = generateOdinRoot(); - const route = signRoute({ - verseId: target.verseId, - authorityRuntimeId: target.authorityRuntimeId, - endpoint: "cultmesh-state+quic://127.0.0.1:9443", - protocolIds: [CULTMESH_REALTIME_STATE_PROTOCOL_ID], - privateKey: root.privateKey, - odinKeyId: root.publicKey.keyId, - issuedAtUnixMilliseconds: Date.now() - 120_000, - expiresAtUnixMilliseconds: Date.now() - 60_000, - }); - const trust: CultMeshAuthorityTrustPolicy = { mode: "authenticated-remote", odinRoots: [root.publicKey] }; - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoute(target.verseId, route)]), - trust, - connectors: [unreachableConnector], - }); - await assertRejectsAndDisposes(manager.connect(target), /valid|expired/i); -}); - -test("trust negative (b): an unsigned remote route is refused", async () => { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const route: CultMeshAuthorityRouteMessage = { - authorityRuntimeId: target.authorityRuntimeId, - endpoint: "cultmesh-state+quic://198.51.100.7:9443", - protocolIds: [CULTMESH_REALTIME_STATE_PROTOCOL_ID], - priority: 0, - generation: "gen-1", - }; - const trust: CultMeshAuthorityTrustPolicy = { mode: "authenticated-remote", odinRoots: [] }; - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoute(target.verseId, route)]), - trust, - connectors: [unreachableConnector], - }); - await assertRejectsAndDisposes(manager.connect(target), /Odin-signed/i); -}); - -test("trust negative (c): a route signed by an untrusted Odin root is refused", async () => { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const signer = generateOdinRoot(); - const trustedRoot = generateOdinRoot(); - const route = signRoute({ - verseId: target.verseId, - authorityRuntimeId: target.authorityRuntimeId, - endpoint: "cultmesh-state+quic://127.0.0.1:9443", - protocolIds: [CULTMESH_REALTIME_STATE_PROTOCOL_ID], - privateKey: signer.privateKey, - odinKeyId: signer.publicKey.keyId, - }); - const trust: CultMeshAuthorityTrustPolicy = { mode: "authenticated-remote", odinRoots: [trustedRoot.publicKey] }; - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoute(target.verseId, route)]), - trust, - connectors: [unreachableConnector], - }); - await assertRejectsAndDisposes(manager.connect(target), /not trusted/i); -}); - -test("trust negative (d): a route that verifies but pins the wrong certificate is refused at the transport", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const root = generateOdinRoot(); - const route = signRoute({ - verseId: target.verseId, - authorityRuntimeId: target.authorityRuntimeId, - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${"0".repeat(64)}`, - protocolIds: [CULTMESH_REALTIME_STATE_PROTOCOL_ID], - privateKey: root.privateKey, - odinKeyId: root.publicKey.keyId, - }); - const trust: CultMeshAuthorityTrustPolicy = { mode: "authenticated-remote", odinRoots: [root.publicKey] }; - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoute(target.verseId, route)]), - trust, - connectors: [new CultMeshQuicRealtimeConnector()], - }); - await assertRejectsAndDisposes(manager.connect(target), /certificate|rejected/i); - } finally { - await listener.close(); - } -}); - -test("trust negative (e): a transport that fails isVerifiedFor is disposed and rejected", async () => { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const root = generateOdinRoot(); - const route = signRoute({ - verseId: target.verseId, - authorityRuntimeId: target.authorityRuntimeId, - endpoint: "cultmesh-state+quic://127.0.0.1:9443", - protocolIds: [CULTMESH_REALTIME_STATE_PROTOCOL_ID], - privateKey: root.privateKey, - odinKeyId: root.publicKey.keyId, - generation: "route-generation-1", - }); - const trust: CultMeshAuthorityTrustPolicy = { mode: "authenticated-remote", odinRoots: [root.publicKey] }; - - let disposed = false; - const staleTransport: CultMeshRealtimeTransport = { - transportId: "msquic-realtime", - endpoint: route.endpoint, - async sendFrame() {}, - async receiveFrame() { - return testFrame(); - }, - dispose() { - disposed = true; - }, - // A replayed/stale generation: the transport proves a different route - // generation than the one the session manager just verified. - isVerifiedFor: () => false, - }; - const staleConnector: CultMeshRealtimeTransportConnector = { - connectorId: "stale", - priority: 0, - canConnect: () => true, - connect: async () => staleTransport, - }; - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoute(target.verseId, route)]), - trust, - connectors: [staleConnector], - }); - - await assertRejectsAndDisposes(manager.connect(target), /did not prove|No realtime state path/i); - assert.equal(disposed, true, "the session manager must dispose a transport that fails isVerifiedFor"); -}); - -test("M3: a real connected transport's isVerifiedFor rejects a generation other than the one it connected with", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "route-generation-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - const transport = await connector.connect(candidate, target); - try { - assert.equal( - transport.isVerifiedFor(target.verseId, target.authorityRuntimeId, CULTMESH_REALTIME_STATE_PROTOCOL_ID, "route-generation-1"), - true, - "the generation it actually connected with must verify", - ); - assert.equal( - transport.isVerifiedFor(target.verseId, target.authorityRuntimeId, CULTMESH_REALTIME_STATE_PROTOCOL_ID, "route-generation-2"), - false, - "a mutated/replayed generation must not verify against a real transport", - ); - } finally { - transport.dispose(); - } - } finally { - await listener.close(); - } -}); - -test("trust negative (e2): a real transport whose generation no longer matches the freshly verified route is disposed", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const root = generateOdinRoot(); - const signedRoute = signRoute({ - verseId: target.verseId, - authorityRuntimeId: target.authorityRuntimeId, - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, - protocolIds: [CULTMESH_REALTIME_STATE_PROTOCOL_ID], - privateKey: root.privateKey, - odinKeyId: root.publicKey.keyId, - generation: "route-generation-1", - }); - const trust: CultMeshAuthorityTrustPolicy = { mode: "authenticated-remote", odinRoots: [root.publicKey] }; - const realConnector = new CultMeshQuicRealtimeConnector(); - let connectedTransport: CultMeshRealtimeTransport | undefined; - // A real transport, connected for real against the raw listener, but - // stamped with a generation different from the one the session manager - // just verified — the way a stale cached transport would look after its - // route rotated underneath it. - const replayConnector: CultMeshRealtimeTransportConnector = { - connectorId: "replay", - priority: 0, - canConnect: (c) => realConnector.canConnect(c), - connect: async (c, t, signal) => { - const accepted = listener.acceptOnce(); - const transport = await realConnector.connect({ ...c, generation: "route-generation-1-stale" }, t, signal); - await accepted; - connectedTransport = transport; - return transport; - }, - }; - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoute(target.verseId, signedRoute)]), - trust, - connectors: [replayConnector], - }); - - await assertRejectsAndDisposes(manager.connect(target), /did not prove|No realtime state path/i); - assert.ok(connectedTransport, "a real transport must have connected"); - // Real cleanup ran: receiveFrame on the disposed transport rejects. - await assert.rejects(connectedTransport!.receiveFrame(), /disposed/i); - } finally { - await listener.close(); - } -}); - -test("the advertised certificate pin is matched case-insensitively (M2)", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex().toUpperCase()}`, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - const transport = await connector.connect(candidate, target); - transport.dispose(); - } finally { - await listener.close(); - } -}); - -// --------------------------------------------------------------------------- -// Fix batch: pump/dispatch isolation (1), peer-shutdown cleanup (3), the -// bounded receive backlog and stream-kind pruning (6). -// --------------------------------------------------------------------------- - -test("fix 1 / M4: a stream-kind/delivery mismatch faults only that connection; the pump keeps running", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const endpoint = (): string => `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`; - - // A reliable stream carrying a frame whose own delivery byte says - // latest-only: `onStreamFrame` throws, which must fault only this - // connection. - const connectorA = new CultMeshQuicRealtimeConnector(); - const acceptedA = listener.acceptOnce(); - const transportA = await connectorA.connect( - { endpoint: endpoint(), authorityRuntimeId: target.authorityRuntimeId, priority: 0, generation: "gen-1" }, - target, - ); - try { - const connectionIdA = await acceptedA; - listener.sendFrame(connectionIdA, testFrame({ delivery: "latest-only" }), CULTMESH_QUIC_STREAM_RELIABLE); - // Bounded: if the mismatch check regresses (never faults), receiveFrame() - // waits forever for a frame that will not arrive, and a bare - // `assert.rejects` would hang this test, and the whole file, right along - // with it (as M4 did before this bound existed). - await assert.rejects(receiveFrameOrTimeout(transportA), /incompatible delivery semantics/i); - // Future receives on the faulted transport reject immediately, with the - // same error, rather than hanging. - await assert.rejects(receiveFrameOrTimeout(transportA), /incompatible delivery semantics/i); - } finally { - transportA.dispose(); - } - - // A second, independent connection through the same process-wide runtime - // and pump must still work: the fault above did not bring it down. - const connectorB = new CultMeshQuicRealtimeConnector(); - const acceptedB = listener.acceptOnce(); - const transportB = await connectorB.connect( - { endpoint: endpoint(), authorityRuntimeId: target.authorityRuntimeId, priority: 0, generation: "gen-1" }, - target, - ); - try { - const connectionIdB = await acceptedB; - listener.sendFrame(connectionIdB, testFrame(), CULTMESH_QUIC_STREAM_LATEST_ONLY); - const received = await transportB.receiveFrame(); - assert.equal(received.bodyId, "body:aetheria:entities"); - } finally { - transportB.dispose(); - } - } finally { - await listener.close(); - } -}); - -test("M4b: a latest-only stream carrying a reliable-ordered frame faults only that connection", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const endpoint = (): string => `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`; - - // The mirror image of the M4 test above: a latest-only stream carrying - // a frame whose own delivery byte says reliable-ordered. - const connectorA = new CultMeshQuicRealtimeConnector(); - const acceptedA = listener.acceptOnce(); - const transportA = await connectorA.connect( - { endpoint: endpoint(), authorityRuntimeId: target.authorityRuntimeId, priority: 0, generation: "gen-1" }, - target, - ); - try { - const connectionIdA = await acceptedA; - listener.sendFrame(connectionIdA, testFrame({ delivery: "reliable-ordered" }), CULTMESH_QUIC_STREAM_LATEST_ONLY); - await assert.rejects(receiveFrameOrTimeout(transportA), /incompatible delivery semantics/i); - } finally { - transportA.dispose(); - } - - // A second, independent connection still works. - const connectorB = new CultMeshQuicRealtimeConnector(); - const acceptedB = listener.acceptOnce(); - const transportB = await connectorB.connect( - { endpoint: endpoint(), authorityRuntimeId: target.authorityRuntimeId, priority: 0, generation: "gen-1" }, - target, - ); - try { - const connectionIdB = await acceptedB; - listener.sendFrame(connectionIdB, testFrame(), CULTMESH_QUIC_STREAM_LATEST_ONLY); - const received = await transportB.receiveFrame(); - assert.equal(received.bodyId, "body:aetheria:entities"); - } finally { - transportB.dispose(); - } - } finally { - await listener.close(); - } -}); - -test("fix 1: a throwing onDisposed handler cannot crash the pump when a malformed frame faults a connection", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const endpoint = (): string => `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`; - - const connectorA = new CultMeshQuicRealtimeConnector(); - const acceptedA = listener.acceptOnce(); - const transportA = await connectorA.connect( - { endpoint: endpoint(), authorityRuntimeId: target.authorityRuntimeId, priority: 0, generation: "gen-1" }, - target, - ); - // A throwing onDisposed handler, registered before the fault: fix 1 - // must run it inside its own try/catch so it cannot escape cleanup(), - // and must have already released the runtime reference regardless. - let secondHandlerRan = false; - transportA.onDisposed?.(() => { - throw new Error("first onDisposed handler throws on purpose"); - }); - transportA.onDisposed?.(() => { - secondHandlerRan = true; - }); - - try { - const connectionIdA = await acceptedA; - // Random bytes on a reliable stream: not a valid encoded frame, so - // decodeRealtimeFrame throws inside onStreamFrame, which native's - // dispatch loop (fix 1's native half) catches and faults only this - // connection. Bounded: if the fault path regresses and never faults - // this connection, receiveFrame() would otherwise wait forever for a - // frame that will not arrive. - listener.sendRaw(connectionIdA, new Uint8Array([9, 9, 9, 9, 9, 9, 9, 9]), CULTMESH_QUIC_STREAM_RELIABLE); - await assert.rejects(receiveFrameOrTimeout(transportA)); - assert.equal(secondHandlerRan, true, "a throwing handler must not stop the next onDisposed handler from running"); - } finally { - transportA.dispose(); - } - - // The process (and this test file's pump) survives, and a second, - // independent connection through the same runtime still works. - const connectorB = new CultMeshQuicRealtimeConnector(); - const acceptedB = listener.acceptOnce(); - const transportB = await connectorB.connect( - { endpoint: endpoint(), authorityRuntimeId: target.authorityRuntimeId, priority: 0, generation: "gen-1" }, - target, - ); - try { - const connectionIdB = await acceptedB; - listener.sendFrame(connectionIdB, testFrame(), CULTMESH_QUIC_STREAM_LATEST_ONLY); - const received = await transportB.receiveFrame(); - assert.equal(received.bodyId, "body:aetheria:entities"); - } finally { - transportB.dispose(); - } - } finally { - await listener.close(); - } -}); - -test("fix 1: a throwing onDisposed handler on a peer shutdown still releases the runtime and runs every handler", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - const accepted = listener.acceptOnce(); - const transport = await connector.connect(candidate, target); - try { - const connectionId = await accepted; - - let secondHandlerRan = false; - transport.onDisposed?.(() => { - throw new Error("first onDisposed handler throws on purpose"); - }); - transport.onDisposed?.(() => { - secondHandlerRan = true; - }); - - const before = CultMeshQuicNativeRuntime.refCount; - listener.shutdownConnection(connectionId); - // Wait for cleanup() to run (driven by the pump's CONNECTION_SHUTDOWN - // dispatch), rather than asserting immediately. Bounded: if the - // shutdown-cleanup path regresses (F3) and never rejects, this would - // otherwise hang instead of failing the assertion below it. - await assert.rejects(receiveFrameOrTimeout(transport)); - assert.equal( - CultMeshQuicNativeRuntime.refCount, - before - 1, - "the runtime reference must be released even though a handler threw", - ); - assert.equal(secondHandlerRan, true, "every onDisposed handler must still run despite an earlier one throwing"); - } finally { - transport.dispose(); - } - } finally { - await listener.close(); - } -}); - -test("M10: an equal generation delivered after the first was already consumed is dropped, not redelivered", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - const accepted = listener.acceptOnce(); - const transport = await connector.connect(candidate, target); - try { - const connectionId = await accepted; - - // Send one frame and consume it, so the coalescer no longer holds - // anything pending for this key: the generation filter's own record - // (`latestGenerations`) is the only thing left that could still know - // this generation was already seen. Sending a frame back-to-back with - // an unconsumed duplicate would mask a broken filter behind the - // coalescer overwriting the pending value with an identical one; this - // sequencing is what M10 needs to be observable at all (Soul's note). - listener.sendFrame(connectionId, testFrame({ producerEpoch: 1n, sequence: 3n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); - const first = await transport.receiveFrame(); - assert.equal(first.sequence, 3n); - - // The exact same generation, sent again after consumption: the - // filter must drop it outright rather than deliver it as new. - listener.sendFrame(connectionId, testFrame({ producerEpoch: 1n, sequence: 3n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const timeout = new AbortController(); - const timer = setTimeout(() => timeout.abort(), 300); - await assert.rejects( - transport.receiveFrame(timeout.signal), - "an equal generation already consumed must not be redelivered", - ); - clearTimeout(timer); - } finally { - transport.dispose(); - } - } finally { - await listener.close(); - } -}); - -// F7 ("a canceled send must not resolve as success") is covered in -// realtime-quic-native.test.ts, at the native binding layer, not here. -// A consumer-level scenario was attempted first: prime the reliable stream, -// start a second send, and shut the connection down from the peer mid-flight. -// It proved unreliable on this host regardless of payload size or added -// delay before the shutdown call — MsQuic here appears to accept and locally -// complete a `StreamSend` (marking it as a plain, non-canceled -// SEND_COMPLETE) faster than any JS-observable window can reliably land a -// shutdown ahead of it, and a delay long enough to guarantee the shutdown -// wins just lets the send finish first instead. This is recorded as not yet -// reached at the consumer layer, not as unreachable: the native-layer test -// asserts the exact field (`code`, decoded through the same struct decode -// F7 mutates) that this scenario would also exercise, so the fix itself is -// defended; only the end-to-end SEND_CANCELED proof through the consumer's -// own `sendFrame()` promise is the open gap. - -test("fix 3: a peer-initiated shutdown rejects a pending receive and cleans up exactly once", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - const accepted = listener.acceptOnce(); - const transport = await connector.connect(candidate, target); - try { - const connectionId = await accepted; - - // Bounded: this is F3's own mutant target (removing the - // CONNECTION_SHUTDOWN cleanup call), and a broken cleanup path means - // this receive never settles. - const pendingReceive = receiveFrameOrTimeout(transport); - listener.shutdownConnection(connectionId); - await assert.rejects(pendingReceive, /closed by the remote peer/i); - - // Cleanup already ran: a further explicit dispose is a harmless no-op, - // and a fresh receive rejects immediately instead of hanging forever - // (the leak fix 3 exists for). - transport.dispose(); - await assert.rejects(receiveFrameOrTimeout(transport), /closed by the remote peer/i); - } finally { - transport.dispose(); - } - } finally { - await listener.close(); - } -}); - -test("fix 6: a slow latest-only reader accumulates at most one pending frame per key, and an equal generation is dropped", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - const accepted = listener.acceptOnce(); - const transport = await connector.connect(candidate, target); - try { - const connectionId = await accepted; - - // Three increasing-sequence frames for the same (channel, body), - // published while nothing reads: an unbounded queue would deliver all - // three in order; the coalescing inbox keeps only the newest. - listener.sendFrame(connectionId, testFrame({ producerEpoch: 1n, sequence: 1n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); - listener.sendFrame(connectionId, testFrame({ producerEpoch: 1n, sequence: 2n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); - listener.sendFrame(connectionId, testFrame({ producerEpoch: 1n, sequence: 3n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); - // An equal generation, sent last: it must be dropped outright, not - // queued as a fourth pending frame. - listener.sendFrame(connectionId, testFrame({ producerEpoch: 1n, sequence: 3n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); - - // Give the pump time to process all four sends before reading. - await new Promise((resolve) => setTimeout(resolve, 200)); - - const first = await transport.receiveFrame(); - assert.equal(first.sequence, 3n, "only the newest pending frame for the key is kept"); - - const timeout = new AbortController(); - const timer = setTimeout(() => timeout.abort(), 300); - await assert.rejects(transport.receiveFrame(timeout.signal), "nothing else should be pending"); - clearTimeout(timer); - } finally { - transport.dispose(); - } - } finally { - await listener.close(); - } -}); - -test("fix 6: a stream shutdown prunes its entry so streamKinds does not grow without bound", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - const accepted = listener.acceptOnce(); - const transport = await connector.connect(candidate, target); - try { - const connectionId = await accepted; - listener.sendFrame(connectionId, testFrame(), CULTMESH_QUIC_STREAM_LATEST_ONLY); - const received = await transport.receiveFrame(); - assert.equal(received.sequence, 1n); - - // The server-side stream the frame arrived on is a fresh latest-only - // stream the listener opened and finished with `fin: true`; the native - // bridge emits its own shutdown event once MsQuic completes teardown. - // Wait for that STREAM_SHUTDOWN to actually land, then assert the - // internal map was pruned, not just that a second frame still works - // (F6kinds's removed prune line has no behavioural symptom until a - // stream id is reused, which QUIC never does within one connection). - await new Promise((resolve) => setTimeout(resolve, 300)); - assert.equal( - (transport as CultMeshQuicRealtimeTransport).trackedStreamCount, - 0, - "streamKinds must be pruned once the stream shuts down, not accumulate one entry per stream", - ); - - listener.sendFrame(connectionId, testFrame({ sequence: 2n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); - const second = await transport.receiveFrame(); - assert.equal(second.sequence, 2n); - } finally { - transport.dispose(); - } - } finally { - await listener.close(); - } -}); - -test("P8: a reliable send still queued behind reliableSendTail rejects instead of hanging when disposal wins the race", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - const transport = await connector.connect(candidate, target); - try { - // Prime the reliable outbound stream and let it fully settle, so its - // id is already cached: a second reliable send then chains straight - // onto `sendOnStream` with no `streamOpen` call of its own in between - // (a `streamOpen` against an already-shut-down connection fails with - // its own native error, which would mask the guard this test targets). - await transport.sendFrame(testFrame({ delivery: "reliable-ordered", sequence: 1n })); - - // `sendFrame` chains onto `reliableSendTail`, which resolves as a - // microtask; calling `dispose()` synchronously right after, in the - // same turn, guarantees `cleanup()` has already set `disposed = true` - // by the time that microtask runs `sendOnStream`. Without the - // `this.disposed` guard `sendOnStream` registers (P8's target), this - // send would sit in `pendingSends` forever: `cleanup()`'s reject sweep - // already ran once before this send was ever registered, and no later - // native event can reach it either, since `offConnectionEvent` already - // ran too. - const send = transport.sendFrame(testFrame({ delivery: "reliable-ordered", sequence: 2n })); - transport.dispose(); - // Bounded: without the guard, `send` never settles (nothing left to - // reject it), so a bare `assert.rejects(send, ...)` would hang this - // whole file instead of failing this one test by assertion. Racing - // against a timeout that rejects with a different message turns a - // regression into a prompt, loud assertion mismatch. - const bounded = Promise.race([ - send, - new Promise((_, reject) => - setTimeout(() => reject(new Error("P8 guard timed out instead of rejecting")), 3_000), - ), - ]); - await assert.rejects(bounded, /disposed/i); - } finally { - transport.dispose(); - } - } finally { - await listener.close(); - } -}); diff --git a/packages/cultmesh-ts/test/realtime-quic-native.test.ts b/packages/cultmesh-ts/test/realtime-quic-native.test.ts deleted file mode 100644 index 039d9047..00000000 --- a/packages/cultmesh-ts/test/realtime-quic-native.test.ts +++ /dev/null @@ -1,388 +0,0 @@ -// The Cut 3 FFI smoke, now run against `CultMeshQuicNativeRuntime` instead of -// raw koffi calls: open a runtime, open a listener with a real credential, -// connect a real loopback connection, exchange one frame's worth of raw -// bytes, and close everything down. This proves the binding's declared ABI -// matches the shipped bridge; frame semantics (delivery, generation) are -// `realtime-quic-consumer.test.ts`'s job. -// -// Skips instead of failing when no native bridge is available: set -// CULTMESH_QUIC_NATIVE_DIR to a directory `scripts/build-quic-native.ps1` or -// `scripts/build-quic-native.sh` built, or run from the committed -// `packages/cultmesh-ts/native/` tree once a later cut lands it. - -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import test from "node:test"; - -import { - CultMeshQuicNativeRuntime, - CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION, - CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED, - CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED, - CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN, - CULTMESH_QUIC_EVENT_STREAM_STARTED, - CULTMESH_QUIC_EVENT_STREAM_FRAME, - CULTMESH_QUIC_EVENT_STREAM_SEND_COMPLETE, - CULTMESH_QUIC_STREAM_RELIABLE, -} from "../src/realtime-quic-native"; -import { nativeBridgeAvailable } from "./support/native-bridge"; - -// __dirname at runtime is dist-test/test; fixtures are binary and are never -// compiled/copied there, so they are read from their source location, two -// levels up. -const FIXTURE_P12 = join(__dirname, "..", "..", "test", "fixtures", "quic-test.p12"); - -async function waitFor(collect: (resolve: (value: T) => void) => void, timeoutMs = 5_000): Promise { - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("timed out waiting for a native QUIC event")), timeoutMs); - collect((value) => { - clearTimeout(timer); - resolve(value); - }); - }); -} - -test("CultMeshQuicNativeRuntime opens and closes with no listener or connection", async (t) => { - if (!nativeBridgeAvailable()) { - t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); - return; - } - const runtime = await CultMeshQuicNativeRuntime.open(); - await runtime.release(); -}); - -test("CultMeshQuicNativeRuntime opens and closes a listener with a real PKCS12 credential", async (t) => { - if (!nativeBridgeAvailable()) { - t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); - return; - } - const runtime = await CultMeshQuicNativeRuntime.open(); - try { - const pkcs12 = readFileSync(FIXTURE_P12); - const { listenerId, boundPort } = runtime.listenerOpen("127.0.0.1", 0, pkcs12, ""); - assert.ok(boundPort > 0); - runtime.listenerClose(listenerId); - } finally { - await runtime.release(); - } -}); - -test("a full loopback connection delivers one raw frame end to end", async (t) => { - if (!nativeBridgeAvailable()) { - t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); - return; - } - const runtime = await CultMeshQuicNativeRuntime.open(); - try { - const pkcs12 = readFileSync(FIXTURE_P12); - const { listenerId, boundPort } = runtime.listenerOpen("127.0.0.1", 0, pkcs12, ""); - - const accepted = waitFor((resolve) => { - runtime.onListenerEvent(listenerId, (event) => { - if (event.type === CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION) resolve(event.connectionId); - }); - }); - - const clientConnectionId = runtime.connectionOpen("127.0.0.1", boundPort); - const clientConnected = waitFor((resolve) => { - runtime.onConnectionEvent(clientConnectionId, (event) => { - if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED) { - runtime.connectionCertificateComplete(clientConnectionId, true); - return; - } - if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED) resolve(); - }); - }); - - const serverConnectionId = await accepted; - const serverFrame = waitFor((resolve) => { - runtime.onConnectionEvent(serverConnectionId, (event) => { - if (event.type === CULTMESH_QUIC_EVENT_STREAM_STARTED) { - assert.equal(event.streamKind, CULTMESH_QUIC_STREAM_RELIABLE); - return; - } - if (event.type === CULTMESH_QUIC_EVENT_STREAM_FRAME) resolve(event.payload); - }); - }); - - await clientConnected; - const streamId = runtime.streamOpen(clientConnectionId, CULTMESH_QUIC_STREAM_RELIABLE); - const sendComplete = waitFor((resolve) => { - runtime.onConnectionEvent(clientConnectionId, (event) => { - if (event.type === CULTMESH_QUIC_EVENT_STREAM_SEND_COMPLETE) resolve(); - }); - }); - runtime.streamSendFrame(streamId, new Uint8Array([1, 2, 3, 4]), true); - await sendComplete; - - const payload = await serverFrame; - assert.deepEqual(Array.from(payload), [1, 2, 3, 4]); - - runtime.connectionShutdown(clientConnectionId, 0n); - runtime.connectionShutdown(serverConnectionId, 0n); - runtime.listenerClose(listenerId); - } finally { - await runtime.release(); - } -}); - -test("F7: a 64-bit event code round-trips exactly through the struct decode", async (t) => { - if (!nativeBridgeAvailable()) { - t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); - return; - } - const runtime = await CultMeshQuicNativeRuntime.open(); - try { - const pkcs12 = readFileSync(FIXTURE_P12); - const { listenerId, boundPort } = runtime.listenerOpen("127.0.0.1", 0, pkcs12, ""); - - // `code` (a `uint64_t`, the same field `CULTMESH_QUIC_SEND_CANCELED` is - // compared against on the stream-send path) is exercised here through - // CONNECTION_SHUTDOWN instead, whose code is an application-chosen value - // this test fully controls: two values above 2^53 (where koffi's decode - // stops being a plain safe-integer `number`) prove the whole - // encode-native-decode-coerce path is exact, deterministically, with no - // dependency on MsQuic's own send-completion timing. - for (const code of [(1n << 53n) + 1n, (1n << 62n) - 1n]) { - const accepted = waitFor((resolve) => { - runtime.onListenerEvent(listenerId, (event) => { - if (event.type === CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION) resolve(event.connectionId); - }); - }); - const clientConnectionId = runtime.connectionOpen("127.0.0.1", boundPort); - const clientConnected = waitFor((resolve) => { - runtime.onConnectionEvent(clientConnectionId, (event) => { - if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED) { - runtime.connectionCertificateComplete(clientConnectionId, true); - return; - } - if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED) resolve(); - }); - }); - const serverConnectionId = await accepted; - await clientConnected; - - const seenOnClient = waitFor((resolve) => { - runtime.onConnectionEvent(clientConnectionId, (event) => { - if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN) resolve(event.code); - }); - }); - runtime.connectionShutdown(serverConnectionId, code); - const seen = await seenOnClient; - assert.equal(typeof seen, "bigint", "a decoded event code must always be bigint, never number"); - assert.equal(seen, code, `shutdown code ${code} must round-trip exactly through the struct decode`); - } - - runtime.listenerClose(listenerId); - } finally { - await runtime.release(); - } -}); - -test( - "P10b: a throwing onFault handler is isolated — the pump survives and other connections' handlers still run", - { timeout: 10_000 }, - async (t) => { - if (!nativeBridgeAvailable()) { - t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); - return; - } - const runtime = await CultMeshQuicNativeRuntime.open(); - try { - const pkcs12 = readFileSync(FIXTURE_P12); - const { listenerId, boundPort } = runtime.listenerOpen("127.0.0.1", 0, pkcs12, ""); - - // Connection B: a normal, fully connected loopback pair, established - // before A ever faults — so proving B still works afterward exercises - // an already-running connection's handler, not a second accept cycle - // racing whatever state A's fault left behind. - const acceptedB = waitFor((resolve) => { - runtime.onListenerEvent(listenerId, (event) => { - if (event.type === CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION) resolve(event.connectionId); - }); - }); - const clientB = runtime.connectionOpen("127.0.0.1", boundPort); - const clientBConnected = waitFor((resolve) => { - runtime.onConnectionEvent(clientB, (event) => { - if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED) { - runtime.connectionCertificateComplete(clientB, true); - return; - } - if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED) resolve(); - }); - }); - const serverBId = await acceptedB; - const serverBFrame = waitFor((resolve) => { - runtime.onConnectionEvent(serverBId, (event) => { - if (event.type === CULTMESH_QUIC_EVENT_STREAM_FRAME) resolve(event.payload); - }); - }); - await clientBConnected; - - // Connection A, opened only after B is fully connected: both its - // listener and its onFault handler throw on the first event delivered - // to it. Without `faultConnection`'s own try/catch isolating a - // throwing `onFault` (P10b's target), that throw would escape the - // pump's dispatch loop and take every other connection — including - // the already-live B — down with it, instead of staying scoped to A. - // - // The server-side connection listener is registered synchronously, - // inside the LISTENER_NEW_CONNECTION callback itself, rather than - // after an `await`: the native bridge can dispatch a connection's next - // event (CERTIFICATE_RECEIVED, or CONNECTED) before a later `await`'s - // continuation ever runs, and an event with no listener registered for - // its connection id is silently dropped (`dispatch`'s own - // `if (!entry) return`) — the same async-registration gap fix 1 closed - // for the provider's own accept path. - const serverAFaulted = waitFor((resolve) => { - runtime.onListenerEvent(listenerId, (event) => { - if (event.type !== CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION) return; - runtime.onConnectionEvent( - event.connectionId, - () => { - throw new Error("P10b: connection A's listener always throws."); - }, - () => { - resolve(); - throw new Error("P10b: connection A's onFault also throws."); - }, - ); - }); - }); - const clientA = runtime.connectionOpen("127.0.0.1", boundPort); - runtime.onConnectionEvent(clientA, (event) => { - if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED) { - runtime.connectionCertificateComplete(clientA, true); - } - }); - await serverAFaulted; - - // Prove B's handler is still live after A's onFault threw: send a - // frame on B and confirm the server side still receives it. - const streamId = runtime.streamOpen(clientB, CULTMESH_QUIC_STREAM_RELIABLE); - runtime.streamSendFrame(streamId, new Uint8Array([9, 9, 9]), true); - const payload = await serverBFrame; - assert.deepEqual( - Array.from(payload), - [9, 9, 9], - "connection B must still deliver frames after A's onFault threw", - ); - - runtime.connectionShutdown(clientA, 0n); - runtime.connectionShutdown(clientB, 0n); - runtime.connectionShutdown(serverBId, 0n); - runtime.listenerClose(listenerId); - } finally { - await runtime.release(); - } - }, -); - -test("fix 2: release() refuses a call with no outstanding reference", async (t) => { - if (!nativeBridgeAvailable()) { - t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); - return; - } - const runtime = await CultMeshQuicNativeRuntime.open(); - await runtime.release(); - await assert.rejects(runtime.release(), /no outstanding reference/i); -}); - -test("negative grep: no koffi callbacks, .async only on nextEvent", () => { - // __dirname at runtime is dist-test/test; the source lives two levels up, - // in src/, since only .js output is mirrored under dist-test. - const source = readFileSync(join(__dirname, "..", "..", "src", "realtime-quic-native.ts"), "utf8"); - // `.async` is referenced (not necessarily called with a literal "(" at the - // reference site: it is handed to `promisify`, which calls it) only for - // `nextEvent`, the ABI's single blocking export. - const asyncUses = [...source.matchAll(/\.async\b/g)]; - assert.ok(asyncUses.length > 0, "expected at least one .async use for nextEvent"); - for (const match of asyncUses) { - const before = source.slice(Math.max(0, match.index! - 40), match.index!); - assert.match(before, /nextEvent/i, "the only .async reference must be nextEvent"); - } - assert.doesNotMatch(source, /koffi\.register/, "no koffi callbacks"); -}); - -// The measurement below runs in its own `node --expose-gc` child process -// instead of in this test's own process: by the time this test would run, -// ~150 earlier tests in the suite have already grown and partly freed the -// glibc heap, so a real per-call native leak can land in already-mapped free -// space and never show up as RSS growth in *this* process (confirmed by hand: -// the in-process version of this test passed even with `nextEvent` mutated -// back to allocating fresh koffi buffers every call). A freshly spawned -// process has no such history, matching the ~8 MiB (fixed) vs ~30 MiB -// (pre-fix) split measured in isolation. -const MEMORY_PROBE_SCRIPT = ` -const { CultMeshQuicNativeRuntime } = require(process.argv[1]); -(async () => { - const runtime = await CultMeshQuicNativeRuntime.open(); - const nextEvent = runtime.nextEvent.bind(runtime); - try { - for (let i = 0; i < 2000; i += 1) await nextEvent(0, null, 0); - global.gc(); - const before = process.memoryUsage().rss; - const ITERATIONS = 200000; - for (let i = 0; i < ITERATIONS; i += 1) await nextEvent(0, null, 0); - global.gc(); - const after = process.memoryUsage().rss; - process.stdout.write(JSON.stringify({ before, after, growthBytes: after - before, ITERATIONS })); - } finally { - await runtime.release(); - } -})().catch((error) => { - console.error(error); - process.exit(1); -}); -`; - -test( - "memory: sustained nextEvent calls do not leak koffi-allocated out-parameter buffers", - { timeout: 60_000 }, - (t) => { - if (!nativeBridgeAvailable()) { - t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); - return; - } - - // `nextEvent` is private; the compiled module path is resolved the same - // way this file's own `import` resolves it, and handed to the child by - // argv so the child needs no path logic of its own. - const modulePath = require.resolve("../src/realtime-quic-native"); - const result = spawnSync(process.execPath, ["--expose-gc", "-e", MEMORY_PROBE_SCRIPT, modulePath], { - encoding: "utf8", - env: process.env, - }); - if (result.status !== 0 || typeof result.stdout !== "string" || result.stdout.trim().length === 0) { - if (/--expose-gc/.test(result.stderr ?? "")) { - t.skip("this Node build does not support --expose-gc."); - return; - } - assert.fail( - `memory probe child process failed (status ${String(result.status)}):\n${result.stderr ?? "(no stderr)"}`, - ); - } - - const { before, after, growthBytes, ITERATIONS } = JSON.parse(result.stdout.trim()) as { - before: number; - after: number; - growthBytes: number; - ITERATIONS: number; - }; - // Measured on Yggdrasil, same build and host, in a dedicated process: - // fixed code (0 `koffi.alloc` calls in the loop): ~8.0 MiB RSS growth. - // pre-fix code (400k `koffi.alloc` calls, never freed): ~29.9 MiB. - // 18 MiB sits well above the fixed baseline and well below the leaking - // one, so the bound stays robust to ordinary run-to-run noise while - // still catching the regression. - const BOUND_BYTES = 18 * 1024 * 1024; - assert.ok( - growthBytes < BOUND_BYTES, - `RSS grew by ${growthBytes} bytes (${(growthBytes / 1024 / 1024).toFixed(2)} MiB, from ${before} to ${after}) ` + - `over ${ITERATIONS} nextEvent calls; expected it to stay under ${BOUND_BYTES / 1024 / 1024} MiB once ` + - "out-parameter buffers are reused instead of allocated per call.", - ); - }, -); diff --git a/packages/cultmesh-ts/test/realtime-quic-provider.test.ts b/packages/cultmesh-ts/test/realtime-quic-provider.test.ts deleted file mode 100644 index 8a541641..00000000 --- a/packages/cultmesh-ts/test/realtime-quic-provider.test.ts +++ /dev/null @@ -1,1083 +0,0 @@ -// `CultMeshQuicRealtimeProvider`, Cut 5's StreamPixels role, exercised -// end-to-end against the real native bridge and the real consumer connector -// from Cut 4 (no mocks): reliable ordering in both directions -// (`CultMeshQuicRealtimeTransportTests.cs:71-110`), latest-only coalescing -// per `(channel, body)` key, a non-blocking broadcast, the uppercase pin, and -// eviction on peer shutdown. -// -// Skips instead of failing when no native bridge is available; see -// `realtime-quic-native.test.ts` for why. - -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import test from "node:test"; - -import { - CultMeshQuicNativeRuntime, -} from "../src/realtime-quic-native"; -import { - CultMeshQuicRealtimeConnector, - CultMeshQuicRealtimeProvider, - CultMeshQuicRealtimeTransport as CultMeshQuicRealtimeTransportClass, - parseQuicRealtimeEndpoint, - type CultMeshRealtimeCandidate, - type CultMeshRealtimeTarget, - type CultMeshRealtimeTransport, -} from "../src/realtime-quic"; -import type { CultMeshRealtimeFrame } from "../src/realtime-wire"; -import { nativeBridgeAvailable } from "./support/native-bridge"; - -// __dirname at runtime is dist-test/test; fixtures are binary and are never -// compiled/copied there, so they are read from their source location, two -// levels up. -const FIXTURE_P12 = join(__dirname, "..", "..", "test", "fixtures", "quic-test.p12"); - -/** - * `assert.rejects`, but safe against a mutant or a real regression that - * makes the promise resolve instead: a transport that unexpectedly connects - * is disposed before the assertion fails, so a broken certificate check - * cannot hang this file the way M1 did before realtime-quic-consumer.test.ts - * gained this same helper. - */ -async function assertRejectsAndDisposes( - promise: Promise<{ dispose(): void }>, - match?: RegExp, -): Promise { - let resolved: { dispose(): void } | undefined; - try { - resolved = await promise; - } catch (error) { - if (match) assert.match(error instanceof Error ? error.message : String(error), match); - return; - } - resolved.dispose(); - assert.fail("expected the promise to reject, but it resolved (the resolved value has been disposed)"); -} - -function testFrame(overrides: Partial = {}): CultMeshRealtimeFrame { - return { - channelId: "aetheria.entities", - schemaId: "eve.entity_soa.v1", - bodyId: "body:aetheria:entities", - producerEpoch: 7n, - sequence: 1n, - delivery: "latest-only", - payload: new Uint8Array([3, 4, 5]), - ...overrides, - }; -} - -async function startProvider(): Promise { - return await CultMeshQuicRealtimeProvider.listen({ - host: "127.0.0.1", - port: 0, - serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, - }); -} - -async function dialProvider(provider: CultMeshQuicRealtimeProvider): Promise { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: provider.advertisedEndpoint, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - return await connector.connect(candidate, target); -} - -async function waitUntil(predicate: () => boolean, timeoutMs = 5_000): Promise { - const deadline = Date.now() + timeoutMs; - while (!predicate()) { - if (Date.now() >= deadline) throw new Error("timed out waiting for a provider condition"); - await new Promise((resolve) => setTimeout(resolve, 10)); - } -} - -/** - * Waits for `CultMeshQuicNativeRuntime.refCount` to stop moving: `dispose()` - * releases its runtime reference fire-and-forget (`void this.runtime.release()`), - * so a just-finished test's cleanup can still be landing when the next test - * starts. A test that snapshots refCount as a baseline without waiting for - * this first can catch that in-flight decrement mid-test and see refCount - * undershoot its own "before" value instead of returning to it. - */ -async function waitForRefCountToStabilize(quietMs = 200, timeoutMs = 5_000): Promise { - const deadline = Date.now() + timeoutMs; - let last = CultMeshQuicNativeRuntime.refCount; - let stableSince = Date.now(); - for (;;) { - await new Promise((resolve) => setTimeout(resolve, 20)); - const current = CultMeshQuicNativeRuntime.refCount; - if (current !== last) { - last = current; - stableSince = Date.now(); - } else if (Date.now() - stableSince >= quietMs) { - return current; - } - if (Date.now() >= deadline) return current; - } -} - -test("the advertised endpoint carries an uppercase cert-sha256 pin the Unity connector requires", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - assert.match( - provider.advertisedEndpoint, - /^cultmesh-state\+quic:\/\/127\.0\.0\.1:\d+\?cert-sha256=[0-9A-F]{64}$/, - ); - // Not merely uppercase-tolerant: it must not contain a lowercase hex digit. - const pin = new URL(provider.advertisedEndpoint).searchParams.get("cert-sha256")!; - assert.equal(pin, pin.toUpperCase()); - assert.notEqual(pin, pin.toLowerCase()); - } finally { - provider.dispose(); - } -}); - -test("reliable-ordered delivers frames in order, provider to consumer and consumer to provider", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - - await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence: 1n })); - await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence: 2n })); - const firstAtConsumer = await consumer.receiveFrame(); - const secondAtConsumer = await consumer.receiveFrame(); - assert.deepEqual([firstAtConsumer.sequence, secondAtConsumer.sequence], [1n, 2n]); - - await consumer.sendFrame(testFrame({ delivery: "reliable-ordered", sequence: 3n })); - await consumer.sendFrame(testFrame({ delivery: "reliable-ordered", sequence: 4n })); - const firstAtProvider = await provider.receive(); - const secondAtProvider = await provider.receive(); - assert.deepEqual([firstAtProvider.sequence, secondAtProvider.sequence], [3n, 4n]); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test("latest-only broadcast coalesces back-to-back sends for the same key to the newest frame, and never blocks on I/O", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - - // Three broadcasts for the same (channelId, bodyId) key, issued back to - // back with no await between them. `broadcast` for latest-only never - // awaits any I/O (mutating it to await the peer's send would make this - // loop, and the assertion below, run after real network round trips - // instead of within one JS turn): the synchronous middle publish is - // therefore always coalesced away, deterministically, not by luck of - // network timing. Mutating the coalescing key (e.g. dropping bodyId) - // would let sequence 200 leak through as a separate delivered frame - // instead of overwriting 100. - const before = Date.now(); - const first = provider.broadcast(testFrame({ sequence: 1n })); - const second = provider.broadcast(testFrame({ sequence: 100n })); - const third = provider.broadcast(testFrame({ sequence: 200n })); - const afterCallsReturned = Date.now(); - // All three promises exist before any of them could possibly have - // waited on the peer's round trip: the loop above ran in well under a - // network round trip's worth of time. - assert.ok(afterCallsReturned - before < 50, "broadcast must not block on any peer's I/O"); - await Promise.all([first, second, third]); - - // The consumer's own receive-side inbox (Cut 4) also coalesces - // latest-only frames it has not yet read, so whether 1 is delivered as - // its own frame or is itself coalesced away by 200 arriving first - // depends on read timing; that is Cut 4's contract, not this test's. - // What this test owns is the provider's outbox: 100 must never appear, - // and the last frame delivered must be 200. - const received: bigint[] = []; - let last: bigint | undefined; - while (last !== 200n) { - const frame = await consumer.receiveFrame(); - received.push(frame.sequence); - last = frame.sequence; - } - assert.ok(!received.includes(100n), `100 must be coalesced away, got ${received}`); - assert.ok(received.length <= 2, `expected at most 2 delivered frames, got ${received}`); - assert.equal(last, 200n); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test("a stalled peer's outbox holds at most one pending frame per key across many broadcasts", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - - // 200 broadcasts, none awaited individually, none read by the consumer - // until they are all issued: mirrors the interop lane's stalled-reader - // scenario at unit-test speed. `broadcast` completing this loop at all - // (bounded time) proves it never blocks on the unread peer. - const started = Date.now(); - const sends: Promise[] = []; - for (let sequence = 1; sequence <= 200; sequence += 1) { - sends.push(provider.broadcast(testFrame({ sequence: BigInt(sequence) }))); - } - await Promise.all(sends); - assert.ok(Date.now() - started < 2_000, "200 broadcasts to one key must complete quickly, unread"); - - const receivedSequences: bigint[] = []; - let last: bigint | undefined; - while (last !== 200n) { - const frame = await consumer.receiveFrame(); - receivedSequences.push(frame.sequence); - last = frame.sequence; - } - assert.equal(last, 200n); - // Coalescing means far fewer than 200 frames were actually put on the - // wire; the exact count depends on scheduling, but it must be small. - assert.ok(receivedSequences.length < 200, `expected coalescing, got ${receivedSequences.length} frames`); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test( - "20,000 client latest-only frames on one key leave at most 1 pending in the provider's receive queue", - { timeout: 120_000 }, - async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - - // The exact scenario Soul's probe reproduced against the unbounded - // `readyFrames` array this replaced: a publish-only StreamPixels-style - // consumer of `receive()` never drains the provider's fan-in queue, - // so a client hammering one key must not be able to grow it past one - // entry no matter how many frames it sends. - const total = 20_000; - const sends: Promise[] = []; - for (let sequence = 1; sequence <= total; sequence += 1) { - sends.push(consumer.sendFrame(testFrame({ delivery: "latest-only", sequence: BigInt(sequence) }))); - } - await Promise.all(sends); - await waitUntil(() => provider.receiveQueueSize > 0, 30_000); - // Let any trailing send_complete events land before asserting the - // queue has settled, so this isn't racing the last few deliveries. - await new Promise((resolve) => setTimeout(resolve, 200)); - - assert.equal( - provider.receiveQueueSize, - 1, - `one client key must coalesce to at most one pending frame, got ${provider.receiveQueueSize}`, - ); - const frame = await provider.receive(); - assert.equal(frame.sequence, BigInt(total), "the one pending frame must be the newest generation sent"); - assert.equal(provider.receiveQueueSize, 0); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } - }, -); - -test( - "many distinct client latest-only keys stay bounded by the key count, not the frame count", - async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - - const keyCount = 50; - const sendsPerKey = 20; - const sends: Promise[] = []; - for (let key = 0; key < keyCount; key += 1) { - for (let sequence = 1; sequence <= sendsPerKey; sequence += 1) { - sends.push( - consumer.sendFrame( - testFrame({ - delivery: "latest-only", - bodyId: `body:${key}`, - sequence: BigInt(sequence), - }), - ), - ); - } - } - await Promise.all(sends); - await waitUntil(() => provider.receiveQueueSize > 0, 10_000); - await new Promise((resolve) => setTimeout(resolve, 200)); - - assert.equal( - provider.receiveQueueSize, - keyCount, - `the queue must hold exactly one pending frame per distinct key, got ${provider.receiveQueueSize}`, - ); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } - }, -); - -test("reliable-ordered client frames are delivered in order at the provider, never coalesced", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - - const total = 500; - for (let sequence = 1; sequence <= total; sequence += 1) { - await consumer.sendFrame(testFrame({ delivery: "reliable-ordered", sequence: BigInt(sequence) })); - } - await waitUntil(() => provider.receiveQueueSize === total, 10_000); - - const received: bigint[] = []; - for (let i = 0; i < total; i += 1) { - received.push((await provider.receive()).sequence); - } - const expected = Array.from({ length: total }, (_, i) => BigInt(i + 1)); - assert.deepEqual(received, expected, "reliable-ordered frames must never be coalesced or reordered"); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test("frames already queued in the provider's receive queue drain before dispose's rejection", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - await consumer.sendFrame(testFrame({ delivery: "reliable-ordered", sequence: 1n })); - await consumer.sendFrame(testFrame({ delivery: "reliable-ordered", sequence: 2n })); - await waitUntil(() => provider.receiveQueueSize === 2); - - // Dispose while two frames are still sitting in the queue, unread. - provider.dispose(); - - const first = await provider.receive(); - const second = await provider.receive(); - assert.deepEqual([first.sequence, second.sequence], [1n, 2n], "already-queued frames must drain first"); - await assert.rejects(() => provider.receive(), /disposed/, "only a call past the drained backlog rejects"); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test("unreliable broadcast fails closed", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - await assert.rejects(provider.broadcast(testFrame({ delivery: "unreliable" })), /datagrams/i); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test("a peer is evicted from the provider on connection shutdown", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - await waitUntil(() => provider.connectionCount === 1); - consumer.dispose(); - await waitUntil(() => provider.connectionCount === 0); - } finally { - provider.dispose(); - } -}); - -test("a reliable-ordered send failure evicts the peer without broadcast() throwing to the caller", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - await waitUntil(() => provider.connectionCount === 1); - consumer.dispose(); - // Keep broadcasting reliable-ordered frames into the dying/dead peer's - // outbound stream for a short window: somewhere in this window the - // send to that peer fails (either the native stream call itself, or - // the peer's own CONNECTION_SHUTDOWN cleanup rejecting it), and - // CultMeshQuicRealtimeProvider.broadcast()'s per-peer try/catch must - // dispose it rather than let the rejection propagate or leave a dead - // peer counted forever. - const deadline = Date.now() + 2_000; - let sequence = 0n; - while (Date.now() < deadline && provider.connectionCount > 0) { - sequence += 1n; - // Must never throw: a removed subtraction of the catch's dispose() - // call would not itself make broadcast() throw, but a regression - // that let the rejection propagate instead of being caught at all - // would, and this loop would then fail loudly instead of hanging. - await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence })); - } - assert.equal(provider.connectionCount, 0, "the dead peer must be evicted, not counted forever"); - } finally { - provider.dispose(); - } -}); - -test("connectionCount reflects accepted peers, and dispose releases the runtime for a fresh listen", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - assert.equal(provider.connectionCount, 0); - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - } finally { - consumer.dispose(); - provider.dispose(); - } - // A leaked runtime reference from any earlier path (accept, handshake - // timeout, dispose) would leave the shared runtime unable to open a fresh - // listener cleanly; opening and tearing down a second provider proves - // release ran on every path exercised above. - const again = await startProvider(); - again.dispose(); -}); - -test("the runtime reference is released when extracting the certificate fails, before any listener opens", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const before = CultMeshQuicNativeRuntime.refCount; - await assert.rejects( - CultMeshQuicRealtimeProvider.listen({ - host: "127.0.0.1", - port: 0, - // Not a PKCS12 credential at all: extractLeafCertificateDer() throws - // before CultMeshQuicNativeRuntime.open() is ever called, so there is - // no reference to leak on this path; this pins that (trivial but - // real) invariant rather than exercising listen()'s own - // `catch { await runtime.release(); throw error; }`, which the next - // test covers with a listener-level failure instead. - serverCertificate: { pkcs12: new Uint8Array([1, 2, 3, 4]), password: "" }, - }), - ); - assert.equal(CultMeshQuicNativeRuntime.refCount, before, "must not leak a reference that was never opened"); -}); - -test("the runtime reference is released when the listener itself fails to open", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const holder = await startProvider(); - try { - const boundPort = new URL(holder.advertisedEndpoint).port; - const before = CultMeshQuicNativeRuntime.refCount; - // A real, valid PKCS12 (so extractLeafCertificateDer succeeds and - // CultMeshQuicNativeRuntime.open() actually runs), but the port - // `holder` already has bound: listenerOpen() itself must fail, driving - // listen()'s `catch { await runtime.release(); throw error; }`. - await assert.rejects( - CultMeshQuicRealtimeProvider.listen({ - host: "127.0.0.1", - port: Number(boundPort), - serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, - }), - ); - assert.equal( - CultMeshQuicNativeRuntime.refCount, - before, - "a listenerOpen() failure must release the reference listen() had already opened", - ); - } finally { - holder.dispose(); - } -}); - -test( - "the runtime reference is released on dispose with a pending send in flight", - { timeout: 10_000 }, - async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const before = CultMeshQuicNativeRuntime.refCount; - const provider = await startProvider(); - const consumer = await dialProvider(provider); - await waitUntil(() => provider.connectionCount === 1); - - // Start a reliable-ordered broadcast and dispose before it can possibly - // have settled; dispose() must tear the peer down (and release its - // runtime reference) regardless of the outstanding send. `broadcast()` - // must itself never hang: an explicit `{ timeout }` above fails this - // test loudly, rather than the whole file, if it ever does. - const broadcasting = provider.broadcast(testFrame({ delivery: "reliable-ordered" })).catch(() => {}); - provider.dispose(); - await broadcasting; - consumer.dispose(); - await waitUntil(() => CultMeshQuicNativeRuntime.refCount === before); - }, -); - -test( - "dispose() evicts a connection still mid-handshake, not just fully attached peers", - { timeout: 10_000 }, - async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - // A long handshake timeout: without dispose() sweeping still-pending - // accepts (fixed alongside this test), a connection whose handshake - // never reaches CONNECTED — here, the client rejects the provider's - // certificate — would sit on its own handshake timer, and once - // attachPeer's own CultMeshQuicNativeRuntime.open() resolves, hold a - // runtime reference, for up to this long after dispose() returns. Found - // this way: mutating the certificate-pin comparison to be - // case-sensitive (M2) made every provider test that dials with an - // uppercase-pinned endpoint reject during the handshake, and the whole - // file then hung for over two minutes past its last visible test. - // Earlier tests' dispose() releases fire-and-forget; wait for the - // shared refCount to actually settle before treating it as a baseline - // (see waitForRefCountToStabilize's own doc comment). - const before = await waitForRefCountToStabilize(); - const provider = await CultMeshQuicRealtimeProvider.listen({ - host: "127.0.0.1", - port: 0, - serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, - handshakeTimeoutMs: 30_000, - }); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - // A pin that can never match this provider's real certificate: the - // connector rejects it during CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED, - // before the connection ever reaches CONNECTED. - endpoint: provider.advertisedEndpoint.replace(/cert-sha256=[0-9A-F]+/, `cert-sha256=${"0".repeat(64)}`), - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - // If the certificate check ever regresses into accepting everything - // (M1), this resolves into a fully attached peer instead of rejecting; - // assertRejectsAndDisposes() disposes the client-side transport either - // way, but only the outer `finally` below disposes the provider itself - // (and the peer it would then hold), which is what actually closes - // the file-hanging gap M1 found here. - await assertRejectsAndDisposes(connector.connect(candidate, target), /certificate|rejected/i); - - // The provider's own accept flow is asynchronous (attachPeer awaits a - // runtime reference before it can even see the rejection); give it a - // moment to actually start before disposing, so this test exercises a - // real in-flight pending accept rather than one that never began. - await new Promise((resolve) => setTimeout(resolve, 100)); - - const disposedAt = Date.now(); - provider.dispose(); - await waitUntil(() => CultMeshQuicNativeRuntime.refCount === before, 5_000); - assert.ok( - Date.now() - disposedAt < 5_000, - "dispose() must evict a mid-handshake connection promptly, not wait out its 30s handshake timeout", - ); - } finally { - provider.dispose(); - } - }, -); - -test("a peer attaching after two latest-only broadcasts for one key receives exactly the newer frame", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - await provider.broadcast(testFrame({ sequence: 1n, payload: new Uint8Array([1]) })); - await provider.broadcast(testFrame({ sequence: 2n, payload: new Uint8Array([2]) })); - - // Attach after both broadcasts have already resolved: nothing in this - // peer's outbox came from `broadcast()`'s own fan-out, only from the - // retained seed on attach. - const consumer = await dialProvider(provider); - try { - const seeded = await consumer.receiveFrame(); - assert.equal(seeded.sequence, 2n, "a late joiner must be seeded with the newest retained generation"); - assert.deepEqual(seeded.payload, new Uint8Array([2])); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test("retention keeps the newer generation when an older one broadcasts afterward", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - await provider.broadcast(testFrame({ sequence: 5n, payload: new Uint8Array([5]) })); - // An older generation broadcast after a newer one must not overwrite the - // retained frame: mutating the retention comparison to a plain - // last-write-wins assignment would regress this. - await provider.broadcast(testFrame({ sequence: 3n, payload: new Uint8Array([3]) })); - - const consumer = await dialProvider(provider); - try { - const seeded = await consumer.receiveFrame(); - assert.equal(seeded.sequence, 5n, "an older generation must not replace the retained newer one"); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test("a reliable-ordered frame is never retained and is not delivered to a late joiner", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const firstConsumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence: 9n })); - assert.deepEqual((await firstConsumer.receiveFrame()).sequence, 9n); - } finally { - firstConsumer.dispose(); - } - await waitUntil(() => provider.connectionCount === 0); - - // A second peer attaches after the reliable-ordered frame above and - // after a real latest-only frame, so the assertion below proves the - // reliable-ordered frame specifically was never retained, rather than - // merely proving the provider had nothing at all to seed. - await provider.broadcast(testFrame({ delivery: "latest-only", sequence: 1n, payload: new Uint8Array([1]) })); - const secondConsumer = await dialProvider(provider); - try { - const seeded = await secondConsumer.receiveFrame(); - assert.equal(seeded.delivery, "latest-only"); - assert.equal(seeded.sequence, 1n, "only the latest-only frame may be seeded, never the reliable-ordered one"); - } finally { - secondConsumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test( - "fix 1: a peer stays connected and keeps receiving well past handshakeTimeoutMs", - { timeout: 10_000 }, - async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - // Must fail on the pre-fix code: the buffering-handler/attachPeer swap - // there loses `state.connected` to whichever handler happens to be - // registered when CONNECTED actually arrives, and the handshake timer - // evicts a fully healthy peer at this deadline regardless. - const handshakeTimeoutMs = 500; - const provider = await CultMeshQuicRealtimeProvider.listen({ - host: "127.0.0.1", - port: 0, - serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, - handshakeTimeoutMs, - }); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - await new Promise((resolve) => setTimeout(resolve, handshakeTimeoutMs * 3)); - assert.equal(provider.connectionCount, 1, "a connected peer must not be evicted by the handshake timer"); - - await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence: 1n })); - const received = await consumer.receiveFrame(); - assert.equal(received.sequence, 1n, "a peer past the handshake deadline must still receive frames"); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } - }, -); - -test("fix 2: an invalid frame throws from broadcast, and connectionCount is unchanged", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - // A 70,000-byte channelId exceeds the wire's 0xffff identity limit: - // `encodeRealtimeFrame` throws. Before fix 2, encoding happened once - // per peer inside `sendFrame`, so the same failure evicted every peer - // while `broadcast()` itself reported success. - await assert.rejects( - provider.broadcast(testFrame({ delivery: "reliable-ordered", channelId: "x".repeat(70_000) })), - /exceeds the QUIC wire limit/i, - ); - assert.equal(provider.connectionCount, 1, "an invalid frame must not evict any peer"); - - // The peer must still be usable afterward: prove the connection itself - // survived, not merely that the count field wasn't decremented. - await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence: 42n })); - assert.equal((await consumer.receiveFrame()).sequence, 42n); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test("fix 2: a latest-only invalid frame also throws from broadcast without evicting any peer", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - await assert.rejects( - provider.broadcast(testFrame({ delivery: "latest-only", channelId: "x".repeat(70_000) })), - /exceeds the QUIC wire limit/i, - ); - assert.equal(provider.connectionCount, 1, "an invalid latest-only frame must not evict any peer"); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test("fix 3: advertisedHost is used in place of the bind address, and round-trips through the module's parser", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await CultMeshQuicRealtimeProvider.listen({ - host: "0.0.0.0", - port: 0, - serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, - advertisedHost: "streampixels.gamecult.org", - }); - try { - assert.match( - provider.advertisedEndpoint, - /^cultmesh-state\+quic:\/\/streampixels\.gamecult\.org:\d+\?cert-sha256=[0-9A-F]{64}$/, - ); - const parsed = parseQuicRealtimeEndpoint(provider.advertisedEndpoint); - assert.equal(parsed.host, "streampixels.gamecult.org"); - assert.equal(parsed.port, Number(new URL(provider.advertisedEndpoint).port)); - assert.ok(parsed.certificateSha256); - } finally { - provider.dispose(); - } -}); - -test("fix 3: an IPv6 bind advertises a bracketed literal that round-trips through the module's parser", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await CultMeshQuicRealtimeProvider.listen({ - host: "::1", - port: 0, - serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, - }); - try { - assert.match( - provider.advertisedEndpoint, - /^cultmesh-state\+quic:\/\/\[::1\]:\d+\?cert-sha256=[0-9A-F]{64}$/, - ); - // `URL`'s own `hostname` keeps the brackets for an IPv6 literal (unlike - // the unbracketed form `formatEndpointHost` accepted as input); the - // round trip's job is proving the endpoint parses at all, not stripping - // brackets the WHATWG URL parser itself never strips. - const parsed = parseQuicRealtimeEndpoint(provider.advertisedEndpoint); - assert.equal(parsed.host, "[::1]"); - } finally { - provider.dispose(); - } -}); - -test("fix 4: receive() with an already-aborted signal rejects immediately instead of hanging", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const controller = new AbortController(); - controller.abort(); - await assert.rejects(provider.receive(controller.signal), /aborted/i); - } finally { - provider.dispose(); - } -}); - -test("P3: a latest-only send failure evicts the peer without broadcast() throwing to the caller", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - await waitUntil(() => provider.connectionCount === 1); - consumer.dispose(); - // Mirrors the existing reliable-ordered eviction test, but for the - // outbox's own send failure path (`CultMeshQuicRealtimeProviderOutbox.pump`'s - // catch, which disposes the peer and reports failure through - // `onSendFailure`) rather than `broadcast()`'s direct per-peer catch. - // - // What made this flaky: unlike the reliable-ordered version, - // `broadcast()` never awaits a latest-only peer's actual send — it only - // hands the frame to that peer's outbox and returns (see - // `CultMeshQuicRealtimeProvider.broadcast`). A `while (Date.now() < - // deadline)` loop therefore does no real I/O per iteration; it just - // spins encoding and enqueueing frames as fast as the CPU allows, - // starving the event-loop turns the native pump needs to actually - // observe the dead connection and fire `send_complete`/ - // `CONNECTION_SHUTDOWN`. Under any extra load in the test process - // (exactly what running under mutation testing adds, regardless of - // which line was mutated) that starvation can burn the whole wall-clock - // bound without ever landing an attempt past the point the connection - // is actually dead — a failure with nothing to do with the mutant under - // test. Raising the bound only buys more spinning, not more real - // attempts. Fixed by decoupling "how many attempts to seed" (a small, - // fixed count, each with a real yield) from "how long to wait for the - // result" (a single `waitUntil` afterward, which is what actually - // bounds this test). - for (let sequence = 0n; sequence < 20n && provider.connectionCount > 0; sequence += 1n) { - await provider.broadcast(testFrame({ delivery: "latest-only", sequence, bodyId: `body:${sequence}` })); - await new Promise((resolve) => setTimeout(resolve, 25)); - } - await waitUntil(() => provider.connectionCount === 0); - assert.equal(provider.connectionCount, 0, "the dead peer must be evicted, not counted forever"); - } finally { - provider.dispose(); - } -}); - -test("I1: an inbox key keeps its first-arrival position when a newer frame overwrites it", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - - // Key A arrives first, then key B, then A is republished with a new - // value while still unread: `CultMeshRealtimeInbox.publish`'s - // coalescing must overwrite A's *value* in place, not move it to the - // back of the queue behind B. `received` is exactly that inbox, - // reached through the provider's client fan-in. - await consumer.sendFrame(testFrame({ delivery: "latest-only", bodyId: "body:A", sequence: 1n })); - await waitUntil(() => provider.receiveQueueSize === 1); - await consumer.sendFrame(testFrame({ delivery: "latest-only", bodyId: "body:B", sequence: 1n })); - await waitUntil(() => provider.receiveQueueSize === 2); - await consumer.sendFrame(testFrame({ delivery: "latest-only", bodyId: "body:A", sequence: 2n })); - // Still 2: the second A publish must coalesce onto the already-queued - // A entry, not add a third. - await new Promise((resolve) => setTimeout(resolve, 200)); - assert.equal(provider.receiveQueueSize, 2, "a republish of a still-pending key must not grow the queue"); - - const first = await provider.receive(); - const second = await provider.receive(); - assert.equal(first.bodyId, "body:A", "A must still drain first: its position must survive the overwrite"); - assert.equal(first.sequence, 2n, "A's value must be the newer, overwritten one"); - assert.equal(second.bodyId, "body:B"); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test("L1: the outbox keeps one send in flight per peer; the next frame dequeues only after send_complete", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - - // Prototype-patched for the life of this test, restored in `finally`: - // the provider builds its peer transport internally, so this is the - // only vantage point outside the module from which to observe - // concurrency of `sendEncodedFrame` calls, which is what - // `CultMeshQuicRealtimeProviderOutbox.pump` serializes one-at-a-time - // against `send_complete`. - const original = CultMeshQuicRealtimeTransportClass.prototype.sendEncodedFrame; - let active = 0; - let maxActive = 0; - CultMeshQuicRealtimeTransportClass.prototype.sendEncodedFrame = function ( - this: InstanceType, - delivery: "reliable-ordered" | "latest-only", - encoded: Uint8Array, - ) { - active += 1; - maxActive = Math.max(maxActive, active); - return original.call(this, delivery, encoded).finally(() => { - active -= 1; - }); - }; - try { - const total = 20; - const sends: Promise[] = []; - for (let key = 0; key < total; key += 1) { - sends.push(provider.broadcast(testFrame({ bodyId: `body:${key}`, sequence: 1n }))); - } - await Promise.all(sends); - - const seen = new Set(); - while (seen.size < total) { - const frame = await consumer.receiveFrame(); - seen.add(frame.bodyId); - } - } finally { - CultMeshQuicRealtimeTransportClass.prototype.sendEncodedFrame = original; - } - assert.equal(maxActive, 1, "the outbox pump must never have two sends in flight for one peer at once"); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test("P15: broadcast() after dispose rejects instead of touching a torn-down provider", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - provider.dispose(); - await assert.rejects(provider.broadcast(testFrame()), /disposed/i); -}); - -test("P17: an accept arriving after dispose is refused, not attached as a peer", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - const runtime = await CultMeshQuicNativeRuntime.open(); - let connectionId: bigint | undefined; - try { - // A real native connection handle this test owns outright (never dialed - // anywhere reachable, so shutting it down is safe): stands in for the - // handle a genuine NEW_CONNECTION event would have carried, without - // fabricating a bogus native pointer. `acceptConnection` is private; - // reaching it directly is what lets this test drive the exact race - // (an accept event landing after `dispose()` has already run) without - // depending on real listener-close timing to reproduce it. - connectionId = runtime.connectionOpen("127.0.0.1", 1); - provider.dispose(); - // This synthetic connection never reaches CONNECTED either way (it was - // never dialed anywhere), so `connectionCount` alone cannot distinguish - // "refused" from "attached but still handshaking". `retain()` is - // synchronous and only the attach path calls it: a stable `refCount` - // across the call proves no transport (and no runtime reference) was - // ever allocated for this connection. - const beforeRefCount = CultMeshQuicNativeRuntime.refCount; - assert.doesNotThrow(() => - (provider as unknown as { acceptConnection(id: bigint): void }).acceptConnection(connectionId!), - ); - assert.equal( - CultMeshQuicNativeRuntime.refCount, - beforeRefCount, - "a post-dispose accept must never retain a runtime reference for a new transport", - ); - assert.equal(provider.connectionCount, 0, "a post-dispose accept must never join peers"); - } finally { - if (connectionId !== undefined) { - try { - runtime.connectionShutdown(connectionId, 0n); - } catch { - // Best-effort: acceptConnection's own disposed-path may already have shut it down. - } - } - await runtime.release(); - } -}); - -test("fix 1: a publish-only provider faults a client that sends to it, and its memory stays empty", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await CultMeshQuicRealtimeProvider.listen({ - host: "127.0.0.1", - port: 0, - serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, - acceptClientFrames: false, - }); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - - // receive() itself rejects clearly rather than hanging forever. - await assert.rejects(provider.receive(), /publish-only/i); - - // Any inbound stream from the client faults that connection. The - // client's own `sendFrame` promise settles on its *local* stream - // send completing (MsQuic hands the bytes off and reports - // send_complete without waiting for the peer to process them), so it - // may well resolve even though the server already faulted its side — - // the proof this test owns is the server's state, not the client's - // send outcome, so the client send is fired and ignored either way. - void consumer.sendFrame(testFrame({ delivery: "latest-only" })).catch(() => {}); - await waitUntil(() => provider.connectionCount === 0); - assert.equal(provider.receiveQueueSize, 0, "no frame from a faulted publish-only client may be queued"); - - // The provider itself is otherwise healthy: broadcasting still works - // for a freshly attached peer. - const second = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence: 1n })); - assert.equal((await second.receiveFrame()).sequence, 1n); - } finally { - second.dispose(); - } - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test("fix 2: a connection beyond maxConnections is refused and existing peers are unaffected", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await CultMeshQuicRealtimeProvider.listen({ - host: "127.0.0.1", - port: 0, - serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, - maxConnections: 1, - }); - try { - const first = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - - // Refused at accept: never reaches CONNECTED, never becomes a peer. - await assertRejectsAndDisposes(dialProvider(provider), /certificate|rejected|closed|shut|timed out/i); - // Give the refusal a moment to be visible either way, then prove the - // existing peer was never touched. - await new Promise((resolve) => setTimeout(resolve, 200)); - assert.equal(provider.connectionCount, 1, "the existing peer must be unaffected by a refused connection"); - - await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence: 7n })); - assert.equal((await first.receiveFrame()).sequence, 7n, "the existing peer must still be fully usable"); - } finally { - first.dispose(); - } - } finally { - provider.dispose(); - } -}); diff --git a/packages/cultmesh-ts/test/realtime-quic-release-uaf.test.ts b/packages/cultmesh-ts/test/realtime-quic-release-uaf.test.ts deleted file mode 100644 index f7958570..00000000 --- a/packages/cultmesh-ts/test/realtime-quic-release-uaf.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Regression test for fix 2: CultMeshQuicNativeRuntime.release() must await -// the pump loop's exit before calling cultmesh_quic_runtime_close, never -// after. Reverting that order (close before awaiting the pump) reliably -// crashes this test under a saturated libuv thread pool and MALLOC_PERTURB_, -// at every pool size tried by hand while verifying this batch; restored -// immediately afterward, never committed. -// -// UV_THREADPOOL_SIZE and MALLOC_PERTURB_ must be set before Node starts, so -// this spawns a child `node` process running test/support/release-uaf-probe.js -// with that environment, once per pool size, and asserts it exits 0. Kept to -// one scenario (see the probe's own header) so the whole test stays bounded -// to a few seconds. - -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { join } from "node:path"; -import test from "node:test"; - -import { nativeBridgeAvailable } from "./support/native-bridge"; - -const PROBE_SCRIPT = join(__dirname, "..", "..", "test", "support", "release-uaf-probe.js"); - -test("fix 2 regression: release()'s ordering survives a saturated thread pool at pool sizes 1, 2 and 4", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - - for (const poolSize of [1, 2, 4]) { - const result = spawnSync(process.execPath, [PROBE_SCRIPT], { - env: { - ...process.env, - UV_THREADPOOL_SIZE: String(poolSize), - MALLOC_PERTURB_: "165", - }, - encoding: "utf8", - timeout: 60_000, - }); - assert.equal( - result.status, - 0, - `pool size ${poolSize}: probe exited status=${result.status} signal=${result.signal}\n` + - `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`, - ); - } -}); diff --git a/packages/cultmesh-ts/test/realtime-quic-session-manager.test.ts b/packages/cultmesh-ts/test/realtime-quic-session-manager.test.ts deleted file mode 100644 index e1843466..00000000 --- a/packages/cultmesh-ts/test/realtime-quic-session-manager.test.ts +++ /dev/null @@ -1,358 +0,0 @@ -// `CultMeshQuicRealtimeSessionManager`'s own rules, isolated from the native -// bridge with fake connectors/transports: eviction on transport death (fix -// 4), single-flight (fix 4), the disposed guard (fix 4), first-success racing -// with abort of the losers (fix 5), the `protocolIds` filter (M7), the -// `authorityRuntimeId` filter (M8), the session key including -// `authorityRuntimeId` (M9), and the `maxRacedCandidates` cap (M6). -// -// Routes use `trust.mode = "local-development"` over loopback endpoints, so -// `verifyAuthorityRoute` short-circuits without a signed certificate -// (`cultmesh-authority.ts:99`): these tests are about the session manager's -// own binding/racing/caching rules, not route trust, which -// `realtime-quic-consumer.test.ts`'s trust negatives already pin. - -import assert from "node:assert/strict"; -import test from "node:test"; - -import type { CultMeshAuthorityTrustPolicy } from "cultnet-ts"; -import type { CultMeshAuthorityRouteMessage, CultMeshVerseDescriptorMessage } from "cultnet-ts/contracts"; - -import { - CultMeshQuicRealtimeSessionManager, - CultMeshStaticRealtimeLookupSource, - CULTMESH_REALTIME_STATE_PROTOCOL_ID, - type CultMeshRealtimeCandidate, - type CultMeshRealtimeTarget, - type CultMeshRealtimeTransport, - type CultMeshRealtimeTransportConnector, -} from "../src/realtime-quic"; - -const trust: CultMeshAuthorityTrustPolicy = { mode: "local-development", odinRoots: [] }; - -function route(overrides: Partial = {}): CultMeshAuthorityRouteMessage { - return { - authorityRuntimeId: "service:aetheria.daemon", - endpoint: "cultmesh-state+quic://127.0.0.1:9443", - protocolIds: [CULTMESH_REALTIME_STATE_PROTOCOL_ID], - priority: 0, - generation: "gen-1", - ...overrides, - }; -} - -function verseWithRoutes(verseId: string, routes: readonly CultMeshAuthorityRouteMessage[]): CultMeshVerseDescriptorMessage { - return { - verseId, - displayName: verseId, - authorityModel: "operator-cluster", - compatibility: { transportVersion: "cultmesh.v1", rulesHash: "test", compatibleVerseIds: [], requiredPluginIds: [], optionalPluginIds: [] }, - discoveryEndpoints: routes.map((r) => r.endpoint), - authorityRuntimeIds: [...new Set(routes.map((r) => r.authorityRuntimeId))], - authorityRoutes: [...routes], - }; -} - -interface FakeTransportHandle { - readonly transport: CultMeshRealtimeTransport; - disposed(): boolean; -} - -/** A transport that never resolves `receiveFrame` and tracks its own disposal. */ -function fakeTransport(endpoint: string, verified = true): FakeTransportHandle { - let disposed = false; - const disposalHandlers: Array<() => void> = []; - const transport: CultMeshRealtimeTransport = { - transportId: "fake", - endpoint, - async sendFrame() {}, - async receiveFrame() { - return await new Promise(() => {}); - }, - dispose() { - if (disposed) return; - disposed = true; - for (const handler of disposalHandlers) handler(); - }, - isVerifiedFor: () => verified, - onDisposed(handler) { - if (disposed) { - handler(); - return; - } - disposalHandlers.push(handler); - }, - }; - return { transport, disposed: () => disposed }; -} - -/** A connector that resolves immediately (unless `delayMs` is given) with one fake transport per call. */ -function spyConnector( - connectorId: string, - priority: number, - options: { delayMs?: number; fails?: boolean; ignoreAbort?: boolean } = {}, -): { - connector: CultMeshRealtimeTransportConnector; - calls: CultMeshRealtimeCandidate[]; - handles: FakeTransportHandle[]; - /** Timestamps at which this connector's abort signal actually fired. */ - aborts: number[]; -} { - const calls: CultMeshRealtimeCandidate[] = []; - const handles: FakeTransportHandle[] = []; - const aborts: number[] = []; - const connector: CultMeshRealtimeTransportConnector = { - connectorId, - priority, - canConnect: () => true, - connect: async (candidate, _target, signal) => { - calls.push(candidate); - if (options.delayMs) { - await new Promise((resolve, reject) => { - const timer = setTimeout(resolve, options.delayMs); - if (!options.ignoreAbort) { - signal?.addEventListener("abort", () => { - clearTimeout(timer); - aborts.push(Date.now()); - reject(new Error(`${connectorId} connect aborted`)); - }); - } - }); - } - if (options.fails) throw new Error(`${connectorId} refused to connect`); - const handle = fakeTransport(candidate.endpoint); - handles.push(handle); - return handle.transport; - }, - }; - return { connector, calls, handles, aborts }; -} - -const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - -test("fix 4: connect() caches a session and evicts it once the transport dies, so the next connect redials", async () => { - const { connector, calls } = spyConnector("fake", 0); - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoutes(target.verseId, [route()])]), - trust, - connectors: [connector], - }); - - const first = await manager.connect(target); - assert.equal(await manager.connect(target), first, "a live session is returned from cache, not redialed"); - assert.equal(calls.length, 1); - - first.dispose(); - const second = await manager.connect(target); - assert.notEqual(second, first, "a dead session is evicted, so the next connect redials"); - assert.equal(calls.length, 2); -}); - -test("fix 4: concurrent connect() calls for the same target share one dial (single-flight)", async () => { - const { connector, calls } = spyConnector("fake", 0, { delayMs: 50 }); - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoutes(target.verseId, [route()])]), - trust, - connectors: [connector], - }); - - const [a, b, c] = await Promise.all([manager.connect(target), manager.connect(target), manager.connect(target)]); - assert.equal(a, b); - assert.equal(b, c); - assert.equal(calls.length, 1, "three concurrent callers must share one dial"); -}); - -test("fix 4/low: dispose() aborts a dial in flight instead of letting it land and discarding the result", async () => { - // A long delay: if `dispose()` did not cancel the dial, this test would - // have to wait it out (or race a timing-dependent assertion) to observe - // that the eventually-created transport gets disposed instead of cached. - const { connector, handles } = spyConnector("fake", 0, { delayMs: 5_000 }); - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoutes(target.verseId, [route()])]), - trust, - connectors: [connector], - }); - - const pending = manager.connect(target); - const start = Date.now(); - manager.dispose(); - await assert.rejects(pending); - assert.ok( - Date.now() - start < 1_000, - "dispose() must cancel the in-flight dial rather than waiting out the connector's own timeout", - ); - assert.equal(handles.length, 0, "an aborted dial must never construct a transport only to immediately discard it"); - await assert.rejects(manager.connect(target), /disposed/i, "a disposed manager refuses further connects"); -}); - -test("fix 4/low: disconnect() during a dial does not let the dial cache a session once it lands", async () => { - const { connector, handles } = spyConnector("fake", 0, { delayMs: 30 }); - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoutes(target.verseId, [route()])]), - trust, - connectors: [connector], - }); - - const pending = manager.connect(target); - manager.disconnect(target); - await assert.rejects(pending); - // Whether or not the aborted dial still managed to construct a transport - // (a race against the abort), it must end up disposed, never cached. - await new Promise((resolve) => setTimeout(resolve, 60)); - for (const handle of handles) assert.equal(handle.disposed(), true, "a cancelled dial's transport must be disposed"); - - // The manager itself is still usable: a fresh connect redials and succeeds. - const { connector: connector2 } = spyConnector("fake", 0); - const manager2 = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoutes(target.verseId, [route()])]), - trust, - connectors: [connector2], - }); - const fresh = await manager2.connect(target); - assert.ok(fresh); -}); - -test("fix 5: the race returns the first verified success and aborts the losers", async () => { - const winner = spyConnector("fast", 0, { delayMs: 10 }); - const loser = spyConnector("slow", 0, { delayMs: 5_000 }); - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([ - verseWithRoutes(target.verseId, [ - route({ endpoint: "cultmesh-state+quic://127.0.0.1:9001" }), - route({ endpoint: "cultmesh-state+quic://127.0.0.1:9002" }), - ]), - ]), - trust, - connectors: [winner.connector, loser.connector], - maxRacedCandidates: 2, - }); - - const startedAt = Date.now(); - const transport = await manager.connect(target); - const elapsedMs = Date.now() - startedAt; - assert.ok(elapsedMs < 1_000, `connect() must return on the first success, not wait out the slow route (took ${elapsedMs}ms)`); - assert.equal(transport.endpoint, "cultmesh-state+quic://127.0.0.1:9001"); -}); - -test("F5abort: a losing candidate is told to abort promptly, not left to run out its own timeout", async () => { - const winner = spyConnector("fast", 0, { delayMs: 10 }); - const loser = spyConnector("slow", 0, { delayMs: 5_000 }); - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([ - verseWithRoutes(target.verseId, [ - route({ endpoint: "cultmesh-state+quic://127.0.0.1:9001" }), - route({ endpoint: "cultmesh-state+quic://127.0.0.1:9002" }), - ]), - ]), - trust, - connectors: [winner.connector, loser.connector], - maxRacedCandidates: 2, - }); - - const startedAt = Date.now(); - await manager.connect(target); - await new Promise((resolve) => setTimeout(resolve, 300)); - assert.equal(loser.aborts.length, 1, "the losing candidate's connect() must observe an abort signal"); - assert.ok( - loser.aborts[0]! - startedAt < 1_000, - "the abort must arrive promptly after the winner is decided, not after the loser's own 5s timeout", - ); -}); - -test("F5loser: a losing candidate that connects anyway (past the point abort helps) is disposed, not left dangling", async () => { - const winner = spyConnector("fast", 0, { delayMs: 10 }); - // Ignores the abort signal entirely, simulating a candidate already past - // the point where cancellation helps: it still produces a real transport. - const loser = spyConnector("slow", 0, { delayMs: 80, ignoreAbort: true }); - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([ - verseWithRoutes(target.verseId, [ - route({ endpoint: "cultmesh-state+quic://127.0.0.1:9001" }), - route({ endpoint: "cultmesh-state+quic://127.0.0.1:9002" }), - ]), - ]), - trust, - connectors: [winner.connector, loser.connector], - maxRacedCandidates: 2, - }); - - const transport = await manager.connect(target); - assert.equal(transport.endpoint, "cultmesh-state+quic://127.0.0.1:9001"); - await new Promise((resolve) => setTimeout(resolve, 200)); - assert.equal(loser.handles.length, 1, "the loser still connects despite racing to lose"); - assert.equal( - loser.handles[0]!.disposed(), - true, - "a losing candidate that connects after the race is decided must be disposed, never left connected", - ); -}); - -test("M6: maxRacedCandidates caps how many candidates in a tier are attempted", async () => { - const spy = spyConnector("fake", 0); - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([ - verseWithRoutes(target.verseId, [ - route({ endpoint: "cultmesh-state+quic://127.0.0.1:9001", priority: 0 }), - route({ endpoint: "cultmesh-state+quic://127.0.0.1:9002", priority: 1 }), - route({ endpoint: "cultmesh-state+quic://127.0.0.1:9003", priority: 2 }), - ]), - ]), - trust, - connectors: [spy.connector], - maxRacedCandidates: 1, - }); - - await manager.connect(target); - assert.equal(spy.calls.length, 1, "only the top maxRacedCandidates entries in the tier are attempted"); - assert.equal(spy.calls[0]!.endpoint, "cultmesh-state+quic://127.0.0.1:9001", "the lowest route priority races first"); -}); - -test("M7: a route whose protocolIds omit the realtime-state protocol is not a candidate", async () => { - const spy = spyConnector("fake", 0); - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([ - verseWithRoutes(target.verseId, [route({ protocolIds: ["cultmesh.other_protocol.v1"] })]), - ]), - trust, - connectors: [spy.connector], - }); - - await assert.rejects(manager.connect(target), /No realtime state route was advertised/); - assert.equal(spy.calls.length, 0); -}); - -test("M8: a route advertised for a different authorityRuntimeId is not a candidate", async () => { - const spy = spyConnector("fake", 0); - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([ - verseWithRoutes(target.verseId, [route({ authorityRuntimeId: "service:other.daemon" })]), - ]), - trust, - connectors: [spy.connector], - }); - - await assert.rejects(manager.connect(target), /No realtime state route was advertised/); - assert.equal(spy.calls.length, 0); -}); - -test("M9: the session key includes authorityRuntimeId, so two authorities in the same Verse get separate sessions", async () => { - const spy = spyConnector("fake", 0); - const targetA: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:a.daemon" }; - const targetB: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:b.daemon" }; - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([ - verseWithRoutes("aetheria", [ - route({ authorityRuntimeId: "service:a.daemon", endpoint: "cultmesh-state+quic://127.0.0.1:9001" }), - route({ authorityRuntimeId: "service:b.daemon", endpoint: "cultmesh-state+quic://127.0.0.1:9002" }), - ]), - ]), - trust, - connectors: [spy.connector], - }); - - const sessionA = await manager.connect(targetA); - const sessionB = await manager.connect(targetB); - assert.notEqual(sessionA, sessionB); - assert.equal(sessionA.endpoint, "cultmesh-state+quic://127.0.0.1:9001"); - assert.equal(sessionB.endpoint, "cultmesh-state+quic://127.0.0.1:9002"); - assert.equal(spy.calls.length, 2); -}); diff --git a/packages/cultmesh-ts/test/realtime-wire.test.ts b/packages/cultmesh-ts/test/realtime-wire.test.ts deleted file mode 100644 index 93f23365..00000000 --- a/packages/cultmesh-ts/test/realtime-wire.test.ts +++ /dev/null @@ -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 } { - const text = readFileSync(path, "utf8"); - const int64s = new Map(); - const pattern = /"label": "(?