diff --git a/.github/workflows/cultmesh-portability.yml b/.github/workflows/cultmesh-portability.yml deleted file mode 100644 index ce361fc8..00000000 --- a/.github/workflows/cultmesh-portability.yml +++ /dev/null @@ -1,97 +0,0 @@ -name: CultMesh portability and performance - -on: - push: - branches: - - main - - "codex/**" - pull_request: - workflow_dispatch: - -jobs: - getting-started: - name: ${{ matrix.os }} one-command multiplayer onboarding - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - os: [windows-latest, ubuntu-latest] - - steps: - - name: Check out CultLib - uses: actions/checkout@v4 - with: - path: CultLib - - - name: Check out the matching Eve contract - uses: actions/checkout@v4 - with: - repository: GameCult/Eve - # Eve main is the contract this sample is written against; the codex - # branch this used to pin was never merged and stopped on 2026-08-25. - ref: ${{ vars.EVE_INTEGRATION_REF || 'main' }} - path: Eve - - - name: Set up .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 10.0.x - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 24 - - - name: Run the documented one-command checkpoint - shell: pwsh - run: ./CultLib/scripts/verify-eve-getting-started.ps1 -EveRoot ./Eve - - reactive-and-resource-lifetime: - name: ${{ matrix.os }} reactive semantics and leases - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - os: [windows-latest, ubuntu-latest] - - steps: - - name: Check out CultLib - uses: actions/checkout@v4 - - - name: Set up .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 10.0.x - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: npm - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install TypeScript workspace dependencies - run: npm install --no-package-lock - - - name: Install Python CultLib packages - run: python -m pip install -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py - - - name: Verify explicit reactive scheduling across runtimes - shell: pwsh - run: ./scripts/verify-reactive-document-scaling.ps1 - - - name: Measure representative reactive workload - shell: pwsh - run: ./scripts/measure-reactive-document-performance.ps1 -Quick -SkipRuntimeBuild - - - name: Churn 100,000 distinct document leases - run: >- - dotnet test tests/GameCult.Mesh.Tests/GameCult.Mesh.Tests.csproj - --filter FullyQualifiedName~Document_OneHundredThousandDistinctLeaseCyclesLeaveNoResourcesOrCallbacks - --logger "console;verbosity=minimal" diff --git a/.github/workflows/cultnet-interop.yml b/.github/workflows/cultnet-interop.yml index 6876a76a..1bde67ef 100644 --- a/.github/workflows/cultnet-interop.yml +++ b/.github/workflows/cultnet-interop.yml @@ -33,6 +33,10 @@ jobs: uses: actions/setup-node@v4 with: node-version: 24 + cache: npm + cache-dependency-path: | + packages/cultcache-ts/package-lock.json + packages/cultnet-ts/package-lock.json - name: Set up Python uses: actions/setup-python@v5 @@ -69,7 +73,7 @@ jobs: run: npm install --no-package-lock - name: Install Python package dependencies - run: python -m pip install -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py + run: python -m pip install -e packages/cultcache-py - name: Build TypeScript workspace dependencies run: npm run build --workspace packages/cultcache-ts @@ -83,24 +87,6 @@ jobs: npm run build --workspace packages/cultnet-ts npx tsc -p packages/cultnet-ts/tsconfig.test.json --pretty false - - name: Verify TypeScript RUDP reliability - run: npm test --workspace packages/cultnet-ts - - - name: Verify Python RUDP reliability - run: | - python -m unittest discover -s packages/cultcache-py/tests - python -m unittest discover -s packages/cultnet-py/tests - python -m unittest discover -s packages/cultmesh-py/tests - - - name: Verify Rust RUDP reliability - run: cargo test --manifest-path packages/cultnet-rs/Cargo.toml rudp - - - name: Verify Rust CultMesh runtime-local surface - run: cargo test --manifest-path packages/cultmesh-rs/Cargo.toml - - - name: Verify C# RUDP reliability - run: dotnet test tests/GameCult.Networking.Tests/GameCult.Networking.Tests.csproj --filter "FullyQualifiedName~Rudp" - - name: Interop RUDP Python lane timeout-minutes: 8 run: node --test --test-name-pattern "TypeScript and Python|Python and TypeScript" packages/cultnet-ts/dist-test/test/interop/cultnet-interop.test.js diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml deleted file mode 100644 index 6061ca7a..00000000 --- a/.github/workflows/publish-packages.yml +++ /dev/null @@ -1,146 +0,0 @@ -name: Publish packages - -# Non-Rust consumers cannot take a package from a subdirectory of a git repo the -# way cargo can, which is why they ended up vendoring forks instead. This gives -# them a versioned artifact to depend on so CultLib stays the only source. -# -# Rust needs nothing here: cargo git dependencies pin a CultLib revision -# directly, which is already how Ghostlight, Epiphany, CodexConnector and Odin -# consume the core stack. -# -# Tag a release to publish, for example `cultcache-ts-v0.13.6` or -# `cultnet-py-v0.1.0`. The tag's version must match the version in the package -# manifest; the workflow refuses the publish otherwise rather than shipping a -# mislabelled artifact. - -on: - push: - tags: - - "cultcache-ts-v*" - - "cultcache-py-v*" - - "cultnet-py-v*" - - "cultmesh-py-v*" - workflow_dispatch: - inputs: - package: - description: Package to build and test (publishes nothing on manual runs) - required: true - type: choice - options: - - cultcache-ts - - cultcache-py - - cultnet-py - - cultmesh-py - -permissions: - contents: read - -jobs: - cultcache-ts: - name: "@gamecult/cultcache-ts to npm" - if: startsWith(github.ref, 'refs/tags/cultcache-ts-v') || inputs.package == 'cultcache-ts' - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Check out CultLib - uses: actions/checkout@v4 - - - name: Set up Node - uses: actions/setup-node@v4 - with: - node-version: 24.x - registry-url: https://registry.npmjs.org - - - name: Install workspace dependencies - run: npm ci - - - name: Verify the tag matches the manifest version - if: startsWith(github.ref, 'refs/tags/cultcache-ts-v') - run: | - manifest="$(node -p "require('./packages/cultcache-ts/package.json').version")" - tagged="${GITHUB_REF_NAME#cultcache-ts-v}" - if [ "$manifest" != "$tagged" ]; then - echo "tag $GITHUB_REF_NAME does not match manifest version $manifest" >&2 - exit 1 - fi - - - name: Build and test - working-directory: packages/cultcache-ts - run: npm test - - # Scoped packages default to restricted even on public npm, and this is - # MIT like the rest of the repo, so say public explicitly. - - name: Publish - if: startsWith(github.ref, 'refs/tags/cultcache-ts-v') - working-directory: packages/cultcache-ts - run: npm publish --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - # The three Python packages layer cultcache-py < cultnet-py < cultmesh-py, the - # same way the Rust crates and npm packages do. One job shape serves all - # three; the matrix entry names the package and the tag prefix that releases it. - python: - name: ${{ matrix.package }} to PyPI - if: startsWith(github.ref, 'refs/tags/cultcache-py-v') || startsWith(github.ref, 'refs/tags/cultnet-py-v') || startsWith(github.ref, 'refs/tags/cultmesh-py-v') || endsWith(inputs.package, '-py') - strategy: - fail-fast: false - matrix: - package: [cultcache-py, cultnet-py, cultmesh-py] - runs-on: ubuntu-latest - timeout-minutes: 20 - environment: pypi - permissions: - contents: read - # PyPI trusted publishing exchanges this for a short-lived token, so no - # long-lived API token is stored in the repository. - id-token: write - steps: - - name: Skip packages this run does not name - id: selected - run: | - if [ "${GITHUB_REF_NAME#${{ matrix.package }}-v}" != "$GITHUB_REF_NAME" ] || [ "${{ inputs.package }}" = "${{ matrix.package }}" ]; then - echo "run=true" >> "$GITHUB_OUTPUT" - else - echo "run=false" >> "$GITHUB_OUTPUT" - fi - - - name: Check out CultLib - if: steps.selected.outputs.run == 'true' - uses: actions/checkout@v4 - - - name: Set up Python - if: steps.selected.outputs.run == 'true' - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Verify the tag matches the manifest version - if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/') - run: | - manifest="$(python -c "import tomllib,pathlib;print(tomllib.loads(pathlib.Path('packages/${{ matrix.package }}/pyproject.toml').read_text())['project']['version'])")" - tagged="${GITHUB_REF_NAME#${{ matrix.package }}-v}" - if [ "$manifest" != "$tagged" ]; then - echo "tag $GITHUB_REF_NAME does not match manifest version $manifest" >&2 - exit 1 - fi - - # 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 - if: steps.selected.outputs.run == 'true' - run: | - python -m pip install -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py - python -m unittest discover -s packages/${{ matrix.package }}/tests - - - name: Build sdist and wheel - if: steps.selected.outputs.run == 'true' - run: | - python -m pip install --upgrade build - python -m build packages/${{ matrix.package }} - - - name: Publish - if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/') - uses: pypa/gh-action-pypi-publish@release/v1 - with: - packages-dir: packages/${{ matrix.package }}/dist diff --git a/.gitignore b/.gitignore index e4661501..d639c709 100644 --- a/.gitignore +++ b/.gitignore @@ -303,22 +303,10 @@ node_modules/ !packages/cultmesh-ts/** !packages/cultcache-py/ !packages/cultcache-py/** -!packages/cultnet-py/ -!packages/cultnet-py/** -!packages/cultmesh-py/ -!packages/cultmesh-py/** !packages/cultcache-rs/ !packages/cultcache-rs/** !packages/cultnet-rs/ !packages/cultnet-rs/** -!packages/cultmesh-rs/ -!packages/cultmesh-rs/** -!packages/cultmath/ -!packages/cultmath/** -!packages/cultmesh-browser/ -!packages/cultmesh-browser/** -!packages/cultmesh-kotlin/ -!packages/cultmesh-kotlin/** packages/**/node_modules/ packages/**/dist/ packages/**/dist-test/ @@ -516,7 +504,3 @@ $RECYCLE.BIN/ # Vim temporary swap files *.swp - -# Python package build output -packages/*-py/build/ -*.egg-info/ diff --git a/.voidbot/status/void-memory-maintenance.json b/.voidbot/status/void-memory-maintenance.json new file mode 100644 index 00000000..0d0b876a --- /dev/null +++ b/.voidbot/status/void-memory-maintenance.json @@ -0,0 +1,7 @@ +{ + "status": "starting", + "startedAt": "2026-06-14T19:46:12.715Z", + "stateFile": "E:\\Projects\\CultLib\\.voidbot\\state\\libby.cc", + "launcher": "repo_face_heartbeat", + "reason": "repo_face_stale_short_term_memory" +} diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 640c1a14..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,3 +0,0 @@ -Repository doctrine for this project lives in AGENTS.md, shared with Codex. - -@AGENTS.md diff --git a/CultLib.sln b/CultLib.sln index fbc20922..f6ae25e9 100644 --- a/CultLib.sln +++ b/CultLib.sln @@ -11,6 +11,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching", "src\Gam EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.MessagePack", "src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj", "{26EE4C38-F06E-4E7A-A89C-908318BCB0DB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.MessagePack.Generator", "src\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj", "{0E990C96-3808-450E-9349-A0C3262195ED}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.MessagePack.Analyzers", "src\GameCult.Caching.MessagePack.Analyzers\GameCult.Caching.MessagePack.Analyzers.csproj", "{DBE06954-EC85-4034-9049-72C7D27556DF}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking", "src\GameCult.Networking\GameCult.Networking.csproj", "{67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh", "src\GameCult.Mesh\GameCult.Mesh.csproj", "{8D1B01A7-BF9E-44E6-88E1-B17255728B42}" @@ -27,28 +31,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Geometry.Tests", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh.Tests", "tests\GameCult.Mesh.Tests\GameCult.Mesh.Tests.csproj", "{F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh.Quic", "src\GameCult.Mesh.Quic\GameCult.Mesh.Quic.csproj", "{B67042B8-1135-4E54-B1EB-D45342C720D7}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh.Quic.Tests", "tests\GameCult.Mesh.Quic.Tests\GameCult.Mesh.Quic.Tests.csproj", "{21F26329-2119-4037-93CC-DD1801A1AB78}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking.WebSockets", "src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj", "{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking.WebSockets.Tests", "tests\GameCult.Networking.WebSockets.Tests\GameCult.Networking.WebSockets.Tests.csproj", "{B9A441EC-DADE-4500-B092-C635F3EB296A}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EveBrowserNetworkSample", "samples\eve-browser-network\EveBrowserNetworkSample.csproj", "{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "packages", "packages", "{809F86A1-1C4C-B159-0CD4-DF9D33D876CE}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cultmath", "cultmath", "{B48EAE30-152F-01E3-6E31-82B350494D3C}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CultMath", "packages\cultmath\src\CultMath\CultMath.csproj", "{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CultMath.Tests", "packages\cultmath\tests\CultMath.Tests\CultMath.Tests.csproj", "{FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.ConsumerBuild", "tests\GameCult.Caching.ConsumerBuild\GameCult.Caching.ConsumerBuild.csproj", "{D3138F3C-9531-4906-A5D3-74BD857D0685}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -95,6 +77,30 @@ Global {26EE4C38-F06E-4E7A-A89C-908318BCB0DB}.Release|x64.Build.0 = Release|Any CPU {26EE4C38-F06E-4E7A-A89C-908318BCB0DB}.Release|x86.ActiveCfg = Release|Any CPU {26EE4C38-F06E-4E7A-A89C-908318BCB0DB}.Release|x86.Build.0 = Release|Any CPU + {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|x64.ActiveCfg = Debug|Any CPU + {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|x64.Build.0 = Debug|Any CPU + {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|x86.ActiveCfg = Debug|Any CPU + {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|x86.Build.0 = Debug|Any CPU + {0E990C96-3808-450E-9349-A0C3262195ED}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0E990C96-3808-450E-9349-A0C3262195ED}.Release|Any CPU.Build.0 = Release|Any CPU + {0E990C96-3808-450E-9349-A0C3262195ED}.Release|x64.ActiveCfg = Release|Any CPU + {0E990C96-3808-450E-9349-A0C3262195ED}.Release|x64.Build.0 = Release|Any CPU + {0E990C96-3808-450E-9349-A0C3262195ED}.Release|x86.ActiveCfg = Release|Any CPU + {0E990C96-3808-450E-9349-A0C3262195ED}.Release|x86.Build.0 = Release|Any CPU + {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|x64.ActiveCfg = Debug|Any CPU + {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|x64.Build.0 = Debug|Any CPU + {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|x86.ActiveCfg = Debug|Any CPU + {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|x86.Build.0 = Debug|Any CPU + {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|Any CPU.Build.0 = Release|Any CPU + {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|x64.ActiveCfg = Release|Any CPU + {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|x64.Build.0 = Release|Any CPU + {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|x86.ActiveCfg = Release|Any CPU + {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|x86.Build.0 = Release|Any CPU {67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}.Debug|Any CPU.Build.0 = Debug|Any CPU {67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -179,102 +185,6 @@ Global {F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Release|x64.Build.0 = Release|Any CPU {F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Release|x86.ActiveCfg = Release|Any CPU {F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Release|x86.Build.0 = Release|Any CPU - {B67042B8-1135-4E54-B1EB-D45342C720D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B67042B8-1135-4E54-B1EB-D45342C720D7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B67042B8-1135-4E54-B1EB-D45342C720D7}.Debug|x64.ActiveCfg = Debug|Any CPU - {B67042B8-1135-4E54-B1EB-D45342C720D7}.Debug|x64.Build.0 = Debug|Any CPU - {B67042B8-1135-4E54-B1EB-D45342C720D7}.Debug|x86.ActiveCfg = Debug|Any CPU - {B67042B8-1135-4E54-B1EB-D45342C720D7}.Debug|x86.Build.0 = Debug|Any CPU - {B67042B8-1135-4E54-B1EB-D45342C720D7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B67042B8-1135-4E54-B1EB-D45342C720D7}.Release|Any CPU.Build.0 = Release|Any CPU - {B67042B8-1135-4E54-B1EB-D45342C720D7}.Release|x64.ActiveCfg = Release|Any CPU - {B67042B8-1135-4E54-B1EB-D45342C720D7}.Release|x64.Build.0 = Release|Any CPU - {B67042B8-1135-4E54-B1EB-D45342C720D7}.Release|x86.ActiveCfg = Release|Any CPU - {B67042B8-1135-4E54-B1EB-D45342C720D7}.Release|x86.Build.0 = Release|Any CPU - {21F26329-2119-4037-93CC-DD1801A1AB78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {21F26329-2119-4037-93CC-DD1801A1AB78}.Debug|Any CPU.Build.0 = Debug|Any CPU - {21F26329-2119-4037-93CC-DD1801A1AB78}.Debug|x64.ActiveCfg = Debug|Any CPU - {21F26329-2119-4037-93CC-DD1801A1AB78}.Debug|x64.Build.0 = Debug|Any CPU - {21F26329-2119-4037-93CC-DD1801A1AB78}.Debug|x86.ActiveCfg = Debug|Any CPU - {21F26329-2119-4037-93CC-DD1801A1AB78}.Debug|x86.Build.0 = Debug|Any CPU - {21F26329-2119-4037-93CC-DD1801A1AB78}.Release|Any CPU.ActiveCfg = Release|Any CPU - {21F26329-2119-4037-93CC-DD1801A1AB78}.Release|Any CPU.Build.0 = Release|Any CPU - {21F26329-2119-4037-93CC-DD1801A1AB78}.Release|x64.ActiveCfg = Release|Any CPU - {21F26329-2119-4037-93CC-DD1801A1AB78}.Release|x64.Build.0 = Release|Any CPU - {21F26329-2119-4037-93CC-DD1801A1AB78}.Release|x86.ActiveCfg = Release|Any CPU - {21F26329-2119-4037-93CC-DD1801A1AB78}.Release|x86.Build.0 = Release|Any CPU - {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|x64.ActiveCfg = Debug|Any CPU - {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|x64.Build.0 = Debug|Any CPU - {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|x86.ActiveCfg = Debug|Any CPU - {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|x86.Build.0 = Debug|Any CPU - {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|Any CPU.Build.0 = Release|Any CPU - {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|x64.ActiveCfg = Release|Any CPU - {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|x64.Build.0 = Release|Any CPU - {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|x86.ActiveCfg = Release|Any CPU - {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|x86.Build.0 = Release|Any CPU - {B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|x64.ActiveCfg = Debug|Any CPU - {B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|x64.Build.0 = Debug|Any CPU - {B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|x86.ActiveCfg = Debug|Any CPU - {B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|x86.Build.0 = Debug|Any CPU - {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|Any CPU.Build.0 = Release|Any CPU - {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x64.ActiveCfg = Release|Any CPU - {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x64.Build.0 = Release|Any CPU - {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x86.ActiveCfg = Release|Any CPU - {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x86.Build.0 = Release|Any CPU - {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x64.ActiveCfg = Debug|Any CPU - {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x64.Build.0 = Debug|Any CPU - {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x86.ActiveCfg = Debug|Any CPU - {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x86.Build.0 = Debug|Any CPU - {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|Any CPU.Build.0 = Release|Any CPU - {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x64.ActiveCfg = Release|Any CPU - {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x64.Build.0 = Release|Any CPU - {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x86.ActiveCfg = Release|Any CPU - {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x86.Build.0 = Release|Any CPU - {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|x64.ActiveCfg = Debug|Any CPU - {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|x64.Build.0 = Debug|Any CPU - {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|x86.ActiveCfg = Debug|Any CPU - {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|x86.Build.0 = Debug|Any CPU - {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|Any CPU.Build.0 = Release|Any CPU - {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|x64.ActiveCfg = Release|Any CPU - {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|x64.Build.0 = Release|Any CPU - {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|x86.ActiveCfg = Release|Any CPU - {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|x86.Build.0 = Release|Any CPU - {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|x64.ActiveCfg = Debug|Any CPU - {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|x64.Build.0 = Debug|Any CPU - {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|x86.ActiveCfg = Debug|Any CPU - {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|x86.Build.0 = Debug|Any CPU - {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|Any CPU.Build.0 = Release|Any CPU - {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|x64.ActiveCfg = Release|Any CPU - {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|x64.Build.0 = Release|Any CPU - {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|x86.ActiveCfg = Release|Any CPU - {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|x86.Build.0 = Release|Any CPU - {D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|x64.ActiveCfg = Debug|Any CPU - {D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|x64.Build.0 = Debug|Any CPU - {D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|x86.ActiveCfg = Debug|Any CPU - {D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|x86.Build.0 = Debug|Any CPU - {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|Any CPU.Build.0 = Release|Any CPU - {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|x64.ActiveCfg = Release|Any CPU - {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|x64.Build.0 = Release|Any CPU - {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|x86.ActiveCfg = Release|Any CPU - {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -283,6 +193,8 @@ Global {72AEDD42-E881-4EB7-99A8-FD3B9667189C} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} {9DDA917D-B4DB-4AC1-9B9A-4ECF06A5ABA1} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} {26EE4C38-F06E-4E7A-A89C-908318BCB0DB} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} + {0E990C96-3808-450E-9349-A0C3262195ED} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} + {DBE06954-EC85-4034-9049-72C7D27556DF} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} {67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} {8D1B01A7-BF9E-44E6-88E1-B17255728B42} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} {E2DC8FF6-D39E-4FCA-9549-1F7681F0AA81} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} @@ -290,14 +202,5 @@ Global {94C086DA-3359-424B-9688-29A198806481} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} {C95A9A21-34FA-4D82-BE5B-88FBDA836D31} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} {F8A5060A-FB0B-4B8B-AE06-5147BB4CA127} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} - {B67042B8-1135-4E54-B1EB-D45342C720D7} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} - {21F26329-2119-4037-93CC-DD1801A1AB78} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} - {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} - {B9A441EC-DADE-4500-B092-C635F3EB296A} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} - {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} - {B48EAE30-152F-01E3-6E31-82B350494D3C} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE} - {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F} = {B48EAE30-152F-01E3-6E31-82B350494D3C} - {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D} = {B48EAE30-152F-01E3-6E31-82B350494D3C} - {D3138F3C-9531-4906-A5D3-74BD857D0685} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} EndGlobalSection EndGlobal diff --git a/CultLib.txt b/CultLib.txt new file mode 100644 index 00000000..4755ac07 --- /dev/null +++ b/CultLib.txt @@ -0,0 +1,132 @@ + + +// E:\Projects\CultLib\src\GameCult.Caching\GameCult.Caching.csproj + + + + CultCache: Concurrent polymorphic cache with modular backing stores for .NET + + + + + + + + + + +// E:\Projects\CultLib\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj + + + + File-based MessagePack backing store for CultCache + + + + + + + + + +// E:\Projects\CultLib\src\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj + + + + + + true + enable + true + false + + + Source generator that emits MessagePack formatters for DatabaseEntry subclasses. + MessagePack;SourceGenerator;DatabaseEntry + + + + + + + + + + + + + + + + +// E:\Projects\CultLib\src\GameCult.Logging\GameCult.Logging.csproj + + + + Simple pluggable logging interface for CultLib + + + +// E:\Projects\CultLib\src\GameCult.Logging.Unity\GameCult.Logging.Unity.csproj + + + + Unity integration for GameCult.Logging + + + + + + + + +// E:\Projects\CultLib\src\GameCult.Networking\GameCult.Networking.csproj + + + + CultNet: Secure, high-performance messaging for games/servers + + + + + + + + + + + + + +// E:\Projects\CultLib\tests\GameCult.Networking.Tests\GameCult.Networking.Tests.csproj + + + + + net8.0 + false + GameCult.Networking.Tests + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + all + + + + diff --git a/Directory.Build.props b/Directory.Build.props index 10c8d749..519e7a93 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,8 +14,7 @@ MIT © Meta Vens and contributors true - 1.0.35 - $(CultLibPackageVersion) + 1.0.3 true diff --git a/README.md b/README.md index 4977a02a..d6e3778c 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,8 @@ The stack is less a single product than a set of capabilities: - logging primitives and implementations - CultCache typed documents, `.cc` persistence, and SoA memory access -- CultNet schema-v0 messages over TCP and LiteNetLib adapters, a bounded - ASP.NET Core WebSocket host adapter, and CultLib's native cross-runtime RUDP - pipe +- CultNet schema-v0 messages over TCP, WebSocket, LiteNetLib adapters, and + CultLib's native cross-runtime RUDP pipe - CultMesh distributed realtime database, Verse discovery, authority leases, shard replication, and witness-authoritative simulation consensus - typed geometry domain/chunk documents for distributed CSG and LOD streaming @@ -38,9 +37,8 @@ and pretending the label makes it furniture. | Job | Start With | Owns | Use When | Do Not Use It For | | --- | --- | --- | --- | --- | | Local typed state | `GameCult.Caching` / CultCache | Document identity, schema compatibility, record keys, indexes, globals, and local persistence | You need a typed cache, file-compatible save data, local reactive reads, or a stable domain document model | Peer discovery, transport security, shard routing, or mesh consensus | -| Portable math | `GameCult.Math` / CultMath | Shader-shaped numeric primitives, deterministic random, noise, and CPU/GPU parity helpers | Shared simulation and geometry code must not depend on an engine's math or RNG types | Domain-specific geometry, rendering, or world authority | -| Procedural geometry | `GameCult.Geometry` / CultGeometry | Isosurface extraction plus CultCache-native domains, LOD requests, selected cuts, and mesh artifacts | C#, Rust, Godot, or remote workers need shared geometry algorithms and typed results | Transport policy, peer discovery, or gameplay authority | -| Network transport and database plumbing | `GameCult.Networking` / CultNet | Native RUDP sessions, LiteNetLib/TCP adapters, an authenticated WebSocket host adapter, schema-v0 wire contracts, shard authority, raw document replication, snapshots, and subscriptions | You need a client/server pipe, login/session flow, schema discovery, reliable UDP, or a low-level distributed CultCache lane | Gameplay-facing mesh ergonomics, Verse policy, mod branches, or simulation consensus composition | +| Procedural geometry state | `GameCult.Geometry` | CultCache-native domain trees, LOD build requests, selected-cut diagnostics, and chunk artifact payloads | Rust, Unity, or remote workers need to share CSG/LOD geometry and graph metadata as typed state | Transport policy, peer discovery, or gameplay authority | +| Network transport and database plumbing | `GameCult.Networking` / CultNet | Native RUDP sessions, LiteNetLib/TCP/WebSocket adapters, authentication, schema-v0 wire contracts, shard authority, raw document replication, snapshots, and subscriptions | You need a client/server pipe, login/session flow, schema discovery, reliable UDP, or a low-level distributed CultCache lane | Gameplay-facing mesh ergonomics, Verse policy, mod branches, or simulation consensus composition | | Distributed realtime gameplay state | `GameCult.Mesh` / CultMesh | Public mesh entrypoints, Verse discovery, peer exchange, shard replication defaults, authority leases, client prediction, and witness consensus | You want the game to treat clients and servers as one reactive database for persistent state, input state, and simulation facts | A tiny local-only tool, a bare transport client, or a storage format contract | | Realtime media/frame streams | `GameCult.Mesh` / CultMesh streaming mode | Stream identity, authority, clock metadata, body transport negotiation, frame cursors, and backpressure state | Audio/video/tensor frames need to move between runtimes through shared memory, GPU handles, platform buffers, or CultCache page refs | Durable document mutation, mesh consensus facts, or pretending inline bytes are zero-copy | | Daemon-published UI | Eve / CultUI | Typed operator and user-facing surfaces over CultMesh state | A daemon should expose controls, inspection, or workflow UI that can lower into GUI, TUI, web, native, overlay, or agent clients | One-off vendor dashboards, untyped debug panels, or runtime-local UI that cannot travel with the daemon | @@ -48,10 +46,8 @@ and pretending the label makes it furniture. Quick rule: - Choose CultCache when the problem is "how do I model and persist typed state?" -- Choose GameCult.Math when the problem is "which portable numeric and random - semantics do these runtimes share?" -- Choose GameCult.Geometry when the problem is "how do geometry workers build - and share engine-neutral geometry?" +- Choose GameCult.Geometry when the problem is "how do geometry workers share + domain trees, LOD build requests, and mesh chunks as typed state?" - Choose CultNet when the problem is "how do peers exchange authenticated, schema-aware database messages?" - Choose CultMesh when the problem is "how does a game join a Verse and share @@ -66,25 +62,8 @@ the stack. If a design needs another peer-to-peer category, first check whether it is really Verse policy, peer exchange, or shard authority wearing a fake mustache. -If you want to build an application rather than tour the component drawer, -start with the maintained -[`CultMesh And Eve Getting Started`](src/GameCult.Mesh/docs/getting-started/README.md) -series. Its first executable package-consumer checkpoint is -[`samples/eve-two-runtime`](samples/eve-two-runtime/README.md). -The real-process browser continuation is -[`samples/eve-browser-network`](samples/eve-browser-network/README.md). -One documented command runs both layers: - -```powershell -pwsh -File ./scripts/verify-eve-getting-started.ps1 -``` - -The implemented browser client boundary and remaining deployed-infrastructure -gates are mapped explicitly in -[`browser-verse-transport.md`](src/GameCult.Mesh/docs/browser-verse-transport.md). - -If you want only the smallest durable "open node -> put typed doc -> get typed doc" -path, use +If you want the smallest durable "open node -> put typed doc -> get typed doc" +path, start with [`src/GameCult.Mesh/docs/durable-node-quickstart.md`](src/GameCult.Mesh/docs/durable-node-quickstart.md). If you want the lower-level "typed local document -> raw wire document -> mesh/runtime watch surface" handoff, follow it with @@ -98,10 +77,10 @@ The solution includes: - `GameCult.Caching`: `DatabaseEntry`-based cache, indexes, global entries, and backing-store abstractions - `GameCult.Caching.MessagePack`: MessagePack-backed persistence for the cache - `GameCult.Caching.NewtonsoftJson`: Newtonsoft.Json-backed persistence for the cache -- `GameCult.Math` / CultMath: portable numeric primitives, deterministic random, noise, and shader-parity helpers -- `GameCult.Geometry` / CultGeometry: engine-neutral geometry algorithms plus CultCache-native domain, selected-cut, and mesh artifact documents +- `GameCult.Caching.MessagePack.Generator`: source generator for MessagePack formatters for cache models +- `GameCult.Caching.MessagePack.Analyzers`: packaging project that delivers the generator to consuming projects +- `GameCult.Geometry`: CultCache-native geometry domain, selected-cut, and chunk artifact documents for VibeGeometry/Fensalir-style pipelines - `GameCult.Networking`: encrypted login/register/verify flows, schema-v0 contracts, transport adapters, and native RUDP sessions -- `GameCult.Networking.WebSockets`: bounded ASP.NET Core binary WebSocket host adapter for browser CultMesh clients - `GameCult.Mesh`: CultMesh package home for distributed realtime database, shard replication, client prediction, Verse discovery, and mesh witness consensus - `GameCult.Caching.Tests`: NUnit tests for cache and backing-store behavior - `GameCult.Networking.Tests`: NUnit tests for networking behavior @@ -109,10 +88,7 @@ The solution includes: - `packages/cultcache-ts`: TypeScript CultCache with MessagePack persistence and inspector tooling - `packages/cultnet-ts`: TypeScript CultNet schema-v0 contracts, framing, discovery, raw document replication, and interop tests - `packages/cultmesh-ts`: TypeScript CultMesh local node and mesh catalog surface for local runtimes such as VoidBot -- `packages/cultmesh-browser`: browser-safe, read-only CultMesh document leases and typed operations over an Odin-resolved WebSocket route -- `packages/cultcache-py`: Python CultCache with CultCache v1 wire parity -- `packages/cultnet-py`: Python CultNet contracts, framing, RUDP transport, replication, and interop peer -- `packages/cultmesh-py`: Python CultMesh local node, discovery, sessions, local server, and daemon +- `packages/cultcache-py`: Python CultCache/CultNet/CultMesh package with CultCache v1 wire parity - `packages/cultcache-rs`: Rust CultCache and derive macro - `packages/cultnet-rs`: Rust CultNet contracts, framing, discovery, and interop peer - `packages/cultmesh-kotlin`: Kotlin/JVM CultMesh and CultNet surface for Android-adjacent runtimes @@ -126,9 +102,10 @@ src/ GameCult.Caching/ GameCult.Caching.MessagePack/ GameCult.Caching.NewtonsoftJson/ + GameCult.Caching.MessagePack.Generator/ + GameCult.Caching.MessagePack.Analyzers/ GameCult.Geometry/ GameCult.Networking/ - GameCult.Networking.WebSockets/ GameCult.Mesh/ GameCult.Unity/ tests/ @@ -136,15 +113,11 @@ tests/ GameCult.Geometry.Tests/ GameCult.Networking.Tests/ packages/ - cultmath/ cultcache-ts/ cultnet-ts/ cultmesh-ts/ - cultmesh-browser/ cultmesh-kotlin/ cultcache-py/ - cultnet-py/ - cultmesh-py/ cultcache-rs/ cultnet-rs/ ``` @@ -168,22 +141,8 @@ npm test --workspace packages/cultcache-ts npm test --workspace packages/cultnet-ts npm run test:interop --workspace packages/cultnet-ts npm test --workspace packages/cultmesh-ts -npm test --workspace packages/cultmesh-browser ``` -Measured reactive, retained-session, and shared-memory hot-body gates: - -```powershell -pwsh -File ./scripts/measure-reactive-document-performance.ps1 -Quick -pwsh -File ./scripts/verify-eve-getting-started.ps1 -``` - -The first command measures the same 16 KiB document workload in C#, -TypeScript, and Python and includes a mapped hot-body probe with explicit copy -telemetry on Windows named maps and Unix file-backed maps. The second includes -10,000 real WebSocket operations on one retained -identity-owned C# session after provider route replacement. - Rust package tests: ```powershell @@ -233,23 +192,41 @@ public class ItemData : DatabaseEntry, INamedEntry - backing stores are persistence adapters - indexes and name maps are maintained inside the cache, not inside the store -### Multiple Backing Stores +### Important: Multiple Backing Stores -Every document type has exactly one home store. There are no mirrors. +When multiple backing stores are added, behavior depends on how they are registered. + +If a store is added with domain types: ```csharp -cache.AddBackingStore(catalogStore, typeof(ItemData), typeof(Faction)); -cache.AddBackingStore(runStore, typeof(SavedGame)); +cache.AddBackingStore(playerStore, typeof(PlayerData)); +cache.AddBackingStore(settingsStore, typeof(AppSettings)); ``` -- the home is chosen by the document's runtime type: the most derived routed type wins, otherwise the untyped store -- at most one untyped store; a type claimed by two stores is an error -- a write with no home is an error once any store is attached -- attaching a store reads it; attach routed stores first and the untyped store (if any) last, because a record's home cannot move after it is loaded -- loading never writes: opening and flushing without a mutation leaves every file byte-identical -- a read-only store refuses writes and is skipped by flush +then that store becomes the direct persistence target for those types. -The contract is `src/GameCult.Caching/Contracts/cultcache-store-composition.md`. Until the C# cache implements it, `AddBackingStore` takes no home types and every attached store receives every record; attach one store per cache. +If a store is added without domain types: + +```csharp +cache.AddBackingStore(primaryStore); +cache.AddBackingStore(mirrorStore); +``` + +then the first generic store acts as the primary writable store for non-domain-specific entries. Additional generic stores subscribe to the existing stores and mirror their change events. + +Implications: + +- order matters for generic stores +- `AddAsync` writes to the type-specific store when one exists +- otherwise `AddAsync` writes to the first generic store +- later generic stores do not become co-primaries; they mirror earlier stores +- `PullAllBackingStoresAsync` pulls from every registered store + +Recommended patterns: + +- use one generic primary store if you want simple persistence +- use domain-specific stores when different entry types belong in different persistence layers +- treat additional generic stores as mirrors or downstream replicas, not independent write targets ## Example: Cache + Networking @@ -345,6 +322,8 @@ Each subproject has a local README with package-specific detail: - [GameCult.Caching](src/GameCult.Caching/README.md) - [GameCult.Caching.MessagePack](src/GameCult.Caching.MessagePack/README.md) - [GameCult.Caching.NewtonsoftJson](src/GameCult.Caching.NewtonsoftJson/README.md) +- [GameCult.Caching.MessagePack.Generator](src/GameCult.Caching.MessagePack.Generator/README.md) +- [GameCult.Caching.MessagePack.Analyzers](src/GameCult.Caching.MessagePack.Analyzers/README.md) - [GameCult.Geometry](src/GameCult.Geometry/README.md) - [GameCult.Networking](src/GameCult.Networking/README.md) - [GameCult.Mesh](src/GameCult.Mesh/README.md) diff --git a/docs/cultmesh-provider-session.md b/docs/cultmesh-provider-session.md deleted file mode 100644 index 3e45c799..00000000 --- a/docs/cultmesh-provider-session.md +++ /dev/null @@ -1,61 +0,0 @@ -# CultMesh Provider Session - -## Authority map - -- Owner: the provider-session broker owns registration, active lease fencing, - accepted publication membership, command retention, receipt acceptance, and - withdrawal. -- Inputs: a physical CultNet session, an explicit registration-authorization - decision, correlated typed operations, the broker clock, provider commands, - and persistence callbacks. The current RUDP adapter does not itself provide - an authenticated claim. -- Outputs: leases, application acceptances, accepted raw documents, pushed - typed commands, persisted receipts, and deterministic removals. -- Derived state: socket sessions, retry schedules, connection health, and - provider projections are observations. They do not authorize publications. -- Forbidden writers: RUDP packet acknowledgements, source addresses, direct raw - document callbacks, renderer-owned catalogs, and arrival-time freshness maps - cannot create accepted provider truth. -- Shared paths: initial connect and reconnect use `provider.register`; initial, - replayed, and live documents use `provider.publication.put`; explicit - withdrawal and lease expiry remove the broker-owned publication set; command - retry ends only when `provider.receipt.put` is accepted. -- Cut line: a receiver must stop accepting unleased provider raw puts before it - claims this protocol as its ingress authority. - -## Wire contract - -Provider lifecycle operations use the reliable ordered CultNet schema channel -and the existing `cultnet.operation_request.v0` / `cultnet.operation_response.v0` -envelopes with service id `gamecult.mesh.provider_session`. Inner payloads are -camel-case MessagePack maps encoded as `messagepack-base64`. - -The v1 operations are: - -| Operation | Request payload | Successful payload | -| --- | --- | --- | -| `provider.register` | `gamecult.mesh.provider_registration.v1` | `gamecult.mesh.provider_lease.v1` | -| `provider.renew` | `gamecult.mesh.provider_lease_renewal.v1` | `gamecult.mesh.provider_lease.v1` | -| `provider.publication.put` | `gamecult.mesh.provider_publication_put.v1` | `gamecult.mesh.provider_mutation_acceptance.v1` | -| `provider.publication.delete` | `gamecult.mesh.provider_publication_delete.v1` | `gamecult.mesh.provider_mutation_acceptance.v1` | -| `provider.receipt.put` | `gamecult.mesh.provider_receipt_put.v1` | `gamecult.mesh.provider_mutation_acceptance.v1` | -| `provider.withdraw` | `gamecult.mesh.provider_withdrawal.v1` | `gamecult.mesh.provider_mutation_acceptance.v1` | - -Broker-to-provider commands are raw typed documents using -`gamecult.mesh.provider_command.v1`. Asset bodies do not travel inside these -operation payloads. - -Responses use `ok`, `conflict`, `expired`, `denied`, or `invalid`. A transport -ACK is never an application acceptance. Renewal issues a new lease id and -immediately fences the previous id. The broker derives the set removed by full -withdrawal; a provider cannot use a supplied list to hide owned publications. - -Public deployment additionally requires a CultNet authentication claim that -authorizes the provider identity. Until that claim is wired and checked, the -TypeScript RUDP adapter is a private-development transport. - -`CultMeshProviderSessionBroker` is an in-memory reference broker and socket -proof. Its command and receipt retention does not survive process restart. -Production receivers must persist that control-plane state atomically with -accepted publication ownership; Odin must not substitute these volatile maps -for its durable broker authority. diff --git a/docs/cultnet-transport-parity.md b/docs/cultnet-transport-parity.md index d0779196..5f0fec91 100644 --- a/docs/cultnet-transport-parity.md +++ b/docs/cultnet-transport-parity.md @@ -31,7 +31,7 @@ CultNet owns cross-runtime transport semantics: | C# interop peer | Schema-v0 MessagePack over TCP-framed or shared RUDP transport | UDP multicast probe/announce with TCP and RUDP transport profiles | Test harness transport parity | | TypeScript `cultnet-ts` | `CultNetPeer` over any Node `Duplex`, TCP-framed transport, or single-peer RUDP socket transport; interop uses TCP | UDP multicast probe/announce in the interop peer | First UDP socket binding for the shared RUDP reliability owner | | Rust `cultnet-rs` | Interop example serves and dials schema-v0 MessagePack over TCP-framed or shared RUDP transport; single-peer RUDP socket transport exists in the library | UDP multicast probe/announce with TCP and RUDP transport profiles | UDP socket binding for the shared RUDP reliability owner | -| Python `cultnet-py` | TCP sockets with 4-byte length-prefixed MessagePack frames for local CultMesh/CultNet server and client; single-peer RUDP socket transport exists in the library | Endpoint lists and CultMesh peer/Verse catalogs | UDP socket binding for the shared RUDP reliability owner | +| Python `cultcache-py` | TCP sockets with 4-byte length-prefixed MessagePack frames for local CultMesh/CultNet server and client; single-peer RUDP socket transport exists in the library | Endpoint lists and CultMesh peer/Verse catalogs | UDP socket binding for the shared RUDP reliability owner | | Kotlin `cultmesh-kotlin` | Channel-aware WebSocket transport connection for reliable ordered `schema` frames; single-peer RUDP socket transport exists in the library; interop CLI serves and dials schema-v0 MessagePack over TCP-framed or shared RUDP transport | CultMesh Verse and peer catalogs, plus endpoint lists carried in schema-v0 catalog messages | WebSocket/TCP adapters plus UDP socket binding for the shared RUDP reliability owner, with build-script and TypeScript harness proof | The live split is therefore: payload language and the native RUDP packet/session @@ -258,19 +258,11 @@ Current progress: echo against C#/Kotlin/Python/Rust UDP peers; local runtime tests prove timeout state transitions. - TypeScript, C#, Rust, Python, and Kotlin now share bounded reliable-send - backpressure for RUDP sessions. At most 32 reliable packets are admitted to - the wire at once; later fragments remain in a session-owned FIFO and - acknowledgements promote the next packets. Receivers emit a cumulative ACK - for packets inside the 32-sequence ACK horizon and an exact ACK for an older - retransmit, so unrelated unreliable traffic cannot strand reliable work. - This keeps the send frontier well - inside the 4,096-sequence receive history instead of blasting an entire large - document and hoping the UDP buffers impersonate flow control. - `maxPendingReliablePackets` bounds both queued and in-flight packets before a - reliable frame is accepted. Socket transports expose an explicit reliable - 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. + backpressure for RUDP sessions. `maxPendingReliablePackets` is advertised on + RUDP transport-profile channels and enforced by the session before reliable + connect, accept, single-packet sends, or fragmented sends enqueue anything; + TCP-framed profiles do not publish the field because they do not own the RUDP + pending-reliable queue. - 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/nuget-packaging.md b/docs/nuget-packaging.md index afc94570..88eb4a2d 100644 --- a/docs/nuget-packaging.md +++ b/docs/nuget-packaging.md @@ -15,9 +15,3 @@ a clean .NET consumer using only `PackageReference Include="GameCult.Mesh"`. The Unity distribution remains `org.gamecult.cultlib`, built by `scripts/build-unity-package.ps1`, because Unity consumes the managed assembly 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.46 -``` diff --git a/docs/parked-features.md b/docs/parked-features.md deleted file mode 100644 index aa65294b..00000000 --- a/docs/parked-features.md +++ /dev/null @@ -1,32 +0,0 @@ -# Parked Features - -A parked feature was built for a stated reason, has no consumer, and was -removed from the tree. Its last working form lives at an annotated tag so it -can be restored instead of reinvented. - -## CultCache SoA table - -- Tag: `parked/cultcache-soa` -- Intent: ECS-style structure-of-arrays columns over cached documents, for - hot-loop performance without giving up POCO document ergonomics. -- Span at the tag: `src/GameCult.Caching/CultManagedDocument.cs:133-414` - (`CultSoaTable`, `CultSoaColumn`, `CultCacheSoaStore`, - `CultCacheSoaTypeTable`, `CultCacheSoaMember`); `CultCache.Soa()` and the - `_soa` field with its upsert/remove hooks in `CultCache.cs`; the SoA tests in - `tests/GameCult.Caching.Tests/BackingStoreTests.cs` and - `tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs`. -- Managed document: `CultManagedDocument` (`CultManagedDocument.cs:72-131` at - the tag) with `CultCache.Document` and `CultNetDatabase.Document`, the - reactive POCO handle added with the SoA storage in 5428870 "Make SoA - cache-managed document storage". Same tag, same intent: the document stays a - POCO while the cache owns its columnar storage. -- Tag message correction: the `parked/cultcache-soa` tag message says - `CultManagedDocument` was "deleted separately, not parked". This note - supersedes it: `CultManagedDocument` is parked under the same tag and is - restored alongside SoA. The tag itself is left as written. -- Consumers when parked: none outside tests. -- Restore: `git show parked/cultcache-soa:src/GameCult.Caching/CultManagedDocument.cs`, - take the SoA types and `CultManagedDocument`, restore both `Document` - openers from `CultCache.cs` and `CultNetDatabase.cs` at the tag, and re-hook a - `CultCacheSoaStore` into the cache's single - admission and eviction paths (`Admit`/`Evict`). diff --git a/docs/persisted-record-encoding.md b/docs/persisted-record-encoding.md deleted file mode 100644 index b8e13f8a..00000000 --- a/docs/persisted-record-encoding.md +++ /dev/null @@ -1,68 +0,0 @@ -# The encoding of a persisted CultCache record is part of its contract - -Changing how a stored record serializes is never a flag flip in this estate. -Consumers verify canonicality by **re-encoding what they decoded and comparing -bytes**, so a record that reads back correctly but re-encodes differently is -refused — and the refusal happens on live data, at startup or on the next read. - -Three guards, three separate stores, one idiom: - -| store | guard | refusal | -|---|---|---| -| Idunn runtime projection | `Idunn/src/drivers.rs:3611` | `service trust-anchor projection is noncanonical` | -| Idunn deployment brake | `Idunn/src/control_plane.rs` (`read_deployment_brake`) | `deployment brake is noncanonical or keyed by another authority` | -| Idunn trust bindings | `Idunn/src/provisioning.rs:546`, `:630` | `trust store contains a noncanonical or mismatched binding` | - -Idunn's control store applies the same rule to its own transactions. Assume the -guard exists and go looking for it before changing any persisted record's -serialization; three independent instances is a house pattern, not a -coincidence. - -## What this means for `#[cultcache(key = N, bytes)]` - -The attribute switches a `Vec` slot from a MessagePack array-of-integers -(serde's default) to a real `bin`. Measured, not assumed -(`packages/cultnet-rs/tests/byte_slot_migration.rs`): - -- **Reading stays compatible in both directions.** `serde_bytes::ByteBuf` - accepts an array marker, and a plain `Vec` accepts `bin`. No dual-read - path is needed, and reverting a binary is survivable. -- **Reproduction does not.** A record written before the flip can be read but - can never re-encode to the bytes it was stored as, and its `canonical_sha256` - moves with it. - -So the cost lands entirely on the guards above, not on readability. A field with -persisted records must flip the attribute **and** rewrite its store in the same -change, one target at a time, and never during a deployment — the projection -store is what the observer reads, and refusing it takes the observer down. - -A field with no live producer is free. Check for one before assuming a -migration: `IdunnAuthenticatedProviderHealthProjectionRecord` is constructed -only inside a test module, so its byte field costs nothing to convert. - -Size, for whether it is worth doing at all: 406 bytes as an array against 278 as -`bin` for a 256-byte payload, and 1234 against 851 for an 848-byte one. About -1.45x on every byte, which matters on a media path and is noise on a control -record. - -## `Option>` cannot take the attribute yet - -The derive emits `serde_bytes::Bytes::new(&self.field)`, which needs `&[u8]`, -and builds the field via `.into_vec()`, which yields `Vec`. Both are type -errors against an `Option>`, so the derive fails loudly rather than -collapsing "absent slot" into "unsigned". `IdunnDeploymentBrakeRecord.signature` -needs an `Option`-aware path in `cultcache-rs-derive` before it can move. - -## Fields validated for shape and produced by nothing - -`trust_binding_sha256` is declared on two records with different schemas -(`idunn.authenticated_provider_health_projection.v1` in cultnet-rs, -`idunn.authenticated_daemon_health_admission.v1` in odin-core), shape-validated -in both, and computed by nothing in CultLib, Idunn or Odin. - -Do not assume that means "omission". It may be a fossil — a contract whose -producer was deleted. The estate has form: Muninn's receiver runs a Reed-Solomon -audio FEC path against parity shards the Rust sender has never emitted, while an -acceptance ledger describes that FEC as having worked. Settling which requires -reading history, not source, and the answer changes whether the right move is to -implement the field or delete it. diff --git a/docs/runtime-parity-scope.md b/docs/runtime-parity-scope.md index 44715c7c..cd3761d7 100644 --- a/docs/runtime-parity-scope.md +++ b/docs/runtime-parity-scope.md @@ -21,23 +21,20 @@ Cross-runtime parity has three layers: transport helpers instead of assembling raw maps and channel strings. SoA performance parity is deliberately not a cross-runtime requirement. C# -owns the shared-memory streaming body path. The C# CultCache SoA table (ECS-style -structure-of-arrays columns over cached documents) is parked at the tag -`parked/cultcache-soa`: it was built for hot-loop performance, had no consumer -outside tests, and is restorable from that tag (`docs/parked-features.md`). -It is not a supported surface in the current tree. Other runtimes may add native hot paths when their -runtime role earns them, but they do not need to mimic Unity/C# memory layout -to speak CultNet or participate in CultMesh. +owns the CPU-local SoA table and shared-memory streaming body path. Other +runtimes may add native hot paths when their runtime role earns them, but they +do not need to mimic Unity/C# memory layout to speak CultNet or participate in +CultMesh. ## Current Targeted Runtimes | Runtime | Role | Claimed parity | Not claimed | | --- | --- | --- | --- | -| C# | Reference implementation and production game/server substrate | CultCache typed documents, CultNet schema-v0, LiteNetLib production path with adapter profile, shared transport profiles, shared RUDP socket transport, C# multi-peer RUDP listener and schema service host, C# interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP-backed schema-client selection for `rudp://` service endpoints, RUDP reconnect policy/controller and reconnect-loop helper, LiteNetLib client reconnect-controller adoption, schema-client transport port for service request/response clients, CultMesh node/session/catalog/stream/simulation/RUDP surfaces, authorized peer lookup | No need to abandon LiteNetLib before the CultNet adapter fully owns production service adoption | +| C# | Reference implementation and production game/server substrate | CultCache typed documents and SoA, CultNet schema-v0, LiteNetLib production path with adapter profile, shared transport profiles, shared RUDP socket transport, C# multi-peer RUDP listener and schema service host, C# interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP-backed schema-client selection for `rudp://` service endpoints, RUDP reconnect policy/controller and reconnect-loop helper, LiteNetLib client reconnect-controller adoption, schema-client transport port for service request/response clients, CultMesh node/session/catalog/stream/simulation/RUDP surfaces, authorized peer lookup | No need to abandon LiteNetLib before the CultNet adapter fully owns production service adoption | | Python | Package runtime, local daemon endpoint, raw interop peer, typed state participant | CultCache/CultNet/CultMesh package surface, branded CultMesh node/schema/shard/Verse/peer/authority/stream/RUDP ergonomics, authorized peer lookup, shard catalogs/logs, schema-transport factory for raw client request/subscription flows, local TCP and RUDP CultMesh server listeners, Python interop RUDP serve/dial path, session/prediction/simulation fact helpers, RUDP codec/session/socket transport, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, benchmark evidence against C# cache paths | Universal C# throughput, full C# daemon/server stack, and RUDP as the only public service pipe | | TypeScript | Node/browser-adjacent projection and interop runtime | CultCache parity tests, CultNet schema-v0 peer, advertised schema/shard catalog wire contracts plus request/sync helpers, transport profiles, TCP-framed transport port and peer helper, shared RUDP socket transport, RUDP interop serve/dial path, RUDP reconnect policy/controller, RUDP reconnect-loop helper, branded CultMesh node/schema/shard/Verse/peer/authority/stream/RUDP ergonomics, authorized peer lookup | Full C# game session/server stack | -| Rust | Low-level cache/transport/runtime substrate | CultCache/CultNet interop lane, schema and shard catalog helpers, typed same-schema alias hydration from raw snapshots, transport profiles, TCP-framed port, shared RUDP codec/session/socket transport, Rust interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, small CultMesh facade for schema registry, shard catalog, peer cards, authority leases, authorized peer lookup, RUDP endpoint parsing, and peer-card/authorized-peer RUDP client construction | Full CultMesh game-session/server facade or production game-session server. `packages/cultmesh-rs` now holds a Rust stream and session surface -- node, document sets, RUDP document publication, stream descriptors, negotiation, stream catalog, and shared-memory frame rings -- but no interop lane exercises it, so it carries no cross-runtime parity claim | -| Kotlin | JVM/Android client substrate | Typed MessagePack cache, one-call typed snapshot document sync through local same-schema aliases, built-in schema catalog advertisement, schema/shard catalogs with shared schema-message transport fetch/sync helpers, authority leases, authorized peer lookup, stream catalog, branded `CultMesh` facade, WebSocket transport profile/connection helper, RUDP client/server sugar, connected RUDP CultMesh client helpers, Kotlin interop RUDP serve/dial path, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence | C# SoA store (parked at `parked/cultcache-soa`), full C# server daemon, or Unity hot-loop memory layout | +| Rust | Low-level cache/transport/runtime substrate | CultCache/CultNet interop lane, schema and shard catalog helpers, typed same-schema alias hydration from raw snapshots, transport profiles, TCP-framed port, shared RUDP codec/session/socket transport, Rust interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, small CultMesh facade for schema registry, shard catalog, peer cards, authority leases, authorized peer lookup, RUDP endpoint parsing, and peer-card/authorized-peer RUDP client construction | Full CultMesh game-session/server facade, stream catalog, or production game-session server | +| Kotlin | JVM/Android client substrate | Typed MessagePack cache, one-call typed snapshot document sync through local same-schema aliases, built-in schema catalog advertisement, schema/shard catalogs with shared schema-message transport fetch/sync helpers, authority leases, authorized peer lookup, stream catalog, branded `CultMesh` facade, WebSocket transport profile/connection helper, RUDP client/server sugar, connected RUDP CultMesh client helpers, Kotlin interop RUDP serve/dial path, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence | C# SoA store, full C# server daemon, or Unity hot-loop memory layout | ## Evidence Gates @@ -55,7 +52,7 @@ to speak CultNet or participate in CultMesh. `reconnectPolicy` profile field from C#, Rust, Python, and Kotlin peers. Runtime-local tests cover the deterministic delay helper and reconnect controller in C#, TypeScript, Rust, Python, and Kotlin. -- `docs/python-runtime-parity.md` is the Python-specific audit ledger. Its +- `packages/cultcache-py/PARITY.md` is the Python-specific audit ledger. Its "Still Not Claimed" section is part of the contract, not an apology. - `docs/cultnet-transport-parity.md` is the transport ownership map. It tracks the remaining work to lower older LiteNetLib/WebSocket bodies behind the @@ -88,16 +85,6 @@ server body, is deliberately not made. backfilling C# server responsibilities it should not own. Kotlin WebSocket and RUDP adapters now share one `CultNetSchemaMessageTransport` helper shape for schema/shard catalog request and sync flows. -- Bring `packages/cultmesh-rs` under the wire claim. It arrived from a consumer repo where it grew - outside any conformance harness, and its stream, negotiation, and shared-memory frame-ring - surfaces have never been checked against the browser, Kotlin, and TypeScript CultMesh bodies - they must interoperate with. Runtime-local tests pass; that is not a parity claim. -- Carry the RUDP receive-path bounds to the other runtimes. Rust now caps tracked ordered - and sequenced channels, caps the out-of-order hold buffer by frames and bytes, refuses a - packet far ahead of the highest received so a forged sequence cannot starve a session, and - reports reliable sequence exhaustion instead of aborting. C#, TypeScript, Python and Kotlin - still accept all of it. These are refusals rather than wire-format changes, so the interop - lanes stay green either way -- which is exactly why the gap will not announce itself. - Keep performance claims evidence-bound: C# owns SoA; Python has benchmark comparison gates; other runtimes should advertise measured native hot paths only when those paths exist. diff --git a/native/GameCult.Mesh.Quic.Native/CMakeLists.txt b/native/GameCult.Mesh.Quic.Native/CMakeLists.txt deleted file mode 100644 index 2850951d..00000000 --- a/native/GameCult.Mesh.Quic.Native/CMakeLists.txt +++ /dev/null @@ -1,20 +0,0 @@ -cmake_minimum_required(VERSION 3.24) -project(gamecult_mesh_quic_native LANGUAGES CXX) - -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) -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") diff --git a/native/GameCult.Mesh.Quic.Native/README.md b/native/GameCult.Mesh.Quic.Native/README.md deleted file mode 100644 index 875ebb3b..00000000 --- a/native/GameCult.Mesh.Quic.Native/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# GameCult.Mesh.Quic.Native - -This Windows desktop bridge keeps MsQuic's callback-heavy C API below the -managed runtime boundary. It accepts a discovery-authorized SHA-256 certificate -pin, negotiates `cultmesh-state-v1`, and emits complete CultMesh realtime frame -payloads. It owns transport only; frame meaning remains in `GameCult.Mesh`. - -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 deleted file mode 100644 index bbb6e193..00000000 --- a/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp +++ /dev/null @@ -1,440 +0,0 @@ -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#if defined(GAMECULT_MESH_QUIC_NATIVE_EXPORTS) -#define CULTMESH_API extern "C" __declspec(dllexport) -#else -#define CULTMESH_API extern "C" __declspec(dllimport) -#endif - -namespace { - -constexpr uint32_t kApiVersion = 2; -constexpr uint64_t kConnectionCloseCode = 0x43554c54; -constexpr uint64_t kStreamAbortCode = 0x53544154; -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"; - -enum class ClientState : int32_t { - Connecting = 0, - Connected = 1, - Failed = 2, - Closed = 3, -}; - -struct Client; - -struct StreamContext { - explicit StreamContext(Client* client) : owner(client) {} - Client* owner; - std::vector buffered; - uint8_t kind = 0; - bool has_kind = false; -}; - -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) { - if (value >= '0' && value <= '9') nibble = static_cast(value - '0'); - else if (value >= 'a' && value <= 'f') nibble = static_cast(value - 'a' + 10); - else if (value >= 'A' && value <= 'F') nibble = static_cast(value - 'A' + 10); - else return false; - return true; -} - -bool ParsePin(const char* value, std::array& pin) { - if (value == nullptr || std::strlen(value) != 64) return false; - for (size_t index = 0; index < pin.size(); ++index) { - uint8_t high = 0; - uint8_t low = 0; - if (!ParseHexNibble(value[index * 2], high) || !ParseHexNibble(value[(index * 2) + 1], low)) - return false; - pin[index] = static_cast((high << 4) | low); - } - return true; -} - -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 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)) - return false; - 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, - 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; - - QUIC_STATUS status = MsQuicOpenVersion( - kApiVersion, - reinterpret_cast(&client->api)); - if (QUIC_FAILED(status)) return status; - - 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; - } - - 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; - } - - *result = client.release(); - return 0; -} - -CULTMESH_API int32_t cultmesh_quic_state(Client* client) { - if (client == nullptr) return static_cast(ClientState::Failed); - return static_cast(client->state.load()); -} - -CULTMESH_API int32_t cultmesh_quic_poll( - 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); - if (client->received.empty()) { - *required_length = 0; - return client->state.load() == ClientState::Failed ? -2 : 0; - } - 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->received.pop_front(); - return 1; -} - -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); - 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(Client* client) { - if (client == nullptr) return; - 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; -} diff --git a/package-lock.json b/package-lock.json index f027ecd5..20910067 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,20 +8,11 @@ "workspaces": [ "packages/cultcache-ts", "packages/cultnet-ts", - "packages/cultmesh-ts", - "packages/cultmesh-browser" - ], - "devDependencies": { - "esbuild": "^0.28.1", - "fast-uri": "3.1.5", - "js-yaml": "4.3.1", - "playwright-core": "^1.62.1" - } + "packages/cultmesh-ts" + ] }, "node_modules/@apidevtools/json-schema-ref-parser": { "version": "11.9.3", - "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", - "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", "dev": true, "license": "MIT", "dependencies": { @@ -36,463 +27,13 @@ "url": "https://github.com/sponsors/philsturgeon" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@gamecult/cultcache-ts": { - "resolved": "packages/cultcache-ts", - "link": true - }, "node_modules/@jsdevtools/ono": { "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", - "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", "dev": true, "license": "MIT" }, "node_modules/@msgpack/msgpack": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz", - "integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==", "license": "ISC", "engines": { "node": ">= 18" @@ -500,22 +41,16 @@ }, "node_modules/@types/json-schema": { "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, "license": "MIT" }, "node_modules/@types/lodash": { "version": "4.17.24", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", - "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", "dev": true, "license": "MIT" }, "node_modules/@types/node": { "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "dev": true, "license": "MIT", "dependencies": { @@ -524,8 +59,6 @@ }, "node_modules/ajv": { "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -540,13 +73,11 @@ }, "node_modules/argparse": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, "license": "Python-2.0" }, - "node_modules/cultmesh-browser": { - "resolved": "packages/cultmesh-browser", + "node_modules/cultcache-ts": { + "resolved": "packages/cultcache-ts", "link": true }, "node_modules/cultmesh-ts": { @@ -557,58 +88,12 @@ "resolved": "packages/cultnet-ts", "link": true }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.3", "funding": [ { "type": "github", @@ -623,8 +108,6 @@ }, "node_modules/fdir": { "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", "engines": { @@ -641,8 +124,6 @@ }, "node_modules/is-extglob": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", "engines": { @@ -651,8 +132,6 @@ }, "node_modules/is-glob": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", "dependencies": { @@ -663,9 +142,7 @@ } }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.0", "dev": true, "funding": [ { @@ -687,8 +164,6 @@ }, "node_modules/json-schema-to-typescript": { "version": "15.0.4", - "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz", - "integrity": "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==", "dev": true, "license": "MIT", "dependencies": { @@ -711,21 +186,15 @@ }, "node_modules/json-schema-traverse": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/lodash": { "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, "node_modules/minimist": { "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, "license": "MIT", "funding": { @@ -734,8 +203,6 @@ }, "node_modules/picomatch": { "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -745,23 +212,8 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/playwright-core": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", - "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/prettier": { "version": "3.9.5", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", - "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", "dev": true, "license": "MIT", "bin": { @@ -776,8 +228,6 @@ }, "node_modules/require-from-string": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -785,8 +235,6 @@ }, "node_modules/tinyglobby": { "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -802,8 +250,6 @@ }, "node_modules/typescript": { "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -816,23 +262,18 @@ }, "node_modules/undici-types": { "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, "node_modules/zod": { "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, "packages/cultcache-ts": { - "name": "@gamecult/cultcache-ts", - "version": "0.14.0", + "version": "0.13.5", "license": "MIT", "dependencies": { "@msgpack/msgpack": "^3.1.3", @@ -841,59 +282,35 @@ "devDependencies": { "@types/node": "^24.0.0", "typescript": "^5.9.0" - }, - "engines": { - "node": ">=18" - } - }, - "packages/cultmesh-browser": { - "version": "0.3.0", - "license": "MIT", - "dependencies": { - "@msgpack/msgpack": "^3.1.3", - "cultnet-ts": "^0.2.0" - }, - "devDependencies": { - "@types/node": "^24.0.0", - "typescript": "^5.9.0" - }, - "engines": { - "node": ">=18" } }, "packages/cultmesh-ts": { - "version": "0.1.1", + "version": "0.1.0", "license": "MIT", "dependencies": { - "@gamecult/cultcache-ts": "^0.14.0", "@msgpack/msgpack": "^3.1.2", - "cultnet-ts": "^0.2.0" + "cultcache-ts": "file:../cultcache-ts", + "cultnet-ts": "file:../cultnet-ts" }, "devDependencies": { "@types/node": "^24.0.0", "typescript": "^5.9.0", "zod": "^3.25.76" - }, - "engines": { - "node": ">=18" } }, "packages/cultnet-ts": { - "version": "0.2.0", + "version": "0.1.0", "license": "MIT", "dependencies": { - "@gamecult/cultcache-ts": "^0.14.0", "@msgpack/msgpack": "^3.1.3", - "ajv": "^8.17.1" + "ajv": "^8.17.1", + "cultcache-ts": "file:../cultcache-ts" }, "devDependencies": { "@types/node": "^24.0.0", "json-schema-to-typescript": "^15.0.4", "typescript": "^5.9.0", "zod": "^3.25.76" - }, - "engines": { - "node": ">=18" } } } diff --git a/package.json b/package.json index c9b79e47..f1b995a2 100644 --- a/package.json +++ b/package.json @@ -4,19 +4,10 @@ "workspaces": [ "packages/cultcache-ts", "packages/cultnet-ts", - "packages/cultmesh-ts", - "packages/cultmesh-browser" + "packages/cultmesh-ts" ], "scripts": { - "build:ts": "node scripts/run-typescript-workspaces.mjs build", - "test:ts": "node scripts/run-typescript-workspaces.mjs test", - "test:pack:ts": "node scripts/test-typescript-package-closure.mjs", - "test:eve-browser-network": "node scripts/verify-eve-browser-network.mjs" - }, - "devDependencies": { - "esbuild": "^0.28.1", - "fast-uri": "3.1.5", - "js-yaml": "4.3.1", - "playwright-core": "^1.62.1" + "build:ts": "npm --workspaces run build", + "test:ts": "npm --workspaces run test" } } diff --git a/docs/python-runtime-parity.md b/packages/cultcache-py/PARITY.md similarity index 96% rename from docs/python-runtime-parity.md rename to packages/cultcache-py/PARITY.md index 1cc222b7..18607d87 100644 --- a/docs/python-runtime-parity.md +++ b/packages/cultcache-py/PARITY.md @@ -1,7 +1,7 @@ # Python Runtime Parity Map -This file is the current audit map for the Python runtime packages -`cultcache-py`, `cultnet-py`, and `cultmesh-py`. It is evidence, not confetti. +This file is the current audit map for `cultcache-py` as a Python runtime for +CultCache, CultNet, and CultMesh. It is evidence, not confetti. ## Proven Surfaces @@ -255,14 +255,14 @@ This file is the current audit map for the Python runtime packages individual discovery, peer exchange, or simulation surfaces unserved without advertising disabled capabilities. - Python package health: - `cultmesh-py-verify --json` checks the public CultCache/CultNet/CultMesh + `cultcache-py-verify --json` checks the public CultCache/CultNet/CultMesh export surface declared by each package `__all__`, `py.typed` markers, a live local CultMesh framed-wire smoke over hello/schema/snapshot/shard catalog/shard log, typed peer-error export availability, peer-advertised mutation contracts, payload schema wire contracts, simulation capability truth, and benchmark sanity for Python-owned hot paths. - Python/C# public cache baseline: - `cultnet_py.compare_csharp` runs Python and C# `CultCache` upsert/get + `cultcache_py.compare_csharp` runs Python and C# `CultCache` upsert/get benchmarks with the same operation names and reports median Python-to-C# ratios from three samples by default. It also emits per-metric threshold checks and an overall parity status so performance claims have an explicit evidence gate. @@ -275,14 +275,12 @@ This file is the current audit map for the Python runtime packages ## Local Python Gates ```powershell -$env:PYTHONPATH="$PWD\packages\cultcache-py\src;$PWD\packages\cultnet-py\src;$PWD\packages\cultmesh-py\src" +$env:PYTHONPATH="$PWD\packages\cultcache-py\src" python -m unittest discover -s packages\cultcache-py\tests -python -m unittest discover -s packages\cultnet-py\tests -python -m unittest discover -s packages\cultmesh-py\tests -python -m cultmesh_py.verify --json -python -m cultnet_py.benchmark --records 1000 --json -python -m cultnet_py.compare_csharp --records 1000 --samples 3 --json -python -m pip wheel --no-deps -w $env:TEMP packages\cultcache-py packages\cultnet-py packages\cultmesh-py +python -m cultcache_py.verify --json +python -m cultcache_py.benchmark --records 1000 --json +python -m cultcache_py.compare_csharp --records 1000 --samples 3 --json +python -m pip wheel --no-deps -w $env:TEMP packages\cultcache-py ``` ## Cross-Runtime Gate diff --git a/packages/cultcache-py/README.md b/packages/cultcache-py/README.md index 23effdbd..48c023ba 100644 --- a/packages/cultcache-py/README.md +++ b/packages/cultcache-py/README.md @@ -1,16 +1,12 @@ -# cultcache-py +# cultcache-py -`cultcache-py` is the Python CultCache runtime. Callers work with registered -domain documents while the cache owns schema identity, routing, globals, name -lookups, indexes, and backing-store envelopes. +`cultcache-py` is the Python runtime package for CultCache, CultNet, and +CultMesh. Callers work with registered domain documents while the cache owns +schema identity, routing, globals, name lookups, indexes, and backing-store +envelopes. It is intentionally small. It is not an ORM, a database, or distributed consensus wearing rented authority. -It is the bottom of the Python CultLib stack and depends only on `msgpack`. -`cultnet-py` (transport and wire contracts) and `cultmesh-py` (nodes, -discovery, sessions, local server) build on it, mirroring `cultcache-rs` / -`cultnet-rs` / `cultmesh-rs` and the npm packages. - ## Current Shape - document types are registered explicitly @@ -21,14 +17,14 @@ discovery, sessions, local server) build on it, mirroring `cultcache-rs` / - payloads decode only through the registered document definition for that type - unknown persisted types fail closed - global documents are singleton-style per type -- one home store per document type: a store added with `add_backing_store` - owns its types, otherwise the generic store is the home; a type claimed by a - second store, or a second generic store, raises `CultCacheError`; a write - touches exactly one store and nothing is mirrored; once any store is - attached, a write whose type has no home raises +- type-specific backing stores beat generic backing stores - the JSONL store uses only the Python standard library for bootstrap consumers - `define_database_entry_type(...)` emits Rust/C#-style slot-indexed MessagePack array payloads for cross-runtime `DatabaseEntry` contracts +- `cultnet_py` exposes schema-v0 MessagePack helpers and 4-byte big-endian + frame helpers for raw document state, subscriptions, shard catch-up, + simulation observations, and witness artifact bundles +- `cultmesh_py` exposes a cache-backed local node surface for Python tools ## Example @@ -148,28 +144,382 @@ That matches the Rust `#[derive(DatabaseEntry)]` formatter shape and the C# `[Key(n)]` intent: schema evolution comes from stable field slots, not source member order. +## CultNet And CultMesh + +`cultnet_py` provides the Python schema-v0 wire helpers and an interop peer: + +```python +from datetime import UTC, datetime + +from cultnet_py import ( + CultNetSecret, + CultNetServerSecurityOptions, + CultNetRawClient, + CultNetSimulationObservation, + CultNetWitnessArtifactBundle, + apply_raw_snapshot, + apply_shard_log_response, + compute_simulation_claim_hash, + database_subscribe, + decode_frame, + encode_frame, + hello, + parse_message, + shard_catalog_request, + simulation_observation, + witness_artifact_bundle, +) +from cultmesh_py import CultMesh + +payload = hello(runtime_id="python-runtime").to_bytes() +message = parse_message(decode_frame(encode_frame(payload))) + +client = CultNetRawClient("127.0.0.1", 3075) +catalog = client.fetch_schema_catalog(kinds=["wire_message"]) +snapshot = client.fetch_snapshot_response(schema_ids=["cultnet.interop-note"]) +shard_catalog = client.fetch_shard_catalog(schema_ids=["cultnet.interop-note"]) +# With a local CultCache and the matching registered document definitions: +# applied = apply_raw_snapshot(cache, [note_doc], snapshot) + +subscription = database_subscribe(subscription_id="ui", schema_ids=["cultnet.interop-note"]) +shard_request = shard_catalog_request(message_id="shards", schema_ids=["cultnet.interop-note"]) +shard_log = client.fetch_shard_log_response(shard_id="interop", shard_epoch=1, after_sequence=0) +# log_changes = apply_shard_log_response(cache, [note_doc], shard_log) +with client.subscribe_database(subscription_id="ui", schema_ids=["cultnet.interop-note"]) as live: + initial_snapshot = live.read_next_snapshot_response() + +# The same raw-client request surface can ride RUDP when the peer advertises a +# rudp:// endpoint and shares the connection id for that lane. +rudp_client = CultMesh.create_client( + endpoint="rudp://127.0.0.1:3076", + connection_id=0x43554C54, +) +rudp_catalog = rudp_client.fetch_schema_catalog(kinds=["wire_message"]) + +claim_hash = compute_simulation_claim_hash("frame:42", "subject:player-1", "hit") +observation = simulation_observation( + message_id="obs-1", + witness_runtime_id="python-runtime", + shard_id="interop", + shard_epoch=1, + frame=42, + subject_id="player-1", + claim_kind="hit", + claim_hash=claim_hash, +) +typed_observation = CultNetSimulationObservation.from_wire(observation.to_wire()) +witness = witness_artifact_bundle( + bundle_id="bundle-1", + witness_kind="interop-proof", + captured_at="2026-06-13T00:00:00Z", + subject={"documentType": "cultnet.interop-note", "subjectId": "note:python"}, + contracts=[{"role": "payload", "schemaId": "cultnet.interop-note"}], + artifacts=[{"role": "log", "uri": "cultcache://bundle-1/log", "mediaType": "text/plain"}], + provenance={"pipelineId": "interop", "runId": "run-1", "runtimeId": "python-runtime"}, +) +typed_witness = CultNetWitnessArtifactBundle.from_wire(witness) +witness_payload = typed_witness.to_payload() + +security = CultNetServerSecurityOptions.development() +token = CultNetSecret.create_session_token( + "318fb4b6-ff5e-4c4f-b911-d81807de53a8", + datetime(2035, 1, 1, tzinfo=UTC), + security, + session_version=1, +) +session = CultNetSecret.try_validate_session_token(token, security) +``` + +AES-GCM string/byte encryption helpers are available through the same +`CultNetSecret` surface when the optional `crypto` extra is installed. + +The peer can serve, dial, and probe the same raw-state interop lane used by the +TypeScript, Rust, and C# test peers: + +```powershell +python -m cultnet_py.interop_peer serve --runtime-id python-peer --runtime-kind python --display-name "Python Peer" --agent-id python-agent --advertise-host 127.0.0.1 --tcp-port 3075 --discovery-port 4075 --discovery-group 239.77.44.11 --schema-path ..\cultnet-ts\integration\contracts\cultnet.interop-note.schema.json +python -m cultnet_py.interop_peer dial --runtime-id python-client --runtime-kind python --display-name "Python Client" --agent-id python-client --target-host 127.0.0.1 --target-port 3075 --schema-path ..\cultnet-ts\integration\contracts\cultnet.interop-note.schema.json +python -m cultnet_py.interop_peer probe --runtime-id python-prober --discovery-port 4075 --discovery-group 239.77.44.11 +``` + +`cultmesh_py` includes a local cache-backed node, schema-v0 helpers for the +CultMesh Verse catalog and peer exchange wire messages, local and verified +authority lease checks, stream transport negotiation, committed simulation fact +documents, and local prediction reconciliation: + +```python +from datetime import UTC, datetime, timedelta + +from cultcache_py import define_database_entry_type +from cultnet_py import ( + CultNetClientAuthorityScope, + CultNetFileShardReplicaCursorStore, + CultNetRawClient, + CultNetSchemaShardLogFetcher, + CultNetSchemaShardSnapshotFetcher, + CultNetShardDescriptor, + CultNetShardReplicator, + CultNetShardReplicatorOptions, +) +from cultmesh_py import ( + CultMesh, + CultMeshAuthorityLease, + CultMeshGameSessionOptions, + CultMeshHmacAuthorityLeaseVerifier, + CultMeshNodeOptions, + CultMeshSnapshotFanout, + peer_exchange_request, +) + +note_doc = define_database_entry_type( + "mesh.note", + [("body", 0), ("name", 1), ("kind", 2)], + name="name", + indexes={"kind": "kind"}, +) +node = CultMesh.start_node( + "mesh.cc", + runtime_id="python-runtime", + options=CultMeshNodeOptions(enable_durable_shard_logs=True), +) +node.database.register_document(note_doc) +unsubscribe = node.database.watch_record(note_doc, "note:1", lambda change: print(change.change_kind)) +unsubscribe_named = node.database.watch_by_name(note_doc, "intro", lambda change: print(change.record_key)) +unsubscribe_kind = node.database.watch_by_index(note_doc, "kind", "guide", lambda change: print(change.record_key)) +node.database.put(note_doc, "note:1", {"body": "hello", "name": "intro", "kind": "guide"}) +live_note = node.database.get_required(note_doc, "note:1") +put_message = node.database.put_raw_message( + note_doc, + "note:2", + {"body": "wire me", "name": "wire", "kind": "interop"}, + shard_id="interop", + shard_epoch=1, +) +snapshot_response = node.database.build_snapshot_response(schema_ids=[note_doc.catalog_entry().schema_id]) +snapshot_wire = node.database.create_snapshot_response(schema_ids=[note_doc.catalog_entry().schema_id]) +delete_message = node.database.delete_raw_message(note_doc, "note:2", shard_id="interop", shard_epoch=1) +shard_log_response = node.database.build_shard_log_response(shard_id="interop", shard_epoch=1) +shard_log_wire = node.database.create_shard_log_response(shard_id="interop", shard_epoch=1) + +station_stock_doc = define_database_entry_type( + "aetheria.station_stock", + [("missiles", 0), ("coolant", 1)], + schema_id="gamecult.aetheria.station_stock.v1", +) +station_stock_ui_doc = define_database_entry_type( + "aetheria.station_stock.ui", + [("missiles", 0), ("coolant", 1)], + schema_id="gamecult.aetheria.station_stock.v1", +) +node.database.register_document(station_stock_doc) +node.database.put(station_stock_ui_doc, "station:starbridge:stock", {"missiles": 24, "coolant": 80}) +stock = node.database.get_required(station_stock_ui_doc, "station:starbridge:stock") + +peers = CultMesh.create_peer_catalog() +response = peers.create_response(peer_exchange_request("pex-1", verse_id="local")) +lease_verifier = CultMeshHmacAuthorityLeaseVerifier({"odin": b"shared-lease-key"}) +lease_catalog = CultMesh.create_authority_lease_catalog( + signature_verifier=lease_verifier.verify, + require_verified_signatures=True, +) +lease_valid_from = datetime.now(UTC) +lease = lease_verifier.issue(CultMeshAuthorityLease( + lease_id="lease:python-runtime", + verse_id="local", + peer_id="python-runtime", + roles=("shard-primary",), + valid_from=lease_valid_from, + expires_at=lease_valid_from + timedelta(minutes=5), + shard_ids=("interop",), + issuer_runtime_id="odin", +)) +lease_catalog.upsert(lease) +session = CultMesh.create_game_session( + node, + CultMeshGameSessionOptions( + client_authority_scopes=( + CultNetClientAuthorityScope("python-runtime", schema_ids=(note_doc.catalog_entry().schema_id,), key_prefix="input:python"), + ), + ), +) +server = CultMesh.serve_node( + node, + peer_catalog=peers, + observation_hub=session.observation_hub, + port=3075, + max_snapshot_documents=1000, + max_snapshot_bytes=4 * 1024 * 1024, +) +``` + +The same local server can be launched as a package daemon when another runtime +or operator process needs a CultNet endpoint without embedding Python glue: + +```powershell +python -m cultmesh_py.daemon --runtime-id python-runtime --display-name "Python Runtime" --host 127.0.0.1 --port 3075 --cache-file mesh.cc --enable-durable-shard-logs --seed-interop-note --seed-shard-id interop --verse-id local --role shard-primary --ready-file mesh.ready.json +``` + +The ready file and stdout line are process readiness hints. The served state and +capability truth still live on the framed CultNet/CultMesh endpoint. Use +`--register-interop-note` to expose the package interop-note schema without +seeding a note, or `--seed-interop-note` to register it and publish one local +record for wire probes. Seeded records are committed through the raw mutation +path, so peers can read them through both snapshot and shard-log catch-up. Add +`--verse-id` and one or more `--role` values to publish the launched endpoint in +its own Verse and peer catalogs for discovery clients. When the daemon is +launched with `--cache-file`, `--enable-durable-shard-logs`, and +`--shard-log-file`, a later daemon process can rehydrate and serve the persisted +snapshot and shard log without reseeding. + +`CultMeshPeerHealthMonitor` probes peer-card endpoints with `cultnet.hello.v0` +and preserves the runtime id, display name, document types, message versions, +and mutation contracts reported by the peer. + +```python + +client = CultMesh.create_verse_discovery_client("127.0.0.1", 3075) +verses = client.fetch_verses(transport_version="cultmesh.v0") +mesh_peers = client.fetch_peers(verse_id="python-interop", roles=["read-replica"]) +client.sync_peer_catalog(peers, verse_id="python-interop", roles=["read-replica"]) +client.fanout_peer_catalog(peers, verse_id="python-interop", roles=["read-replica"]) +replica_notes = client.sync_documents( + node.database, + peers, + verse_id="python-interop", + roles=["read-replica"], + documents=[(note_doc, "note:remote")], +) +snapshot_fanout = CultMeshSnapshotFanout( + client, + node.database, + peers, + verse_id="python-interop", + roles=["read-replica"], + documents=[(note_doc, "note:remote")], + on_document=lambda note: print(note), +) +snapshot_fanout.sync_once() + +raw_client = CultNetRawClient("127.0.0.1", 3075) +synced_note = node.database.sync_document(raw_client, note_doc, "note:remote") +synced_stock = CultMesh.sync_document( + node, + raw_client, + station_stock_ui_doc, + "station:starbridge:stock", +) +with CultMesh.subscribe_document( + node, + raw_client, + station_stock_ui_doc, + "station:starbridge:stock", +) as stock_subscription: + stock = stock_subscription.sync_initial() + next_change = stock_subscription.read_next_change() +node.database.sync_shard_log(raw_client, shard_id="interop", shard_epoch=1) +replicator = CultNetShardReplicator( + node.database, + CultNetShardReplicatorOptions( + fetcher=CultNetSchemaShardLogFetcher(), + snapshot_fetcher=CultNetSchemaShardSnapshotFetcher(), + cursor_store=CultNetFileShardReplicaCursorStore("mesh.replica-cursors.msgpack"), + poll_interval_seconds=1.0, + on_error=lambda error: print(error), + ), +) +replica_shard = CultNetShardDescriptor( + shard_id="interop", + owner_runtime_id="primary-runtime", + epoch=1, + schema_ids=(note_doc.catalog_entry().schema_id,), + primary_endpoints=("cultnet://127.0.0.1:3075",), +) +replicator.pull_once(replica_shard) +replicator.start([replica_shard]) +replicator.stop() +server.stop() + +streams = CultMesh.create_stream_catalog() + +claim_hash = "accepted-claim-hash" +facts = CultMesh.create_simulation_fact_committer(node) +committed = facts.commit({ + "shardId": "arena", + "shardEpoch": 4, + "frame": 100, + "subjectId": "bob", + "claimKind": "hit", + "claimHash": claim_hash, + "witnessCount": 2, + "supportWeight": 2.0, + "totalWeight": 2.0, + "confidence": 1.0, + "hasQuorum": True, +}) +session_commits = session.submit_and_commit(typed_observation) +prediction = session.predict(note_doc, "input:python:move", {"body": "predicted input"}) +unsubscribe() +``` + ## Wire Parity -See [docs/python-runtime-parity.md](../../docs/python-runtime-parity.md) for -the evidence map across the three Python packages. +See [PARITY.md](PARITY.md) for the current evidence map, gates, and things this +package still does not claim. -The store interop peer is `cultcache_py.interop`: +The Python interop peer is `cultcache_py.interop`: ```powershell python -m cultcache_py.interop write --file cache.cc --runtime-id python python -m cultcache_py.interop read --file cache.cc ``` -`packages/cultcache-ts/test/cult-cache.test.ts` includes Python in the shared -CultCache v1 parity matrix with TypeScript, Rust, and C#. `cultcache_py` ships -a `py.typed` marker so downstream type checkers can inspect the package surface. +Current receipts: -## Tests +- `packages/cultcache-ts/test/cult-cache.test.ts` includes Python in the shared + CultCache v1 parity matrix with TypeScript, Rust, and C#. +- `packages/cultnet-ts/test/interop/cultnet-interop.test.ts` includes Python in + the live TS/Rust/C#/Python schema-v0 peer ring: discovery, hello, schema + catalog, wire-message catalog discovery, raw snapshot, raw document + put/delete, mutation receipt, fire-command receipt, database subscription + changes, shard catalog, shard log catch-up, simulation consensus candidates, + committed simulation fact snapshots, Python session prediction reconciliation, + and witness artifact bundle round-trips. The same test asks the Python peer + for CultMesh Verse catalog and peer exchange responses over the CultNet pipe, + then verifies the public `CultMeshDiscoveryClient` can fetch typed Python + Verse and peer descriptors from that live endpoint. It also verifies + `CultNetRawClient` can fetch the live Python peer's schema catalog, raw + snapshot, and shard catalog. +- `packages/cultcache-py/tests/test_cultcache.py` covers Python CultMesh + Verse catalog, peer exchange, authority lease, stream negotiation, CultNet + helper shapes, raw snapshot/shard-log application, simulation claim hashing, + consensus aggregation, game-session fact commits, local prediction + reconciliation, committed simulation fact payload slots, the local CultMesh + server's TCP and RUDP schema request paths, and witness artifact bundle + payload slots. +- `cultcache_py`, `cultnet_py`, and `cultmesh_py` ship `py.typed` markers so + downstream type checkers can inspect the package surface instead of treating + the runtime as an untyped xenos swamp. + +## Performance Baseline + +The package ships a lightweight benchmark for Python-owned hot paths. In a +CultLib source checkout, it can also run the C# reference benchmark beside it: ```powershell -$env:PYTHONPATH="$PWD\packages\cultcache-py\src" -python -m unittest discover -s packages\cultcache-py\tests +python -m cultcache_py.benchmark --records 1000 --json +python -m cultcache_py.compare_csharp --records 1000 --json +python -m cultcache_py.verify --json ``` -The suite runs with only this package on the path. If a test here needs -`cultnet_py` or `cultmesh_py`, it belongs in that package. +It reports slot-indexed `DatabaseEntry` encode/decode throughput, framed +CultNet MessagePack parse throughput, raw snapshot application into a +registered cache, and public `CultCache` upsert/get throughput. The C# compare +command also runs `packages/cultcache-py/tools/GameCult.Caching.Benchmark` and +reports median Python-to-C# ratios for the shared public cache operations. It +uses three samples by default; pass `--samples` when you need a different +evidence shape. Both runtimes precompute record keys before measured cache +upsert/get loops so the comparison is about public cache access, not string +construction noise. The public in-memory cache owner stores records in +type-partitioned dictionaries so hot `get(...)` calls avoid per-call +composite-key allocation. Treat the benchmark numbers as a local baseline, not +a claim that Python matches the C# reference in every workload. diff --git a/packages/cultcache-py/pyproject.toml b/packages/cultcache-py/pyproject.toml index 6c46d102..93ebf849 100644 --- a/packages/cultcache-py/pyproject.toml +++ b/packages/cultcache-py/pyproject.toml @@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta" [project] name = "cultcache-py" -version = "0.3.0" -description = "Python CultCache runtime for GameCult typed state." +version = "0.1.0" +description = "Python CultCache, CultNet, and CultMesh runtime package for GameCult typed state." readme = "README.md" requires-python = ">=3.10" -license = "MIT" +license = { text = "Private GameCult infrastructure" } authors = [{ name = "GameCult" }] classifiers = [ "Programming Language :: Python :: 3", @@ -17,11 +17,23 @@ classifiers = [ "Programming Language :: Python :: 3.12", ] dependencies = ["msgpack>=1.0"] + +[project.optional-dependencies] +msgpack = ["msgpack>=1.0"] +crypto = ["cryptography>=42"] + [project.scripts] cultcache-py-interop = "cultcache_py.interop:main" +cultnet-py-interop = "cultnet_py.interop_peer:main" +cultcache-py-benchmark = "cultcache_py.benchmark:main" +cultcache-py-compare-csharp = "cultcache_py.compare_csharp:main" +cultcache-py-verify = "cultcache_py.verify:main" +cultmesh-py-daemon = "cultmesh_py.daemon:main" [tool.setuptools.packages.find] where = ["src"] [tool.setuptools.package-data] cultcache_py = ["py.typed"] +cultnet_py = ["py.typed"] +cultmesh_py = ["py.typed"] diff --git a/packages/cultnet-py/src/cultnet_py/benchmark.py b/packages/cultcache-py/src/cultcache_py/benchmark.py similarity index 98% rename from packages/cultnet-py/src/cultnet_py/benchmark.py rename to packages/cultcache-py/src/cultcache_py/benchmark.py index 83834635..a444acd9 100644 --- a/packages/cultnet-py/src/cultnet_py/benchmark.py +++ b/packages/cultcache-py/src/cultcache_py/benchmark.py @@ -19,7 +19,7 @@ class BenchItem: def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(prog="cultnet-py-benchmark") + parser = argparse.ArgumentParser(prog="cultcache-py-benchmark") parser.add_argument("--records", type=int, default=5000) parser.add_argument("--json", action="store_true", dest="as_json") args = parser.parse_args(argv) diff --git a/packages/cultcache-py/src/cultcache_py/cache.py b/packages/cultcache-py/src/cultcache_py/cache.py index 0da2c04a..f7ad1c12 100644 --- a/packages/cultcache-py/src/cultcache_py/cache.py +++ b/packages/cultcache-py/src/cultcache_py/cache.py @@ -1,52 +1,15 @@ from __future__ import annotations -import functools -import threading -from dataclasses import dataclass, field, replace -from typing import Any, Callable, Generic, TypeVar +from dataclasses import dataclass, field +from typing import Any, Generic, TypeVar from .backing_store import BackingStore, CultCacheEnvelope from .documents import DocumentDefinition, extract_value T = TypeVar("T") -F = TypeVar("F", bound=Callable[..., Any]) GLOBAL_KEY = "__global__" -def _locked(method: F) -> F: - """Runs a read holding the cache's lock. Reads are reentrant and may run inside a mutation.""" - - @functools.wraps(method) - def wrapper(self: "CultCache", *args: Any, **kwargs: Any) -> Any: - with self._lock: - return method(self, *args, **kwargs) - - return wrapper # type: ignore[return-value] - - -def _mutating(method: F) -> F: - """Runs an attach, pull, registration, write or delete holding the cache's lock, so its validation, - store I/O and apply cannot interleave with another thread's. One started on a thread that is already - inside a mutation of this cache (from a store call, decoder, encoder, extractor or updater) raises - before touching anything.""" - - @functools.wraps(method) - def wrapper(self: "CultCache", *args: Any, **kwargs: Any) -> Any: - if self._mutating_thread == threading.get_ident(): - raise CultCacheError( - f"CultCache refuses a re-entrant {method.__name__}: a mutation cannot start from inside a store call, " - "decoder, encoder, extractor or updater of another mutation on the same cache" - ) - with self._lock: - self._mutating_thread = threading.get_ident() - try: - return method(self, *args, **kwargs) - finally: - self._mutating_thread = None - - return wrapper # type: ignore[return-value] - - class CultCacheError(RuntimeError): pass @@ -57,31 +20,12 @@ class _State: documents_by_schema_name: dict[str, DocumentDefinition[Any]] = field(default_factory=dict) values: dict[str, dict[str, Any]] = field(default_factory=dict) envelopes: dict[str, dict[str, CultCacheEnvelope]] = field(default_factory=dict) - stores_by_type: dict[str, BackingStore] = field(default_factory=dict) - generic_store: BackingStore | None = None + stores_by_type: dict[str, list[BackingStore]] = field(default_factory=dict) + generic_stores: list[BackingStore] = field(default_factory=list) name_extractors: dict[str, str | Any] = field(default_factory=dict) index_extractors: dict[str, dict[str, str | Any]] = field(default_factory=dict) names: dict[tuple[str, str], str] = field(default_factory=dict) indexes: dict[tuple[str, str, str], str] = field(default_factory=dict) - # The name and index values each held record produced when it was admitted. Removing a - # record reads these and runs no extractor, so removal has no fallible step. - lookup_keys: dict[str, dict[str, "_LookupKeys"]] = field(default_factory=dict) - # Load compatibility shim: a global persisted under a key other than GLOBAL_KEY is held as that - # type's global under GLOBAL_KEY, and its persisted key is kept here so the first write or delete - # of that global removes it from the home store. Remove once no store holds a global under a key - # other than GLOBAL_KEY. - legacy_global_keys: dict[str, str] = field(default_factory=dict) - - -@dataclass(frozen=True) -class _LookupKeys: - name: str | None - indexes: dict[str, str] - - -_Names = dict[tuple[str, str], str] -_Indexes = dict[tuple[str, str, str], str] -_LookupKeysByType = dict[str, dict[str, _LookupKeys]] class CultCacheBuilder: @@ -125,135 +69,46 @@ class CultCache: def __init__(self) -> None: self._state = _State() - # Mutations hold this lock and refuse to start on the thread already inside one (see _mutating). - # It is reentrant so a mutation's extractors, decoders and updaters may still read the cache. - # Single-structure reads (get, get_all, get_envelope, key lookups) stay unlocked. The guarantee is - # per call: each reads one dict under the GIL and sees it wholly before or after a write's install. - # Two unlocked calls can straddle a write or pull, and see different states. - self._lock = threading.RLock() - self._mutating_thread: int | None = None - @_mutating def register_document_type(self, document: DocumentDefinition[Any]) -> None: - self._register_document_type(document) - - def _register_document_type(self, document: DocumentDefinition[Any]) -> None: if document.type in self._state.documents: raise CultCacheError(f"Document type already registered: {document.type}") schema_name = document.catalog_entry().schema_name if schema_name in self._state.documents_by_schema_name: raise CultCacheError(f"Document schema name already registered: {schema_name}") - name_extractors = dict(self._state.name_extractors) - index_extractors = {type: dict(indexes) for type, indexes in self._state.index_extractors.items()} - if document.name is not None: - name_extractors[document.type] = document.name - if document.indexes: - index_extractors.setdefault(document.type, {}).update(document.indexes) - self._install_lookups(name_extractors, index_extractors) self._state.documents[document.type] = document self._state.documents_by_schema_name[schema_name] = document + if document.name is not None: + self.register_name_lookup(document, document.name) + for index, extractor in document.indexes.items(): + self.register_index(document, index, extractor) - @_mutating def register_registry(self, documents: list[DocumentDefinition[Any]] | tuple[DocumentDefinition[Any], ...]) -> None: for document in documents: - self._register_document_type(document) + self.register_document_type(document) - @_mutating def register_name_lookup(self, document: DocumentDefinition[Any], extractor: str | Any) -> None: self._assert_registered(document) - name_extractors = dict(self._state.name_extractors) - name_extractors[document.type] = extractor - self._install_lookups(name_extractors, self._state.index_extractors) + self._state.name_extractors[document.type] = extractor + self._rebuild_indexes() - @_mutating def register_index(self, document: DocumentDefinition[Any], index: str, extractor: str | Any) -> None: self._assert_registered(document) - index_extractors = {type: dict(indexes) for type, indexes in self._state.index_extractors.items()} - index_extractors.setdefault(document.type, {})[index] = extractor - self._install_lookups(self._state.name_extractors, index_extractors) + self._state.index_extractors.setdefault(document.type, {})[index] = extractor + self._rebuild_indexes() - def _install_lookups( - self, name_extractors: dict[str, str | Any], index_extractors: dict[str, dict[str, str | Any]] - ) -> None: - """Derives every lookup under the candidate extractors, then installs extractors and lookups - together. An extractor that raises on a held value installs nothing.""" - names, indexes, lookup_keys = self._derive_lookups(self._state.values, name_extractors, index_extractors) - ( - self._state.name_extractors, - self._state.index_extractors, - self._state.names, - self._state.indexes, - self._state.lookup_keys, - ) = (name_extractors, index_extractors, names, indexes, lookup_keys) - - @_mutating def add_backing_store(self, store: BackingStore, types: list[str] | tuple[str, ...] | set[str]) -> None: - """Makes the store home to the given types, or the generic store when there are none. - - A type has exactly one home store, and attaching cannot move the home of a record the cache holds. - """ - types = list(types) - if not types: - self._add_generic_store(store) - return for type in types: - if type in self._state.stores_by_type: - raise CultCacheError( - f"Document type {type} is already routed to another backing store; it cannot also route to this one" - ) - routes = dict(self._state.stores_by_type) - routes.update({type: store for type in types}) - self._refuse_home_moves(routes, self._state.generic_store) - self._state.stores_by_type = routes + self._state.stores_by_type.setdefault(type, []).append(store) - @_mutating def add_generic_store(self, store: BackingStore) -> None: - """Makes the store home to every type no other store claims. A cache has at most one.""" - self._add_generic_store(store) + self._state.generic_stores.append(store) - def _add_generic_store(self, store: BackingStore) -> None: - if self._state.generic_store is not None: - raise CultCacheError( - "Backing store would be a second generic store; name the types it is home to" - ) - self._refuse_home_moves(self._state.stores_by_type, store) - self._state.generic_store = store - - def _refuse_home_moves(self, routes: dict[str, BackingStore], generic: BackingStore | None) -> None: - for type, values in self._state.values.items(): - if not values: - continue - before = self._state.stores_by_type.get(type, self._state.generic_store) - after = routes.get(type, generic) - if before is not after: - raise CultCacheError( - f"Attaching this backing store would move {type} from {self._describe(before, routes, generic)} " - f"to {self._describe(after, routes, generic)}; attach routed stores before the generic store" - ) - - @staticmethod - def _describe(store: BackingStore | None, routes: dict[str, BackingStore], generic: BackingStore | None) -> str: - if store is None: - return "memory" - if store is generic: - return "the generic store" - return "the store routed to " + ", ".join(sorted(type for type, routed in routes.items() if routed is store)) - - @_mutating def pull_all_backing_stores(self) -> None: - """Builds the complete next state (values, envelopes, name and index lookups) in local - structures, running every check and every user extractor; the cache's state is replaced - only once that build finishes, so a refused load admits nothing.""" - loaded_values: dict[str, dict[str, Any]] = {} - loaded_envelopes: dict[str, dict[str, CultCacheEnvelope]] = {} - loaded_legacy_global_keys: dict[str, str] = {} - routes, generic = self._state.stores_by_type, self._state.generic_store + self._state.values.clear() + self._state.envelopes.clear() seen_globals: set[str] = set() - stores: list[BackingStore] = [] - for candidate in [*self._state.stores_by_type.values(), self._state.generic_store]: - if candidate is not None and all(candidate is not store for store in stores): - stores.append(candidate) - for store in stores: + for store in [*self._all_specific_stores(), *self._state.generic_stores]: for envelope in store.pull_all(): document = self._resolve_document_for_envelope(envelope) if document is None: @@ -267,34 +122,14 @@ class CultCache: schema_id=envelope.schema_id, catalog_entry=envelope.catalog_entry, ) - home = routes.get(envelope.type, generic) - if home is not store: - raise CultCacheError( - f"{envelope.type} record {envelope.key} was loaded from {self._describe(store, routes, generic)}, " - f"but its home is {self._describe(home, routes, generic)}" - ) if document.global_document: - if envelope.type in seen_globals: + if envelope.type in seen_globals and envelope.key == GLOBAL_KEY: raise CultCacheError(f"Duplicate global document for type: {envelope.type}") seen_globals.add(envelope.type) - if envelope.key != GLOBAL_KEY: - loaded_legacy_global_keys[envelope.type] = envelope.key - envelope = replace(envelope, key=GLOBAL_KEY) value = document.decode_payload(envelope.payload) - loaded_values.setdefault(envelope.type, {})[envelope.key] = value - loaded_envelopes.setdefault(envelope.type, {})[envelope.key] = envelope - loaded_names, loaded_indexes, loaded_lookup_keys = self._derive_lookups( - loaded_values, self._state.name_extractors, self._state.index_extractors - ) - # Plain attribute assignments: nothing fallible runs once the swap begins. - ( - self._state.values, - self._state.envelopes, - self._state.names, - self._state.indexes, - self._state.lookup_keys, - self._state.legacy_global_keys, - ) = (loaded_values, loaded_envelopes, loaded_names, loaded_indexes, loaded_lookup_keys, loaded_legacy_global_keys) + self._state.values.setdefault(envelope.type, {})[envelope.key] = value + self._state.envelopes.setdefault(envelope.type, {})[envelope.key] = envelope + self._rebuild_indexes() def get(self, document: DocumentDefinition[T], key: str) -> T | None: self._assert_registered(document) @@ -334,9 +169,6 @@ class CultCache: self._assert_registered(document) return self._state.names.get((document.type, name)) - # Name and index resolution read the lookup and then the values, two structures a pull swaps and a - # write updates in sequence, so they hold the lock to resolve a key and its value from one state. - @_locked def get_by_name(self, document: DocumentDefinition[T], name: str) -> T | None: key = self.get_key_by_name(document, name) return None if key is None else self.get(document, key) @@ -345,19 +177,17 @@ class CultCache: self._assert_registered(document) return self._state.indexes.get((document.type, index, value)) - @_locked def get_by_index(self, document: DocumentDefinition[T], index: str, value: str) -> T | None: key = self.get_key_by_index(document, index, value) return None if key is None else self.get(document, key) - @_mutating def put(self, document: DocumentDefinition[T], key: str, value: T) -> None: - self._put(document, key, value) - - def _put(self, document: DocumentDefinition[T], key: str, value: T) -> None: self._assert_registered(document) if document.global_document and key != GLOBAL_KEY: raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}") + values = self._state.values.setdefault(document.type, {}) + envelopes = self._state.envelopes.setdefault(document.type, {}) + old_value = values.get(key) catalog_entry = document.catalog_entry() envelope = CultCacheEnvelope.create( key=key, @@ -366,139 +196,100 @@ class CultCache: schema_id=catalog_entry.schema_id, catalog_entry=catalog_entry, ) - self._write(document, [(envelope, value)], batch=False) + stores = self._stores_for_type(document.type) + for store in stores: + store.push(envelope) + if old_value is not None: + self._remove_value_indexes(document.type, key, old_value) + values[key] = value + envelopes[key] = envelope + self._add_value_indexes(document.type, key, value) - @_mutating def put_envelope(self, document: DocumentDefinition[T], envelope: CultCacheEnvelope) -> T: self._assert_registered(document) - self._check_envelope(document, envelope) - value = document.decode_payload(envelope.payload) - self._write(document, [(envelope, value)], batch=False) - return value - - @_mutating - def put_envelopes(self, document: DocumentDefinition[T], envelopes: list[CultCacheEnvelope]) -> list[T]: - self._assert_registered(document) - values: list[T] = [] - for envelope in envelopes: - self._check_envelope(document, envelope) - values.append(document.decode_payload(envelope.payload)) - self._write(document, list(zip(envelopes, values)), batch=True) - return values - - @staticmethod - def _check_envelope(document: DocumentDefinition[Any], envelope: CultCacheEnvelope) -> None: if envelope.type != document.type: raise CultCacheError( f"Envelope type {envelope.type} does not match document type {document.type}" ) - if not envelope.key or not envelope.key.strip(): - raise CultCacheError(f"Envelope key for document type {document.type} must be non-empty") - if document.global_document and envelope.key != GLOBAL_KEY: - raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}") + values = self._state.values.setdefault(document.type, {}) + envelopes = self._state.envelopes.setdefault(document.type, {}) + old_value = values.get(envelope.key) + value = document.decode_payload(envelope.payload) + for store in self._stores_for_type(document.type): + store.push(envelope) + if old_value is not None: + self._remove_value_indexes(document.type, envelope.key, old_value) + values[envelope.key] = value + envelopes[envelope.key] = envelope + self._add_value_indexes(document.type, envelope.key, value) + return value - def _write( - self, document: DocumentDefinition[Any], records: list[tuple[CultCacheEnvelope, Any]], *, batch: bool - ) -> None: - """Validates every record (home and name and index extractors included) before the store is - touched; once the store accepts, applying to the cache has no fallible step.""" - type = document.type - name_extractor = self._state.name_extractors.get(type) - index_extractors = self._state.index_extractors.get(type, {}) - admitted = [ - (envelope, value, self._lookup_keys_for(value, name_extractor, index_extractors)) - for envelope, value in records - ] - store = self._store_for_type(type) - legacy_key = self._state.legacy_global_keys.get(type) - if store is not None: - if batch: - store.push_all([envelope for envelope, _ in records]) - else: - store.push(records[0][0]) - if legacy_key is not None: - self._retire_legacy_global(store, type, legacy_key) - self._state.legacy_global_keys.pop(type, None) - values = self._state.values.setdefault(type, {}) - envelopes = self._state.envelopes.setdefault(type, {}) - lookup_keys = self._state.lookup_keys.setdefault(type, {}) - for envelope, value, keys in admitted: - self._remove_lookups(type, envelope.key) - values[envelope.key] = value - envelopes[envelope.key] = envelope - lookup_keys[envelope.key] = keys - if keys.name is not None: - self._state.names[(type, keys.name)] = envelope.key - for index, index_value in keys.indexes.items(): - self._state.indexes[(type, index, index_value)] = envelope.key + def put_envelopes(self, document: DocumentDefinition[T], envelopes: list[CultCacheEnvelope]) -> list[T]: + self._assert_registered(document) + values: list[T] = [] + for envelope in envelopes: + if envelope.type != document.type: + raise CultCacheError( + f"Envelope type {envelope.type} does not match document type {document.type}" + ) + if document.global_document and envelope.key != GLOBAL_KEY: + raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}") + values.append(document.decode_payload(envelope.payload)) - @staticmethod - def _retire_legacy_global(store: BackingStore, type: str, legacy_key: str) -> None: - """The first write of a global loaded from a legacy key removes that record in the same write. If - the removal fails, the new record is taken back out, so the store keeps only the legacy record.""" - try: - store.delete(type, legacy_key) - except BaseException: - try: - store.delete(type, GLOBAL_KEY) - except Exception: # noqa: BLE001 - the removal failure is the error reported - pass - raise + stores = self._stores_for_type(document.type) + for store in stores: + store.push_all(envelopes) + values_by_key = self._state.values.setdefault(document.type, {}) + envelopes_by_key = self._state.envelopes.setdefault(document.type, {}) + for envelope, value in zip(envelopes, values): + old_value = values_by_key.get(envelope.key) + if old_value is not None: + self._remove_value_indexes(document.type, envelope.key, old_value) + values_by_key[envelope.key] = value + envelopes_by_key[envelope.key] = envelope + self._add_value_indexes(document.type, envelope.key, value) + return values - @_mutating def put_global(self, document: DocumentDefinition[T], value: T) -> None: self._assert_global(document) - self._put(document, GLOBAL_KEY, value) + self.put(document, GLOBAL_KEY, value) - @_mutating def update(self, document: DocumentDefinition[T], key: str, updater: Any) -> T: - return self._update(document, key, updater) - - def _update(self, document: DocumentDefinition[T], key: str, updater: Any) -> T: current = self.get_required(document, key) updated = updater(current) - self._put(document, key, updated) + self.put(document, key, updated) return updated - @_mutating def update_global(self, document: DocumentDefinition[T], updater: Any) -> T: self._assert_global(document) - return self._update(document, GLOBAL_KEY, updater) + return self.update(document, GLOBAL_KEY, updater) - @_mutating def delete(self, document: DocumentDefinition[Any], key: str) -> None: - self._delete(document, key) - - def _delete(self, document: DocumentDefinition[Any], key: str) -> None: self._assert_registered(document) - store = self._store_for_type(document.type) - legacy_key = self._state.legacy_global_keys.get(document.type) if key == GLOBAL_KEY else None - if store is not None: - store.delete(document.type, key if legacy_key is None else legacy_key) - if legacy_key is not None: - del self._state.legacy_global_keys[document.type] - self._remove_lookups(document.type, key) - for by_type in (self._state.values, self._state.envelopes, self._state.lookup_keys): - held = by_type.get(document.type) - if held is not None: - held.pop(key, None) - if not held: - by_type.pop(document.type, None) + for store in self._stores_for_type(document.type): + store.delete(document.type, key) + values = self._state.values.get(document.type) + old_value = None if values is None else values.pop(key, None) + if values == {}: + self._state.values.pop(document.type, None) + envelopes = self._state.envelopes.get(document.type) + if envelopes is not None: + envelopes.pop(key, None) + if envelopes == {}: + self._state.envelopes.pop(document.type, None) + if old_value is not None: + self._remove_value_indexes(document.type, key, old_value) - @_mutating def delete_global(self, document: DocumentDefinition[Any]) -> None: self._assert_global(document) - self._delete(document, GLOBAL_KEY) + self.delete(document, GLOBAL_KEY) - # Snapshots iterate dicts a concurrent write would resize mid-iteration, so they hold the lock. - @_locked def snapshot(self) -> dict[str, dict[str, Any]]: out: dict[str, dict[str, Any]] = {} for type, values in self._state.values.items(): out[type] = dict(values) return out - @_locked def snapshot_envelopes(self) -> list[CultCacheEnvelope]: return [ envelope @@ -506,70 +297,75 @@ class CultCache: for envelope in envelopes.values() ] - def _store_for_type(self, type: str) -> BackingStore | None: - """The store that claims the type, else the generic store. + def _stores_for_type(self, type: str) -> list[BackingStore]: + specific = self._state.stores_by_type.get(type) + return specific if specific else self._state.generic_stores - Returns None only for a cache with no stores, which is an in-memory cache. - """ - store = self._state.stores_by_type.get(type, self._state.generic_store) - if store is None and self._state.stores_by_type: - raise CultCacheError(f"No backing store is home to document type: {type}") - return store + def _all_specific_stores(self) -> list[BackingStore]: + stores: list[BackingStore] = [] + seen: set[int] = set() + for routed in self._state.stores_by_type.values(): + for store in routed: + marker = id(store) + if marker not in seen: + stores.append(store) + seen.add(marker) + return stores - @staticmethod - def _lookup_keys_for( - value: Any, name_extractor: str | Any | None, index_extractors: dict[str, str | Any] - ) -> _LookupKeys: - name = None if name_extractor is None else extract_value(value, name_extractor) - indexes: dict[str, str] = {} - for index, extractor in index_extractors.items(): + def _rebuild_indexes(self) -> None: + self._state.names.clear() + self._state.indexes.clear() + for type, values in self._state.values.items(): + for key, value in values.items(): + self._add_value_indexes(type, key, value) + + def _add_value_indexes(self, type: str, key: str, value: Any) -> None: + name_extractor = self._state.name_extractors.get(type) + if name_extractor is not None: + name = extract_value(value, name_extractor) + if name is not None: + self._state.names[(type, str(name))] = key + for index, extractor in self._state.index_extractors.get(type, {}).items(): index_value = extract_value(value, extractor) if index_value is not None: - indexes[index] = str(index_value) - return _LookupKeys(None if name is None else str(name), indexes) + self._state.indexes[(type, index, str(index_value))] = key - def _derive_lookups( - self, - values_by_type: dict[str, dict[str, Any]], - name_extractors: dict[str, str | Any], - index_extractors: dict[str, dict[str, str | Any]], - ) -> tuple[_Names, _Indexes, _LookupKeysByType]: - """Builds name and index lookups for the given values in fresh dicts, touching no cache state.""" - names: _Names = {} - indexes: _Indexes = {} - lookup_keys: _LookupKeysByType = {} - for type, values in values_by_type.items(): - name_extractor = name_extractors.get(type) - type_index_extractors = index_extractors.get(type, {}) - for key, value in values.items(): - keys = self._lookup_keys_for(value, name_extractor, type_index_extractors) - lookup_keys.setdefault(type, {})[key] = keys - if keys.name is not None: - names[(type, keys.name)] = key - for index, index_value in keys.indexes.items(): - indexes[(type, index, index_value)] = key - return names, indexes, lookup_keys + def _remove_value_indexes(self, type: str, key: str, value: Any) -> None: + name_extractor = self._state.name_extractors.get(type) + if name_extractor is not None: + name = extract_value(value, name_extractor) + if name is not None: + self._remove_name_index(type, key, str(name)) + for index, extractor in self._state.index_extractors.get(type, {}).items(): + index_value = extract_value(value, extractor) + if index_value is not None: + self._remove_secondary_index(type, key, index, str(index_value)) - def _remove_lookups(self, type: str, key: str) -> None: - """Removes the lookups the held record produced at admission, handing a name or index value to - another held record that produced it too. Reads stored lookup keys only; runs no extractor.""" - held = self._state.lookup_keys.get(type, {}) - keys = held.get(key) - if keys is None: + def _remove_name_index(self, type: str, key: str, name: str) -> None: + lookup_key = (type, name) + if self._state.names.get(lookup_key) != key: return - if keys.name is not None and self._state.names.get((type, keys.name)) == key: - del self._state.names[(type, keys.name)] - for candidate_key, candidate in held.items(): - if candidate_key != key and candidate.name == keys.name: - self._state.names[(type, keys.name)] = candidate_key - for index, index_value in keys.indexes.items(): - lookup_key = (type, index, index_value) - if self._state.indexes.get(lookup_key) != key: - continue - del self._state.indexes[lookup_key] - for candidate_key, candidate in held.items(): - if candidate_key != key and candidate.indexes.get(index) == index_value: - self._state.indexes[lookup_key] = candidate_key + self._state.names.pop(lookup_key, None) + name_extractor = self._state.name_extractors.get(type) + if name_extractor is None: + return + for candidate_key, candidate in self._state.values.get(type, {}).items(): + candidate_name = extract_value(candidate, name_extractor) + if candidate_key != key and candidate_name is not None and str(candidate_name) == name: + self._state.names[lookup_key] = candidate_key + + def _remove_secondary_index(self, type: str, key: str, index: str, value: str) -> None: + lookup_key = (type, index, value) + if self._state.indexes.get(lookup_key) != key: + return + self._state.indexes.pop(lookup_key, None) + extractor = self._state.index_extractors.get(type, {}).get(index) + if extractor is None: + return + for candidate_key, candidate in self._state.values.get(type, {}).items(): + candidate_value = extract_value(candidate, extractor) + if candidate_key != key and candidate_value is not None and str(candidate_value) == value: + self._state.indexes[lookup_key] = candidate_key def _assert_registered(self, document: DocumentDefinition[Any]) -> None: if self._state.documents.get(document.type) is not document: diff --git a/packages/cultnet-py/src/cultnet_py/compare_csharp.py b/packages/cultcache-py/src/cultcache_py/compare_csharp.py similarity index 99% rename from packages/cultnet-py/src/cultnet_py/compare_csharp.py rename to packages/cultcache-py/src/cultcache_py/compare_csharp.py index a2654a33..8a9e4c5b 100644 --- a/packages/cultnet-py/src/cultnet_py/compare_csharp.py +++ b/packages/cultcache-py/src/cultcache_py/compare_csharp.py @@ -16,7 +16,7 @@ DEFAULT_PARITY_THRESHOLD = 0.90 def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(prog="cultnet-py-compare-csharp") + parser = argparse.ArgumentParser(prog="cultcache-py-compare-csharp") parser.add_argument("--records", type=int, default=5000) parser.add_argument("--samples", type=int, default=DEFAULT_SAMPLE_COUNT) parser.add_argument("--parity-threshold", type=float, default=DEFAULT_PARITY_THRESHOLD) diff --git a/packages/cultmesh-py/src/cultmesh_py/verify.py b/packages/cultcache-py/src/cultcache_py/verify.py similarity index 98% rename from packages/cultmesh-py/src/cultmesh_py/verify.py rename to packages/cultcache-py/src/cultcache_py/verify.py index 068f5dc2..b6a85430 100644 --- a/packages/cultmesh-py/src/cultmesh_py/verify.py +++ b/packages/cultcache-py/src/cultcache_py/verify.py @@ -6,7 +6,7 @@ import json from importlib import resources from typing import Any -from cultnet_py.benchmark import run_benchmark +from .benchmark import run_benchmark EXPECTED_EXPORTS: dict[str, tuple[str, ...]] = { @@ -150,7 +150,7 @@ EXPECTED_EXPORTS: dict[str, tuple[str, ...]] = { def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(prog="cultmesh-py-verify") + parser = argparse.ArgumentParser(prog="cultcache-py-verify") parser.add_argument("--records", type=int, default=64) parser.add_argument("--json", action="store_true", dest="emit_json") args = parser.parse_args(argv) @@ -159,7 +159,7 @@ def main(argv: list[str] | None = None) -> int: if args.emit_json: print(json.dumps(result, indent=2, sort_keys=True)) else: - print(f"cultmesh-py verify: {result['status']}") + print(f"cultcache-py verify: {result['status']}") for check in result["checks"]: print(f"- {check['name']}: {check['status']}") return 0 if result["status"] == "ok" else 1 diff --git a/packages/cultmesh-py/src/cultmesh_py/__init__.py b/packages/cultcache-py/src/cultmesh_py/__init__.py similarity index 95% rename from packages/cultmesh-py/src/cultmesh_py/__init__.py rename to packages/cultcache-py/src/cultmesh_py/__init__.py index af4da272..95aee189 100644 --- a/packages/cultmesh-py/src/cultmesh_py/__init__.py +++ b/packages/cultcache-py/src/cultmesh_py/__init__.py @@ -14,10 +14,8 @@ from .node import ( CultMeshDatabase, CultMeshDatabaseChange, CultMeshDocumentPublicationSource, - CultMeshDocumentWriter, CultMeshNode, CultMeshNodeOptions, - CultMeshObservedDocument, CultMeshPublicationDocumentBinding, CultMeshReactiveDocument, CultMeshReactiveDocumentOptions, @@ -39,7 +37,7 @@ from .simulation import ( CultMeshSimulationFactCommitter, simulation_fact_document, ) -from cultnet_py.cultmesh_contracts import ( +from .wire import ( PEER_EXCHANGE_REQUEST, PEER_EXCHANGE_RESPONSE, VERSE_CATALOG_REQUEST, @@ -70,10 +68,8 @@ __all__ = [ "CultMeshDatabase", "CultMeshDatabaseChange", "CultMeshDocumentPublicationSource", - "CultMeshDocumentWriter", "CultMesh", "CultMeshPublicationDocumentBinding", - "CultMeshObservedDocument", "CultMeshReactiveDocument", "CultMeshReactiveDocumentOptions", "CultMeshReactiveDocumentReconciliation", diff --git a/packages/cultmesh-py/src/cultmesh_py/client.py b/packages/cultcache-py/src/cultmesh_py/client.py similarity index 99% rename from packages/cultmesh-py/src/cultmesh_py/client.py rename to packages/cultcache-py/src/cultmesh_py/client.py index 2e1c3ab1..e69e0403 100644 --- a/packages/cultmesh-py/src/cultmesh_py/client.py +++ b/packages/cultcache-py/src/cultmesh_py/client.py @@ -9,7 +9,7 @@ from urllib.parse import urlparse from cultcache_py.documents import DocumentDefinition from cultnet_py import CultNetDatabaseSubscription, CultNetRawClient, CultNetRawSnapshotResponse, hello -from cultnet_py.cultmesh_contracts import ( +from .wire import ( PEER_EXCHANGE_RESPONSE, VERSE_CATALOG_RESPONSE, CultMeshPeerCard, diff --git a/packages/cultmesh-py/src/cultmesh_py/daemon.py b/packages/cultcache-py/src/cultmesh_py/daemon.py similarity index 99% rename from packages/cultmesh-py/src/cultmesh_py/daemon.py rename to packages/cultcache-py/src/cultmesh_py/daemon.py index d21d42d8..087dc218 100644 --- a/packages/cultmesh-py/src/cultmesh_py/daemon.py +++ b/packages/cultcache-py/src/cultmesh_py/daemon.py @@ -13,7 +13,7 @@ from cultnet_py import CultNetSimulationConsensusOptions, CultNetSimulationObser from .facade import CultMesh from .server import CultMeshLocalServer -from cultnet_py.cultmesh_contracts import ( +from .wire import ( CultMeshPeerCard, CultMeshPeerCatalog, CultMeshVerseCatalog, diff --git a/packages/cultmesh-py/src/cultmesh_py/facade.py b/packages/cultcache-py/src/cultmesh_py/facade.py similarity index 97% rename from packages/cultmesh-py/src/cultmesh_py/facade.py rename to packages/cultcache-py/src/cultmesh_py/facade.py index 53328aab..115259f3 100644 --- a/packages/cultmesh-py/src/cultmesh_py/facade.py +++ b/packages/cultcache-py/src/cultmesh_py/facade.py @@ -24,17 +24,17 @@ from cultnet_py import ( from .client import CultMeshDocumentSubscription, CultMeshPeerExchangeClient, CultMeshVerseDiscoveryClient from .node import ( CultMeshDocumentPublicationSource, - CultMeshDocumentWriter, CultMeshNode, CultMeshNodeOptions, - CultMeshObservedDocument, CultMeshPublicationDocumentBinding, + CultMeshReactiveDocument, + CultMeshReactiveDocumentOptions, create_node, ) from .server import CultMeshLocalServer from .session import CultMeshGameSession, CultMeshGameSessionOptions from .simulation import CultMeshSimulationFactCommitter -from cultnet_py.cultmesh_contracts import ( +from .wire import ( AuthorityLeaseVerifier, CultMeshAuthorityLeaseCatalog, CultMeshPeerCard, @@ -301,20 +301,13 @@ class CultMesh: ) @staticmethod - def authoritative_writer( + def reactive_document( node: CultMeshNode, document: DocumentDefinition[Any], key: str, - ) -> CultMeshDocumentWriter: - return node.authoritative_writer(document, key) - - @staticmethod - def observe_document( - node: CultMeshNode, - document: DocumentDefinition[Any], - key: str, - ) -> CultMeshObservedDocument: - return node.observe_document(document, key) + options: CultMeshReactiveDocumentOptions | None = None, + ) -> CultMeshReactiveDocument: + return node.reactive_document(document, key, options) @staticmethod def publication_source_from_peer_snapshot( diff --git a/packages/cultmesh-py/src/cultmesh_py/node.py b/packages/cultcache-py/src/cultmesh_py/node.py similarity index 87% rename from packages/cultmesh-py/src/cultmesh_py/node.py rename to packages/cultcache-py/src/cultmesh_py/node.py index 09ac2012..142fe226 100644 --- a/packages/cultmesh-py/src/cultmesh_py/node.py +++ b/packages/cultcache-py/src/cultmesh_py/node.py @@ -1,9 +1,6 @@ from __future__ import annotations import threading -import heapq -import time -import traceback from dataclasses import dataclass, field from datetime import UTC, datetime from pathlib import Path @@ -87,90 +84,10 @@ class CultMeshReactiveDocumentReconciliation: @dataclass(frozen=True) class CultMeshReactiveDocumentOptions: flush_delay_seconds: float = 0.016 + detect_local_changes: bool = True replace_dirty_current_on_canonical_snapshot: bool = False -class _CultMeshScheduledCall: - def __init__(self, callback: Callable[[], None], wake: Callable[[], None]) -> None: - self._callback: Callable[[], None] | None = callback - self._wake = wake - self._lock = threading.Lock() - - def cancel(self) -> None: - with self._lock: - self._callback = None - self._wake() - - def take(self) -> Callable[[], None] | None: - with self._lock: - callback = self._callback - self._callback = None - return callback - - @property - def cancelled(self) -> bool: - with self._lock: - return self._callback is None - - -class _CultMeshReactiveScheduler: - """One lazy daemon worker owns delayed reactive flushes for the process.""" - - def __init__(self) -> None: - self._condition = threading.Condition() - self._queue: list[tuple[float, int, _CultMeshScheduledCall]] = [] - self._sequence = 0 - self._worker: threading.Thread | None = None - - def schedule(self, delay_seconds: float, callback: Callable[[], None]) -> _CultMeshScheduledCall: - call = _CultMeshScheduledCall(callback, self._wake) - with self._condition: - self._sequence += 1 - heapq.heappush( - self._queue, - (time.monotonic() + max(0.0, delay_seconds), self._sequence, call), - ) - if self._worker is None: - self._worker = threading.Thread( - target=self._run, - name="cultmesh-reactive-scheduler", - daemon=True, - ) - self._worker.start() - self._condition.notify() - return call - - def _wake(self) -> None: - with self._condition: - self._condition.notify() - - def _run(self) -> None: - while True: - callback: Callable[[], None] | None = None - with self._condition: - while callback is None: - while self._queue and self._queue[0][2].cancelled: - heapq.heappop(self._queue) - if not self._queue: - self._condition.wait() - continue - deadline, _, call = self._queue[0] - remaining = deadline - time.monotonic() - if remaining > 0: - self._condition.wait(remaining) - continue - heapq.heappop(self._queue) - callback = call.take() - if callback is not None: - try: - callback() - except Exception: - traceback.print_exc() - - -_REACTIVE_SCHEDULER = _CultMeshReactiveScheduler() - - @dataclass class CultMeshNode: cache: CultCache = field(default_factory=CultCache) @@ -331,19 +248,13 @@ class CultMeshNode: shard_epoch=shard_epoch, ) - def authoritative_writer( + def reactive_document( self, document: DocumentDefinition[Any], key: str, - ) -> "CultMeshDocumentWriter": - return self.database.authoritative_writer(document, key) - - def observe_document( - self, - document: DocumentDefinition[Any], - key: str, - ) -> "CultMeshObservedDocument": - return self.database.observe_document(document, key) + options: CultMeshReactiveDocumentOptions | None = None, + ) -> "CultMeshReactiveDocument": + return self.database.reactive_document(document, key, options) def sync_document_from_publication( self, @@ -833,27 +744,17 @@ class CultMeshDatabase: ) return self.get_required(requested, key) - def authoritative_writer( + def reactive_document( self, document: DocumentDefinition[Any], key: str, - ) -> "CultMeshDocumentWriter": + options: CultMeshReactiveDocumentOptions | None = None, + ) -> "CultMeshReactiveDocument": if not key: raise ValueError("key must be non-empty") if not any(self._documents_alias(document, registered) for registered in self.documents): self.register_document(document) - return CultMeshDocumentWriter(self, document, key) - - def observe_document( - self, - document: DocumentDefinition[Any], - key: str, - ) -> "CultMeshObservedDocument": - if not key: - raise ValueError("key must be non-empty") - if not any(self._documents_alias(document, registered) for registered in self.documents): - self.register_document(document) - return CultMeshObservedDocument(self, document, key) + return CultMeshReactiveDocument(self, document, key, options) def sync_document_from_publication( self, @@ -1353,119 +1254,32 @@ def _document_matches_schema_id(document: DocumentDefinition[Any], schema_id: st return _infer_schema_name(schema_id) == entry.schema_name -class CultMeshDocumentWriter: +class CultMeshReactiveDocument: def __init__( self, database: CultMeshDatabase, document: DocumentDefinition[Any], key: str, - ) -> None: - self.database = database - self.document = document - self.key = key - - def write(self, value: Any) -> None: - self.database.put(self.document, self.key, self._clone(value)) - - def update(self, callback: Callable[[Any], Any | None]) -> Any: - draft = self._clone(self.database.get_required(self.document, self.key)) - replacement = callback(draft) - next_value = draft if replacement is None else replacement - self.write(next_value) - return self._clone(next_value) - - def reactive( - self, options: CultMeshReactiveDocumentOptions | None = None, - ) -> "CultMeshReactiveDocument": - return CultMeshReactiveDocument(self, options) - - def _clone(self, value: Any) -> Any: - return self.document.decode_payload(self.document.encode_payload(value)) - - -class CultMeshObservedDocument: - def __init__( - self, - database: CultMeshDatabase, - document: DocumentDefinition[Any], - key: str, ) -> None: self._database = database self._document = document self._key = key - self._lock = threading.RLock() - self._disposed = False - self._current = self._clone(database.get_required(document, key)) - self._unsubscribe = database.watch_record(document, key, self._apply_canonical_change) - - @property - def current(self) -> Any: - with self._lock: - self._raise_if_disposed() - return self._current - - def refresh(self) -> Any: - with self._lock: - self._raise_if_disposed() - self._current = self._clone( - self._database.get_required(self._document, self._key) - ) - return self._current - - def dispose(self) -> None: - with self._lock: - if self._disposed: - return - self._disposed = True - self._unsubscribe() - - def __enter__(self) -> "CultMeshObservedDocument": - return self - - def __exit__(self, _exc_type: Any, _exc: Any, _tb: Any) -> None: - self.dispose() - - def _apply_canonical_change(self, change: CultMeshDatabaseChange) -> None: - if change.value is None: - return - with self._lock: - if not self._disposed: - self._current = self._clone(change.value) - - def _clone(self, value: Any) -> Any: - return self._document.decode_payload(self._document.encode_payload(value)) - - def _raise_if_disposed(self) -> None: - if self._disposed: - raise RuntimeError("CultMeshObservedDocument has been disposed") - - -class CultMeshReactiveDocument: - def __init__( - self, - writer: CultMeshDocumentWriter, - options: CultMeshReactiveDocumentOptions | None = None, - ) -> None: - self.writer = writer - self._database = writer.database - self._document = writer.document - self._key = writer.key self._options = options or CultMeshReactiveDocumentOptions() self._lock = threading.RLock() self._disposed = False self._dirty = False self._flushing = False self._flush_queued = False - self._flush_timer: _CultMeshScheduledCall | None = None + self._flush_timer: threading.Timer | None = None + self._detect_timer: threading.Timer | None = None self._reconciliation_version = 0 self.reconciliation: CultMeshReactiveDocumentReconciliation | None = None - self._current = self._clone(self._database.get_required(self._document, self._key)) - self._unsubscribe = self._database.watch_record( - self._document, - self._key, - self._apply_canonical_change, - ) + self.current = self._clone(self._database.get_required(document, key)) + self._last_clean_payload = self._serialize(self.current) + self._unsubscribe = self._database.watch_record(document, key, self._apply_canonical_change) + if self._options.detect_local_changes: + self._schedule_detection() @property def document(self) -> DocumentDefinition[Any]: @@ -1480,43 +1294,42 @@ class CultMeshReactiveDocument: with self._lock: return self._dirty - @property - def snapshot(self) -> Any: + def update(self, callback: Callable[[Any], None]) -> Any: with self._lock: self._raise_if_disposed() - return self._clone(self._current) - - def update(self, callback: Callable[[Any], Any | None]) -> Any: - with self._lock: - self._raise_if_disposed() - draft = self._clone(self._current) - replacement = callback(draft) - self._current = self._clone(draft if replacement is None else replacement) + callback(self.current) self._dirty = True self._schedule_flush_locked() - return self._clone(self._current) + return self.current - def replace_local(self, value: Any) -> Any: + def set_current(self, value: Any) -> Any: + with self._lock: + self._raise_if_disposed() + self.current = self._clone(value) + self._dirty = True + self._schedule_flush_locked() + return self.current + + def mark_dirty(self) -> None: with self._lock: self._raise_if_disposed() - self._current = self._clone(value) self._dirty = True self._schedule_flush_locked() - return self._clone(self._current) def refresh(self) -> Any: with self._lock: self._raise_if_disposed() - self._current = self._clone( - self._database.get_required(self._document, self._key) - ) + self.current = self._clone(self._database.get_required(self._document, self._key)) + self._last_clean_payload = self._serialize(self.current) self._dirty = False self.reconciliation = None - return self._clone(self._current) + return self.current def flush(self) -> None: with self._lock: self._raise_if_disposed() + if not self._dirty: + self._detect_local_changes_locked() if not self._dirty: return if self._flushing: @@ -1527,14 +1340,16 @@ class CultMeshReactiveDocument: self._flush_timer = None self._flushing = True self._dirty = False - predicted = self._clone(self._current) + predicted = self._clone(self.current) + self._last_clean_payload = self._serialize(predicted) try: - self.writer.write(predicted) + self._database.put(self._document, self._key, predicted) finally: should_flush_again = False with self._lock: self._flushing = False + self._detect_local_changes_locked() should_flush_again = self._flush_queued or self._dirty self._flush_queued = False if should_flush_again: @@ -1552,6 +1367,8 @@ class CultMeshReactiveDocument: self._disposed = True if self._flush_timer is not None: self._flush_timer.cancel() + if self._detect_timer is not None: + self._detect_timer.cancel() self._unsubscribe() def __enter__(self) -> "CultMeshReactiveDocument": @@ -1566,13 +1383,11 @@ class CultMeshReactiveDocument: with self._lock: if self._disposed: return - replacement = None + canonical = self._clone(change.value) if self._dirty or self._flushing: - delta = self._create_delta(self._current, change.value) + predicted = self._clone(self.current) + delta = self._create_delta(predicted, canonical) if delta: - canonical = self._clone(change.value) - predicted = self._clone(self._current) - replacement = canonical self._reconciliation_version += 1 self.reconciliation = CultMeshReactiveDocumentReconciliation( canonical=canonical, @@ -1585,20 +1400,45 @@ class CultMeshReactiveDocument: self.reconciliation = None if not self._options.replace_dirty_current_on_canonical_snapshot: return - self._current = ( - replacement - if replacement is not None - else self._clone(change.value) - ) + self.current = canonical + self._last_clean_payload = self._serialize(self.current) self.reconciliation = None + def _schedule_detection(self) -> None: + delay = max(self._options.flush_delay_seconds, 0.001) + timer = threading.Timer(delay, self._detect_timer_elapsed) + timer.daemon = True + self._detect_timer = timer + timer.start() + + def _detect_timer_elapsed(self) -> None: + with self._lock: + if self._disposed: + return + if not self._dirty and not self._flushing: + self._detect_local_changes_locked() + if self._dirty: + self._schedule_flush_locked() + if not self._disposed and self._options.detect_local_changes: + self._schedule_detection() + + def _detect_local_changes_locked(self) -> None: + if self._serialize(self.current) != self._last_clean_payload: + self._dirty = True + def _schedule_flush_locked(self) -> None: + if self._options.flush_delay_seconds <= 0: + threading.Thread(target=self.flush, daemon=True).start() + return if self._flush_timer is not None: self._flush_timer.cancel() - self._flush_timer = _REACTIVE_SCHEDULER.schedule( - self._options.flush_delay_seconds, - self.flush, - ) + timer = threading.Timer(self._options.flush_delay_seconds, self.flush) + timer.daemon = True + self._flush_timer = timer + timer.start() + + def _serialize(self, value: Any) -> bytes: + return self._document.encode_payload(value) def _clone(self, value: Any) -> Any: return self._document.decode_payload(self._document.encode_payload(value)) diff --git a/packages/cultmesh-py/src/cultmesh_py/py.typed b/packages/cultcache-py/src/cultmesh_py/py.typed similarity index 100% rename from packages/cultmesh-py/src/cultmesh_py/py.typed rename to packages/cultcache-py/src/cultmesh_py/py.typed diff --git a/packages/cultmesh-py/src/cultmesh_py/server.py b/packages/cultcache-py/src/cultmesh_py/server.py similarity index 98% rename from packages/cultmesh-py/src/cultmesh_py/server.py rename to packages/cultcache-py/src/cultmesh_py/server.py index e0c7fdd0..d560cc83 100644 --- a/packages/cultmesh-py/src/cultmesh_py/server.py +++ b/packages/cultcache-py/src/cultmesh_py/server.py @@ -24,7 +24,7 @@ from cultnet_py import ( ) from .node import CultMeshNode -from cultnet_py.cultmesh_contracts import ( +from .wire import ( PEER_EXCHANGE_REQUEST, VERSE_CATALOG_REQUEST, CultMeshPeerCatalog, @@ -245,8 +245,6 @@ class CultMeshLocalServer: 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 @@ -267,12 +265,8 @@ class CultMeshLocalServer: 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 packet.packet_type == CultNetRudpPacketType.DATA or result.delivered: + self._send_rudp_packet(rudp_socket, remote_addr, peer.session.create_ack()) def _poll_rudp_resends( self, diff --git a/packages/cultmesh-py/src/cultmesh_py/session.py b/packages/cultcache-py/src/cultmesh_py/session.py similarity index 98% rename from packages/cultmesh-py/src/cultmesh_py/session.py rename to packages/cultcache-py/src/cultmesh_py/session.py index c02696f0..7e4b1bc1 100644 --- a/packages/cultmesh-py/src/cultmesh_py/session.py +++ b/packages/cultcache-py/src/cultmesh_py/session.py @@ -23,7 +23,7 @@ from .simulation import ( CultMeshSimulationFactCommitter, simulation_fact_document, ) -from cultnet_py.cultmesh_contracts import CultMeshAuthorityLeaseCatalog, CultMeshPeerCatalog, CultMeshVerseCatalog +from .wire import CultMeshAuthorityLeaseCatalog, CultMeshPeerCatalog, CultMeshVerseCatalog if TYPE_CHECKING: from .server import CultMeshLocalServer diff --git a/packages/cultcache-py/src/cultmesh_py/simulation.py b/packages/cultcache-py/src/cultmesh_py/simulation.py new file mode 100644 index 00000000..4b727fd5 --- /dev/null +++ b/packages/cultcache-py/src/cultmesh_py/simulation.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +import hashlib +from typing import Any + +from cultcache_py import define_database_entry_type +from cultcache_py.documents import DocumentDefinition + +from .node import CultMeshNode + +SIMULATION_FACT_DOCUMENT_TYPE = "gamecult.mesh.simulation_fact" +SIMULATION_FACT_SCHEMA_VERSION = "gamecult.mesh.simulation_fact.v1" + + +@dataclass(frozen=True) +class CultMeshSimulationFact: + fact_id: str + shard_id: str + shard_epoch: int + frame: int + subject_id: str + claim_kind: str + claim_hash: str + claim_summary: str | None + witness_count: int + support_weight: float + total_weight: float + confidence: float + committed_at: str + + @staticmethod + def compute_fact_id(candidate: dict[str, Any]) -> str: + canonical = "\x1f".join([ + _candidate_string(candidate, "shardId", "shard_id"), + str(_candidate_int(candidate, "shardEpoch", "shard_epoch")), + str(_candidate_int(candidate, "frame", "frame")), + _candidate_string(candidate, "subjectId", "subject_id"), + _candidate_string(candidate, "claimKind", "claim_kind"), + ]) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + @staticmethod + def create_record_key(candidate: dict[str, Any]) -> str: + return f"simulation:{CultMeshSimulationFact.compute_fact_id(candidate)}" + + @staticmethod + def from_candidate(candidate: dict[str, Any], *, committed_at: str | None = None) -> "CultMeshSimulationFact": + return CultMeshSimulationFact( + fact_id=CultMeshSimulationFact.compute_fact_id(candidate), + shard_id=_candidate_string(candidate, "shardId", "shard_id"), + shard_epoch=_candidate_int(candidate, "shardEpoch", "shard_epoch"), + frame=_candidate_int(candidate, "frame", "frame"), + subject_id=_candidate_string(candidate, "subjectId", "subject_id"), + claim_kind=_candidate_string(candidate, "claimKind", "claim_kind"), + claim_hash=_candidate_string(candidate, "claimHash", "claim_hash"), + claim_summary=_candidate_optional_string(candidate, "claimSummary", "claim_summary"), + witness_count=_candidate_int(candidate, "witnessCount", "witness_count"), + support_weight=_candidate_float(candidate, "supportWeight", "support_weight"), + total_weight=_candidate_float(candidate, "totalWeight", "total_weight"), + confidence=_candidate_float(candidate, "confidence", "confidence"), + committed_at=committed_at or datetime.now(UTC).isoformat(), + ) + + +simulation_fact_document: DocumentDefinition[CultMeshSimulationFact] = define_database_entry_type( + SIMULATION_FACT_DOCUMENT_TYPE, + [ + ("fact_id", 0), + ("shard_id", 1), + ("shard_epoch", 2), + ("frame", 3), + ("subject_id", 4), + ("claim_kind", 5), + ("claim_hash", 6), + ("claim_summary", 7, None), + ("witness_count", 8), + ("support_weight", 9), + ("total_weight", 10), + ("confidence", 11), + ("committed_at", 12), + ], + cls=CultMeshSimulationFact, + name="fact_id", + indexes={"shard": "shard_id", "frame": "frame", "subject": "subject_id", "claim_kind": "claim_kind"}, + schema_name=SIMULATION_FACT_DOCUMENT_TYPE, + schema_version=SIMULATION_FACT_SCHEMA_VERSION, +) + + +@dataclass(frozen=True) +class CultMeshSimulationFactCommit: + key: str + fact: CultMeshSimulationFact + + +@dataclass +class CultMeshSimulationFactCommitter: + node: CultMeshNode + document: DocumentDefinition[CultMeshSimulationFact] = simulation_fact_document + + def __post_init__(self) -> None: + if all(document.type != self.document.type for document in self.node.documents): + self.node.register_document(self.document) + + def commit(self, candidate: dict[str, Any], *, committed_at: str | None = None) -> CultMeshSimulationFactCommit: + if not bool(candidate.get("hasQuorum") if "hasQuorum" in candidate else candidate.get("has_quorum")): + raise ValueError("Simulation candidate cannot be committed before quorum") + key = CultMeshSimulationFact.create_record_key(candidate) + fact = CultMeshSimulationFact.from_candidate(candidate, committed_at=committed_at) + self.node.put(self.document, key, fact) + return CultMeshSimulationFactCommit(key=key, fact=fact) + + +def _candidate_string(candidate: dict[str, Any], camel: str, snake: str) -> str: + value = candidate.get(camel, candidate.get(snake, "")) + if value is None: + return "" + return str(value) + + +def _candidate_optional_string(candidate: dict[str, Any], camel: str, snake: str) -> str | None: + value = candidate.get(camel, candidate.get(snake)) + if value is None: + return None + return str(value) + + +def _candidate_int(candidate: dict[str, Any], camel: str, snake: str) -> int: + return int(candidate.get(camel, candidate.get(snake, 0))) + + +def _candidate_float(candidate: dict[str, Any], camel: str, snake: str) -> float: + return float(candidate.get(camel, candidate.get(snake, 0.0))) diff --git a/packages/cultnet-py/src/cultnet_py/cultmesh_contracts.py b/packages/cultcache-py/src/cultmesh_py/wire.py similarity index 84% rename from packages/cultnet-py/src/cultnet_py/cultmesh_contracts.py rename to packages/cultcache-py/src/cultmesh_py/wire.py index 91d0805b..9a666a1e 100644 --- a/packages/cultnet-py/src/cultnet_py/cultmesh_contracts.py +++ b/packages/cultcache-py/src/cultmesh_py/wire.py @@ -1,18 +1,14 @@ from __future__ import annotations import base64 -import hashlib import hmac from dataclasses import dataclass, field, replace -from datetime import UTC, datetime +from datetime import datetime from hashlib import sha256 from typing import Any, Callable, Mapping import msgpack -from cultcache_py import define_database_entry_type -from cultcache_py.documents import DocumentDefinition - VERSE_CATALOG_REQUEST = "cultmesh.verse_catalog_request.v0" VERSE_CATALOG_RESPONSE = "cultmesh.verse_catalog_response.v0" @@ -617,104 +613,3 @@ def copy_budget_for(transport: str) -> str: if transport == "inline-bytes": return "opaque-runtime" return "opaque-runtime" - - -SIMULATION_FACT_DOCUMENT_TYPE = "gamecult.mesh.simulation_fact" -SIMULATION_FACT_SCHEMA_VERSION = "gamecult.mesh.simulation_fact.v1" - - -@dataclass(frozen=True) -class CultMeshSimulationFact: - fact_id: str - shard_id: str - shard_epoch: int - frame: int - subject_id: str - claim_kind: str - claim_hash: str - claim_summary: str | None - witness_count: int - support_weight: float - total_weight: float - confidence: float - committed_at: str - - @staticmethod - def compute_fact_id(candidate: dict[str, Any]) -> str: - canonical = "\x1f".join([ - _candidate_string(candidate, "shardId", "shard_id"), - str(_candidate_int(candidate, "shardEpoch", "shard_epoch")), - str(_candidate_int(candidate, "frame", "frame")), - _candidate_string(candidate, "subjectId", "subject_id"), - _candidate_string(candidate, "claimKind", "claim_kind"), - ]) - return hashlib.sha256(canonical.encode("utf-8")).hexdigest() - - @staticmethod - def create_record_key(candidate: dict[str, Any]) -> str: - return f"simulation:{CultMeshSimulationFact.compute_fact_id(candidate)}" - - @staticmethod - def from_candidate(candidate: dict[str, Any], *, committed_at: str | None = None) -> "CultMeshSimulationFact": - return CultMeshSimulationFact( - fact_id=CultMeshSimulationFact.compute_fact_id(candidate), - shard_id=_candidate_string(candidate, "shardId", "shard_id"), - shard_epoch=_candidate_int(candidate, "shardEpoch", "shard_epoch"), - frame=_candidate_int(candidate, "frame", "frame"), - subject_id=_candidate_string(candidate, "subjectId", "subject_id"), - claim_kind=_candidate_string(candidate, "claimKind", "claim_kind"), - claim_hash=_candidate_string(candidate, "claimHash", "claim_hash"), - claim_summary=_candidate_optional_string(candidate, "claimSummary", "claim_summary"), - witness_count=_candidate_int(candidate, "witnessCount", "witness_count"), - support_weight=_candidate_float(candidate, "supportWeight", "support_weight"), - total_weight=_candidate_float(candidate, "totalWeight", "total_weight"), - confidence=_candidate_float(candidate, "confidence", "confidence"), - committed_at=committed_at or datetime.now(UTC).isoformat(), - ) - - -simulation_fact_document: DocumentDefinition[CultMeshSimulationFact] = define_database_entry_type( - SIMULATION_FACT_DOCUMENT_TYPE, - [ - ("fact_id", 0), - ("shard_id", 1), - ("shard_epoch", 2), - ("frame", 3), - ("subject_id", 4), - ("claim_kind", 5), - ("claim_hash", 6), - ("claim_summary", 7, None), - ("witness_count", 8), - ("support_weight", 9), - ("total_weight", 10), - ("confidence", 11), - ("committed_at", 12), - ], - cls=CultMeshSimulationFact, - name="fact_id", - indexes={"shard": "shard_id", "frame": "frame", "subject": "subject_id", "claim_kind": "claim_kind"}, - schema_name=SIMULATION_FACT_DOCUMENT_TYPE, - schema_version=SIMULATION_FACT_SCHEMA_VERSION, -) - - -def _candidate_string(candidate: dict[str, Any], camel: str, snake: str) -> str: - value = candidate.get(camel, candidate.get(snake, "")) - if value is None: - return "" - return str(value) - - -def _candidate_optional_string(candidate: dict[str, Any], camel: str, snake: str) -> str | None: - value = candidate.get(camel, candidate.get(snake)) - if value is None: - return None - return str(value) - - -def _candidate_int(candidate: dict[str, Any], camel: str, snake: str) -> int: - return int(candidate.get(camel, candidate.get(snake, 0))) - - -def _candidate_float(candidate: dict[str, Any], camel: str, snake: str) -> float: - return float(candidate.get(camel, candidate.get(snake, 0.0))) diff --git a/packages/cultnet-py/src/cultnet_py/__init__.py b/packages/cultcache-py/src/cultnet_py/__init__.py similarity index 79% rename from packages/cultnet-py/src/cultnet_py/__init__.py rename to packages/cultcache-py/src/cultnet_py/__init__.py index b1734f25..ec54059d 100644 --- a/packages/cultnet-py/src/cultnet_py/__init__.py +++ b/packages/cultcache-py/src/cultnet_py/__init__.py @@ -7,34 +7,6 @@ from .client import ( create_rudp_schema_transport, create_tcp_framed_schema_transport, ) -from .cultmesh_contracts import ( - PEER_EXCHANGE_REQUEST, - PEER_EXCHANGE_RESPONSE, - SIMULATION_FACT_DOCUMENT_TYPE, - SIMULATION_FACT_SCHEMA_VERSION, - VERSE_CATALOG_REQUEST, - VERSE_CATALOG_RESPONSE, - AuthorityLeaseVerifier, - CultMeshAuthorityLease, - CultMeshAuthorityLeaseCatalog, - CultMeshHmacAuthorityLeaseVerifier, - CultMeshPeerCard, - CultMeshPeerCatalog, - CultMeshSimulationFact, - CultMeshStreamCatalog, - CultMeshStreamConsumerProfile, - CultMeshStreamDescriptor, - CultMeshStreamFrameHandle, - CultMeshStreamNegotiation, - CultMeshVerseCatalog, - CultMeshVerseCompatibility, - CultMeshVerseDescriptor, - peer_exchange_request, - peer_from_wire, - simulation_fact_document, - verse_catalog_request, - verse_from_wire, -) from .framing import decode_frame, encode_frame, read_frame, write_frame from .messages import ( CultNetMessage, @@ -136,32 +108,6 @@ from .simulation import ( ) __all__ = [ - "PEER_EXCHANGE_REQUEST", - "PEER_EXCHANGE_RESPONSE", - "SIMULATION_FACT_DOCUMENT_TYPE", - "SIMULATION_FACT_SCHEMA_VERSION", - "VERSE_CATALOG_REQUEST", - "VERSE_CATALOG_RESPONSE", - "AuthorityLeaseVerifier", - "CultMeshAuthorityLease", - "CultMeshAuthorityLeaseCatalog", - "CultMeshHmacAuthorityLeaseVerifier", - "CultMeshPeerCard", - "CultMeshPeerCatalog", - "CultMeshSimulationFact", - "CultMeshStreamCatalog", - "CultMeshStreamConsumerProfile", - "CultMeshStreamDescriptor", - "CultMeshStreamFrameHandle", - "CultMeshStreamNegotiation", - "CultMeshVerseCatalog", - "CultMeshVerseCompatibility", - "CultMeshVerseDescriptor", - "peer_exchange_request", - "peer_from_wire", - "simulation_fact_document", - "verse_catalog_request", - "verse_from_wire", "CultNetMessage", "CultNetWitnessArtifactBundle", "CultNetDatabaseSubscription", diff --git a/packages/cultnet-py/src/cultnet_py/client.py b/packages/cultcache-py/src/cultnet_py/client.py similarity index 100% rename from packages/cultnet-py/src/cultnet_py/client.py rename to packages/cultcache-py/src/cultnet_py/client.py diff --git a/packages/cultnet-py/src/cultnet_py/framing.py b/packages/cultcache-py/src/cultnet_py/framing.py similarity index 100% rename from packages/cultnet-py/src/cultnet_py/framing.py rename to packages/cultcache-py/src/cultnet_py/framing.py diff --git a/packages/cultnet-py/src/cultnet_py/interop_peer.py b/packages/cultcache-py/src/cultnet_py/interop_peer.py similarity index 99% rename from packages/cultnet-py/src/cultnet_py/interop_peer.py rename to packages/cultcache-py/src/cultnet_py/interop_peer.py index ca3c692d..d26f1b70 100644 --- a/packages/cultnet-py/src/cultnet_py/interop_peer.py +++ b/packages/cultcache-py/src/cultnet_py/interop_peer.py @@ -24,7 +24,7 @@ from cultcache_py import ( define_document_type, ) from cultcache_py.documents import DocumentDefinition -from .cultmesh_contracts import ( +from cultmesh_py import ( PEER_EXCHANGE_REQUEST, SIMULATION_FACT_DOCUMENT_TYPE, VERSE_CATALOG_REQUEST, @@ -306,8 +306,6 @@ def rudp_loop( result = peer.session.receive(packet, now_ms()) if result.reply is not None: send_rudp_packet(sock, remote, result.reply) - for ready in result.ready_to_send: - send_rudp_packet(sock, remote, ready) if result.disconnected: peers.pop(remote, None) continue @@ -319,8 +317,8 @@ def rudp_loop( continue for response in handle_server_message(state, message, peer.subscriptions): send_rudp_schema_frame(sock, remote, peer.session, response) - if packet.reliable or packet.packet_type == CultNetRudpPacketType.DATA or result.delivered: - send_rudp_packet(sock, remote, peer.session.create_ack_for_received(packet.sequence)) + if packet.packet_type == CultNetRudpPacketType.DATA or result.delivered: + send_rudp_packet(sock, remote, peer.session.create_ack()) except Exception as error: sys.stderr.write(json.dumps({ "event": "rudpMessageError", diff --git a/packages/cultnet-py/src/cultnet_py/messages.py b/packages/cultcache-py/src/cultnet_py/messages.py similarity index 100% rename from packages/cultnet-py/src/cultnet_py/messages.py rename to packages/cultcache-py/src/cultnet_py/messages.py diff --git a/packages/cultnet-py/src/cultnet_py/prediction.py b/packages/cultcache-py/src/cultnet_py/prediction.py similarity index 100% rename from packages/cultnet-py/src/cultnet_py/prediction.py rename to packages/cultcache-py/src/cultnet_py/prediction.py diff --git a/packages/cultnet-py/src/cultnet_py/py.typed b/packages/cultcache-py/src/cultnet_py/py.typed similarity index 100% rename from packages/cultnet-py/src/cultnet_py/py.typed rename to packages/cultcache-py/src/cultnet_py/py.typed diff --git a/packages/cultnet-py/src/cultnet_py/replication.py b/packages/cultcache-py/src/cultnet_py/replication.py similarity index 100% rename from packages/cultnet-py/src/cultnet_py/replication.py rename to packages/cultcache-py/src/cultnet_py/replication.py diff --git a/packages/cultnet-py/src/cultnet_py/schema_catalog.py b/packages/cultcache-py/src/cultnet_py/schema_catalog.py similarity index 100% rename from packages/cultnet-py/src/cultnet_py/schema_catalog.py rename to packages/cultcache-py/src/cultnet_py/schema_catalog.py diff --git a/packages/cultnet-py/src/cultnet_py/security.py b/packages/cultcache-py/src/cultnet_py/security.py similarity index 100% rename from packages/cultnet-py/src/cultnet_py/security.py rename to packages/cultcache-py/src/cultnet_py/security.py diff --git a/packages/cultnet-py/src/cultnet_py/shard_catalog.py b/packages/cultcache-py/src/cultnet_py/shard_catalog.py similarity index 100% rename from packages/cultnet-py/src/cultnet_py/shard_catalog.py rename to packages/cultcache-py/src/cultnet_py/shard_catalog.py diff --git a/packages/cultnet-py/src/cultnet_py/shard_log.py b/packages/cultcache-py/src/cultnet_py/shard_log.py similarity index 100% rename from packages/cultnet-py/src/cultnet_py/shard_log.py rename to packages/cultcache-py/src/cultnet_py/shard_log.py diff --git a/packages/cultnet-py/src/cultnet_py/shard_replication.py b/packages/cultcache-py/src/cultnet_py/shard_replication.py similarity index 100% rename from packages/cultnet-py/src/cultnet_py/shard_replication.py rename to packages/cultcache-py/src/cultnet_py/shard_replication.py diff --git a/packages/cultnet-py/src/cultnet_py/simulation.py b/packages/cultcache-py/src/cultnet_py/simulation.py similarity index 100% rename from packages/cultnet-py/src/cultnet_py/simulation.py rename to packages/cultcache-py/src/cultnet_py/simulation.py diff --git a/packages/cultnet-py/src/cultnet_py/snapshot.py b/packages/cultcache-py/src/cultnet_py/snapshot.py similarity index 100% rename from packages/cultnet-py/src/cultnet_py/snapshot.py rename to packages/cultcache-py/src/cultnet_py/snapshot.py diff --git a/packages/cultnet-py/src/cultnet_py/subscription.py b/packages/cultcache-py/src/cultnet_py/subscription.py similarity index 100% rename from packages/cultnet-py/src/cultnet_py/subscription.py rename to packages/cultcache-py/src/cultnet_py/subscription.py diff --git a/packages/cultnet-py/src/cultnet_py/transport.py b/packages/cultcache-py/src/cultnet_py/transport.py similarity index 82% rename from packages/cultnet-py/src/cultnet_py/transport.py rename to packages/cultcache-py/src/cultnet_py/transport.py index 73bd1e62..25a4c891 100644 --- a/packages/cultnet-py/src/cultnet_py/transport.py +++ b/packages/cultcache-py/src/cultnet_py/transport.py @@ -280,7 +280,6 @@ class CultNetRudpDeliveredFrame: @dataclass(frozen=True) class CultNetRudpReceiveResult: delivered: tuple[CultNetRudpDeliveredFrame, ...] = field(default_factory=tuple) - ready_to_send: tuple[CultNetRudpPacket, ...] = field(default_factory=tuple) reply: CultNetRudpPacket | None = None pong: bool = False pong_payload: bytes = b"" @@ -396,9 +395,6 @@ def create_rudp_transport_profile( class CultNetRudpSession: - RELIABLE_SEND_WINDOW_PACKETS = 32 - RECEIVED_SEQUENCE_WINDOW = 4_096 - def __init__(self, options: CultNetRudpSessionOptions) -> None: self.connection_id = _uint32(options.connection_id, "connection_id") self.resend_delay_ms = options.resend_delay_ms @@ -406,33 +402,20 @@ class CultNetRudpSession: raise ValueError("RUDP max_pending_reliable_packets must be greater than zero") self.max_pending_reliable_packets = options.max_pending_reliable_packets self._next_sequence = _uint32(options.initial_sequence, "initial_sequence") - self._next_sequenced_by_channel: dict[str, int] = {} self._next_fragment_id = 1 self._connected = False self._last_received_at_ms: int | None = None self._highest_received_sequence: int | None = None self._received_sequences: set[int] = set() - self._latest_sequenced_by_channel: dict[str, int] = {} self._pending_reliable: dict[int, _PendingReliablePacket] = {} - self._queued_reliable: deque[CultNetRudpPacket] = deque() self._ordered_next_sequence_by_channel: dict[str, int] = {} self._ordered_buffers: dict[str, dict[int, tuple[CultNetRudpDeliveredFrame, int]]] = {} self._fragment_buffers: dict[tuple[str, int], dict[str, Any]] = {} - # Matches cultnet-rs max_pending_fragment_sets. A set that loses one - # fragment is never completed; without a bound it is kept for the life - # of the session and the map grows under any loss on a fragmenting channel. - self._max_pending_fragment_sets = 64 - self._fragment_sets_evicted = 0 @property def connected(self) -> bool: return self._connected - @property - def fragment_sets_evicted(self) -> int: - """Incomplete fragment sets dropped to admit newer ones; one lost payload each.""" - return self._fragment_sets_evicted - @property def last_received_at_ms(self) -> int | None: return self._last_received_at_ms @@ -441,14 +424,6 @@ class CultNetRudpSession: def pending_reliable_sequences(self) -> tuple[int, ...]: return tuple(sorted(self._pending_reliable)) - @property - def queued_reliable_packet_count(self) -> int: - return len(self._queued_reliable) - - @property - def outstanding_reliable_packet_count(self) -> int: - return len(self._pending_reliable) + len(self._queued_reliable) - def create_connect(self, now_ms: int = 0, payload: bytes = b"") -> CultNetRudpPacket: self._ensure_reliable_capacity(1) packet = self._create_packet( @@ -490,10 +465,7 @@ class CultNetRudpSession: payload: bytes, options: CultNetRudpSendOptions | None = None, ) -> CultNetRudpPacket: - resolved = options or CultNetRudpSendOptions() - if resolved.reliable and len(self._pending_reliable) >= self.RELIABLE_SEND_WINDOW_PACKETS: - raise ValueError("RUDP reliable send window is full; receive acknowledgements before sending") - return self.send_many(channel_id, payload, resolved)[0] + return self.send_many(channel_id, payload, options)[0] def send_many( self, @@ -507,8 +479,6 @@ class CultNetRudpSession: raise ValueError("Cannot send RUDP data before the session is connected") options = options or CultNetRudpSendOptions() - if options.ordered and not options.reliable: - raise ValueError("RUDP ordered delivery requires reliability") if max_fragment_bytes is not None and max_fragment_bytes <= 0: raise ValueError("RUDP max_fragment_bytes must be greater than zero") @@ -522,7 +492,9 @@ class CultNetRudpSession: ordered=options.ordered, sequenced=options.sequenced, ) - return self._admit_reliable_packets((packet,), options.now_ms) if packet.reliable else (packet,) + if packet.reliable: + self._track_reliable(packet, options.now_ms) + return (packet,) fragment_count = (len(payload) + max_fragment_bytes - 1) // max_fragment_bytes if fragment_count > 0xFFFF: @@ -544,13 +516,14 @@ class CultNetRudpSession: fragment_index=index, fragment_count=fragment_count, ) + if packet.reliable: + self._track_reliable(packet, options.now_ms) packets.append(packet) - return self._admit_reliable_packets(tuple(packets), options.now_ms) if options.reliable else tuple(packets) + return tuple(packets) def receive(self, packet: CultNetRudpPacket, now_ms: int = 0) -> CultNetRudpReceiveResult: self._require_connection(packet) self._apply_acknowledgements(packet) - ready_to_send = self._promote_queued_reliable(now_ms) self._last_received_at_ms = now_ms expected_sequence_if_uninitialized = ( packet.sequence @@ -561,93 +534,51 @@ class CultNetRudpSession: if packet.packet_type == CultNetRudpPacketType.ACCEPT: self._remember_received(packet.sequence) self._connected = True - return CultNetRudpReceiveResult(ready_to_send=ready_to_send) + return CultNetRudpReceiveResult() if packet.packet_type == CultNetRudpPacketType.PING: + self._remember_received(packet.sequence) return CultNetRudpReceiveResult( - ready_to_send=ready_to_send, reply=self._create_packet(CultNetRudpPacketType.PONG, "control", packet.payload) ) if packet.packet_type in (CultNetRudpPacketType.ACK, CultNetRudpPacketType.PONG): + self._remember_received(packet.sequence) return CultNetRudpReceiveResult( - ready_to_send=ready_to_send, pong=packet.packet_type == CultNetRudpPacketType.PONG, pong_payload=bytes(packet.payload) if packet.packet_type == CultNetRudpPacketType.PONG else b"", ) if packet.packet_type == CultNetRudpPacketType.DISCONNECT: + self._remember_received(packet.sequence) self._connected = False return CultNetRudpReceiveResult( - ready_to_send=ready_to_send, disconnected=True, disconnect_reason=bytes(packet.payload), ) if packet.packet_type != CultNetRudpPacketType.DATA: - return CultNetRudpReceiveResult(ready_to_send=ready_to_send) + return CultNetRudpReceiveResult() - duplicate = packet.reliable and ( - packet.sequence in self._received_sequences or ( - self._highest_received_sequence is not None - and packet.sequence < self._highest_received_sequence - and self._highest_received_sequence - packet.sequence >= self.RECEIVED_SEQUENCE_WINDOW - ) - ) - if packet.reliable: - self._remember_received(packet.sequence) + duplicate = packet.sequence in self._received_sequences + self._remember_received(packet.sequence) if duplicate: - return CultNetRudpReceiveResult(ready_to_send=ready_to_send) + return CultNetRudpReceiveResult() reassembled = self._reassemble(packet) if reassembled is None: - return CultNetRudpReceiveResult(ready_to_send=ready_to_send) + return CultNetRudpReceiveResult() frame, ordered, next_sequence = reassembled - if not ordered and packet.sequenced: - newest_sequence = next_sequence - 1 - latest_sequence = self._latest_sequenced_by_channel.get(frame.channel_id) - if latest_sequence is not None and newest_sequence <= latest_sequence: - return CultNetRudpReceiveResult(ready_to_send=ready_to_send) - self._latest_sequenced_by_channel[frame.channel_id] = newest_sequence - return CultNetRudpReceiveResult(delivered=(frame,), ready_to_send=ready_to_send) if not ordered: - return CultNetRudpReceiveResult(delivered=(frame,), ready_to_send=ready_to_send) + return CultNetRudpReceiveResult(delivered=(frame,)) return CultNetRudpReceiveResult( delivered=tuple( self._deliver_ordered(frame, next_sequence, expected_sequence_if_uninitialized) - ), - ready_to_send=ready_to_send, + ) ) def create_ack(self) -> CultNetRudpPacket: - ack, ack_mask = self._ack_state() - return CultNetRudpPacket( - packet_type=CultNetRudpPacketType.ACK, - connection_id=self.connection_id, - sequence=0, - ack=ack, - ack_mask=ack_mask, - channel_id="control", - payload=b"", - ) - - def create_ack_for(self, sequence: int) -> CultNetRudpPacket: - return CultNetRudpPacket( - packet_type=CultNetRudpPacketType.ACK, - connection_id=self.connection_id, - sequence=0, - ack=_uint32(sequence, "ack sequence"), - ack_mask=0, - channel_id="control", - payload=b"", - ) - - def create_ack_for_received(self, sequence: int) -> CultNetRudpPacket: - received_sequence = _uint32(sequence, "received sequence") - ack, _ = self._ack_state() - if ack >= received_sequence and ack - received_sequence <= 32: - return self.create_ack() - return self.create_ack_for(received_sequence) + return self._create_packet(CultNetRudpPacketType.ACK, "control", b"") def create_ping(self, payload: bytes = b"") -> CultNetRudpPacket: return self._create_packet(CultNetRudpPacketType.PING, "control", payload) @@ -685,17 +616,8 @@ class CultNetRudpSession: fragment_index: int = 0, fragment_count: int = 0, ) -> CultNetRudpPacket: - if reliable: - sequence = self._next_sequence - self._next_sequence = _uint32(self._next_sequence + 1, "reliable sequence") - elif sequenced: - sequence = self._next_sequenced_by_channel.get(channel_id, 1) - self._next_sequenced_by_channel[channel_id] = _uint32( - sequence + 1, - "sequenced channel sequence", - ) - else: - sequence = 0 + sequence = self._next_sequence + self._next_sequence = _uint32(self._next_sequence + 1, "sequence") ack, ack_mask = self._ack_state() return CultNetRudpPacket( packet_type=packet_type, @@ -733,31 +655,10 @@ class CultNetRudpSession: last_sent_at_ms=now_ms, ) - def _admit_reliable_packets( - self, - packets: tuple[CultNetRudpPacket, ...], - now_ms: int, - ) -> tuple[CultNetRudpPacket, ...]: - available = max(0, self.RELIABLE_SEND_WINDOW_PACKETS - len(self._pending_reliable)) - ready = packets[:available] - for packet in ready: - self._track_reliable(packet, now_ms) - self._queued_reliable.extend(packets[available:]) - return ready - - def _promote_queued_reliable(self, now_ms: int) -> tuple[CultNetRudpPacket, ...]: - available = max(0, self.RELIABLE_SEND_WINDOW_PACKETS - len(self._pending_reliable)) - ready: list[CultNetRudpPacket] = [] - while len(ready) < available and self._queued_reliable: - packet = self._queued_reliable.popleft() - self._track_reliable(packet, now_ms) - ready.append(packet) - return tuple(ready) - def _ensure_reliable_capacity(self, packet_count: int) -> None: if packet_count == 0 or self.max_pending_reliable_packets is None: return - if self.outstanding_reliable_packet_count + packet_count > self.max_pending_reliable_packets: + if len(self._pending_reliable) + packet_count > self.max_pending_reliable_packets: raise ValueError("RUDP reliable send queue is full") def _apply_acknowledgements(self, packet: CultNetRudpPacket) -> None: @@ -770,9 +671,6 @@ class CultNetRudpSession: self._received_sequences.add(sequence) if self._highest_received_sequence is None or sequence > self._highest_received_sequence: self._highest_received_sequence = sequence - if len(self._received_sequences) > self.RECEIVED_SEQUENCE_WINDOW: - keep_from = max(0, (self._highest_received_sequence or sequence) - self.RECEIVED_SEQUENCE_WINDOW + 1) - self._received_sequences = {received for received in self._received_sequences if received >= keep_from} def _ack_state(self) -> tuple[int, int]: ack = self._highest_received_sequence or 0 @@ -795,11 +693,6 @@ class CultNetRudpSession: raise ValueError("RUDP fragment index must be lower than fragment count") key = (packet.channel_id, packet.fragment_id) - if key not in self._fragment_buffers and len(self._fragment_buffers) >= self._max_pending_fragment_sets: - # Evict the oldest stranded set rather than refusing the payload; dicts - # iterate in insertion order, so the first key is the oldest. - del self._fragment_buffers[next(iter(self._fragment_buffers))] - self._fragment_sets_evicted += 1 buffer = self._fragment_buffers.setdefault( key, { @@ -945,10 +838,6 @@ class CultNetRudpSocketTransportConnection: frames_sent=self._frames_sent, ) - @property - def outstanding_reliable_packet_count(self) -> int: - return self.session.outstanding_reliable_packet_count - def connect(self, payload: bytes = b"") -> None: if self.mode != CultNetRudpSocketMode.CLIENT: raise ValueError("Only a client RUDP socket transport can initiate connect") @@ -990,8 +879,6 @@ class CultNetRudpSocketTransportConnection: result = self.session.receive(packet, _now_ms()) if result.reply is not None: self._send_packet(result.reply) - for ready in result.ready_to_send: - self._send_packet(ready) if result.pong: self.pong_payloads.append(result.pong_payload) if result.disconnected: @@ -1005,8 +892,8 @@ class CultNetRudpSocketTransportConnection: self._frames_received += 1 frame = self._delivered_frames.popleft() if self._delivered_frames else None - if packet.reliable or packet.packet_type == CultNetRudpPacketType.ACCEPT or frame is not None: - self._send_packet(self.session.create_ack_for_received(packet.sequence)) + if packet.packet_type == CultNetRudpPacketType.ACCEPT or frame is not None: + self._send_packet(self.session.create_ack()) return frame def receive(self, timeout_seconds: float | None = None) -> CultNetTransportFrame: @@ -1019,29 +906,6 @@ class CultNetRudpSocketTransportConnection: if deadline is not None and time.monotonic() >= deadline: raise TimeoutError("Timed out waiting for RUDP schema frame") - def flush_reliable(self, timeout_seconds: float = 30.0) -> None: - deadline = time.monotonic() + max(0.0, timeout_seconds) - original_timeout = self.socket.gettimeout() - poll_timeout = min(original_timeout, 0.01) if original_timeout is not None else 0.01 - preserved = deque(self._delivered_frames) - self._delivered_frames.clear() - self.socket.settimeout(poll_timeout) - try: - while self.session.outstanding_reliable_packet_count > 0: - if time.monotonic() >= deadline: - raise TimeoutError( - "RUDP reliable flush timed out with " - f"{self.session.outstanding_reliable_packet_count} packets outstanding" - ) - frame = self.receive_once() - if frame is not None: - preserved.append(frame) - self.poll_resends() - finally: - preserved.extend(self._delivered_frames) - self._delivered_frames = preserved - self.socket.settimeout(original_timeout) - def disconnect(self, reason: bytes = b"") -> None: self._send_packet(self.session.create_disconnect(reason)) diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index ac81cc6d..3ecb763f 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -1,12 +1,23 @@ -from __future__ import annotations +from __future__ import annotations +import json +import os +import socket +import subprocess +import sys import tempfile import threading +import time import unittest -from dataclasses import asdict, dataclass, replace +import hashlib +import hmac +import io +from dataclasses import asdict, dataclass +from datetime import UTC, datetime, timedelta from pathlib import Path -from cultcache_py.backing_store import CultCacheEnvelope -from cultcache_py.cache import CultCacheError +from typing import Callable +from uuid import uuid4 + from cultcache_py import ( CultCache, JsonLinesBackingStore, @@ -14,7 +25,155 @@ from cultcache_py import ( define_database_entry_type, define_document_type, ) -from cultcache_py.interop import read_note, write_note +from cultcache_py.benchmark import run_benchmark +from cultcache_py.compare_csharp import ( + DEFAULT_PARITY_THRESHOLD, + DEFAULT_SAMPLE_COUNT, + _compare_common_metrics, + _median_result, + _parity_status, +) +from cultcache_py.interop import INTEROP_SCHEMA_VERSION, interop_note_document, read_note, write_note +from cultcache_py.verify import verify +from cultnet_py.interop_peer import append_shard_log_put, build_state, raw_snapshot_response +from cultnet_py import ( + compute_simulation_claim_hash, + CultNetDatabaseChange, + CultNetClientAuthorityScope, + CultNetRawClient, + CultNetRawDocumentRecord, + CultNetRawSnapshotResponse, + CultNetSchemaCatalog, + CultNetSchemaDescriptor, + CultNetShardCatalog, + CultNetShardDescriptor, + CultNetShardLogEntry, + CultNetShardLogResponse, + CultNetFileShardReplicaCursorStore, + CultNetFileShardMutationLogStore, + CultNetInMemoryShardReplicaCursorStore, + CultNetSchemaWriteForwarder, + CultNetSchemaShardLogFetcher, + CultNetSchemaShardSnapshotFetcher, + CultNetPeerError, + CultNetClientSecurityOptions, + CultNetSecret, + CultNetServerSecurityOptions, + CultNetShardReplicator, + CultNetShardReplicatorOptions, + CultNetShardMutationLogStore, + CultNetShardWriteForwarder, + CultNetSimulationConsensusOptions, + CultNetSimulationConsensusCandidate, + CultNetSimulationObservation, + CultNetSimulationObservationHub, + CultNetRudpPacket, + CultNetRudpPacketType, + CultNetRudpSendOptions, + CultNetRudpSession, + CultNetRudpSessionOptions, + CultNetRudpSocketMode, + CultNetRudpReconnectLoop, + CultNetRudpSocketTransportConnection, + CultNetRudpSocketTransportOptions, + TcpFramedTransportConnection, + CultNetWitnessArtifactBundle, + apply_raw_document_record, + apply_raw_snapshot, + apply_shard_log_response, + create_rudp_transport_profile, + create_tcp_framed_transport_profile, + create_reconnect_policy, + CultNetReconnectController, + database_subscribe, + database_unsubscribe, + decode_frame, + decode_rudp_packet, + decode_witness_artifact_bundle_payload, + document_delete, + document_put_raw, + encode_frame, + encode_rudp_packet, + encode_witness_artifact_bundle_payload, + hello, + login, + login_success, + parse_message, + register, + schema_catalog_request, + schema_document_map, + shard_catalog_request, + shard_log_request, + simulation_observation, + snapshot_request, + verify_session, + wire_message_schema_catalog, + wire_message_schema_descriptors, + witness_artifact_bundle, + compute_reconnect_delay_ms, +) +from cultmesh_py import create_node +from cultmesh_py import ( + CultMesh, + CultMeshDatabase, + CultMeshDatabaseChange, + CultMeshGameSessionOptions, + CultMeshNodeOptions, + CultMeshReactiveDocumentOptions, + CultMeshPeerCard, + CultMeshPeerCatalog, + CultMeshSimulationFact, + CultMeshAuthorityLease, + CultMeshAuthorityLeaseCatalog, + CultMeshHmacAuthorityLeaseVerifier, + CultMeshDiscoveryClient, + CultMeshPeerExchangeClient, + CultMeshPeerHealthMonitor, + CultMeshSimulationObservationFanout, + CultMeshSnapshotFanout, + CultMeshStreamCatalog, + CultMeshStreamConsumerProfile, + CultMeshStreamDescriptor, + CultMeshStreamFrameHandle, + CultMeshVerseCatalog, + CultMeshVerseCompatibility, + CultMeshVerseDescriptor, + CultMeshVerseDiscoveryClient, + READY_SCHEMA_VERSION, + peer_exchange_request, + simulation_fact_document, + verse_catalog_request, +) + + +def bind_udp_socket() -> socket.socket: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.bind(("127.0.0.1", 0)) + sock.settimeout(0.02) + return sock + + +def pump_rudp_handshake( + client: CultNetRudpSocketTransportConnection, + server: CultNetRudpSocketTransportConnection, +) -> None: + for _ in range(20): + server.receive_once() + client.receive_once() + server.receive_once() + if client.connected and server.connected: + return + time.sleep(0.005) + raise AssertionError("RUDP socket handshake did not complete") + + +def receive_rudp_frame(transport: CultNetRudpSocketTransportConnection): + for _ in range(20): + frame = transport.receive_once() + if frame is not None: + return frame + time.sleep(0.005) + raise AssertionError("RUDP socket frame was not delivered") @dataclass @@ -35,6 +194,14 @@ def item_doc(): class CultCacheTests(unittest.TestCase): + def _wait_until(self, predicate: Callable[[], bool], *, timeout_seconds: float = 2.0) -> None: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + if predicate(): + return + time.sleep(0.01) + self.fail("Timed out waiting for condition") + def test_round_trips_registered_documents(self) -> None: with tempfile.TemporaryDirectory() as tmp: document = item_doc() @@ -241,549 +408,100 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(loaded["authorRuntimeId"], "python-test") self.assertIn("interop", loaded["tags"]) - def test_second_generic_store_rejected(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - cache = CultCache() - cache.add_generic_store(SingleFileMessagePackBackingStore(Path(tmp) / "a.cc")) - with self.assertRaisesRegex(CultCacheError, "second generic store"): - cache.add_generic_store(SingleFileMessagePackBackingStore(Path(tmp) / "b.cc")) + def test_benchmark_reports_core_hot_path_metrics(self) -> None: + result = run_benchmark(8) + metric_names = {metric["name"] for metric in result["metrics"]} + self.assertEqual(result["records"], 8) + self.assertEqual(result["wireMessageCount"], 8) + self.assertEqual(metric_names, { + "database_entry_encode", + "database_entry_decode", + "cultnet_frame_parse", + "raw_snapshot_apply", + "cache_upsert", + "cache_get", + }) + self.assertTrue(all(metric["opsPerSecond"] > 0 for metric in result["metrics"])) - def test_type_claimed_twice_rejected(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - cache = CultCache() - cache.add_backing_store(SingleFileMessagePackBackingStore(Path(tmp) / "a.cc"), ["item"]) - with self.assertRaisesRegex(CultCacheError, "already routed"): - cache.add_backing_store(SingleFileMessagePackBackingStore(Path(tmp) / "b.cc"), ["settings", "item"]) - # A refused registration claims nothing, so "settings" is still free. - cache.add_backing_store(SingleFileMessagePackBackingStore(Path(tmp) / "c.cc"), ["settings"]) + def test_compare_csharp_median_result_summarizes_samples(self) -> None: + self.assertEqual(DEFAULT_SAMPLE_COUNT, 3) + self.assertEqual(DEFAULT_PARITY_THRESHOLD, 0.90) + samples = [ + { + "runtime": "python", + "records": 3, + "metrics": [ + {"name": "cache_get", "operations": 3, "elapsedMs": 30.0, "opsPerSecond": 100.0}, + {"name": "cache_upsert", "operations": 3, "elapsedMs": 60.0, "opsPerSecond": 50.0}, + ], + }, + { + "runtime": "python", + "records": 3, + "metrics": [ + {"name": "cache_get", "operations": 3, "elapsedMs": 10.0, "opsPerSecond": 300.0}, + {"name": "cache_upsert", "operations": 3, "elapsedMs": 20.0, "opsPerSecond": 150.0}, + ], + }, + { + "runtime": "python", + "records": 3, + "metrics": [ + {"name": "cache_get", "operations": 3, "elapsedMs": 20.0, "opsPerSecond": 200.0}, + {"name": "cache_upsert", "operations": 3, "elapsedMs": 40.0, "opsPerSecond": 75.0}, + ], + }, + ] - def test_types_route_to_home_store(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - item = item_doc() - settings = define_database_entry_type("settings", [("theme", 0)]) - generic_path = Path(tmp) / "generic.cc" - settings_path = Path(tmp) / "settings.cc" + summarized = _median_result(samples) - def build() -> CultCache: - return ( - CultCache.builder() - .register_document_type(item) - .register_document_type(settings) - .add_backing_store(SingleFileMessagePackBackingStore(settings_path), ["settings"]) - .add_generic_store(SingleFileMessagePackBackingStore(generic_path)) - .build() - ) + self.assertEqual(summarized["sampleCount"], 3) + self.assertEqual(summarized["metrics"][0]["name"], "cache_get") + self.assertEqual(summarized["metrics"][0]["elapsedMs"], 20.0) + self.assertEqual(summarized["metrics"][0]["opsPerSecond"], 200.0) + self.assertEqual(summarized["metrics"][1]["opsPerSecond"], 75.0) - cache = build() - cache.put(item, "item:potion", Item(name="Potion", category="Consumable", value=50)) - cache.put(settings, "app", {"theme": "ash"}) + def test_compare_csharp_marks_threshold_status_per_metric(self) -> None: + python_result = { + "metrics": [ + {"name": "cache_get", "operations": 3, "elapsedMs": 2.0, "opsPerSecond": 50.0}, + {"name": "cache_upsert", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 95.0}, + ], + } + csharp_result = { + "metrics": [ + {"name": "cache_get", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 100.0}, + {"name": "cache_upsert", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 100.0}, + ], + } - generic = SingleFileMessagePackBackingStore(generic_path).pull_all() - routed = SingleFileMessagePackBackingStore(settings_path).pull_all() - self.assertEqual([(e.key, e.type) for e in generic], [("item:potion", "item")]) - self.assertEqual([(e.key, e.type) for e in routed], [("app", "settings")]) + comparison = _compare_common_metrics(python_result, csharp_result, parity_threshold=0.90) + by_name = {item["name"]: item for item in comparison} - cache.delete(settings, "app") - self.assertEqual(SingleFileMessagePackBackingStore(settings_path).pull_all(), []) - self.assertEqual(len(SingleFileMessagePackBackingStore(generic_path).pull_all()), 1) + self.assertFalse(by_name["cache_get"]["meetsParityThreshold"]) + self.assertTrue(by_name["cache_upsert"]["meetsParityThreshold"]) + self.assertEqual(_parity_status(comparison, "ok"), "below-threshold") + self.assertEqual(_parity_status([by_name["cache_upsert"]], "ok"), "meets-threshold") + self.assertEqual(_parity_status(comparison, "failed"), "unknown") - reloaded = build() - reloaded.pull_all_backing_stores() - self.assertEqual(reloaded.get_required(item, "item:potion").value, 50) - self.assertIsNone(reloaded.get(settings, "app")) - - def test_attach_that_would_move_a_held_record_home_is_refused(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - settings = define_database_entry_type("settings", [("theme", 0)]) - generic_path = Path(tmp) / "generic.cc" - settings_path = Path(tmp) / "settings.cc" - cache = ( - CultCache.builder() - .register_document_type(settings) - .add_generic_store(SingleFileMessagePackBackingStore(generic_path)) - .build() - ) - cache.put(settings, "app", {"theme": "v1-generic"}) - - with self.assertRaisesRegex( - CultCacheError, "would move settings from the generic store to the store routed to settings" - ): - cache.add_backing_store(SingleFileMessagePackBackingStore(settings_path), ["settings"]) - # Nothing attached: the next write still lands in the generic store. - cache.put(settings, "app", {"theme": "v2-generic"}) - generic = SingleFileMessagePackBackingStore(generic_path).pull_all() - self.assertEqual([(e.key, e.type) for e in generic], [("app", "settings")]) - self.assertFalse(settings_path.exists()) - - def test_load_from_a_store_that_is_not_the_record_home_is_refused(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - settings = define_database_entry_type("settings", [("theme", 0)]) - generic_path = Path(tmp) / "generic.cc" - settings_path = Path(tmp) / "settings.cc" - writer = CultCache.builder().register_document_type(settings).add_generic_store( - SingleFileMessagePackBackingStore(generic_path) - ).build() - writer.put(settings, "app", {"theme": "stray"}) - generic_bytes = generic_path.read_bytes() - - cache = ( - CultCache.builder() - .register_document_type(settings) - .add_backing_store(SingleFileMessagePackBackingStore(settings_path), ["settings"]) - .add_generic_store(SingleFileMessagePackBackingStore(generic_path)) - .build() - ) - # The cache already holds a record, in its home store, before the refused load. - cache.put(settings, "held", {"theme": "home"}) - before = cache.snapshot_envelopes() - settings_bytes = settings_path.read_bytes() - with self.assertRaisesRegex( - CultCacheError, - "settings record app was loaded from the generic store, but its home is the store routed to settings", - ): - cache.pull_all_backing_stores() - self.assertIsNone(cache.get(settings, "app")) - self.assertEqual(cache.snapshot_envelopes(), before) - self.assertEqual(cache.get_required(settings, "held"), {"theme": "home"}) - self.assertEqual(generic_path.read_bytes(), generic_bytes) - self.assertEqual(settings_path.read_bytes(), settings_bytes) - - def test_pull_with_duplicate_global_leaves_cache_unchanged(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - item = item_doc() - settings = define_document_type("settings", global_document=True) - store_path = Path(tmp) / "generic.cc" - cache = ( - CultCache.builder() - .register_document_type(item) - .register_document_type(settings) - .add_generic_store(SingleFileMessagePackBackingStore(store_path)) - .build() - ) - cache.put(item, "item:potion", Item(name="Potion", category="Consumable", value=50)) - cache.put_global(settings, {"theme": "ash"}) - before = cache.snapshot_envelopes() - - # A second record of the global type lands in the store file behind the cache's back. - stray = replace(cache.get_required_envelope(settings, CultCache.GLOBAL_KEY), key="stray") - SingleFileMessagePackBackingStore(store_path).push(stray) - - with self.assertRaisesRegex(CultCacheError, "Duplicate global document for type: settings"): - cache.pull_all_backing_stores() - self.assertEqual(cache.snapshot_envelopes(), before) - self.assertEqual(cache.get_required(item, "item:potion").value, 50) - self.assertEqual(cache.get_key_by_name(item, "Potion"), "item:potion") - self.assertEqual(cache.get_required_global(settings)["theme"], "ash") - - def test_pull_with_throwing_index_accessor_leaves_cache_unchanged(self) -> None: - def category(item: Item) -> str: - if item.name == "Bomb": - raise ValueError("category accessor refuses Bomb") - return item.category - - with tempfile.TemporaryDirectory() as tmp: - item = define_document_type( - "item", - encode=lambda value: asdict(value), - decode=lambda raw: Item(**raw), - name="name", - indexes={"category": category}, - ) - settings = define_document_type("settings", global_document=True) - store_path = Path(tmp) / "generic.cc" - cache = ( - CultCache.builder() - .register_document_type(item) - .register_document_type(settings) - .add_generic_store(SingleFileMessagePackBackingStore(store_path)) - .build() - ) - cache.put(item, "item:potion", Item(name="Potion", category="Consumable", value=50)) - cache.put_global(settings, {"theme": "ash"}) - before_envelopes = cache.snapshot_envelopes() - before_values = cache.snapshot() - - # A record whose index accessor throws lands in the store file behind the cache's back. - bomb = replace( - cache.get_required_envelope(item, "item:potion"), - key="item:bomb", - payload=item.encode_payload(Item(name="Bomb", category="Explosive", value=1)), - ) - SingleFileMessagePackBackingStore(store_path).push(bomb) - - with self.assertRaisesRegex(ValueError, "category accessor refuses Bomb"): - cache.pull_all_backing_stores() - self.assertEqual(cache.snapshot_envelopes(), before_envelopes) - self.assertEqual(cache.snapshot(), before_values) - self.assertEqual(cache.get_required(item, "item:potion").value, 50) - self.assertIsNone(cache.get(item, "item:bomb")) - self.assertEqual(cache.get_key_by_name(item, "Potion"), "item:potion") - self.assertIsNone(cache.get_key_by_name(item, "Bomb")) - self.assertEqual(cache.get_key_by_index(item, "category", "Consumable"), "item:potion") - self.assertEqual(cache.get_required_global(settings)["theme"], "ash") - - def _store_state(self, path: Path) -> list[tuple[str, str, bytes]]: - return sorted((e.type, e.key, e.payload) for e in SingleFileMessagePackBackingStore(path).pull_all()) - - def test_put_with_throwing_name_extractor_changes_neither_store_nor_cache(self) -> None: - def name(value: dict) -> str: - if value["n"] == "bad": - raise ValueError("extractor refuses bad") - return value["n"] - - with tempfile.TemporaryDirectory() as tmp: - item = define_document_type("item", name=name) - path = Path(tmp) / "generic.cc" - cache = CultCache.builder().register_document_type(item).add_generic_store( - SingleFileMessagePackBackingStore(path) - ).build() - cache.put(item, "k", {"n": "good"}) - store_before, cache_before = self._store_state(path), cache.snapshot_envelopes() - - with self.assertRaisesRegex(ValueError, "extractor refuses bad"): - cache.put(item, "k", {"n": "bad"}) - self.assertEqual(self._store_state(path), store_before) - self.assertEqual(cache.snapshot_envelopes(), cache_before) - self.assertEqual(cache.get_required(item, "k"), {"n": "good"}) - self.assertEqual(cache.get_key_by_name(item, "good"), "k") - - def test_overwrite_under_a_registered_extractor_that_now_raises_changes_neither_store_nor_cache(self) -> None: - refusing = {"on": False} - - def category(value: dict) -> str: - if refusing["on"]: - raise ValueError("extractor refuses now") - return value["c"] - - with tempfile.TemporaryDirectory() as tmp: - item = define_document_type("item") - path = Path(tmp) / "generic.cc" - cache = CultCache.builder().register_document_type(item).add_generic_store( - SingleFileMessagePackBackingStore(path) - ).build() - cache.put(item, "k", {"c": "old"}) - cache.register_index(item, "category", category) - refusing["on"] = True - store_before, cache_before = self._store_state(path), cache.snapshot_envelopes() - - with self.assertRaisesRegex(ValueError, "extractor refuses now"): - cache.put(item, "k", {"c": "new"}) - self.assertEqual(self._store_state(path), store_before) - self.assertEqual(cache.snapshot_envelopes(), cache_before) - self.assertEqual(cache.get_key_by_index(item, "category", "old"), "k") - # Deleting runs no extractor: it uses the lookup keys stored when the record was admitted. - cache.delete(item, "k") - self.assertIsNone(cache.get_key_by_index(item, "category", "old")) - - def test_registering_an_extractor_that_raises_on_a_held_value_installs_nothing(self) -> None: - def name(value: dict) -> str: - if value["n"] == "bad": - raise ValueError("extractor refuses bad") - return value["n"] - - with tempfile.TemporaryDirectory() as tmp: - item = define_document_type("item") - path = Path(tmp) / "generic.cc" - cache = CultCache.builder().register_document_type(item).add_generic_store( - SingleFileMessagePackBackingStore(path) - ).build() - cache.put(item, "old", {"n": "bad"}) - with self.assertRaisesRegex(ValueError, "extractor refuses bad"): - cache.register_name_lookup(item, name) - cache.put(item, "old", {"n": "fine"}) - self.assertEqual(cache.get_required(item, "old"), {"n": "fine"}) - self.assertIsNone(cache.get_key_by_name(item, "fine")) - self.assertEqual([item.decode_payload(e.payload) for e in SingleFileMessagePackBackingStore(path).pull_all()], [{"n": "fine"}]) - - def test_put_envelopes_with_throwing_extractor_changes_neither_store_nor_cache(self) -> None: - def name(value: dict) -> str: - if value["n"] == "bad": - raise ValueError("extractor refuses bad") - return value["n"] - - with tempfile.TemporaryDirectory() as tmp: - item = define_document_type("item", name=name) - path = Path(tmp) / "generic.cc" - cache = CultCache.builder().register_document_type(item).add_generic_store( - SingleFileMessagePackBackingStore(path) - ).build() - cache.put(item, "seed", {"n": "seed"}) - seed = cache.get_required_envelope(item, "seed") - first = replace(seed, key="a", payload=item.encode_payload({"n": "a"})) - second = replace(seed, key="b", payload=item.encode_payload({"n": "bad"})) - store_before, cache_before = self._store_state(path), cache.snapshot_envelopes() - - with self.assertRaisesRegex(ValueError, "extractor refuses bad"): - cache.put_envelopes(item, [first, second]) - self.assertEqual(self._store_state(path), store_before) - self.assertEqual(cache.snapshot_envelopes(), cache_before) - self.assertIsNone(cache.get_key_by_name(item, "a")) - - def test_global_replace_with_failing_store_push_keeps_old_global(self) -> None: - class FailingPushStore(SingleFileMessagePackBackingStore): - fail = False - - def push(self, envelope): - if self.fail: - raise OSError("disk full") - super().push(envelope) - - with tempfile.TemporaryDirectory() as tmp: - settings = define_document_type("settings", global_document=True) - path = Path(tmp) / "generic.cc" - store = FailingPushStore(path) - cache = CultCache.builder().register_document_type(settings).add_generic_store(store).build() - cache.put_global(settings, {"theme": "base"}) - store_before, cache_before = self._store_state(path), cache.snapshot_envelopes() - store.fail = True - - with self.assertRaisesRegex(OSError, "disk full"): - cache.put_global(settings, {"theme": "new"}) - with self.assertRaisesRegex(CultCacheError, "must use key __global__"): - cache.put(settings, "other", {"theme": "new"}) - self.assertEqual(self._store_state(path), store_before) - self.assertEqual(cache.snapshot_envelopes(), cache_before) - self.assertEqual(cache.get_required_global(settings), {"theme": "base"}) - - @staticmethod - def _pausing_store(path: Path, barrier: threading.Barrier, resume: threading.Event): - """A store whose first push writes, meets the other thread at the barrier, then waits until that - thread's call has returned (or a timeout: a locked cache makes the other thread wait on us).""" - - class PausingStore(SingleFileMessagePackBackingStore): - pushes = 0 - paused = False - other_reached_store_while_paused = False - - def push(self, envelope): - self.other_reached_store_while_paused |= self.paused - self.pushes += 1 - super().push(envelope) - if self.pushes == 1: - self.paused = True - barrier.wait(timeout=5) - resume.wait(timeout=0.5) - self.paused = False - - return PausingStore(path) - - @staticmethod - def _run_racing(first, second, resume: threading.Event) -> list[BaseException]: - errors: list[BaseException] = [] - - def run(action, signal): - try: - action() - except BaseException as error: # noqa: BLE001 - reported to the test - errors.append(error) - finally: - if signal: - resume.set() - - threads = [threading.Thread(target=run, args=(first, False)), threading.Thread(target=run, args=(second, True))] - for thread in threads: - thread.start() - for thread in threads: - thread.join(timeout=10) - return errors - - def test_concurrent_put_and_attach_cannot_land_record_in_non_home_store(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - settings = define_database_entry_type("settings", [("theme", 0)]) - generic_path, settings_path = Path(tmp) / "generic.cc", Path(tmp) / "settings.cc" - barrier, resume = threading.Barrier(2), threading.Event() - generic = self._pausing_store(generic_path, barrier, resume) - cache = CultCache.builder().register_document_type(settings).add_generic_store(generic).build() - - def attach() -> None: - barrier.wait(timeout=5) - cache.add_backing_store(SingleFileMessagePackBackingStore(settings_path), ["settings"]) - - errors = self._run_racing(lambda: cache.put(settings, "app", {"theme": "t"}), attach, resume) - - # Either the attach was refused (the record is held, so its home cannot move) or it won; - # in every case each held record must be in the store that is its home. - home = SingleFileMessagePackBackingStore(generic_path if errors else settings_path) - self.assertEqual([(e.key, e.type) for e in home.pull_all()], [("app", "settings")]) - self.assertTrue(all(isinstance(error, CultCacheError) for error in errors), errors) - - def test_concurrent_global_puts_cannot_both_reach_disk(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - settings = define_document_type("settings", global_document=True) - path = Path(tmp) / "generic.cc" - barrier, resume = threading.Barrier(2), threading.Event() - store = self._pausing_store(path, barrier, resume) - cache = CultCache.builder().register_document_type(settings).add_generic_store(store).build() - - def second() -> None: - barrier.wait(timeout=5) - cache.put_global(settings, {"theme": "second"}) - - errors = self._run_racing(lambda: cache.put_global(settings, {"theme": "first"}), second, resume) - - self.assertEqual(errors, []) - # While the first write was between its store push and its apply, the second never reached disk. - self.assertFalse(store.other_reached_store_while_paused) - on_disk = SingleFileMessagePackBackingStore(path).pull_all() - self.assertEqual(len(on_disk), 1) - self.assertEqual(on_disk[0].payload, cache.get_required_envelope(settings, CultCache.GLOBAL_KEY).payload) - self.assertEqual(cache.get_required_global(settings), {"theme": "second"}) - - @staticmethod - def _envelope(document, key: str, value) -> CultCacheEnvelope: - catalog_entry = document.catalog_entry() - return CultCacheEnvelope.create( - key=key, - type=document.type, - payload=document.encode_payload(value), - schema_id=catalog_entry.schema_id, - catalog_entry=catalog_entry, + def test_compare_csharp_marks_meets_threshold_when_all_common_metrics_pass(self) -> None: + comparison = _compare_common_metrics( + { + "metrics": [ + {"name": "cache_get", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 95.0}, + {"name": "cache_upsert", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 120.0}, + ], + }, + { + "metrics": [ + {"name": "cache_get", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 100.0}, + {"name": "cache_upsert", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 100.0}, + ], + }, + parity_threshold=0.90, ) - def test_legacy_key_global_loads_without_writing_and_first_write_leaves_one_global(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - settings = define_document_type("settings", global_document=True) - path = Path(tmp) / "generic.cc" - - def build() -> CultCache: - return CultCache.builder().register_document_type(settings).add_generic_store( - SingleFileMessagePackBackingStore(path) - ).build() - - SingleFileMessagePackBackingStore(path).push(self._envelope(settings, "legacy", {"theme": "old"})) - stored_bytes = path.read_bytes() - - cache = build() - cache.pull_all_backing_stores() - self.assertEqual(cache.get_global(settings), {"theme": "old"}) - self.assertEqual(cache.get(settings, CultCache.GLOBAL_KEY), {"theme": "old"}) - self.assertEqual(path.read_bytes(), stored_bytes) - - cache.put_global(settings, {"theme": "new"}) - self.assertEqual([e.key for e in SingleFileMessagePackBackingStore(path).pull_all()], [CultCache.GLOBAL_KEY]) - reloaded = build() - reloaded.pull_all_backing_stores() - self.assertEqual(reloaded.get_required_global(settings), {"theme": "new"}) - - def test_deleting_a_legacy_key_global_removes_the_legacy_record(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - settings = define_document_type("settings", global_document=True) - path = Path(tmp) / "generic.cc" - SingleFileMessagePackBackingStore(path).push(self._envelope(settings, "legacy", {"theme": "old"})) - cache = CultCache.builder().register_document_type(settings).add_generic_store( - SingleFileMessagePackBackingStore(path) - ).build() - cache.pull_all_backing_stores() - - cache.delete_global(settings) - self.assertEqual(SingleFileMessagePackBackingStore(path).pull_all(), []) - self.assertIsNone(cache.get_global(settings)) - - def test_two_globals_of_one_type_on_disk_are_refused_under_any_keys(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - settings = define_document_type("settings", global_document=True) - for keys in (["one", "two"], ["legacy", CultCache.GLOBAL_KEY]): - path = Path(tmp) / f"{keys[0]}.cc" - store = SingleFileMessagePackBackingStore(path) - store.push_all([self._envelope(settings, key, {"theme": key}) for key in keys]) - cache = CultCache.builder().register_document_type(settings).add_generic_store(store).build() - with self.assertRaisesRegex(CultCacheError, "Duplicate global document for type: settings"): - cache.pull_all_backing_stores() - self.assertIsNone(cache.get_global(settings)) - - def test_decoder_writing_during_pull_raises_and_changes_neither_store_nor_cache(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "generic.cc" - holder: dict[str, CultCache] = {} - - def decode(raw): - holder["cache"].put(item, "side", {"name": "side"}) - return raw - - item = define_document_type("item", decode=decode, encode=lambda value: value) - store = SingleFileMessagePackBackingStore(path) - store.push(self._envelope(item, "a", {"name": "a"})) - stored_bytes = path.read_bytes() - cache = holder["cache"] = CultCache.builder().register_document_type(item).add_generic_store(store).build() - - with self.assertRaisesRegex(CultCacheError, "re-entrant put"): - cache.pull_all_backing_stores() - self.assertEqual(path.read_bytes(), stored_bytes) - self.assertEqual(cache.snapshot_envelopes(), []) - self.assertIsNone(cache.get(item, "side")) - # The refusal released the cache: an ordinary write still works. - cache.put(item, "b", {"name": "b"}) - self.assertEqual(cache.get(item, "b"), {"name": "b"}) - - def test_index_extractor_writing_during_register_index_raises_and_installs_nothing(self) -> None: - thing = define_document_type("thing") - cache = CultCache.builder().register_document_type(thing).build() - cache.put(thing, "k1", {"n": "one", "cat": "x"}) - - def category(value: dict) -> str: - cache.put(thing, "k2", {"n": "two", "cat": "y"}) - return value["cat"] - - with self.assertRaisesRegex(CultCacheError, "re-entrant put"): - cache.register_index(thing, "cat", category) - self.assertIsNone(cache.get(thing, "k2")) - self.assertNotIn("thing", cache._state.index_extractors) - self.assertIsNone(cache.get_key_by_index(thing, "cat", "x")) - - def test_put_envelope_refuses_a_global_under_another_key(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - settings = define_document_type("settings", global_document=True) - path = Path(tmp) / "generic.cc" - cache = CultCache.builder().register_document_type(settings).add_generic_store( - SingleFileMessagePackBackingStore(path) - ).build() - cache.put_global(settings, {"theme": "base"}) - store_before, cache_before = self._store_state(path), cache.snapshot_envelopes() - stray = replace(cache.get_required_envelope(settings, CultCache.GLOBAL_KEY), key="stray") - - with self.assertRaisesRegex(CultCacheError, "must use key __global__"): - cache.put_envelope(settings, stray) - with self.assertRaisesRegex(CultCacheError, "must be non-empty"): - cache.put_envelope(settings, replace(stray, key="")) - self.assertEqual(self._store_state(path), store_before) - self.assertEqual(cache.snapshot_envelopes(), cache_before) - - def test_refused_put_leaves_no_empty_type_entry(self) -> None: - def refuse(value: dict) -> dict: - raise ValueError("encode refuses") - - with tempfile.TemporaryDirectory() as tmp: - other = define_document_type("other", encode=refuse) - path = Path(tmp) / "generic.cc" - cache = CultCache.builder().register_document_type(other).add_generic_store( - SingleFileMessagePackBackingStore(path) - ).build() - with self.assertRaisesRegex(ValueError, "encode refuses"): - cache.put(other, "x", {"a": 1}) - self.assertEqual(cache.snapshot(), {}) - self.assertFalse(path.exists()) - - def test_attach_with_empty_type_list_is_the_generic_store(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - settings = define_database_entry_type("settings", [("theme", 0)]) - store_path = Path(tmp) / "generic.cc" - cache = CultCache.builder().register_document_type(settings).build() - cache.add_backing_store(SingleFileMessagePackBackingStore(store_path), []) - cache.put(settings, "y", {"theme": "t"}) - self.assertEqual( - [(e.key, e.type) for e in SingleFileMessagePackBackingStore(store_path).pull_all()], - [("y", "settings")], - ) - with self.assertRaisesRegex(CultCacheError, "second generic store"): - cache.add_backing_store(SingleFileMessagePackBackingStore(Path(tmp) / "b.cc"), []) - - def test_zero_store_cache_writes_and_deletes_in_memory(self) -> None: - settings = define_database_entry_type("settings", [("theme", 0)]) - cache = CultCache.builder().register_document_type(settings).build() - cache.put(settings, "x", {"theme": "mem"}) - self.assertEqual(cache.get_required(settings, "x"), {"theme": "mem"}) - cache.delete(settings, "x") - self.assertIsNone(cache.get(settings, "x")) + self.assertEqual(_parity_status(comparison, "ok"), "meets-threshold") def test_cache_put_without_backing_store_keeps_in_memory_value(self) -> None: document = define_database_entry_type( @@ -801,6 +519,5893 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(cache.get(document, "item:one"), {"name": "one", "value": 1}) self.assertEqual(cache.get_required_envelope(document, "item:one").key, "item:one") + def test_verify_reports_python_runtime_surface_health(self) -> None: + result = verify(records=4) + + self.assertEqual(result["status"], "ok") + self.assertEqual( + {check["name"] for check in result["checks"]}, + { + "public_exports", + "typed_markers", + "local_cultmesh_wire_smoke", + "cultmesh_capability_truth", + "benchmark_sanity", + }, + ) + by_name = {check["name"]: check for check in result["checks"]} + self.assertEqual(by_name["public_exports"]["missing"], {}) + self.assertEqual(by_name["cultmesh_capability_truth"]["failures"], []) + + def test_cultnet_security_options_match_cultlib_defaults_and_environment_rules(self) -> None: + client = CultNetClientSecurityOptions.development() + server = CultNetServerSecurityOptions.development() + + self.assertEqual(client.connection_key, "gamecult-dev-connection-key") + self.assertEqual(server.connection_key, "gamecult-dev-connection-key") + self.assertTrue(server.is_development) + self.assertEqual(server.to_client_options().connection_key, client.connection_key) + self.assertEqual(client.encryption_key(), hashlib.sha256(b"gamecult-dev-connection-key").digest()) + self.assertEqual(server.session_signing_key(), hashlib.sha256(b"gamecult-dev-session-signing-secret").digest()) + self.assertEqual( + CultNetServerSecurityOptions.from_environment({}, allow_development_defaults=True), + server, + ) + with self.assertRaisesRegex(ValueError, "not configured"): + CultNetServerSecurityOptions.from_environment({}) + with self.assertRaisesRegex(ValueError, "GAMECULT_SESSION_SIGNING_SECRET"): + CultNetServerSecurityOptions.from_environment({"GAMECULT_CONNECTION_KEY": "key"}) + configured = CultNetServerSecurityOptions.from_environment({ + "GAMECULT_CONNECTION_KEY": "prod-key", + "GAMECULT_SESSION_SIGNING_SECRET": "prod-secret", + }) + self.assertEqual(configured.connection_key, "prod-key") + self.assertFalse(configured.is_development) + + def test_cultnet_secret_helpers_validate_csharp_versioned_session_tokens(self) -> None: + security = CultNetServerSecurityOptions("connection-key", "session-secret") + user_id = uuid4() + expires = datetime(2035, 1, 2, 3, 4, 5, tzinfo=UTC) + + token = CultNetSecret.create_session_token(user_id, expires, security, session_version=7) + payload_b64, signature_b64 = token.split(".") + self.assertEqual( + CultNetSecret.from_base64url(payload_b64).decode("utf-8"), + f"{user_id.hex}|{int(expires.timestamp())}|7", + ) + self.assertEqual( + CultNetSecret.from_base64url(signature_b64), + hmac.new( + hashlib.sha256(b"session-secret").digest(), + CultNetSecret.from_base64url(payload_b64), + hashlib.sha256, + ).digest(), + ) + + validated = CultNetSecret.try_validate_session_token( + token, + security, + now_utc=datetime(2035, 1, 2, 3, 4, 4, tzinfo=UTC), + ) + self.assertIsNotNone(validated) + assert validated is not None + self.assertEqual(validated.user_id, user_id) + self.assertEqual(validated.expires_at_utc, expires) + self.assertEqual(validated.session_version, 7) + + tampered_payload = CultNetSecret.to_base64url(CultNetSecret.from_base64url(payload_b64) + b"tamper") + tampered = f"{tampered_payload}.{signature_b64}" + self.assertIsNone(CultNetSecret.try_validate_session_token(tampered, security)) + self.assertIsNone(CultNetSecret.try_validate_session_token(token, security, now_utc=expires)) + + def test_cultnet_secret_helpers_validate_legacy_two_field_session_tokens(self) -> None: + security = CultNetServerSecurityOptions.development() + user_id = uuid4() + expires_at_seconds = int(datetime(2036, 1, 1, tzinfo=UTC).timestamp()) + payload = f"{user_id.hex}|{expires_at_seconds}".encode("utf-8") + signature = hmac.new(security.session_signing_key(), payload, hashlib.sha256).digest() + token = f"{CultNetSecret.to_base64url(payload)}.{CultNetSecret.to_base64url(signature)}" + + validated = CultNetSecret.try_validate_session_token( + token, + security, + now_utc=datetime(2035, 12, 31, tzinfo=UTC), + ) + + self.assertIsNotNone(validated) + assert validated is not None + self.assertEqual(validated.user_id, user_id) + self.assertEqual(validated.session_version, 0) + + def test_cultnet_secret_encryption_helpers_are_optional_without_crypto_dependency(self) -> None: + nonce = b"123456789012" + security = CultNetServerSecurityOptions.development() + try: + encrypted = CultNetSecret.encrypt_string("hello", nonce, security.to_client_options()) + except ImportError as exc: + self.assertIn("cryptography", str(exc)) + return + self.assertEqual(CultNetSecret.decrypt_string(encrypted, nonce, security), "hello") + + def test_cultnet_schema_message_frame_round_trip(self) -> None: + message = hello( + runtime_id="python-test", + supported_schema_versions=["cultnet.hello.v0"], + transport_profiles=[ + { + "schemaVersion": "cultnet.transport_profile.v0", + "runtimeId": "python-test", + "transports": [ + { + "transportId": "test", + "protocol": "tcp_framed", + "channels": [{"channelId": "schema", "delivery": "reliable", "ordering": "ordered"}], + } + ], + } + ], + ) + parsed = parse_message(decode_frame(encode_frame(message.to_bytes()))) + self.assertEqual(parsed.schema_version, "cultnet.hello.v0") + self.assertEqual(parsed.body["runtimeId"], "python-test") + self.assertEqual(parsed.body["transportProfiles"][0]["transports"][0]["protocol"], "tcp_framed") + + def test_cultnet_tcp_framed_transport_carries_schema_payloads_with_stats(self) -> None: + payload = b"cultnet-payload" + output = io.BytesIO() + sender = TcpFramedTransportConnection( + output, + profile=create_tcp_framed_transport_profile("sender", transport_id="test-tcp"), + ) + sender.send("schema", payload) + self.assertEqual(sender.stats.frames_sent, 1) + self.assertEqual(sender.stats.bytes_sent, len(payload) + 4) + with self.assertRaisesRegex(ValueError, "only supports the schema channel"): + sender.send("unreliable", b"") + + receiver = TcpFramedTransportConnection( + io.BytesIO(output.getvalue()), + profile=create_tcp_framed_transport_profile("receiver", transport_id="test-tcp"), + ) + frame = receiver.receive() + self.assertEqual(frame.channel_id, "schema") + self.assertEqual(frame.payload, payload) + self.assertEqual(receiver.stats.frames_received, 1) + self.assertEqual(receiver.stats.bytes_received, len(payload) + 4) + self.assertEqual(receiver.profile["transports"][0]["protocol"], "tcp_framed") + + def test_cultnet_raw_client_uses_schema_transport_factory(self) -> None: + import msgpack # type: ignore + + sent_messages: list[dict[str, object]] = [] + test_case = self + + class FakeSchemaTransport: + profile = create_tcp_framed_transport_profile("fake-client") + + def __enter__(self) -> "FakeSchemaTransport": + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + self.close() + + def send(self, channel_id: str, payload: bytes) -> None: + test_case.assertEqual(channel_id, "schema") + sent_messages.append(msgpack.unpackb(payload, raw=False)) + + def receive(self) -> object: + response = { + "schemaVersion": "cultnet.schema_catalog_response.v0", + "messageId": "transport-factory", + "schemas": [], + } + return type("Frame", (), {"channel_id": "schema", "payload": msgpack.packb(response, use_bin_type=True)})() + + def close(self) -> None: + pass + + client = CultNetRawClient( + "unused.example.test", + 1, + create_transport=FakeSchemaTransport, + ) + + response = client.fetch_schema_catalog(message_id="transport-factory") + + self.assertEqual(response["schemaVersion"], "cultnet.schema_catalog_response.v0") + self.assertEqual(sent_messages[0]["schemaVersion"], "cultnet.schema_catalog_request.v0") + self.assertEqual(sent_messages[0]["messageId"], "transport-factory") + + def test_cultmesh_client_uses_rudp_schema_transport_endpoint(self) -> None: + import msgpack # type: ignore + + server_socket = bind_udp_socket() + server_host, server_port = server_socket.getsockname()[:2] + connection_id = 0x43554C54 + server = CultNetRudpSocketTransportConnection( + CultNetRudpSocketTransportOptions( + runtime_id="python-rudp-schema-server", + socket=server_socket, + mode=CultNetRudpSocketMode.SERVER, + connection_id=connection_id, + initial_sequence=100, + resend_delay_ms=25, + ) + ) + received_messages: list[dict[str, object]] = [] + + def serve_once() -> None: + deadline = time.monotonic() + 2.0 + try: + while time.monotonic() < deadline: + frame = server.receive_once() + server.poll_resends() + if frame is None: + time.sleep(0.005) + continue + received = msgpack.unpackb(frame.payload, raw=False) + received_messages.append(received) + response = { + "schemaVersion": "cultnet.schema_catalog_response.v0", + "messageId": received["messageId"], + "schemas": [], + } + server.send("schema", msgpack.packb(response, use_bin_type=True)) + return + raise AssertionError("RUDP schema server did not receive a request") + finally: + server.close() + + server_thread = threading.Thread(target=serve_once, daemon=True) + server_thread.start() + client = CultMesh.create_client( + endpoint=f"rudp://{server_host}:{server_port}", + timeout_seconds=2.0, + connection_id=connection_id, + runtime_id="python-rudp-schema-client", + ) + + response = client.fetch_schema_catalog(message_id="rudp-transport-factory") + server_thread.join(timeout=2.0) + + self.assertFalse(server_thread.is_alive()) + self.assertEqual(response["schemaVersion"], "cultnet.schema_catalog_response.v0") + self.assertEqual(response["messageId"], "rudp-transport-factory") + self.assertEqual(received_messages[0]["schemaVersion"], "cultnet.schema_catalog_request.v0") + self.assertEqual(received_messages[0]["messageId"], "rudp-transport-factory") + + def test_cultnet_rudp_packet_codec_uses_deterministic_reliable_ordered_fixture(self) -> None: + encoded = encode_rudp_packet( + CultNetRudpPacket( + packet_type=CultNetRudpPacketType.DATA, + connection_id=0x01020304, + sequence=0x0000002A, + ack=0x00000029, + ack_mask=0x80000001, + channel_id="schema", + reliable=True, + ordered=True, + fragment_id=7, + fragment_index=1, + fragment_count=3, + payload=b"hello", + ) + ) + + self.assertEqual( + encoded.hex(), + "434e523000030b2a010203040000002a0000002980000001000700010003000000050600736368656d6168656c6c6f", + ) + + decoded = decode_rudp_packet(encoded) + self.assertEqual(decoded.packet_type, CultNetRudpPacketType.DATA) + self.assertEqual(decoded.connection_id, 0x01020304) + self.assertEqual(decoded.sequence, 0x0000002A) + self.assertEqual(decoded.ack, 0x00000029) + self.assertEqual(decoded.ack_mask, 0x80000001) + self.assertEqual(decoded.channel_id, "schema") + self.assertTrue(decoded.reliable) + self.assertTrue(decoded.ordered) + self.assertFalse(decoded.sequenced) + self.assertEqual(decoded.fragment_id, 7) + self.assertEqual(decoded.fragment_index, 1) + self.assertEqual(decoded.fragment_count, 3) + self.assertEqual(decoded.payload, b"hello") + + def test_cultnet_rudp_transport_profile_advertises_state_and_realtime_channels(self) -> None: + profile = create_rudp_transport_profile( + "python-rudp", + transport_id="public-rudp", + host="127.0.0.1", + port=7777, + max_payload_bytes=1200, + max_fragment_bytes=1000, + ) + + self.assertEqual(profile["transports"][0]["protocol"], "rudp") + self.assertEqual(profile["transports"][0]["reconnectPolicy"]["schemaVersion"], "cultnet.reconnect_policy.v0") + self.assertEqual(profile["transports"][0]["reconnectPolicy"]["baseDelayMs"], 1_000) + self.assertEqual( + [ + (channel["channelId"], channel["delivery"], channel["ordering"]) + for channel in profile["transports"][0]["channels"] + ], + [ + ("schema", "reliable", "ordered"), + ("latest", "unreliable", "sequenced"), + ("realtime", "unreliable", "unordered"), + ], + ) + + def test_cultnet_reconnect_policy_exposes_portable_delay_contract(self) -> None: + policy = create_reconnect_policy(policy_id="rudp-default", max_attempts=8) + + self.assertEqual(policy.schema_version, "cultnet.reconnect_policy.v0") + self.assertEqual(policy.policy_id, "rudp-default") + self.assertEqual(policy.max_attempts, 8) + self.assertEqual(policy.to_wire()["policyId"], "rudp-default") + self.assertEqual(policy.to_wire()["maxAttempts"], 8) + self.assertEqual(compute_reconnect_delay_ms(policy, 1), 1_000) + self.assertEqual(compute_reconnect_delay_ms(policy, 3, 17), 4_017) + self.assertEqual(compute_reconnect_delay_ms(policy, 9, 999), 30_250) + self.assertEqual(compute_reconnect_delay_ms(policy, 0, -5), 1_000) + + def test_cultnet_reconnect_controller_schedules_attempts_and_reset(self) -> None: + controller = CultNetReconnectController(create_reconnect_policy(max_attempts=2)) + + first = controller.record_failure(10_000) + self.assertEqual(first.attempt, 1) + self.assertTrue(first.should_retry) + self.assertEqual(first.delay_ms, 1_000) + self.assertEqual(first.next_attempt_at_ms, 11_000) + self.assertFalse(first.exhausted) + self.assertFalse(controller.can_attempt(10_999)) + self.assertTrue(controller.can_attempt(11_000)) + + second = controller.record_failure(11_000, 17) + self.assertEqual(second.attempt, 2) + self.assertEqual(second.delay_ms, 2_017) + self.assertEqual(second.next_attempt_at_ms, 13_017) + self.assertTrue(second.should_retry) + + exhausted = controller.record_failure(13_017) + self.assertEqual(exhausted.attempt, 2) + self.assertFalse(exhausted.should_retry) + self.assertEqual(exhausted.delay_ms, 0) + self.assertIsNone(exhausted.next_attempt_at_ms) + self.assertTrue(exhausted.exhausted) + self.assertFalse(controller.can_attempt(99_000)) + + controller.reset() + self.assertEqual(controller.attempt, 0) + self.assertIsNone(controller.next_attempt_at_ms) + self.assertFalse(controller.exhausted) + self.assertTrue(controller.can_attempt(99_000)) + + def test_cultnet_rudp_reconnect_loop_consumes_shared_controller(self) -> None: + class FakeRudpReconnectTransport: + def __init__(self) -> None: + self.connect_calls = 0 + self.close_calls = 0 + self.last_payload: bytes | None = None + + def connect(self, payload: bytes = b"") -> None: + self.connect_calls += 1 + self.last_payload = bytes(payload) + + def close(self) -> None: + self.close_calls += 1 + + now_ms = 10_000 + captured_callback: list[Callable[[], None]] = [] + transports: list[FakeRudpReconnectTransport] = [] + + def create_transport() -> FakeRudpReconnectTransport: + transport = FakeRudpReconnectTransport() + transports.append(transport) + return transport + + def scheduler(delay_ms: int, callback: Callable[[], None]) -> Callable[[], None]: + self.assertEqual(delay_ms, 1_017) + captured_callback[:] = [callback] + + def cancel() -> None: + captured_callback.clear() + + return cancel + + loop = CultNetRudpReconnectLoop( + create_transport, + reconnect_policy=create_reconnect_policy(max_attempts=2), + connect_payload=b"join", + now_ms=lambda: now_ms, + jitter_ms=lambda: 17, + scheduler=scheduler, + ) + + first = loop.start() + self.assertEqual(first.connect_calls, 1) + self.assertEqual(first.last_payload, b"join") + self.assertIs(loop.transport, first) + + decision = loop.handle_closed() + self.assertIsNotNone(decision) + self.assertEqual(decision.attempt, 1) + self.assertTrue(decision.should_retry) + self.assertEqual(decision.delay_ms, 1_017) + self.assertEqual(decision.next_attempt_at_ms, 11_017) + self.assertEqual(loop.reconnect_controller.attempt, 1) + self.assertEqual(loop.reconnect_controller.next_attempt_at_ms, 11_017) + self.assertEqual(len(captured_callback), 1) + + now_ms = 11_017 + captured_callback[0]() + self.assertEqual(len(transports), 2) + self.assertEqual(transports[1].connect_calls, 1) + self.assertIs(loop.transport, transports[1]) + + loop.mark_connected() + self.assertEqual(loop.reconnect_controller.attempt, 0) + + loop.stop() + self.assertEqual(transports[1].close_calls, 1) + self.assertIsNone(loop.transport) + + def test_cultnet_rudp_session_handshake_acks_reliable_connect_and_accept_packets(self) -> None: + client = CultNetRudpSession( + CultNetRudpSessionOptions(connection_id=0x0A0B0C0D, initial_sequence=1, resend_delay_ms=50) + ) + server = CultNetRudpSession( + CultNetRudpSessionOptions(connection_id=0x0A0B0C0D, initial_sequence=100, resend_delay_ms=50) + ) + + connect = client.create_connect(0, b"join") + self.assertEqual(connect.packet_type, CultNetRudpPacketType.CONNECT) + self.assertEqual(connect.sequence, 1) + self.assertEqual(client.pending_reliable_sequences, (1,)) + + accept = server.accept_connect(connect, 10, b"ok") + self.assertEqual(accept.packet_type, CultNetRudpPacketType.ACCEPT) + self.assertEqual(accept.ack, 1) + self.assertTrue(server.connected) + self.assertEqual(server.pending_reliable_sequences, (100,)) + + client.receive(accept, 20) + self.assertTrue(client.connected) + self.assertEqual(client.pending_reliable_sequences, ()) + + ack = client.create_ack() + self.assertEqual(ack.ack, 100) + server.receive(ack, 30) + self.assertEqual(server.pending_reliable_sequences, ()) + + def test_cultnet_rudp_session_computes_ack_masks_and_clears_pending_reliable_packets(self) -> None: + sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=7, initial_sequence=10, resend_delay_ms=100)) + receiver = CultNetRudpSession( + CultNetRudpSessionOptions(connection_id=7, initial_sequence=200, resend_delay_ms=100) + ) + sender.receive( + CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 7, 1, 0, 0, "control") + ) + receiver.receive( + CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 7, 2, 0, 0, "control") + ) + + first = sender.send( + "schema", + b"first", + CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=0), + ) + second = sender.send( + "schema", + b"second", + CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=0), + ) + third = sender.send( + "schema", + b"third", + CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=0), + ) + self.assertEqual(sender.pending_reliable_sequences, (10, 11, 12)) + + receiver.receive(first) + receiver.receive(third) + ack_with_gap = receiver.create_ack() + self.assertEqual(ack_with_gap.ack, 12) + self.assertEqual(ack_with_gap.ack_mask, 0b10 | (1 << 9)) + sender.receive(ack_with_gap) + self.assertEqual(sender.pending_reliable_sequences, (11,)) + + receiver.receive(second) + full_ack = receiver.create_ack() + self.assertEqual(full_ack.ack, 12) + self.assertEqual(full_ack.ack_mask, 0b11 | (1 << 9)) + sender.receive(full_ack) + self.assertEqual(sender.pending_reliable_sequences, ()) + + def test_cultnet_rudp_session_schedules_reliable_resends_until_acked(self) -> None: + session = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=99, initial_sequence=1, resend_delay_ms=100)) + session.receive( + CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 99, 50, 0, 0, "control") + ) + sent = session.send( + "schema", + b"payload", + CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=10), + ) + + self.assertEqual(session.due_resends(90), ()) + self.assertEqual(tuple(packet.sequence for packet in session.due_resends(110)), (sent.sequence,)) + self.assertEqual(session.due_resends(150), ()) + + session.receive( + CultNetRudpPacket(CultNetRudpPacketType.ACK, 99, 51, sent.sequence, 0, "control") + ) + self.assertEqual(session.due_resends(250), ()) + + def test_cultnet_rudp_session_pings_and_detects_receive_timeout(self) -> None: + client = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=101, initial_sequence=1)) + server = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=101, initial_sequence=100)) + connect = client.create_connect(0, b"join") + accept = server.accept_connect(connect, 10) + client.receive(accept, 20) + + ping = client.create_ping(b"pulse") + ping_result = server.receive(ping, 30) + self.assertIsNotNone(ping_result.reply) + self.assertEqual(ping_result.reply.packet_type, CultNetRudpPacketType.PONG) + self.assertEqual(ping_result.reply.payload, b"pulse") + + pong_result = client.receive(ping_result.reply, 40) + self.assertTrue(pong_result.pong) + self.assertEqual(pong_result.pong_payload, b"pulse") + self.assertFalse(client.check_timeout(90, 50)) + self.assertTrue(client.check_timeout(91, 50)) + self.assertFalse(client.connected) + + def test_cultnet_rudp_session_bounds_pending_reliable_packets_before_enqueue(self) -> None: + session = CultNetRudpSession( + CultNetRudpSessionOptions(connection_id=102, initial_sequence=1, max_pending_reliable_packets=2) + ) + session.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 102, 50, 0, 0, "control")) + session.send("schema", b"first", CultNetRudpSendOptions(reliable=True, ordered=True)) + session.send("schema", b"second", CultNetRudpSendOptions(reliable=True, ordered=True)) + with self.assertRaisesRegex(ValueError, "reliable send queue is full"): + session.send("schema", b"third", CultNetRudpSendOptions(reliable=True, ordered=True)) + self.assertEqual(session.pending_reliable_sequences, (1, 2)) + + fragmented = CultNetRudpSession( + CultNetRudpSessionOptions(connection_id=103, initial_sequence=1, max_pending_reliable_packets=3) + ) + fragmented.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 103, 50, 0, 0, "control")) + with self.assertRaisesRegex(ValueError, "reliable send queue is full"): + fragmented.send_many( + "schema", + b"fragment-me", + CultNetRudpSendOptions(reliable=True, ordered=True), + max_fragment_bytes=3, + ) + self.assertEqual(fragmented.pending_reliable_sequences, ()) + + def test_cultnet_rudp_session_suppresses_duplicates_and_delivers_reliable_ordered_payloads(self) -> None: + sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=123, initial_sequence=1)) + receiver = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=123, initial_sequence=100)) + sender.receive( + CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 123, 90, 0, 0, "control") + ) + receiver.receive( + CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 123, 91, 0, 0, "control") + ) + + first = sender.send("schema", b"first", CultNetRudpSendOptions(reliable=True, ordered=True)) + second = sender.send("schema", b"second", CultNetRudpSendOptions(reliable=True, ordered=True)) + third = sender.send("schema", b"third", CultNetRudpSendOptions(reliable=True, ordered=True)) + + self.assertEqual([frame.payload.decode("utf-8") for frame in receiver.receive(first).delivered], ["first"]) + self.assertEqual(receiver.receive(third).delivered, ()) + self.assertEqual(receiver.receive(first).delivered, ()) + self.assertEqual( + [frame.payload.decode("utf-8") for frame in receiver.receive(second).delivered], + ["second", "third"], + ) + + def test_cultnet_rudp_session_skips_control_packets_while_ordering_schema_payloads(self) -> None: + sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=124, initial_sequence=1)) + receiver = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=124, initial_sequence=100)) + sender.receive( + CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 124, 90, 0, 0, "control") + ) + receiver.receive( + CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 124, 91, 0, 0, "control") + ) + + first = sender.send("schema", b"first", CultNetRudpSendOptions(reliable=True, ordered=True)) + control = sender.create_ack() + second = sender.send("schema", b"second", CultNetRudpSendOptions(reliable=True, ordered=True)) + + self.assertEqual([frame.payload.decode("utf-8") for frame in receiver.receive(first).delivered], ["first"]) + self.assertEqual(receiver.receive(control).delivered, ()) + self.assertEqual([frame.payload.decode("utf-8") for frame in receiver.receive(second).delivered], ["second"]) + + def test_cultnet_rudp_session_fragments_and_reassembles_reliable_ordered_payloads(self) -> None: + sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=456, initial_sequence=1)) + receiver = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=456, initial_sequence=100)) + sender.receive( + CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 456, 90, 0, 0, "control") + ) + receiver.receive( + CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 456, 91, 0, 0, "control") + ) + + packets = sender.send_many( + "schema", + b"fragment-me-please", + CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=10), + max_fragment_bytes=5, + ) + self.assertEqual(len(packets), 4) + self.assertEqual(tuple(packet.fragment_count for packet in packets), (4, 4, 4, 4)) + self.assertEqual(tuple(packet.fragment_index for packet in packets), (0, 1, 2, 3)) + self.assertTrue(all(packet.fragment_id == packets[0].fragment_id for packet in packets)) + + self.assertEqual(receiver.receive(packets[0]).delivered, ()) + self.assertEqual(receiver.receive(packets[1]).delivered, ()) + self.assertEqual(receiver.receive(packets[2]).delivered, ()) + delivered = receiver.receive(packets[3]).delivered + self.assertEqual(len(delivered), 1) + self.assertEqual(delivered[0].payload, b"fragment-me-please") + self.assertEqual(delivered[0].sequence, packets[0].sequence) + + def test_cultnet_rudp_socket_transport_handshakes_and_carries_reliable_ordered_schema_frames(self) -> None: + server_socket = bind_udp_socket() + client_socket = bind_udp_socket() + connection_id = 0x10203040 + server = CultNetRudpSocketTransportConnection( + CultNetRudpSocketTransportOptions( + runtime_id="python-rudp-server", + socket=server_socket, + mode=CultNetRudpSocketMode.SERVER, + connection_id=connection_id, + initial_sequence=100, + resend_delay_ms=25, + ) + ) + client = CultNetRudpSocketTransportConnection( + CultNetRudpSocketTransportOptions( + runtime_id="python-rudp-client", + socket=client_socket, + mode=CultNetRudpSocketMode.CLIENT, + remote_addr=server_socket.getsockname(), + connection_id=connection_id, + initial_sequence=1, + resend_delay_ms=25, + ) + ) + + try: + client.connect(b"join") + pump_rudp_handshake(client, server) + self.assertTrue(client.connected) + self.assertTrue(server.connected) + + client.send("schema", b"client-state") + server_frame = receive_rudp_frame(server) + self.assertEqual(server_frame.channel_id, "schema") + self.assertEqual(server_frame.payload, b"client-state") + + server.send("schema", b"server-state") + client_frame = receive_rudp_frame(client) + self.assertEqual(client_frame.channel_id, "schema") + self.assertEqual(client_frame.payload, b"server-state") + self.assertEqual(server.profile["transports"][0]["protocol"], "rudp") + self.assertEqual(client.stats.frames_sent, 1) + self.assertEqual(server.stats.frames_received, 1) + finally: + client.close() + server.close() + + def test_cultnet_rudp_socket_transport_carries_fragmented_reliable_ordered_schema_frames(self) -> None: + server_socket = bind_udp_socket() + client_socket = bind_udp_socket() + connection_id = 0x10203041 + server = CultNetRudpSocketTransportConnection( + CultNetRudpSocketTransportOptions( + runtime_id="python-rudp-fragment-server", + socket=server_socket, + mode=CultNetRudpSocketMode.SERVER, + connection_id=connection_id, + initial_sequence=100, + resend_delay_ms=25, + max_fragment_bytes=8, + ) + ) + client = CultNetRudpSocketTransportConnection( + CultNetRudpSocketTransportOptions( + runtime_id="python-rudp-fragment-client", + socket=client_socket, + mode=CultNetRudpSocketMode.CLIENT, + remote_addr=server_socket.getsockname(), + connection_id=connection_id, + initial_sequence=1, + resend_delay_ms=25, + max_fragment_bytes=8, + ) + ) + + try: + payload = b"this-schema-frame-is-larger-than-one-rudp-fragment" + client.connect(b"join") + pump_rudp_handshake(client, server) + client.send("schema", payload) + server_frame = receive_rudp_frame(server) + self.assertEqual(server_frame.channel_id, "schema") + self.assertEqual(server_frame.payload, payload) + self.assertEqual(client.stats.frames_sent, 1) + self.assertEqual(server.stats.frames_received, 1) + finally: + client.close() + server.close() + + def test_cultmesh_facade_creates_rudp_client_from_peer_endpoint(self) -> None: + connection_id = 0x10203042 + server = CultMesh.create_rudp_server( + "python-cultmesh-rudp-server", + connection_id, + initial_sequence=100, + resend_delay_ms=25, + max_fragment_bytes=1024, + max_pending_reliable_packets=16, + ) + server_host, server_port = server.socket.getsockname()[:2] + endpoint = CultMesh.parse_rudp_endpoint(f"rudp://{server_host}:{server_port}") + self.assertEqual(endpoint.host, server_host) + self.assertEqual(endpoint.port, server_port) + peer = CultMeshPeerCard( + peer_id="python-cultmesh-rudp-server", + verse_id="local", + endpoints=(endpoint.uri,), + roles=("schema",), + authority_lease_id="lease:python-cultmesh-rudp-server", + ) + peers = CultMesh.create_peer_catalog() + leases = CultMesh.create_authority_lease_catalog() + peers.upsert(peer) + with self.assertRaisesRegex(ValueError, "No authorized RUDP peer"): + CultMesh.create_rudp_client_for_authorized_peer( + "python-cultmesh-rudp-client", + connection_id, + peers, + leases, + "local", + "schema", + resend_delay_ms=25, + max_fragment_bytes=1024, + max_pending_reliable_packets=16, + ) + leases.upsert( + CultMeshAuthorityLease( + lease_id="lease:python-cultmesh-rudp-server", + verse_id="local", + peer_id="python-cultmesh-rudp-server", + roles=("schema",), + valid_from=datetime.now(UTC) - timedelta(seconds=1), + expires_at=datetime.now(UTC) + timedelta(seconds=30), + issuer_runtime_id="python-authority", + ) + ) + client = CultMesh.create_rudp_client_for_authorized_peer( + "python-cultmesh-rudp-client", + connection_id, + peers, + leases, + "local", + "schema", + resend_delay_ms=25, + max_fragment_bytes=1024, + max_pending_reliable_packets=16, + ) + + try: + client.connect(b"join") + pump_rudp_handshake(client, server) + self.assertTrue(client.connected) + self.assertTrue(server.connected) + + client.send("schema", b"client-state") + server_frame = receive_rudp_frame(server) + self.assertEqual(server_frame.channel_id, "schema") + self.assertEqual(server_frame.payload, b"client-state") + self.assertEqual(server.profile["transports"][0]["protocol"], "rudp") + self.assertEqual(client.profile["transports"][0]["protocol"], "rudp") + finally: + client.close() + server.close() + + def test_cultnet_database_subscription_helpers_match_schema_v0_shape(self) -> None: + subscribe = database_subscribe( + message_id="sub-message", + subscription_id="sub-1", + schema_ids=["schema-a"], + record_keys=["record-a"], + include_snapshot=False, + ).to_wire() + self.assertEqual(subscribe["schemaVersion"], "cultnet.database_subscribe.v0") + self.assertEqual(subscribe["messageId"], "sub-message") + self.assertEqual(subscribe["subscriptionId"], "sub-1") + self.assertEqual(subscribe["schemaIds"], ["schema-a"]) + self.assertEqual(subscribe["recordKeys"], ["record-a"]) + self.assertFalse(subscribe["includeSnapshot"]) + + unsubscribe = database_unsubscribe(message_id="unsub-message", subscription_id="sub-1").to_wire() + self.assertEqual(unsubscribe["schemaVersion"], "cultnet.database_unsubscribe.v0") + self.assertEqual(unsubscribe["messageId"], "unsub-message") + self.assertEqual(unsubscribe["subscriptionId"], "sub-1") + + def test_cultnet_database_change_parses_put_and_delete_shapes(self) -> None: + put_change = CultNetDatabaseChange.from_wire({ + "schemaVersion": "cultnet.database_change_raw.v0", + "messageId": "change-put", + "subscriptionId": "sub-1", + "changeKind": "added", + "document": { + "schemaId": "schema-note", + "recordKey": "note:1", + "payload": b"payload", + }, + }) + self.assertEqual(put_change.schema_id, "schema-note") + self.assertEqual(put_change.record_key, "note:1") + self.assertIsInstance(put_change.raw_document, CultNetRawDocumentRecord) + self.assertEqual(put_change.raw_document.payload, b"payload") + self.assertEqual(put_change.to_wire()["document"]["recordKey"], "note:1") + + delete_change = CultNetDatabaseChange.from_wire({ + "schemaVersion": "cultnet.database_change_raw.v0", + "messageId": "change-delete", + "subscriptionId": "sub-1", + "changeKind": "removed", + "schemaId": "schema-note", + "recordKey": "note:1", + }) + self.assertIsNone(delete_change.document) + self.assertIsNone(delete_change.raw_document) + self.assertEqual(delete_change.schema_id, "schema-note") + self.assertEqual(delete_change.to_wire()["recordKey"], "note:1") + + def test_cultnet_raw_put_helper_carries_message_id(self) -> None: + put = document_put_raw( + message_id="put-1", + key="record-a", + schema_id="schema-a", + stored_at="2026-06-13T00:00:00Z", + payload=b"payload", + source_runtime_id="python-test", + shard_id="interop", + shard_epoch=1, + ).to_wire() + self.assertEqual(put["schemaVersion"], "cultnet.document_put_raw.v0") + self.assertEqual(put["messageId"], "put-1") + self.assertEqual(put["document"]["recordKey"], "record-a") + self.assertEqual(put["document"]["sourceRuntimeId"], "python-test") + self.assertEqual(put["shardId"], "interop") + self.assertEqual(put["shardEpoch"], 1) + + def test_cultnet_catalog_and_snapshot_helpers_accept_filters(self) -> None: + catalog = schema_catalog_request( + message_id="catalog-1", + include_schema_json=True, + schema_ids=["schema-a"], + kinds=["wire_message"], + ).to_wire() + self.assertEqual(catalog["schemaVersion"], "cultnet.schema_catalog_request.v0") + self.assertEqual(catalog["messageId"], "catalog-1") + self.assertTrue(catalog["includeSchemaJson"]) + self.assertEqual(catalog["schemaIds"], ["schema-a"]) + self.assertEqual(catalog["kinds"], ["wire_message"]) + + snapshot = snapshot_request( + message_id="snapshot-1", + schema_ids=["schema-a"], + record_keys=["record-a"], + shard_id="interop", + shard_epoch=1, + ).to_wire() + self.assertEqual(snapshot["schemaVersion"], "cultnet.snapshot_request.v0") + self.assertEqual(snapshot["messageId"], "snapshot-1") + self.assertEqual(snapshot["schemaIds"], ["schema-a"]) + self.assertEqual(snapshot["recordKeys"], ["record-a"]) + self.assertEqual(snapshot["shardId"], "interop") + self.assertEqual(snapshot["shardEpoch"], 1) + + def test_cultnet_raw_snapshot_response_filters_and_roundtrips_records(self) -> None: + response = CultNetRawSnapshotResponse.from_wire({ + "schemaVersion": "cultnet.snapshot_response_raw.v0", + "messageId": "snapshot-1", + "shardId": "notes", + "shardEpoch": 3, + "shardLogSequence": 8, + "documents": [ + CultNetRawDocumentRecord( + schema_id="schema-note", + record_key="note:1", + stored_at="2026-06-14T00:00:00Z", + payload=b"note", + source_runtime_id="python-test", + tags=("snapshot",), + ).to_wire(), + { + "schemaId": "schema-fact", + "recordKey": "fact:1", + "payloadEncoding": "messagepack", + "payload": b"fact", + }, + ], + }) + self.assertEqual(response.shard_id, "notes") + self.assertEqual(response.shard_epoch, 3) + self.assertEqual(response.shard_log_sequence, 8) + self.assertEqual(response.documents[0].source_runtime_id, "python-test") + self.assertEqual(response.filter(schema_ids=["schema-note"])[0].record_key, "note:1") + self.assertEqual(response.filter(record_keys=["fact:1"])[0].schema_id, "schema-fact") + self.assertEqual(response.to_wire()["documents"][0]["tags"], ["snapshot"]) + + def test_cultnet_raw_client_fetches_schema_snapshot_and_shard_reads(self) -> None: + import msgpack # type: ignore + from cultnet_py import read_frame, write_frame + + received_versions: list[str] = [] + ready = threading.Event() + server_error: list[BaseException] = [] + port_holder: list[int] = [] + + def serve_requests() -> None: + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("127.0.0.1", 0)) + port_holder.append(server.getsockname()[1]) + server.listen(4) + ready.set() + for _ in range(4): + connection, _ = server.accept() + with connection: + stream = connection.makefile("rwb") + request = msgpack.unpackb(read_frame(stream), raw=False) + received_versions.append(request["schemaVersion"]) + if request["schemaVersion"] == "cultnet.schema_catalog_request.v0": + self.assertEqual(request["kinds"], ["wire_message"]) + response = {"schemaVersion": "cultnet.schema_catalog_response.v0", "messageId": request["messageId"], "schemas": []} + elif request["schemaVersion"] == "cultnet.snapshot_request.v0": + self.assertEqual(request["shardId"], "interop") + response = {"schemaVersion": "cultnet.snapshot_response_raw.v0", "messageId": request["messageId"], "documents": []} + elif request["schemaVersion"] == "cultnet.shard_catalog_request.v0": + response = { + "schemaVersion": "cultnet.shard_catalog_response.v0", + "messageId": request["messageId"], + "shards": [{ + "shardId": "interop", + "ownerRuntimeId": "socket-test", + "epoch": 1, + "schemaIds": request["schemaIds"], + "keyPrefix": "record-", + "primaryEndpoints": ["cultnet://127.0.0.1:1"], + }], + } + elif request["schemaVersion"] == "cultnet.shard_log_request.v0": + self.assertEqual(request["afterSequence"], 7) + response = { + "schemaVersion": "cultnet.shard_log_response.v0", + "messageId": request["messageId"], + "shardId": request["shardId"], + "shardEpoch": request["shardEpoch"], + "entries": [], + "resyncRequired": False, + } + else: + raise AssertionError(f"unexpected request {request['schemaVersion']}") + write_frame(stream, msgpack.packb(response, use_bin_type=True)) + stream.flush() + except BaseException as error: + server_error.append(error) + ready.set() + + thread = threading.Thread(target=serve_requests, daemon=True) + thread.start() + self.assertTrue(ready.wait(2.0)) + self.assertFalse(server_error) + + client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0) + self.assertEqual(client.fetch_schema_catalog(kinds=["wire_message"])["schemaVersion"], "cultnet.schema_catalog_response.v0") + typed_snapshot = client.fetch_snapshot_response(shard_id="interop", shard_epoch=1) + self.assertEqual(typed_snapshot.shard_id, None) + self.assertEqual(typed_snapshot.documents, ()) + shard_catalog = CultNetShardCatalog() + synced_shards = client.sync_shard_catalog(shard_catalog, schema_ids=["schema-a"]) + self.assertEqual(synced_shards[0].shard_id, "interop") + self.assertEqual(shard_catalog.get("interop"), synced_shards[0]) + shard_log = client.fetch_shard_log_response(shard_id="interop", shard_epoch=1, after_sequence=7) + self.assertEqual(shard_log.shard_id, "interop") + self.assertFalse(shard_log.resync_required) + self.assertEqual(shard_log.last_sequence, 0) + + thread.join(2.0) + self.assertFalse(server_error) + self.assertEqual(received_versions, [ + "cultnet.schema_catalog_request.v0", + "cultnet.snapshot_request.v0", + "cultnet.shard_catalog_request.v0", + "cultnet.shard_log_request.v0", + ]) + + def test_cultnet_raw_client_raises_typed_peer_error_response(self) -> None: + import msgpack # type: ignore + from cultnet_py import read_frame, write_frame + + ready = threading.Event() + port_holder: list[int] = [] + + def serve_error() -> None: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("127.0.0.1", 0)) + port_holder.append(server.getsockname()[1]) + server.listen(1) + ready.set() + connection, _ = server.accept() + with connection: + stream = connection.makefile("rwb") + read_frame(stream) + write_frame(stream, msgpack.packb({ + "schemaVersion": "cultnet.error.v0", + "messageId": "error-1", + "error": "Snapshot document limit exceeded", + }, use_bin_type=True)) + stream.flush() + + thread = threading.Thread(target=serve_error, daemon=True) + thread.start() + self.assertTrue(ready.wait(2.0)) + + client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0) + with self.assertRaisesRegex(CultNetPeerError, "Snapshot document limit exceeded") as raised: + client.fetch_snapshot() + + self.assertEqual(raised.exception.peer_error, "Snapshot document limit exceeded") + self.assertEqual(raised.exception.response["schemaVersion"], "cultnet.error.v0") + thread.join(2.0) + + def test_cultnet_database_subscription_reads_snapshot_and_change(self) -> None: + import msgpack # type: ignore + from cultnet_py import read_frame, write_frame + + document = define_database_entry_type( + "sub.item", + [ + ("name", 0), + ("category", 1), + ("value", 2, 0), + ], + cls=Item, + ) + schema_id = document.catalog_entry().schema_id + ready = threading.Event() + server_error: list[BaseException] = [] + port_holder: list[int] = [] + received_versions: list[str] = [] + + def serve_subscription() -> None: + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("127.0.0.1", 0)) + port_holder.append(server.getsockname()[1]) + server.listen(1) + ready.set() + connection, _ = server.accept() + with connection: + stream = connection.makefile("rwb") + subscribe = msgpack.unpackb(read_frame(stream), raw=False) + received_versions.append(subscribe["schemaVersion"]) + self.assertEqual(subscribe["subscriptionId"], "sub-1") + snapshot = { + "schemaVersion": "cultnet.snapshot_response_raw.v0", + "messageId": subscribe["messageId"], + "documents": [], + } + write_frame(stream, msgpack.packb(snapshot, use_bin_type=True)) + stream.flush() + + put = msgpack.unpackb(read_frame(stream), raw=False) + received_versions.append(put["schemaVersion"]) + change = { + "schemaVersion": "cultnet.database_change_raw.v0", + "messageId": "change-1", + "subscriptionId": "sub-1", + "changeKind": "added", + "document": put["document"], + } + write_frame(stream, msgpack.packb(change, use_bin_type=True)) + stream.flush() + + unsubscribe = msgpack.unpackb(read_frame(stream), raw=False) + received_versions.append(unsubscribe["schemaVersion"]) + except BaseException as error: + server_error.append(error) + ready.set() + + thread = threading.Thread(target=serve_subscription, daemon=True) + thread.start() + self.assertTrue(ready.wait(2.0)) + self.assertFalse(server_error) + + client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0) + put = document_put_raw( + message_id="put-sub", + key="item:sub", + schema_id=schema_id, + stored_at="2026-06-14T00:00:00Z", + payload=document.encode_payload(Item("orb", "gear", 8)), + ) + with client.subscribe_database(subscription_id="sub-1", schema_ids=[schema_id]) as subscription: + snapshot = subscription.read_next_snapshot_response() + subscription.send(put) + change = subscription.read_next_change() + + thread.join(2.0) + self.assertFalse(server_error) + self.assertIsInstance(snapshot, CultNetRawSnapshotResponse) + self.assertEqual(snapshot.message_id, "cultnet-python-subscribe") + self.assertEqual(snapshot.to_wire()["schemaVersion"], "cultnet.snapshot_response_raw.v0") + self.assertEqual(change.change_kind, "added") + self.assertEqual(change.record_key, "item:sub") + self.assertIsInstance(change.raw_document, CultNetRawDocumentRecord) + self.assertEqual(change.raw_document.record_key, "item:sub") + self.assertEqual(received_versions, [ + "cultnet.database_subscribe.v0", + "cultnet.document_put_raw.v0", + "cultnet.database_unsubscribe.v0", + ]) + + def test_cultnet_database_subscription_raises_typed_peer_error(self) -> None: + import msgpack # type: ignore + from cultnet_py import read_frame, write_frame + + ready = threading.Event() + port_holder: list[int] = [] + + def serve_subscription_error() -> None: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("127.0.0.1", 0)) + port_holder.append(server.getsockname()[1]) + server.listen(1) + ready.set() + connection, _ = server.accept() + with connection: + stream = connection.makefile("rwb") + read_frame(stream) + write_frame(stream, msgpack.packb({ + "schemaVersion": "cultnet.error.v0", + "messageId": "subscription-error", + "error": "subscription rejected", + }, use_bin_type=True)) + stream.flush() + + thread = threading.Thread(target=serve_subscription_error, daemon=True) + thread.start() + self.assertTrue(ready.wait(2.0)) + + client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0) + with client.subscribe_database(subscription_id="sub-error") as subscription: + with self.assertRaisesRegex(CultNetPeerError, "subscription rejected") as raised: + subscription.read_next() + self.assertEqual(raised.exception.response["messageId"], "subscription-error") + thread.join(2.0) + + def test_cultnet_replication_helpers_apply_raw_snapshot_and_shard_log(self) -> None: + document = define_database_entry_type( + "replica.item", + [ + ("name", 0), + ("category", 1), + ("value", 2, 0), + ], + cls=Item, + ) + cache = CultCache() + cache.register_document_type(document) + schema_id = document.catalog_entry().schema_id + + snapshot = { + "schemaVersion": "cultnet.snapshot_response_raw.v0", + "messageId": "snapshot-1", + "documents": [ + { + "schemaId": schema_id, + "recordKey": "item:1", + "storedAt": "2026-06-13T00:00:00Z", + "payloadEncoding": "messagepack", + "payload": document.encode_payload(Item("sword", "gear", 3)), + } + ], + } + applied_snapshot = apply_raw_snapshot(cache, [document], snapshot) + self.assertEqual(applied_snapshot[0].record_key, "item:1") + self.assertEqual(cache.get_required(document, "item:1").value, 3) + typed_snapshot = CultNetRawSnapshotResponse.from_wire(snapshot) + direct_applied = apply_raw_document_record(cache, schema_document_map([document]), typed_snapshot.documents[0]) + self.assertEqual(direct_applied.schema_id, schema_id) + self.assertEqual(direct_applied.record_key, "item:1") + typed_snapshot_applied = apply_raw_snapshot(cache, [document], typed_snapshot) + self.assertEqual(typed_snapshot_applied[0].record_key, "item:1") + + shard_log = { + "schemaVersion": "cultnet.shard_log_response.v0", + "messageId": "log-1", + "shardId": "interop", + "shardEpoch": 1, + "resyncRequired": False, + "entries": [ + { + "sequence": 0, + "changeKind": "updated", + "put": { + "schemaVersion": "cultnet.document_put_raw.v0", + "messageId": "put-unknown", + "document": { + "schemaId": "replica.unknown.v1", + "recordKey": "unknown:1", + "storedAt": "2026-06-13T00:00:01Z", + "payloadEncoding": "messagepack", + "payload": document.encode_payload(Item("ignored", "unknown", 0)), + }, + "shardId": "interop", + "shardEpoch": 1, + }, + }, + { + "sequence": 1, + "changeKind": "updated", + "put": { + "schemaVersion": "cultnet.document_put_raw.v0", + "messageId": "put-1", + "document": { + "schemaId": schema_id, + "recordKey": "item:1", + "storedAt": "2026-06-13T00:00:01Z", + "payloadEncoding": "messagepack", + "payload": document.encode_payload(Item("sword", "gear", 12)), + }, + "shardId": "interop", + "shardEpoch": 1, + }, + }, + { + "sequence": 2, + "changeKind": "removed", + "delete": { + "schemaVersion": "cultnet.document_delete.v0", + "messageId": "delete-1", + "schemaId": schema_id, + "recordKey": "item:1", + "shardId": "interop", + "shardEpoch": 1, + }, + }, + ], + } + applied_log = apply_shard_log_response(cache, [document], shard_log) + self.assertEqual([change.change_kind for change in applied_log], ["updated", "removed"]) + self.assertIsNone(cache.get(document, "item:1")) + + cache.put(document, "item:1", Item("sword", "gear", 3)) + typed_shard_log = CultNetShardLogResponse.from_wire({ + **shard_log, + "entries": shard_log["entries"][1:], + }) + typed_applied_log = apply_shard_log_response(cache, [document], typed_shard_log) + self.assertEqual([change.change_kind for change in typed_applied_log], ["updated", "removed"]) + self.assertIsNone(cache.get(document, "item:1")) + + def test_cultnet_replication_helpers_recover_schema_stamped_raw_records(self) -> None: + document = define_database_entry_type( + "replica.runtime-policy", + [ + ("schema_version", 0), + ("name", 1), + ("value", 2), + ], + schema_id="replica.runtime-policy.current", + schema_name="replica.runtime_policy", + schema_version="replica.runtime_policy.v1", + ) + cache = CultCache() + cache.register_document_type(document) + stale_schema_id = "sha256:stale-replica-runtime-policy" + payload = document.encode_payload({ + "schema_version": "replica.runtime_policy.v1", + "name": "policy", + "value": "still synced", + }) + record = { + "schemaId": stale_schema_id, + "recordKey": "policy:1", + "storedAt": "2026-06-25T00:00:00Z", + "payloadEncoding": "messagepack", + "payload": payload, + } + snapshot = { + "schemaVersion": "cultnet.snapshot_response_raw.v0", + "messageId": "snapshot-stale", + "documents": [record], + } + + applied_snapshot = apply_raw_snapshot(cache, [document], snapshot) + local_schema_id = document.catalog_entry().schema_id + self.assertEqual(applied_snapshot[0].schema_id, local_schema_id) + self.assertEqual(cache.get_required(document, "policy:1")["value"], "still synced") + self.assertEqual(cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id) + direct_applied = apply_raw_document_record(cache, schema_document_map([document]), record) + self.assertEqual(direct_applied.schema_id, local_schema_id) + self.assertEqual(cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id) + + shard_log = { + "schemaVersion": "cultnet.shard_log_response.v0", + "messageId": "log-stale", + "shardId": "interop", + "shardEpoch": 1, + "resyncRequired": False, + "entries": [ + { + "sequence": 0, + "changeKind": "updated", + "put": { + "schemaVersion": "cultnet.document_put_raw.v0", + "messageId": "put-stale", + "document": { + **record, + "payload": document.encode_payload({ + "schema_version": "replica.runtime_policy.v1", + "name": "policy", + "value": "updated through log", + }), + }, + "shardId": "interop", + "shardEpoch": 1, + }, + } + ], + } + + applied_log = apply_shard_log_response(cache, [document], shard_log) + self.assertEqual(applied_log[0].schema_id, local_schema_id) + self.assertEqual(cache.get_required(document, "policy:1")["value"], "updated through log") + self.assertEqual(cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id) + + delete_log = { + "schemaVersion": "cultnet.shard_log_response.v0", + "messageId": "log-delete-alias", + "shardId": "interop", + "shardEpoch": 1, + "resyncRequired": False, + "entries": [ + { + "sequence": 1, + "changeKind": "removed", + "delete": { + "schemaVersion": "cultnet.document_delete.v0", + "messageId": "delete-version-alias", + "schemaId": "replica.runtime_policy.v1", + "recordKey": "policy:1", + "shardId": "interop", + "shardEpoch": 1, + }, + } + ], + } + applied_delete = apply_shard_log_response(cache, [document], delete_log) + self.assertEqual(applied_delete[0].schema_id, local_schema_id) + self.assertEqual(applied_delete[0].change_kind, "removed") + self.assertIsNone(cache.get(document, "policy:1")) + + def test_cultmesh_node_applies_foreign_schema_snapshot_as_local_update(self) -> None: + document = define_database_entry_type( + "mesh.runtime-policy", + [ + ("schema_version", 0), + ("name", 1), + ("value", 2), + ], + schema_id="mesh.runtime-policy.current", + schema_name="mesh.runtime_policy", + schema_version="mesh.runtime_policy.v1", + ) + node = create_node(runtime_id="python-node") + node.register_document(document) + node.put(document, "policy:1", { + "schema_version": "mesh.runtime_policy.v1", + "name": "policy", + "value": "old", + }) + observed: list[CultMeshDatabaseChange] = [] + unsubscribe = node.database.watch_record(document, "policy:1", observed.append) + try: + applied = node.database.apply_snapshot_response({ + "schemaVersion": "cultnet.snapshot_response_raw.v0", + "messageId": "foreign-schema-snapshot", + "documents": [ + { + "schemaId": "sha256:foreign-mesh-runtime-policy", + "recordKey": "policy:1", + "storedAt": "2026-06-25T00:00:00Z", + "payloadEncoding": "messagepack", + "payload": document.encode_payload({ + "schema_version": "mesh.runtime_policy.v1", + "name": "policy", + "value": "new", + }), + }, + ], + }) + finally: + unsubscribe() + + local_schema_id = document.catalog_entry().schema_id + self.assertEqual(applied[0].schema_id, local_schema_id) + self.assertEqual(observed[0].schema_id, local_schema_id) + self.assertEqual(observed[0].change_kind, "updated") + self.assertEqual(observed[0].previous_value["value"], "old") + self.assertEqual(observed[0].value["value"], "new") + self.assertEqual(node.cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id) + + def test_cultmesh_node_syncs_snapshot_and_shard_log_through_cultnet_client(self) -> None: + import msgpack # type: ignore + from cultnet_py import read_frame, write_frame + + document = define_database_entry_type( + "mesh.sync_item", + [ + ("name", 0), + ("category", 1), + ("value", 2, 0), + ], + cls=Item, + ) + node = create_node(runtime_id="python-node") + node.register_document(document) + schema_id = document.catalog_entry().schema_id + ready = threading.Event() + server_error: list[BaseException] = [] + port_holder: list[int] = [] + + def serve_requests() -> None: + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("127.0.0.1", 0)) + port_holder.append(server.getsockname()[1]) + server.listen(2) + ready.set() + for _ in range(2): + connection, _ = server.accept() + with connection: + stream = connection.makefile("rwb") + request = msgpack.unpackb(read_frame(stream), raw=False) + if request["schemaVersion"] == "cultnet.snapshot_request.v0": + response = { + "schemaVersion": "cultnet.snapshot_response_raw.v0", + "messageId": request["messageId"], + "documents": [ + { + "schemaId": schema_id, + "recordKey": "item:node", + "storedAt": "2026-06-13T00:00:00Z", + "payloadEncoding": "messagepack", + "payload": document.encode_payload(Item("node", "mesh", 1)), + } + ], + } + elif request["schemaVersion"] == "cultnet.shard_log_request.v0": + response = { + "schemaVersion": "cultnet.shard_log_response.v0", + "messageId": request["messageId"], + "shardId": request["shardId"], + "shardEpoch": request["shardEpoch"], + "resyncRequired": False, + "entries": [ + { + "sequence": 1, + "changeKind": "updated", + "put": { + "schemaVersion": "cultnet.document_put_raw.v0", + "messageId": "put-node", + "document": { + "schemaId": schema_id, + "recordKey": "item:node", + "storedAt": "2026-06-13T00:00:01Z", + "payloadEncoding": "messagepack", + "payload": document.encode_payload(Item("node", "mesh", 9)), + }, + "shardId": request["shardId"], + "shardEpoch": request["shardEpoch"], + }, + } + ], + } + else: + raise AssertionError(f"unexpected request {request['schemaVersion']}") + write_frame(stream, msgpack.packb(response, use_bin_type=True)) + stream.flush() + except BaseException as error: + server_error.append(error) + ready.set() + + thread = threading.Thread(target=serve_requests, daemon=True) + thread.start() + self.assertTrue(ready.wait(2.0)) + self.assertFalse(server_error) + + client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0) + snapshot_changes = node.sync_snapshot(client, schema_ids=[schema_id]) + log_changes = node.sync_shard_log(client, shard_id="mesh", shard_epoch=1) + + thread.join(2.0) + self.assertFalse(server_error) + self.assertEqual(snapshot_changes[0].record_key, "item:node") + self.assertEqual(log_changes[0].change_kind, "updated") + self.assertEqual(node.get_required(document, "item:node").value, 9) + + def test_cultmesh_node_sync_document_returns_requested_alias_type(self) -> None: + @dataclass + class CanonicalNote: + body: str + + @dataclass + class UiNote: + body: str + + document = define_database_entry_type( + "mesh.sync_alias_note", + [("body", 0)], + cls=CanonicalNote, + schema_id="mesh.sync_alias_note.v1", + schema_name="mesh.sync_alias_note", + schema_version="mesh.sync_alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.sync_alias_note.ui", + [("body", 0)], + cls=UiNote, + schema_id="mesh.sync_alias_note.v1", + schema_name="mesh.sync_alias_note", + schema_version="mesh.sync_alias_note.v1", + ) + node = create_node(runtime_id="python-node") + node.register_document(document) + requests: list[dict[str, Any]] = [] + + class SnapshotClient: + def fetch_snapshot_response( + self, + *, + schema_ids: list[str] | None = None, + record_keys: list[str] | None = None, + shard_id: str | None = None, + shard_epoch: int | None = None, + ) -> dict[str, Any]: + requests.append({ + "schema_ids": schema_ids, + "record_keys": record_keys, + "shard_id": shard_id, + "shard_epoch": shard_epoch, + }) + return { + "schemaVersion": "cultnet.snapshot_response_raw.v0", + "messageId": "sync-document", + "documents": [ + { + "schemaId": document.catalog_entry().schema_id, + "recordKey": "note:remote", + "storedAt": "2026-06-27T00:00:00Z", + "payloadEncoding": "messagepack", + "payload": document.encode_payload(CanonicalNote("synced-alias")), + } + ], + } + + synced = node.sync_document(SnapshotClient(), alias, "note:remote") + facade_synced = CultMesh.sync_document(node, SnapshotClient(), alias, "note:remote") + + self.assertIsInstance(synced, UiNote) + self.assertEqual(synced.body, "synced-alias") + self.assertIsInstance(facade_synced, UiNote) + self.assertEqual(facade_synced.body, "synced-alias") + self.assertEqual(node.get_required(document, "note:remote").body, "synced-alias") + self.assertIsInstance(node.get_required(alias, "note:remote"), UiNote) + self.assertEqual(requests, [ + { + "schema_ids": ["mesh.sync_alias_note.v1"], + "record_keys": ["note:remote"], + "shard_id": None, + "shard_epoch": None, + }, + { + "schema_ids": ["mesh.sync_alias_note.v1"], + "record_keys": ["note:remote"], + "shard_id": None, + "shard_epoch": None, + }, + ]) + + def test_cultmesh_node_sync_document_from_publication_returns_requested_alias_type(self) -> None: + @dataclass + class CanonicalNote: + body: str + + @dataclass + class UiNote: + body: str + + document = define_database_entry_type( + "mesh.publication_alias_note", + [("body", 0)], + cls=CanonicalNote, + schema_id="mesh.publication_alias_note.v1", + schema_name="mesh.publication_alias_note", + schema_version="mesh.publication_alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.publication_alias_note.ui", + [("body", 0)], + cls=UiNote, + schema_id="mesh.publication_alias_note.v1", + schema_name="mesh.publication_alias_note", + schema_version="mesh.publication_alias_note.v1", + ) + node = create_node(runtime_id="python-publication-node") + node.register_document(document) + requests: list[dict[str, Any]] = [] + + class SnapshotClient: + def fetch_snapshot_response( + self, + *, + schema_ids: list[str] | None = None, + record_keys: list[str] | None = None, + shard_id: str | None = None, + shard_epoch: int | None = None, + ) -> dict[str, Any]: + requests.append({ + "schema_ids": schema_ids, + "record_keys": record_keys, + "shard_id": shard_id, + "shard_epoch": shard_epoch, + }) + return { + "schemaVersion": "cultnet.snapshot_response_raw.v0", + "messageId": "publication-sync-document", + "documents": [ + { + "schemaId": document.catalog_entry().schema_id, + "recordKey": "note:published-peer", + "storedAt": "2026-06-27T00:00:00Z", + "payloadEncoding": "messagepack", + "payload": document.encode_payload(CanonicalNote("peer-publication-alias")), + } + ], + } + + source = CultMesh.publication_source_from_peer_snapshot( + SnapshotClient(), + shard_id="notes", + shard_epoch=3, + ) + + synced = node.sync_document_from_publication(source, alias, "note:published-peer") + facade_synced = CultMesh.sync_document_from_publication(node, source, alias, "note:published-peer") + + self.assertIsInstance(synced, UiNote) + self.assertEqual(synced.body, "peer-publication-alias") + self.assertIsInstance(facade_synced, UiNote) + self.assertEqual(facade_synced.body, "peer-publication-alias") + self.assertEqual(node.get_required(document, "note:published-peer").body, "peer-publication-alias") + self.assertEqual(requests, [ + { + "schema_ids": ["mesh.publication_alias_note.v1"], + "record_keys": ["note:published-peer"], + "shard_id": "notes", + "shard_epoch": 3, + }, + { + "schema_ids": ["mesh.publication_alias_note.v1"], + "record_keys": ["note:published-peer"], + "shard_id": "notes", + "shard_epoch": 3, + }, + ]) + + def test_cultmesh_node_sync_document_from_single_file_publication_uses_node_aliases(self) -> None: + @dataclass + class CanonicalNote: + body: str + + @dataclass + class UiNote: + body: str + + document = define_database_entry_type( + "mesh.file_publication_alias_note", + [("body", 0)], + cls=CanonicalNote, + schema_id="mesh.file_publication_alias_note.v1", + schema_name="mesh.file_publication_alias_note", + schema_version="mesh.file_publication_alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.file_publication_alias_note.ui", + [("body", 0)], + cls=UiNote, + schema_id="mesh.file_publication_alias_note.v1", + schema_name="mesh.file_publication_alias_note", + schema_version="mesh.file_publication_alias_note.v1", + ) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "publication.ccmp" + writer = CultCache() + writer.register_document_type(document) + writer.add_generic_store(SingleFileMessagePackBackingStore(path)) + writer.put(document, "note:published-file", CanonicalNote("file-publication-alias")) + + node = create_node(runtime_id="python-file-publication-node") + node.register_document(document) + source = CultMesh.publication_source_from_single_file(path) + + synced = CultMesh.sync_document_from_publication(node, source, alias, "note:published-file") + + self.assertIsInstance(synced, UiNote) + self.assertEqual(synced.body, "file-publication-alias") + self.assertEqual(node.get_required(document, "note:published-file").body, "file-publication-alias") + self.assertIsInstance(node.get_required(alias, "note:published-file"), UiNote) + + def test_cultmesh_node_sync_documents_from_publication_uses_binding_sources_and_aliases(self) -> None: + @dataclass + class CanonicalNote: + body: str + + @dataclass + class UiNote: + body: str + + document = define_database_entry_type( + "mesh.catalog_publication_alias_note", + [("body", 0)], + cls=CanonicalNote, + schema_id="mesh.catalog_publication_alias_note.v1", + schema_name="mesh.catalog_publication_alias_note", + schema_version="mesh.catalog_publication_alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.catalog_publication_alias_note.ui", + [("body", 0)], + cls=UiNote, + schema_id="mesh.catalog_publication_alias_note.v1", + schema_name="mesh.catalog_publication_alias_note", + schema_version="mesh.catalog_publication_alias_note.v1", + ) + with tempfile.TemporaryDirectory() as tmp: + first_path = Path(tmp) / "first.ccmp" + second_path = Path(tmp) / "second.ccmp" + first = CultCache() + first.register_document_type(document) + first.add_generic_store(SingleFileMessagePackBackingStore(first_path)) + first.put(document, "note:first", CanonicalNote("first catalog publication")) + second = CultCache() + second.register_document_type(document) + second.add_generic_store(SingleFileMessagePackBackingStore(second_path)) + second.put(document, "note:second", CanonicalNote("second catalog publication")) + + node = create_node(runtime_id="python-catalog-publication-node") + node.register_document(document) + source = CultMesh.publication_source_from_single_file(first_path) + bindings = [ + CultMesh.publication_document(alias, "note:first"), + CultMesh.publication_document( + alias, + "note:second", + source=CultMesh.publication_source_from_single_file(second_path), + ), + ] + + synced = node.sync_documents_from_publication(source, bindings) + facade_synced = CultMesh.sync_documents_from_publication(node, source, bindings) + + self.assertEqual([value.body for value in synced], [ + "first catalog publication", + "second catalog publication", + ]) + self.assertTrue(all(isinstance(value, UiNote) for value in synced)) + self.assertEqual([value.body for value in facade_synced], [ + "first catalog publication", + "second catalog publication", + ]) + self.assertEqual(node.get_required(document, "note:first").body, "first catalog publication") + self.assertEqual(node.get_required(document, "note:second").body, "second catalog publication") + self.assertIsInstance(node.get_required(alias, "note:second"), UiNote) + + def test_cultmesh_node_emits_raw_put_and_delete_messages_for_local_writes(self) -> None: + document = define_database_entry_type( + "mesh.emit_item", + [ + ("name", 0), + ("category", 1), + ("value", 2, 0), + ], + cls=Item, + ) + node = create_node(runtime_id="python-node") + node.register_document(document) + + put = node.put_raw_message( + document, + "item:emit", + Item("wand", "gear", 4), + message_id="put-emit", + shard_id="mesh", + shard_epoch=1, + ).to_wire() + self.assertEqual(node.get_required(document, "item:emit").name, "wand") + self.assertEqual(put["schemaVersion"], "cultnet.document_put_raw.v0") + self.assertEqual(put["messageId"], "put-emit") + self.assertEqual(put["document"]["schemaId"], document.catalog_entry().schema_id) + self.assertEqual(put["document"]["recordKey"], "item:emit") + self.assertEqual(put["document"]["sourceRuntimeId"], "python-node") + self.assertEqual(put["shardId"], "mesh") + self.assertEqual(put["shardEpoch"], 1) + + delete = node.delete_raw_message( + document, + "item:emit", + message_id="delete-emit", + shard_id="mesh", + shard_epoch=1, + ).to_wire() + self.assertIsNone(node.get(document, "item:emit")) + self.assertEqual(delete["schemaVersion"], "cultnet.document_delete.v0") + self.assertEqual(delete["messageId"], "delete-emit") + self.assertEqual(delete["schemaId"], document.catalog_entry().schema_id) + self.assertEqual(delete["recordKey"], "item:emit") + self.assertEqual(delete["shardId"], "mesh") + self.assertEqual(delete["shardEpoch"], 1) + + def test_cultmesh_node_resolves_same_schema_aliases_for_local_state(self) -> None: + @dataclass + class CanonicalNote: + body: str + + @dataclass + class UiNote: + body: str + + document = define_database_entry_type( + "mesh.alias_note", + [("body", 0)], + cls=CanonicalNote, + schema_id="mesh.alias_note.v1", + schema_name="mesh.alias_note", + schema_version="mesh.alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.alias_note.ui", + [("body", 0)], + cls=UiNote, + schema_id="mesh.alias_note.v1", + schema_name="mesh.alias_note", + schema_version="mesh.alias_note.v1", + ) + node = create_node(runtime_id="python-node") + node.register_document(document) + seen: list[CultMeshDatabaseChange] = [] + all_seen: list[CultMeshDatabaseChange] = [] + node.database.watch(all_seen.append, document=alias) + node.database.watch_record(alias, "note:alias", seen.append) + + node.put(alias, "note:alias", UiNote("from-alias-put")) + self.assertEqual(node.get_required(document, "note:alias").body, "from-alias-put") + alias_value = node.get_required(alias, "note:alias") + self.assertIsInstance(alias_value, UiNote) + self.assertEqual(alias_value.body, "from-alias-put") + alias_collection = node.get_all(alias) + self.assertEqual(len(alias_collection), 1) + self.assertIsInstance(alias_collection[0], UiNote) + self.assertEqual(alias_collection[0].body, "from-alias-put") + self.assertEqual(seen[0].document.type, alias.type) + self.assertEqual(seen[0].schema_id, "mesh.alias_note.v1") + self.assertIsInstance(seen[0].value, UiNote) + self.assertEqual(seen[0].value.body, "from-alias-put") + self.assertEqual(all_seen[0].document.type, alias.type) + self.assertIsInstance(all_seen[0].value, UiNote) + self.assertEqual(all_seen[0].value.body, "from-alias-put") + + node.put(document, "note:alias", CanonicalNote("from-canonical-put")) + self.assertEqual(seen[1].document.type, alias.type) + self.assertIsInstance(seen[1].value, UiNote) + self.assertIsInstance(seen[1].previous_value, UiNote) + self.assertEqual(seen[1].value.body, "from-canonical-put") + self.assertEqual(seen[1].previous_value.body, "from-alias-put") + + put = node.put_raw_message( + alias, + "note:alias", + UiNote("from-alias-raw-put"), + message_id="alias-put", + ).to_wire() + self.assertEqual(put["document"]["schemaId"], "mesh.alias_note.v1") + self.assertEqual(put["document"]["recordKey"], "note:alias") + self.assertEqual(node.get_required(alias, "note:alias").body, "from-alias-raw-put") + + delete = node.delete_raw_message(alias, "note:alias", message_id="alias-delete").to_wire() + self.assertEqual(delete["schemaId"], "mesh.alias_note.v1") + self.assertIsNone(node.get(alias, "note:alias")) + self.assertEqual(node.get_all(alias), []) + + def test_cultmesh_reactive_document_coalesces_direct_alias_member_writes(self) -> None: + @dataclass + class CanonicalNote: + body: str + revision: int + + @dataclass + class UiNote: + body: str + revision: int + + document = define_database_entry_type( + "mesh.reactive_alias_note", + [("body", 0), ("revision", 1)], + cls=CanonicalNote, + schema_id="mesh.reactive_alias_note.v1", + schema_name="mesh.reactive_alias_note", + schema_version="mesh.reactive_alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.reactive_alias_note.ui", + [("body", 0), ("revision", 1)], + cls=UiNote, + schema_id="mesh.reactive_alias_note.v1", + schema_name="mesh.reactive_alias_note", + schema_version="mesh.reactive_alias_note.v1", + ) + node = create_node(runtime_id="python-reactive") + node.register_document(document) + node.put(document, "note:reactive", CanonicalNote("initial", 1)) + seen: list[CultMeshDatabaseChange] = [] + node.database.watch_record(alias, "note:reactive", seen.append) + + reactive = node.reactive_document( + alias, + "note:reactive", + CultMeshReactiveDocumentOptions(flush_delay_seconds=0.02), + ) + try: + self.assertIsInstance(reactive.current, UiNote) + reactive.current.body = "first-local-edit" + reactive.current.body = "second-local-edit" + reactive.current.revision = 2 + + self._wait_until(lambda: node.get_required(alias, "note:reactive").body == "second-local-edit") + + self.assertFalse(reactive.is_dirty) + self.assertEqual(node.get_required(document, "note:reactive").revision, 2) + self.assertEqual(seen[-1].value.body, "second-local-edit") + finally: + reactive.dispose() + + def test_cultmesh_reactive_document_tracks_canonical_reconciliation_delta(self) -> None: + @dataclass + class Note: + body: str + revision: int + + document = define_database_entry_type( + "mesh.reactive_reconcile_note", + [("body", 0), ("revision", 1)], + cls=Note, + schema_id="mesh.reactive_reconcile_note.v1", + schema_name="mesh.reactive_reconcile_note", + schema_version="mesh.reactive_reconcile_note.v1", + ) + node = create_node(runtime_id="python-reactive-reconcile") + node.register_document(document) + node.put(document, "note:reconcile", Note("initial", 1)) + + reactive = CultMesh.reactive_document( + node, + document, + "note:reconcile", + CultMeshReactiveDocumentOptions(flush_delay_seconds=60), + ) + try: + reactive.update(lambda note: setattr(note, "body", "local-prediction")) + reactive.current.revision = 2 + + node.put(document, "note:reconcile", Note("authoritative", 7)) + + self.assertEqual(reactive.current.body, "local-prediction") + self.assertIsNotNone(reactive.reconciliation) + self.assertEqual(reactive.reconciliation.canonical.body, "authoritative") + self.assertEqual(reactive.reconciliation.predicted.body, "local-prediction") + self.assertEqual(reactive.reconciliation.delta["body"], "local-prediction") + self.assertEqual(reactive.reconciliation.delta["revision"], -5) + + reactive.flush() + self.assertEqual(node.get_required(document, "note:reconcile").body, "local-prediction") + self.assertIsNone(reactive.reconciliation) + finally: + reactive.dispose() + + def test_cultnet_document_delete_helper_matches_schema_v0_shape(self) -> None: + message = document_delete( + message_id="delete-1", + schema_id="schema-a", + record_key="record-a", + shard_id="interop", + shard_epoch=1, + ).to_wire() + self.assertEqual(message["schemaVersion"], "cultnet.document_delete.v0") + self.assertEqual(message["messageId"], "delete-1") + self.assertEqual(message["schemaId"], "schema-a") + self.assertEqual(message["recordKey"], "record-a") + self.assertEqual(message["shardId"], "interop") + self.assertEqual(message["shardEpoch"], 1) + + def test_cultnet_auth_session_helpers_match_schema_v0_shape(self) -> None: + self.assertEqual( + login(nonce="nonce", auth="encrypted-auth", password="encrypted-password").to_wire(), + { + "schemaVersion": "cultnet.login.v0", + "nonce": "nonce", + "auth": "encrypted-auth", + "password": "encrypted-password", + }, + ) + self.assertEqual( + register( + nonce="nonce", + email="encrypted-email", + password="encrypted-password", + name="encrypted-name", + ).to_wire(), + { + "schemaVersion": "cultnet.register.v0", + "nonce": "nonce", + "email": "encrypted-email", + "password": "encrypted-password", + "name": "encrypted-name", + }, + ) + self.assertEqual( + verify_session(nonce="nonce", session="encrypted-session").to_wire(), + { + "schemaVersion": "cultnet.verify.v0", + "nonce": "nonce", + "session": "encrypted-session", + }, + ) + self.assertEqual( + login_success(nonce="nonce", session="encrypted-session").to_wire(), + { + "schemaVersion": "cultnet.login_success.v0", + "nonce": "nonce", + "session": "encrypted-session", + }, + ) + + def test_cultnet_wire_catalog_describes_python_handled_messages(self) -> None: + descriptors = wire_message_schema_descriptors(include_schema_json=True) + by_version = {descriptor["schemaVersion"]: descriptor for descriptor in descriptors} + for schema_version in [ + "cultnet.error.v0", + "cultnet.login.v0", + "cultnet.register.v0", + "cultnet.verify.v0", + "cultnet.login_success.v0", + "cultnet.transport_profile.v0", + "cultnet.document_delete.v0", + "cultnet.database_change_raw.v0", + "cultnet.shard_log_response.v0", + "cultnet.simulation_consensus_candidate.v0", + "cultmesh.peer_exchange_response.v0", + ]: + self.assertIn(schema_version, by_version) + expected_kind = "shared_contract" if schema_version == "cultnet.transport_profile.v0" else "wire_message" + self.assertEqual(by_version[schema_version]["kind"], expected_kind) + self.assertIn("cultnet.schema.v0", by_version[schema_version]["wireContracts"]) + self.assertIn(schema_version, by_version[schema_version]["schemaJson"]) + self.assertEqual(len(by_version[schema_version]["contentHash"]), 64) + + delete_schema = json.loads(by_version["cultnet.document_delete.v0"]["schemaJson"]) + self.assertEqual( + delete_schema["required"], + ["schemaVersion", "messageId", "schemaId", "recordKey"], + ) + self.assertEqual(delete_schema["properties"]["recordKey"]["type"], "string") + + error_schema = json.loads(by_version["cultnet.error.v0"]["schemaJson"]) + self.assertEqual(error_schema["required"], ["schemaVersion", "error"]) + self.assertEqual(error_schema["properties"]["error"]["type"], "string") + self.assertIn("details", error_schema["properties"]) + + login_schema = json.loads(by_version["cultnet.login.v0"]["schemaJson"]) + self.assertEqual(login_schema["required"], ["schemaVersion", "nonce", "auth", "password"]) + self.assertEqual(login_schema["properties"]["auth"]["type"], "string") + + register_schema = json.loads(by_version["cultnet.register.v0"]["schemaJson"]) + self.assertEqual(register_schema["required"], ["schemaVersion", "nonce", "email", "password", "name"]) + self.assertEqual(register_schema["properties"]["email"]["type"], "string") + + verify_schema = json.loads(by_version["cultnet.verify.v0"]["schemaJson"]) + self.assertEqual(verify_schema["required"], ["schemaVersion", "nonce", "session"]) + self.assertEqual(verify_schema["properties"]["session"]["type"], "string") + + success_schema = json.loads(by_version["cultnet.login_success.v0"]["schemaJson"]) + self.assertEqual(success_schema["required"], ["schemaVersion", "nonce", "session"]) + + transport_profile_schema = json.loads(by_version["cultnet.transport_profile.v0"]["schemaJson"]) + self.assertEqual(transport_profile_schema["required"], ["schemaVersion", "runtimeId", "transports"]) + transport = transport_profile_schema["properties"]["transports"]["items"] + self.assertEqual(transport["properties"]["protocol"]["enum"], ["tcp_framed", "litenetlib", "websocket", "rudp"]) + self.assertEqual( + transport["properties"]["reconnectPolicy"]["properties"]["schemaVersion"]["const"], + "cultnet.reconnect_policy.v0", + ) + channel = transport["properties"]["channels"]["items"] + self.assertEqual(channel["properties"]["ordering"]["enum"], ["ordered", "unordered", "sequenced"]) + + change_schema = json.loads(by_version["cultnet.database_change_raw.v0"]["schemaJson"]) + self.assertEqual(change_schema["properties"]["changeKind"]["enum"], ["added", "updated", "removed"]) + self.assertIn("document", change_schema["properties"]) + self.assertIn("schemaId", change_schema["properties"]) + self.assertIn("recordKey", change_schema["properties"]) + + log_schema = json.loads(by_version["cultnet.shard_log_response.v0"]["schemaJson"]) + self.assertIn("entries", log_schema["required"]) + self.assertIn("resyncRequired", log_schema["required"]) + self.assertIn("compactedThrough", log_schema["properties"]) + + hello_schema = json.loads(by_version["cultnet.hello.v0"]["schemaJson"]) + mutation_contract = hello_schema["properties"]["supportedMutationContracts"]["items"] + self.assertEqual(mutation_contract["type"], "object") + self.assertIn("operations", mutation_contract["properties"]) + self.assertEqual( + hello_schema["properties"]["transportProfiles"]["items"]["$ref"], + "https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json", + ) + + consensus_schema = json.loads(by_version["cultnet.simulation_consensus_candidate.v0"]["schemaJson"]) + for required_field in ["witnessCount", "supportWeight", "totalWeight", "hasQuorum", "confidence"]: + self.assertIn(required_field, consensus_schema["required"]) + + def test_cultnet_schema_catalog_applies_filters_and_responses(self) -> None: + catalog = wire_message_schema_catalog(include_schema_json=True) + descriptor = catalog.get("https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put-raw.schema.json") + self.assertIsNotNone(descriptor) + assert descriptor is not None + self.assertEqual(descriptor.kind, "wire_message") + self.assertEqual(descriptor.schema_version, "cultnet.document_put_raw.v0") + self.assertIsNotNone(descriptor.schema_json) + + filtered = catalog.list(kinds=["wire_message"], schema_ids=[descriptor.schema_id]) + self.assertEqual(filtered, [descriptor]) + + response = catalog.create_response( + message_id="catalog-response", + include_schema_json=False, + schema_ids=[descriptor.schema_id], + kinds=["wire_message"], + ) + self.assertEqual(response["schemaVersion"], "cultnet.schema_catalog_response.v0") + self.assertNotIn("schemaJson", response["schemas"][0]) + + remote = CultNetSchemaCatalog() + applied = remote.apply_response({ + "schemaVersion": "cultnet.schema_catalog_response.v0", + "messageId": "remote", + "schemas": [ + CultNetSchemaDescriptor( + schema_id="schema:custom", + kind="shared_contract", + schema_version="custom.v0", + wire_contracts=("cultnet.schema.v0",), + content_hash="hash", + schema_json="{}", + ).to_wire() + ], + }) + self.assertEqual(applied[0].schema_id, "schema:custom") + self.assertEqual(remote.get("schema:custom"), applied[0]) + + def test_cultnet_shard_helpers_match_schema_v0_shape(self) -> None: + catalog = shard_catalog_request( + message_id="catalog-1", + schema_ids=["schema-a"], + record_keys=["record-a"], + ).to_wire() + self.assertEqual(catalog["schemaVersion"], "cultnet.shard_catalog_request.v0") + self.assertEqual(catalog["messageId"], "catalog-1") + self.assertEqual(catalog["schemaIds"], ["schema-a"]) + self.assertEqual(catalog["recordKeys"], ["record-a"]) + + log = shard_log_request( + message_id="log-1", + shard_id="interop", + shard_epoch=7, + after_sequence=3, + limit=2, + ).to_wire() + self.assertEqual(log["schemaVersion"], "cultnet.shard_log_request.v0") + self.assertEqual(log["messageId"], "log-1") + self.assertEqual(log["shardId"], "interop") + self.assertEqual(log["shardEpoch"], 7) + self.assertEqual(log["afterSequence"], 3) + self.assertEqual(log["limit"], 2) + + def test_cultnet_shard_catalog_applies_filters_and_responses(self) -> None: + catalog = CultNetShardCatalog() + primary = catalog.upsert(CultNetShardDescriptor( + shard_id="notes", + owner_runtime_id="python-runtime", + epoch=2, + is_primary=True, + schema_ids=("schema-note.v1",), + key_prefix="note:", + primary_endpoints=("cultnet://127.0.0.1:3075",), + read_replica_endpoints=("cultnet://127.0.0.1:3075",), + region="local", + )) + catalog.upsert({ + "shardId": "facts", + "ownerRuntimeId": "python-runtime", + "epoch": 1, + "schemaIds": ["schema-fact"], + "keyPrefix": "fact:", + "primaryEndpoints": ["cultnet://127.0.0.1:3076"], + }) + + self.assertTrue(primary.serves(schema_id="schema-note.v1", record_key="note:1")) + self.assertTrue(primary.serves(schema_id="schema-note", record_key="note:1")) + self.assertFalse(primary.serves(schema_id="schema-note", record_key="fact:1")) + self.assertEqual(catalog.list(schema_ids=["schema-note.v1"]), [primary]) + self.assertEqual(catalog.list(schema_ids=["schema-note"]), [primary]) + self.assertEqual(catalog.list(record_keys=["note:1"]), [primary]) + + response = catalog.create_response( + message_id="shards", + schema_ids=["schema-note"], + record_keys=["note:1"], + ) + self.assertEqual(response["schemaVersion"], "cultnet.shard_catalog_response.v0") + self.assertEqual(response["shards"][0]["shardId"], "notes") + self.assertEqual(response["shards"][0]["ownerRuntimeId"], "python-runtime") + + remote = CultNetShardCatalog() + applied = remote.apply_response(response) + self.assertEqual(applied[0].shard_id, "notes") + self.assertEqual(remote.get("notes"), applied[0]) + + def test_cultnet_shard_log_response_tracks_cursor_and_resync_state(self) -> None: + response = CultNetShardLogResponse.from_wire({ + "schemaVersion": "cultnet.shard_log_response.v0", + "messageId": "log", + "shardId": "notes", + "shardEpoch": 2, + "entries": [ + CultNetShardLogEntry( + sequence=2, + change_kind="updated", + put={"schemaVersion": "cultnet.document_put_raw.v0", "messageId": "put", "document": {"schemaId": "schema-note", "recordKey": "note:1", "payload": b"p"}}, + committed_at="2026-06-14T00:00:00Z", + ).to_wire(), + {"sequence": 3, "changeKind": "removed", "delete": {"schemaId": "schema-note", "recordKey": "note:1"}}, + ], + "resyncRequired": False, + }) + self.assertEqual(response.last_sequence, 3) + self.assertFalse(response.resync_required) + self.assertEqual(response.entries[0].put["document"]["recordKey"], "note:1") + self.assertIsInstance(response.entries[0].raw_document, CultNetRawDocumentRecord) + self.assertEqual(response.entries[0].raw_document.record_key, "note:1") + self.assertEqual(response.entries[1].delete_schema_id, "schema-note") + self.assertEqual(response.entries[1].delete_record_key, "note:1") + self.assertEqual(response.to_wire()["entries"][1]["delete"]["recordKey"], "note:1") + self.assertIs(response.require_usable(), response) + + resync = CultNetShardLogResponse.from_wire({ + "schemaVersion": "cultnet.shard_log_response.v0", + "messageId": "log-resync", + "shardId": "notes", + "shardEpoch": 2, + "entries": [], + "resyncRequired": True, + "reason": "compacted", + "compactedThrough": 12, + }) + self.assertEqual(resync.last_sequence, 12) + with self.assertRaisesRegex(ValueError, "compacted"): + resync.require_usable() + + def test_cultnet_simulation_observation_helper_matches_schema_v0_shape(self) -> None: + claim_hash = compute_simulation_claim_hash("frame:42", "subject:player-1", "hit") + self.assertEqual(len(claim_hash), 64) + message = simulation_observation( + message_id="obs-1", + witness_runtime_id="python-test", + shard_id="interop", + shard_epoch=1, + frame=42, + subject_id="player-1", + claim_kind="hit", + claim_hash=claim_hash, + claim_summary="player-1 hit target-a", + observed_at="2026-06-13T00:00:02Z", + ).to_wire() + self.assertEqual(message["schemaVersion"], "cultnet.simulation_observation.v0") + self.assertEqual(message["messageId"], "obs-1") + self.assertEqual(message["observation"]["witnessRuntimeId"], "python-test") + self.assertEqual(message["observation"]["claimHash"], claim_hash) + self.assertEqual(message["observation"]["weight"], 1.0) + observation = CultNetSimulationObservation.from_wire(message) + self.assertEqual(observation.witness_runtime_id, "python-test") + self.assertEqual(observation.claim_summary, "player-1 hit target-a") + self.assertEqual(observation.to_message_wire(message_id="obs-2")["observation"], message["observation"]) + + def test_cultnet_witness_artifact_bundle_uses_csharp_slot_order(self) -> None: + import msgpack # type: ignore + + bundle = witness_artifact_bundle( + bundle_id="bundle-1", + witness_kind="interop-proof", + captured_at="2026-06-13T00:00:03Z", + subject={"documentType": "cultnet.interop-note", "subjectId": "note:python"}, + contracts=[{"role": "payload", "schemaId": "schema-a"}], + artifacts=[{"role": "log", "uri": "cultcache://bundle-1/log", "mediaType": "text/plain"}], + timing_witnesses=[{"stage": "roundtrip", "startedAt": "2026-06-13T00:00:03Z", "completedAt": "2026-06-13T00:00:04Z", "latencyMs": 1.0}], + provenance={"pipelineId": "interop", "runId": "run-1", "runtimeId": "python-test"}, + ) + payload = encode_witness_artifact_bundle_payload(bundle) + decoded = decode_witness_artifact_bundle_payload(payload) + typed = CultNetWitnessArtifactBundle.from_wire(bundle) + typed_payload = typed.to_payload() + typed_decoded = CultNetWitnessArtifactBundle.from_payload(typed_payload) + slots = msgpack.unpackb(payload, raw=False) + + self.assertEqual(payload, typed_payload) + self.assertEqual(slots[0], "bundle-1") + self.assertEqual(slots[1], "interop-proof") + self.assertEqual(slots[2], "2026-06-13T00:00:03Z") + self.assertEqual(slots[3]["subjectId"], "note:python") + self.assertEqual(slots[4][0]["schemaId"], "schema-a") + self.assertEqual(slots[5][0]["uri"], "cultcache://bundle-1/log") + self.assertEqual(slots[6][0]["latencyMs"], 1.0) + self.assertEqual(slots[7]["runtimeId"], "python-test") + self.assertEqual(decoded["bundleId"], "bundle-1") + self.assertEqual(decoded["witnessKind"], "interop-proof") + self.assertEqual(decoded["subject"]["subjectId"], "note:python") + self.assertEqual(decoded["contracts"][0]["schemaId"], "schema-a") + self.assertEqual(decoded["artifacts"][0]["mediaType"], "text/plain") + self.assertEqual(decoded["provenance"]["runtimeId"], "python-test") + self.assertEqual(typed.bundle_id, "bundle-1") + self.assertEqual(typed_decoded.to_wire(), bundle) + + def test_cultmesh_node_uses_cultcache_store(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + document = define_database_entry_type("mesh.note", [("body", 0)]) + store_path = Path(tmp) / "mesh.cc" + node = create_node(store_path, runtime_id="mesh-test") + self.assertIsInstance(node.database, CultMeshDatabase) + node.database.register_document(document) + node.database.put(document, "note:1", {"body": "hello"}) + self.assertEqual(node.get_required(document, "note:1")["body"], "hello") + + reopened = create_node(store_path, runtime_id="mesh-test") + reopened.database.register_document(document) + reopened.database.pull() + self.assertEqual(reopened.database.get(document, "note:1")["body"], "hello") + + def test_cultmesh_node_options_attach_default_durable_shard_log_store(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + document = define_database_entry_type( + "mesh.durable_option_note", + [("body", 0)], + schema_id="mesh.durable_option_note.v1", + ) + cache_path = Path(tmp) / "world.cc" + node = CultMesh.create_node( + cache_path, + runtime_id="durable-option-primary", + options=CultMeshNodeOptions(enable_durable_shard_logs=True), + ) + node.database.register_document(document) + node.database.put_raw_message(document, "note:1", {"body": "stored"}, shard_id="notes", shard_epoch=5) + + log_path = Path(tmp) / "world.cultmesh" / "shard-logs" + self.assertTrue(log_path.exists()) + + reopened = CultMesh.start_node( + cache_path, + runtime_id="durable-option-primary", + enable_durable_shard_logs=True, + ) + reopened.database.register_document(document) + response = reopened.database.build_shard_log_response(shard_id="notes", shard_epoch=5) + + self.assertEqual(reopened.database.shard_ids(), ["notes"]) + self.assertEqual([entry.sequence for entry in response.entries], [1]) + self.assertEqual(response.entries[0].raw_document.record_key, "note:1") + + def test_cultmesh_database_watchers_observe_local_changes_and_unsubscribe(self) -> None: + document = define_database_entry_type("mesh.watch", [("body", 0)]) + node = CultMesh.create_node(runtime_id="mesh-watch") + node.database.register_document(document) + changes: list[CultMeshDatabaseChange] = [] + all_changes: list[CultMeshDatabaseChange] = [] + + unsubscribe_record = node.database.watch_record(document, "note:1", changes.append) + unsubscribe_all = node.database.watch(all_changes.append, document=document) + + node.database.put(document, "note:1", {"body": "hello"}) + node.database.put(document, "note:2", {"body": "skip"}) + node.database.put(document, "note:1", {"body": "updated"}) + node.database.delete(document, "note:1") + unsubscribe_record() + node.database.put(document, "note:1", {"body": "after-unsubscribe"}) + unsubscribe_all() + + self.assertEqual([change.change_kind for change in changes], ["added", "updated", "removed"]) + self.assertEqual([change.record_key for change in changes], ["note:1", "note:1", "note:1"]) + self.assertEqual(changes[1].previous_value, {"body": "hello"}) + self.assertIsNone(changes[2].value) + self.assertEqual([change.record_key for change in all_changes], ["note:1", "note:2", "note:1", "note:1", "note:1"]) + + def test_cultmesh_database_register_document_is_idempotent(self) -> None: + document = define_database_entry_type("mesh.idempotent", [("body", 0)]) + conflicting = define_database_entry_type("mesh.idempotent", [("title", 0)]) + node = CultMesh.create_node(runtime_id="mesh-idempotent") + + node.database.register_document(document) + node.database.register_document(document) + node.database.put(document, "note:1", {"body": "still registered once"}) + + self.assertEqual(node.database.get_required(document, "note:1")["body"], "still registered once") + self.assertEqual([registered.type for registered in node.documents], ["mesh.idempotent"]) + with self.assertRaises(ValueError): + node.database.register_document(conflicting) + + def test_cultmesh_database_global_document_facade_uses_singleton_key_and_watchers(self) -> None: + document = define_database_entry_type( + "mesh.global_settings", + [("theme", 0)], + global_document=True, + ) + node = CultMesh.create_node(runtime_id="mesh-global") + node.database.register_document(document) + changes: list[CultMeshDatabaseChange] = [] + + node.database.watch_global(document, changes.append) + node.database.put_global(document, {"theme": "ash"}) + node.put_global(document, {"theme": "ember"}) + node.delete_global(document) + + self.assertIsNone(node.database.get_global(document)) + self.assertEqual([change.record_key for change in changes], [node.cache.GLOBAL_KEY] * 3) + self.assertEqual([change.change_kind for change in changes], ["added", "updated", "removed"]) + self.assertEqual(changes[1].previous_value, {"theme": "ash"}) + self.assertEqual(changes[2].previous_value, {"theme": "ember"}) + with self.assertRaises(Exception): + node.database.put(document, "settings:wrong", {"theme": "bad"}) + + def test_cultmesh_database_creates_raw_snapshot_response_from_envelopes(self) -> None: + document = define_database_entry_type( + "mesh.snapshot_note", + [("body", 0)], + schema_id="mesh.snapshot_note.v1", + ) + source = CultMesh.create_node(runtime_id="mesh-snapshot-source") + source.database.register_document(document) + source.database.put(document, "note:1", {"body": "include"}) + source.database.put(document, "note:2", {"body": "skip"}) + + typed_response = source.database.build_snapshot_response( + message_id="snapshot-1", + schema_ids=["mesh.snapshot_note.v1"], + record_keys=["note:1"], + shard_id="notes", + shard_epoch=4, + shard_log_sequence=7, + ) + response = source.database.create_snapshot_response( + message_id="snapshot-1", + schema_ids=["mesh.snapshot_note.v1"], + record_keys=["note:1"], + shard_id="notes", + shard_epoch=4, + shard_log_sequence=7, + ) + + self.assertIsInstance(typed_response, CultNetRawSnapshotResponse) + self.assertEqual(typed_response.message_id, "snapshot-1") + self.assertEqual(typed_response.documents[0].record_key, "note:1") + self.assertEqual(typed_response.to_wire(), response) + self.assertEqual(source.build_snapshot_response(schema_ids=["mesh.snapshot_note.v1"]).documents[0].record_key, "note:1") + self.assertEqual(response["schemaVersion"], "cultnet.snapshot_response_raw.v0") + self.assertEqual(response["messageId"], "snapshot-1") + self.assertEqual(response["shardId"], "notes") + self.assertEqual(response["shardEpoch"], 4) + self.assertEqual(response["shardLogSequence"], 7) + self.assertEqual([record["recordKey"] for record in response["documents"]], ["note:1"]) + self.assertEqual(response["documents"][0]["schemaId"], "mesh.snapshot_note.v1") + self.assertEqual(response["documents"][0]["payloadEncoding"], "messagepack") + + target = CultMesh.create_node(runtime_id="mesh-snapshot-target") + target.database.register_document(document) + applied = target.database.apply_snapshot_response(response) + self.assertEqual([(record.schema_id, record.record_key) for record in applied], [("mesh.snapshot_note.v1", "note:1")]) + self.assertEqual(target.database.get_required(document, "note:1"), {"body": "include"}) + self.assertIsNone(target.database.get(document, "note:2")) + + typed_target = CultMesh.create_node(runtime_id="mesh-snapshot-typed-target") + typed_target.database.register_document(document) + typed_applied = typed_target.database.apply_snapshot_response(typed_response) + self.assertEqual([(record.schema_id, record.record_key) for record in typed_applied], [("mesh.snapshot_note.v1", "note:1")]) + self.assertEqual(typed_target.database.get_required(document, "note:1"), {"body": "include"}) + + def test_cultmesh_database_filters_snapshot_by_logged_shard_membership(self) -> None: + document = define_database_entry_type( + "mesh.sharded_snapshot_note", + [("body", 0)], + schema_id="mesh.sharded_snapshot_note.v1", + ) + source = CultMesh.create_node(runtime_id="mesh-sharded-snapshot-source") + source.database.register_document(document) + source.database.put_raw_message(document, "note:notes", {"body": "include"}, shard_id="notes", shard_epoch=2) + source.database.put_raw_message(document, "note:other", {"body": "skip"}, shard_id="other", shard_epoch=1) + + response = source.database.build_snapshot_response( + schema_ids=["mesh.sharded_snapshot_note.v1"], + shard_id="notes", + shard_epoch=2, + ) + + self.assertEqual(response.shard_id, "notes") + self.assertEqual(response.shard_epoch, 2) + self.assertEqual([record.record_key for record in response.documents], ["note:notes"]) + + def test_python_interop_peer_filters_shard_snapshot_by_logged_membership(self) -> None: + runtime_id = f"python-interop-test-{uuid4().hex}" + state = build_state( + runtime_id=runtime_id, + runtime_kind="python", + display_name="Python Interop Test", + agent_id="python-interop-test-agent", + schema_path=str(Path("packages/cultnet-ts/integration/contracts/cultnet.interop-note.schema.json")), + ) + binding = state.bindings["note"] + logged_value = { + "schemaVersion": "cultnet.interop_note.v0", + "documentId": "note:logged", + "authorRuntimeId": runtime_id, + "title": "Logged", + "body": "This record belongs to the shard log.", + "tags": ["interop", "logged"], + } + unlogged_value = { + **logged_value, + "documentId": "note:unlogged", + "title": "Unlogged", + "body": "This record is in cache but not in the shard log.", + } + state.cache.put(binding.document, "note:logged", logged_value) + state.cache.put(binding.document, "note:unlogged", unlogged_value) + logged_record = { + "schemaId": state.note_schema_id, + "recordKey": "note:logged", + "storedAt": "2026-06-14T00:00:00Z", + "payloadEncoding": "messagepack", + "payload": binding.document.encode_payload(logged_value), + } + append_shard_log_put( + state, + {"messageId": "logged-put"}, + logged_record, + ) + + response = raw_snapshot_response( + state, + { + "schemaVersion": "cultnet.snapshot_request.v0", + "messageId": "interop-shard-snapshot", + "schemaIds": [state.note_schema_id], + "shardId": state.shard_id, + }, + ) + + self.assertEqual(response["shardId"], state.shard_id) + self.assertEqual(response["shardLogSequence"], 1) + self.assertEqual([record["recordKey"] for record in response["documents"]], ["note:logged"]) + + def test_cultmesh_database_creates_shard_log_response_from_raw_mutations(self) -> None: + document = define_database_entry_type( + "mesh.log_note", + [("body", 0)], + schema_id="mesh.log_note.v1", + ) + source = CultMesh.create_node(runtime_id="mesh-log-source") + source.database.register_document(document) + source.database.put_raw_message(document, "note:1", {"body": "first"}, shard_id="notes", shard_epoch=3) + source.database.put_raw_message(document, "note:1", {"body": "second"}, shard_id="notes", shard_epoch=3) + source.database.put_raw_message(document, "note:2", {"body": "other"}, shard_id="other", shard_epoch=1) + source.database.delete_raw_message(document, "note:1", shard_id="notes", shard_epoch=3) + + self.assertEqual(source.database.shard_ids(), ["notes", "other"]) + self.assertEqual(source.database.shard_schema_ids("notes"), ["mesh.log_note.v1"]) + + typed_response = source.database.build_shard_log_response( + message_id="log-1", + shard_id="notes", + shard_epoch=3, + after_sequence=1, + limit=2, + ) + response = source.database.create_shard_log_response( + message_id="log-1", + shard_id="notes", + shard_epoch=3, + after_sequence=1, + limit=2, + ) + + self.assertIsInstance(typed_response, CultNetShardLogResponse) + self.assertEqual(typed_response.message_id, "log-1") + self.assertEqual([entry.sequence for entry in typed_response.entries], [2, 3]) + self.assertEqual(typed_response.to_wire(), response) + self.assertEqual([entry.sequence for entry in source.build_shard_log_response(shard_id="notes").entries], [1, 2, 3]) + self.assertEqual(response["schemaVersion"], "cultnet.shard_log_response.v0") + self.assertEqual(response["messageId"], "log-1") + self.assertEqual(response["shardId"], "notes") + self.assertEqual(response["shardEpoch"], 3) + self.assertFalse(response["resyncRequired"]) + self.assertEqual([entry["sequence"] for entry in response["entries"]], [2, 3]) + self.assertEqual([entry["changeKind"] for entry in response["entries"]], ["updated", "removed"]) + self.assertEqual(response["entries"][0]["put"]["document"]["recordKey"], "note:1") + self.assertEqual(response["entries"][1]["delete"]["recordKey"], "note:1") + empty = source.database.create_shard_log_response(shard_id="missing") + self.assertEqual(empty["shardEpoch"], 0) + self.assertEqual(empty["entries"], []) + self.assertTrue(empty["resyncRequired"]) + self.assertEqual(empty["reason"], "unknown_shard") + stale = source.database.create_shard_log_response(shard_id="notes", shard_epoch=2) + self.assertEqual(stale["shardEpoch"], 3) + self.assertEqual(stale["entries"], []) + self.assertTrue(stale["resyncRequired"]) + self.assertEqual(stale["reason"], "stale_epoch") + with self.assertRaises(ValueError): + source.database.create_shard_log_response(shard_id="notes", after_sequence=-1) + with self.assertRaises(ValueError): + source.database.create_shard_log_response(shard_id="notes", limit=-1) + + target = CultMesh.create_node(runtime_id="mesh-log-target") + target.database.register_document(document) + target.database.put(document, "note:1", {"body": "first"}) + applied = target.database.apply_shard_log_response(response) + self.assertEqual([record.change_kind for record in applied], ["updated", "removed"]) + self.assertIsNone(target.database.get(document, "note:1")) + self.assertIsNone(target.database.get(document, "note:2")) + + typed_target = CultMesh.create_node(runtime_id="mesh-log-typed-target") + typed_target.database.register_document(document) + typed_target.database.put(document, "note:1", {"body": "first"}) + typed_applied = typed_target.database.apply_shard_log_response(typed_response) + self.assertEqual([record.change_kind for record in typed_applied], ["updated", "removed"]) + self.assertIsNone(typed_target.database.get(document, "note:1")) + + def test_cultnet_file_shard_mutation_log_store_persists_authoritative_entries(self) -> None: + document = define_database_entry_type( + "mesh.durable_log_note", + [("body", 0)], + schema_id="mesh.durable_log_note.v1", + ) + with tempfile.TemporaryDirectory() as tmp: + store: CultNetShardMutationLogStore = CultNetFileShardMutationLogStore(Path(tmp) / "logs") + source = CultMesh.create_node(runtime_id="durable-primary") + source.database.register_document(document) + source.database.use_shard_mutation_log_store(store) + source.database.put_raw_message(document, "note:1", {"body": "first"}, shard_id="durable", shard_epoch=4) + source.database.put_raw_message(document, "note:2", {"body": "second"}, shard_id="durable", shard_epoch=4) + + reopened = CultMesh.create_node(runtime_id="durable-primary") + reopened.database.register_document(document) + reopened.database.use_shard_mutation_log_store(CultNetFileShardMutationLogStore(Path(tmp) / "logs")) + response = reopened.database.build_shard_log_response(shard_id="durable", shard_epoch=4) + self.assertEqual(reopened.database.shard_ids(), ["durable"]) + self.assertEqual(reopened.database.shard_schema_ids("durable"), ["mesh.durable_log_note.v1"]) + self.assertEqual([entry.sequence for entry in response.entries], [1, 2]) + self.assertEqual(response.entries[0].raw_document.record_key, "note:1") + + store.compact_through("durable", 1) + compacted = reopened.database.build_shard_log_response(shard_id="durable", shard_epoch=4, after_sequence=0) + retained = reopened.database.build_shard_log_response(shard_id="durable", shard_epoch=4, after_sequence=1) + self.assertTrue(compacted.resync_required) + self.assertEqual(compacted.reason, "compacted_history") + self.assertEqual(compacted.compacted_through, 1) + self.assertEqual([entry.sequence for entry in retained.entries], [2]) + + def test_cultnet_shard_replicator_pulls_logs_and_resyncs_from_snapshot(self) -> None: + document = define_database_entry_type( + "mesh.replica_note", + [("body", 0)], + schema_id="mesh.replica_note.v1", + ) + source = CultMesh.create_node(runtime_id="primary") + source.database.register_document(document) + source.database.put_raw_message(document, "note:1", {"body": "one"}, shard_id="notes", shard_epoch=3) + source.database.put_raw_message(document, "note:2", {"body": "two"}, shard_id="notes", shard_epoch=3) + with tempfile.TemporaryDirectory() as tmp: + cursor_path = Path(tmp) / "replica-cursors.msgpack" + server = CultMesh.serve_node(source) + try: + shard = CultNetShardDescriptor( + shard_id="notes", + owner_runtime_id="primary", + epoch=3, + is_primary=False, + schema_ids=("mesh.replica_note.v1",), + primary_endpoints=(f"cultnet://127.0.0.1:{server.port}",), + ) + target = CultMesh.create_node(runtime_id="replica") + target.database.register_document(document) + cursor_store = CultNetFileShardReplicaCursorStore(cursor_path) + replicator = CultNetShardReplicator( + target.database, + CultNetShardReplicatorOptions( + fetcher=CultNetSchemaShardLogFetcher(timeout_seconds=2.0), + snapshot_fetcher=CultNetSchemaShardSnapshotFetcher(timeout_seconds=2.0), + cursor_store=cursor_store, + batch_size=16, + ), + ) + + first_sequence = replicator.pull_once(shard) + restarted_replicator = CultNetShardReplicator( + target.database, + CultNetShardReplicatorOptions( + fetcher=CultNetSchemaShardLogFetcher(timeout_seconds=2.0), + snapshot_fetcher=CultNetSchemaShardSnapshotFetcher(timeout_seconds=2.0), + cursor_store=CultNetFileShardReplicaCursorStore(cursor_path), + batch_size=16, + ), + ) + second_sequence = restarted_replicator.pull_once(shard) + + self.assertEqual(first_sequence, 2) + self.assertEqual(second_sequence, 2) + self.assertEqual(target.get_required(document, "note:1")["body"], "one") + self.assertEqual(target.get_required(document, "note:2")["body"], "two") + cursor = CultNetFileShardReplicaCursorStore(cursor_path).read("notes") + self.assertIsNotNone(cursor) + assert cursor is not None + self.assertEqual(cursor.last_applied_sequence, 2) + self.assertEqual(cursor.shard_epoch, 3) + finally: + server.stop() + + resync_target = CultMesh.create_node(runtime_id="resync-replica") + resync_target.database.register_document(document) + snapshot = source.database.build_snapshot_response(shard_id="notes", shard_epoch=3, shard_log_sequence=2) + + class CompactingFetcher: + def fetch(self, shard: CultNetShardDescriptor, *, after_sequence: int, limit: int | None = None) -> CultNetShardLogResponse: + return CultNetShardLogResponse( + message_id="compact", + shard_id=shard.shard_id, + shard_epoch=shard.epoch, + entries=(), + resync_required=True, + reason="compacted_history", + compacted_through=after_sequence, + ) + + class SnapshotFetcher: + def fetch(self, shard: CultNetShardDescriptor) -> CultNetRawSnapshotResponse: + return snapshot + + resync_replicator = CultNetShardReplicator( + resync_target.database, + CultNetShardReplicatorOptions( + fetcher=CompactingFetcher(), + snapshot_fetcher=SnapshotFetcher(), + cursor_store=CultNetInMemoryShardReplicaCursorStore(), + ), + ) + resync_sequence = resync_replicator.pull_once(CultNetShardDescriptor( + shard_id="notes", + owner_runtime_id="primary", + epoch=3, + is_primary=False, + schema_ids=("mesh.replica_note.v1",), + primary_endpoints=("cultnet://primary:3075",), + )) + + self.assertEqual(resync_sequence, 2) + self.assertEqual(resync_target.get_required(document, "note:1")["body"], "one") + + def test_cultnet_shard_replicator_background_loop_polls_non_primary_shards(self) -> None: + document = define_database_entry_type( + "mesh.background_replica_note", + [("body", 0)], + schema_id="mesh.background_replica_note.v1", + ) + source = CultMesh.create_node(runtime_id="background-primary") + source.database.register_document(document) + target = CultMesh.create_node(runtime_id="background-replica") + target.database.register_document(document) + cursor_store = CultNetInMemoryShardReplicaCursorStore() + errors: list[Exception] = [] + + class FlakySourceFetcher: + def __init__(self) -> None: + self.calls = 0 + + def fetch(self, shard: CultNetShardDescriptor, *, after_sequence: int, limit: int | None = None) -> CultNetShardLogResponse: + self.calls += 1 + if self.calls == 1: + raise RuntimeError("transient fetch failure") + return source.database.build_shard_log_response( + shard_id=shard.shard_id, + shard_epoch=shard.epoch, + after_sequence=after_sequence, + limit=limit, + ) + + fetcher = FlakySourceFetcher() + shard = CultNetShardDescriptor( + shard_id="background", + owner_runtime_id="background-primary", + epoch=7, + is_primary=False, + schema_ids=("mesh.background_replica_note.v1",), + primary_endpoints=("cultnet://127.0.0.1:3075",), + ) + replicator = CultNetShardReplicator( + target.database, + CultNetShardReplicatorOptions( + fetcher=fetcher, + cursor_store=cursor_store, + batch_size=1, + poll_interval_seconds=0.01, + on_error=errors.append, + ), + ) + try: + source.database.put_raw_message(document, "note:1", {"body": "one"}, shard_id="background", shard_epoch=7) + replicator.start([shard]) + self._wait_until(lambda: target.database.get(document, "note:1") is not None) + source.database.put_raw_message(document, "note:2", {"body": "two"}, shard_id="background", shard_epoch=7) + self._wait_until(lambda: target.database.get(document, "note:2") is not None) + finally: + replicator.stop() + + cursor = cursor_store.read("background") + self.assertEqual(target.get_required(document, "note:1")["body"], "one") + self.assertEqual(target.get_required(document, "note:2")["body"], "two") + self.assertIsNotNone(cursor) + assert cursor is not None + self.assertEqual(cursor.last_applied_sequence, 2) + self.assertEqual(cursor.shard_epoch, 7) + self.assertEqual(len(errors), 1) + self.assertEqual(str(errors[0]), "transient fetch failure") + + def test_cultnet_schema_write_forwarder_sends_raw_writes_to_primary(self) -> None: + document = define_database_entry_type( + "mesh.forward_note", + [("body", 0)], + schema_id="mesh.forward_note.v1", + ) + primary = CultMesh.create_node(runtime_id="forward-primary") + primary.database.register_document(document) + server = CultMesh.serve_node(primary) + try: + shard = CultNetShardDescriptor( + shard_id="forward", + owner_runtime_id="forward-primary", + epoch=9, + is_primary=False, + schema_ids=("mesh.forward_note.v1",), + primary_endpoints=(f"cultnet://127.0.0.1:{server.port}",), + ) + forwarder: CultNetShardWriteForwarder = CultNetSchemaWriteForwarder(timeout_seconds=2.0) + put = document_put_raw( + message_id="forward-put", + key="note:forward", + schema_id=document.catalog_entry().schema_id, + stored_at="2026-06-14T00:00:00Z", + payload=document.encode_payload({"body": "forwarded"}), + ).to_wire() + delete = document_delete( + message_id="forward-delete", + schema_id=document.catalog_entry().schema_id, + record_key="note:forward", + ).to_wire() + + forwarder.forward_put(shard, put) + self._eventually(lambda: primary.database.get(document, "note:forward") is not None) + self.assertEqual(primary.database.get_required(document, "note:forward")["body"], "forwarded") + forwarder.forward_delete(shard, delete) + self._eventually(lambda: primary.database.get(document, "note:forward") is None) + + log = primary.database.build_shard_log_response(shard_id="forward", shard_epoch=9) + self.assertIsNone(primary.database.get(document, "note:forward")) + self.assertEqual([entry.change_kind for entry in log.entries], ["added", "removed"]) + self.assertEqual(log.entries[0].put["shardId"], "forward") + self.assertEqual(log.entries[1].delete["shardEpoch"], 9) + finally: + server.stop() + + def _eventually(self, predicate: Callable[[], bool], *, timeout_seconds: float = 2.0) -> None: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + if predicate(): + return + time.sleep(0.01) + self.assertTrue(predicate()) + + def test_cultmesh_database_watchers_observe_name_and_index_changes(self) -> None: + document = define_database_entry_type( + "mesh.named_watch", + [("name", 0), ("kind", 1)], + name="name", + indexes={"kind": "kind"}, + ) + node = CultMesh.create_node(runtime_id="mesh-named-watch") + node.database.register_document(document) + name_changes: list[CultMeshDatabaseChange] = [] + index_changes: list[CultMeshDatabaseChange] = [] + + node.database.watch_by_name(document, "Potion", name_changes.append) + node.database.watch_by_index(document, "kind", "consumable", index_changes.append) + + node.database.put(document, "item:1", {"name": "Potion", "kind": "consumable"}) + node.database.put(document, "item:2", {"name": "Sword", "kind": "weapon"}) + node.database.put(document, "item:1", {"name": "Elixir", "kind": "rare"}) + node.database.delete(document, "item:1") + + self.assertEqual([change.change_kind for change in name_changes], ["added", "updated"]) + self.assertEqual([change.record_key for change in name_changes], ["item:1", "item:1"]) + self.assertEqual(name_changes[1].previous_value, {"name": "Potion", "kind": "consumable"}) + self.assertEqual([change.change_kind for change in index_changes], ["added", "updated"]) + self.assertEqual([change.record_key for change in index_changes], ["item:1", "item:1"]) + self.assertEqual(index_changes[1].value, {"name": "Elixir", "kind": "rare"}) + + def test_cultmesh_database_collection_reads_resolve_same_schema_aliases(self) -> None: + @dataclass + class CanonicalItem: + name: str + kind: str + + @dataclass + class UiItem: + name: str + kind: str + + document = define_database_entry_type( + "mesh.alias_collection_item", + [("name", 0), ("kind", 1)], + cls=CanonicalItem, + name="name", + indexes={"kind": "kind"}, + schema_id="mesh.alias_collection_item.v1", + schema_name="mesh.alias_collection_item", + schema_version="mesh.alias_collection_item.v1", + ) + alias = define_database_entry_type( + "mesh.alias_collection_item.ui", + [("name", 0), ("kind", 1)], + cls=UiItem, + name="name", + indexes={"kind": "kind"}, + schema_id="mesh.alias_collection_item.v1", + schema_name="mesh.alias_collection_item", + schema_version="mesh.alias_collection_item.v1", + ) + node = CultMesh.create_node(runtime_id="mesh-alias-collection") + node.database.register_document(document) + + node.database.put(alias, "item:potion", UiItem("Potion", "consumable")) + node.database.put(document, "item:sword", CanonicalItem("Sword", "weapon")) + + all_items = node.database.get_all(alias) + potion = node.database.get_by_name(alias, "Potion") + sword = node.get_by_index(alias, "kind", "weapon") + + self.assertEqual([item.name for item in all_items], ["Potion", "Sword"]) + self.assertTrue(all(isinstance(item, UiItem) for item in all_items)) + self.assertIsInstance(potion, UiItem) + self.assertEqual(potion.kind, "consumable") + self.assertIsInstance(sword, UiItem) + self.assertEqual(sword.name, "Sword") + self.assertIsNone(node.database.get_by_name(alias, "Missing")) + + def test_cultmesh_database_alias_collection_watches_emit_alias_values(self) -> None: + @dataclass + class CanonicalItem: + name: str + kind: str + + @dataclass + class UiItem: + name: str + kind: str + + document = define_database_entry_type( + "mesh.alias_collection_watch_item", + [("name", 0), ("kind", 1)], + cls=CanonicalItem, + name="name", + indexes={"kind": "kind"}, + schema_id="mesh.alias_collection_watch_item.v1", + schema_name="mesh.alias_collection_watch_item", + schema_version="mesh.alias_collection_watch_item.v1", + ) + alias = define_database_entry_type( + "mesh.alias_collection_watch_item.ui", + [("name", 0), ("kind", 1)], + cls=UiItem, + name="name", + indexes={"kind": "kind"}, + schema_id="mesh.alias_collection_watch_item.v1", + schema_name="mesh.alias_collection_watch_item", + schema_version="mesh.alias_collection_watch_item.v1", + ) + node = CultMesh.create_node(runtime_id="mesh-alias-collection-watch") + node.database.register_document(document) + name_changes: list[CultMeshDatabaseChange] = [] + index_changes: list[CultMeshDatabaseChange] = [] + + node.database.watch_by_name(alias, "Potion", name_changes.append) + node.database.watch_by_index(alias, "kind", "consumable", index_changes.append) + + node.database.put(document, "item:potion", CanonicalItem("Potion", "consumable")) + node.database.put(document, "item:potion", CanonicalItem("Elixir", "rare")) + + self.assertEqual([change.change_kind for change in name_changes], ["added", "updated"]) + self.assertTrue(all(change.document.type == alias.type for change in name_changes)) + self.assertIsInstance(name_changes[0].value, UiItem) + self.assertIsInstance(name_changes[1].value, UiItem) + self.assertIsInstance(name_changes[1].previous_value, UiItem) + self.assertEqual(name_changes[1].previous_value.name, "Potion") + self.assertEqual(name_changes[1].value.name, "Elixir") + self.assertEqual([change.change_kind for change in index_changes], ["added", "updated"]) + self.assertTrue(all(change.document.type == alias.type for change in index_changes)) + self.assertIsInstance(index_changes[0].value, UiItem) + self.assertIsInstance(index_changes[1].previous_value, UiItem) + + def test_cultmesh_database_watch_by_name_and_index_validate_lookup_shape(self) -> None: + document = define_database_entry_type("mesh.unnamed_watch", [("body", 0)]) + node = CultMesh.create_node(runtime_id="mesh-watch-validation") + node.database.register_document(document) + + with self.assertRaises(ValueError): + node.database.watch_by_name(document, "missing", lambda _: None) + with self.assertRaises(ValueError): + node.database.watch_by_index(document, "missing", "value", lambda _: None) + with self.assertRaises(ValueError): + node.database.watch_by_index(document, "", "value", lambda _: None) + with self.assertRaises(ValueError): + node.database.watch_by_index(document, "missing", "", lambda _: None) + + def test_cultmesh_facade_matches_peer_runtime_entrypoints(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + store_path = Path(tmp) / "mesh.cc" + node = CultMesh.start_node(store_path, runtime_id="mesh-facade") + + self.assertEqual(node.runtime_id, "mesh-facade") + self.assertIsInstance(CultMesh.create_node(), type(node)) + self.assertIsInstance(CultMesh.create_verse_catalog(), CultMeshVerseCatalog) + self.assertIsInstance(CultMesh.create_peer_catalog(), CultMeshPeerCatalog) + self.assertIsInstance(CultMesh.create_authority_lease_catalog(), CultMeshAuthorityLeaseCatalog) + self.assertIsInstance(CultMesh.create_stream_catalog(), CultMeshStreamCatalog) + self.assertIsInstance(CultMesh.create_schema_catalog(), CultNetSchemaCatalog) + self.assertIsInstance(CultMesh.create_builtin_schema_catalog(), CultNetSchemaCatalog) + self.assertIsInstance(CultMesh.create_shard_catalog(), CultNetShardCatalog) + builtins = CultMesh.create_builtin_schema_catalog( + include_schema_json=False, + kinds=["shared_contract"], + ) + self.assertEqual( + builtins.list()[0].schema_id, + "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/cultnet-ts/contracts/cultmesh.peer-exchange-request.schema.json" + ) + filtered_builtin = CultMesh.create_builtin_schema_catalog( + include_schema_json=True, + schema_ids=[peer_exchange_schema_id], + ) + self.assertEqual( + filtered_builtin.list()[0].schema_version, + "cultmesh.peer_exchange_request.v0", + ) + self.assertIn( + "cultmesh.peer_exchange_request.v0", + filtered_builtin.list()[0].schema_json or "", + ) + shard_catalog = CultMesh.create_shard_catalog() + shard_catalog.upsert( + CultNetShardDescriptor( + shard_id="python-notes-a", + owner_runtime_id="mesh-facade", + epoch=7, + is_primary=True, + schema_ids=("python.note.v1",), + key_prefix="note:", + ) + ) + self.assertEqual( + [ + shard.shard_id + for shard in shard_catalog.list(schema_ids=["python.note.v1"], record_keys=["note:1"]) + ], + ["python-notes-a"], + ) + verse_client = CultMesh.create_verse_discovery_client("127.0.0.1", 4010, timeout_seconds=1.5) + peer_client = CultMesh.create_peer_exchange_client("127.0.0.1", 4011, timeout_seconds=1.5) + raw_client = CultMesh.create_client("127.0.0.1", 4012, timeout_seconds=1.5) + connected_client = CultMesh.connect_client("127.0.0.1", 4013, timeout_seconds=1.5) + self.assertIsInstance(verse_client, CultMeshVerseDiscoveryClient) + self.assertIsInstance(peer_client, CultMeshPeerExchangeClient) + self.assertIsInstance(verse_client, CultMeshDiscoveryClient) + self.assertIsInstance(peer_client, CultMeshDiscoveryClient) + self.assertIsInstance(raw_client, CultNetRawClient) + self.assertIsInstance(connected_client, CultNetRawClient) + self.assertEqual((verse_client.host, verse_client.port, verse_client.timeout_seconds), ("127.0.0.1", 4010, 1.5)) + self.assertEqual((peer_client.host, peer_client.port, peer_client.timeout_seconds), ("127.0.0.1", 4011, 1.5)) + self.assertEqual((raw_client.host, raw_client.port, raw_client.timeout_seconds), ("127.0.0.1", 4012, 1.5)) + self.assertEqual((connected_client.host, connected_client.port, connected_client.timeout_seconds), ("127.0.0.1", 4013, 1.5)) + + def test_cultmesh_simulation_fact_uses_csharp_slot_contract(self) -> None: + import msgpack # type: ignore + + candidate = { + "shardId": "arena", + "shardEpoch": 4, + "frame": 100, + "subjectId": "bob", + "claimKind": "hit", + "claimHash": compute_simulation_claim_hash("hit", "alice", "bob", "frame:100"), + "claimSummary": "alice shot bob first", + "witnessCount": 2, + "supportWeight": 2.0, + "totalWeight": 2.0, + "confidence": 1.0, + "hasQuorum": True, + } + + fact = CultMeshSimulationFact.from_candidate(candidate, committed_at="2026-06-13T00:00:00Z") + payload = simulation_fact_document.encode_payload(fact) + slots = msgpack.unpackb(payload, raw=False) + + self.assertEqual(CultMeshSimulationFact.create_record_key(candidate), f"simulation:{fact.fact_id}") + self.assertEqual(slots[0], fact.fact_id) + self.assertEqual(slots[1], "arena") + self.assertEqual(slots[2], 4) + self.assertEqual(slots[3], 100) + self.assertEqual(slots[4], "bob") + self.assertEqual(slots[5], "hit") + self.assertEqual(slots[6], candidate["claimHash"]) + self.assertEqual(slots[7], "alice shot bob first") + self.assertEqual(slots[8], 2) + self.assertEqual(slots[9], 2.0) + self.assertEqual(slots[10], 2.0) + self.assertEqual(slots[11], 1.0) + self.assertEqual(slots[12], "2026-06-13T00:00:00Z") + + def test_cultmesh_simulation_fact_committer_rejects_without_quorum_and_stores_fact(self) -> None: + candidate = { + "shardId": "arena", + "shardEpoch": 4, + "frame": 100, + "subjectId": "bob", + "claimKind": "hit", + "claimHash": compute_simulation_claim_hash("hit", "alice", "bob", "frame:100"), + "claimSummary": "alice shot bob first", + "witnessCount": 2, + "supportWeight": 2.0, + "totalWeight": 2.0, + "confidence": 1.0, + "hasQuorum": True, + } + + with tempfile.TemporaryDirectory() as tmp: + node = CultMesh.create_node(Path(tmp) / "facts.cc", runtime_id="mesh-facts") + committer = CultMesh.create_simulation_fact_committer(node) + + rejected = dict(candidate) + rejected["hasQuorum"] = False + with self.assertRaisesRegex(ValueError, "before quorum"): + committer.commit(rejected) + + committed = committer.commit(candidate, committed_at="2026-06-13T00:00:00Z") + stored = node.get_required(simulation_fact_document, committed.key) + + self.assertEqual(stored.claim_hash, candidate["claimHash"]) + self.assertEqual(stored.committed_at, "2026-06-13T00:00:00Z") + self.assertEqual(committed.fact.fact_id, stored.fact_id) + + def test_cultnet_simulation_consensus_dedupes_witnesses_and_requires_quorum(self) -> None: + from cultnet_py import CultNetSimulationConsensus + + consensus = CultNetSimulationConsensus( + CultNetSimulationConsensusOptions(minimum_witnesses=2, quorum_ratio=1.0) + ) + claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100") + observations = [ + { + "witnessRuntimeId": "watcher-1", + "shardId": "arena", + "shardEpoch": 4, + "frame": 100, + "subjectId": "bob", + "claimKind": "hit", + "claimHash": claim_hash, + "claimSummary": "alice shot bob first", + "weight": 1.0, + }, + { + "witnessRuntimeId": "watcher-1", + "shardId": "arena", + "shardEpoch": 4, + "frame": 100, + "subjectId": "bob", + "claimKind": "hit", + "claimHash": "stale-duplicate", + "weight": 0.5, + }, + { + "witnessRuntimeId": "watcher-2", + "shardId": "arena", + "shardEpoch": 4, + "frame": 100, + "subjectId": "bob", + "claimKind": "hit", + "claimHash": claim_hash, + "weight": 1.0, + }, + ] + + candidates = consensus.build_candidates(observations) + typed_candidates = consensus.build_candidate_objects([ + CultNetSimulationObservation.from_wire(observation) + for observation in observations + ]) + + self.assertEqual(len(candidates), 1) + self.assertEqual(candidates[0]["claimHash"], claim_hash) + self.assertEqual(candidates[0]["witnessCount"], 2) + self.assertEqual(candidates[0]["supportWeight"], 2.0) + self.assertTrue(candidates[0]["hasQuorum"]) + self.assertEqual(len(typed_candidates), 1) + self.assertIsInstance(typed_candidates[0], CultNetSimulationConsensusCandidate) + self.assertEqual(typed_candidates[0].claim_hash, claim_hash) + self.assertEqual(typed_candidates[0].to_wire(), candidates[0]) + self.assertEqual(CultNetSimulationConsensusCandidate.from_wire(candidates[0]), typed_candidates[0]) + + def test_cultmesh_game_session_submits_observations_and_commits_quorum_once(self) -> None: + claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100") + first = simulation_observation( + message_id="obs-1", + witness_runtime_id="watcher-1", + shard_id="arena", + shard_epoch=4, + frame=100, + subject_id="bob", + claim_kind="hit", + claim_hash=claim_hash, + claim_summary="alice shot bob first", + ).to_wire() + second = simulation_observation( + message_id="obs-2", + witness_runtime_id="watcher-2", + shard_id="arena", + shard_epoch=4, + frame=100, + subject_id="bob", + claim_kind="hit", + claim_hash=claim_hash, + claim_summary="alice shot bob first", + ).to_wire() + + with tempfile.TemporaryDirectory() as tmp: + node = CultMesh.create_node(Path(tmp) / "session.cc", runtime_id="mesh-session") + session = CultMesh.create_game_session( + node, + CultMeshGameSessionOptions( + consensus_options=CultNetSimulationConsensusOptions(minimum_witnesses=2, quorum_ratio=1.0) + ), + ) + seen_candidates: list[CultNetSimulationConsensusCandidate] = [] + seen_facts: list[CultMeshDatabaseChange] = [] + unsubscribe_candidates = session.watch_candidates(seen_candidates.append) + unsubscribe_facts = session.watch_simulation_facts(seen_facts.append) + + self.assertEqual(session.submit_and_commit(CultNetSimulationObservation.from_wire(first)), []) + typed_candidates = session.submit_observation_candidates(CultNetSimulationObservation.from_wire(second)) + commits = session.commit_quorum_candidate_objects(typed_candidates) + replay = session.commit_quorum_candidates(session.submit_observation(second)) + unsubscribe_candidates() + unsubscribe_facts() + + self.assertEqual(len(typed_candidates), 1) + self.assertTrue(typed_candidates[0].has_quorum) + self.assertEqual(len(commits), 1) + self.assertEqual(replay, []) + self.assertEqual([candidate.claim_hash for candidate in seen_candidates], [claim_hash, claim_hash, claim_hash]) + self.assertEqual(len(seen_facts), 1) + self.assertEqual(seen_facts[0].change_kind, "added") + self.assertEqual(seen_facts[0].value.claim_hash, claim_hash) + stored = node.get_required(simulation_fact_document, commits[0].key) + self.assertEqual(stored.claim_hash, claim_hash) + self.assertEqual(stored.witness_count, 2) + + def test_cultmesh_game_session_serves_catalogs_and_observations_over_wire(self) -> None: + verse_catalog = CultMeshVerseCatalog() + peer_catalog = CultMeshPeerCatalog() + verse_catalog.upsert(CultMeshVerseDescriptor( + verse_id="session-verse", + display_name="Session Verse", + authority_model="local", + compatibility=CultMeshVerseCompatibility("cultmesh.v0", "session-rules"), + )) + peer_catalog.upsert(CultMeshPeerCard( + peer_id="session-peer", + verse_id="session-verse", + endpoints=("cultnet://127.0.0.1:0",), + roles=("simulation-observer", "read-replica"), + shard_ids=("arena",), + )) + session = CultMesh.create_game_session( + CultMesh.create_node(runtime_id="session-peer"), + CultMeshGameSessionOptions( + verse_catalog=verse_catalog, + peer_catalog=peer_catalog, + consensus_options=CultNetSimulationConsensusOptions(minimum_witnesses=2, quorum_ratio=1.0), + ), + ) + server = session.serve(display_name="Session Peer") + try: + raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) + hello_response = raw_client.request( + hello(runtime_id="session-prober"), + expected_schema_version="cultnet.hello.v0", + ) + discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", server.port, timeout_seconds=2.0) + verses = discovery_client.fetch_verses(transport_version="cultmesh.v0") + peers = discovery_client.fetch_peers(verse_id="session-verse", roles=["simulation-observer"]) + claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100") + first = raw_client.request( + simulation_observation( + message_id="session-wire-1", + witness_runtime_id="watcher-1", + shard_id="arena", + shard_epoch=4, + frame=100, + subject_id="bob", + claim_kind="hit", + claim_hash=claim_hash, + claim_summary="alice shot bob first", + ), + expected_schema_version="cultnet.simulation_consensus_candidate.v0", + ) + second = raw_client.request( + simulation_observation( + message_id="session-wire-2", + witness_runtime_id="watcher-2", + shard_id="arena", + shard_epoch=4, + frame=100, + subject_id="bob", + claim_kind="hit", + claim_hash=claim_hash, + claim_summary="alice shot bob first", + ), + expected_schema_version="cultnet.simulation_consensus_candidate.v0", + ) + finally: + server.stop() + + self.assertIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"]) + self.assertEqual(verses[0].verse_id, "session-verse") + self.assertEqual(peers[0].peer_id, "session-peer") + self.assertIn("simulation-observer", peers[0].roles) + self.assertFalse(first["hasQuorum"]) + self.assertEqual(first["witnessCount"], 1) + self.assertTrue(second["hasQuorum"]) + self.assertEqual(second["witnessCount"], 2) + + def test_cultmesh_game_session_can_disable_served_simulation_observations(self) -> None: + verse_catalog = CultMeshVerseCatalog() + verse_catalog.upsert(CultMeshVerseDescriptor( + verse_id="session-quiet", + display_name="Quiet Session", + authority_model="local", + compatibility=CultMeshVerseCompatibility("cultmesh.v0", "quiet-rules"), + )) + session = CultMesh.create_game_session( + CultMesh.create_node(runtime_id="quiet-session-peer"), + CultMeshGameSessionOptions( + verse_catalog=verse_catalog, + serve_simulation_observations=False, + ), + ) + server = session.serve(display_name="Quiet Session Peer") + try: + raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) + hello_response = raw_client.request( + hello(runtime_id="quiet-session-prober"), + expected_schema_version="cultnet.hello.v0", + ) + discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", server.port, timeout_seconds=2.0) + verses = discovery_client.fetch_verses(transport_version="cultmesh.v0") + with self.assertRaisesRegex(CultNetPeerError, "Simulation observations are not enabled") as raised: + raw_client.request( + simulation_observation( + message_id="quiet-session-sim", + witness_runtime_id="watcher-1", + shard_id="arena", + shard_epoch=4, + frame=100, + subject_id="bob", + claim_kind="hit", + claim_hash=compute_simulation_claim_hash("hit", "alice", "bob", "frame:100"), + ), + expected_schema_version="cultnet.simulation_consensus_candidate.v0", + ) + finally: + server.stop() + + self.assertNotIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"]) + self.assertEqual(verses[0].verse_id, "session-quiet") + self.assertEqual(raised.exception.response["code"], "simulation_observations_disabled") + + def test_cultmesh_game_session_prediction_requires_scope_and_reconciles_shard_log(self) -> None: + note_doc = define_database_entry_type( + "mesh.input", + [("body", 0)], + schema_id="mesh.input.v1", + ) + with tempfile.TemporaryDirectory() as tmp: + node = CultMesh.create_node(Path(tmp) / "prediction.cc", runtime_id="client-a") + node.register_document(note_doc) + session = CultMesh.create_game_session( + node, + CultMeshGameSessionOptions( + client_authority_scopes=( + CultNetClientAuthorityScope( + "client-a", + schema_ids=("mesh.input.v1",), + key_prefix="input:client-a", + ), + ) + ), + ) + + with self.assertRaisesRegex(ValueError, "does not have client prediction authority"): + session.predict(note_doc, "input:other", {"body": "nope"}) + + prediction = session.predict(note_doc, "input:client-a:move", {"body": "predicted"}) + put = document_put_raw( + message_id="authoritative", + key=prediction.key, + schema_id=prediction.schema_id, + stored_at="2026-06-13T00:00:00Z", + payload=note_doc.encode_payload({"body": "authoritative"}), + shard_id="inputs", + shard_epoch=1, + ) + authoritative_response = { + "schemaVersion": "cultnet.shard_log_response.v0", + "messageId": "inputs-log", + "shardId": "inputs", + "shardEpoch": 1, + "entries": [ + { + "sequence": 1, + "committedAt": "2026-06-13T00:00:00Z", + "changeKind": "updated", + "put": put.to_wire(), + } + ], + "resyncRequired": False, + } + changes = session.apply_shard_log_response(CultNetShardLogResponse.from_wire(authoritative_response)) + + self.assertEqual(changes[0].change_kind, "reconciled") + self.assertEqual(node.get_required(note_doc, prediction.key)["body"], "authoritative") + self.assertEqual(session.pending_predictions(), ()) + self.assertEqual(session.resimulation_inputs(), ()) + + def test_cultmesh_game_session_partial_reconciliation_keeps_unmatched_predictions(self) -> None: + note_doc = define_database_entry_type( + "mesh.partial_input", + [("body", 0)], + schema_id="mesh.partial_input.v1", + ) + node = CultMesh.create_node(runtime_id="client-partial") + node.register_document(note_doc) + session = CultMesh.create_game_session( + node, + CultMeshGameSessionOptions( + client_authority_scopes=( + CultNetClientAuthorityScope( + "client-partial", + schema_ids=("mesh.partial_input.v1",), + key_prefix="input:client-partial", + ), + ) + ), + ) + pending = session.predict(note_doc, "input:client-partial:pending", {"body": "pending-local"}) + accepted = session.predict(note_doc, "input:client-partial:accepted", {"body": "accepted-local"}) + put = document_put_raw( + message_id="authoritative-partial", + key=accepted.key, + schema_id=accepted.schema_id, + stored_at="2026-06-13T00:00:00Z", + payload=note_doc.encode_payload({"body": "accepted-authority"}), + shard_id="inputs", + shard_epoch=1, + ) + + changes = session.apply_shard_log_response({ + "schemaVersion": "cultnet.shard_log_response.v0", + "messageId": "inputs-partial-log", + "shardId": "inputs", + "shardEpoch": 1, + "entries": [ + { + "sequence": 1, + "committedAt": "2026-06-13T00:00:00Z", + "changeKind": "updated", + "put": put.to_wire(), + } + ], + "resyncRequired": False, + }) + + self.assertEqual([(change.change_kind, change.record_key) for change in changes], [ + ("reconciled", accepted.key), + ]) + self.assertEqual(node.get_required(note_doc, accepted.key)["body"], "accepted-authority") + self.assertEqual(node.get_required(note_doc, pending.key)["body"], "pending-local") + self.assertEqual([prediction.key for prediction in session.pending_predictions()], [pending.key]) + self.assertEqual([prediction.key for prediction in session.resimulation_inputs()], [pending.key]) + + def test_cultmesh_game_session_rolls_back_pending_predictions(self) -> None: + note_doc = define_database_entry_type( + "mesh.rollback_input", + [("body", 0)], + schema_id="mesh.rollback_input.v1", + ) + node = CultMesh.create_node(runtime_id="client-rollback") + node.register_document(note_doc) + node.put(note_doc, "input:client-rollback:existing", {"body": "previous"}) + session = CultMesh.create_game_session( + node, + CultMeshGameSessionOptions( + client_authority_scopes=( + CultNetClientAuthorityScope( + "client-rollback", + schema_ids=("mesh.rollback_input.v1",), + key_prefix="input:client-rollback", + ), + ) + ), + ) + + existing = session.predict(note_doc, "input:client-rollback:existing", {"body": "predicted-existing"}) + created = session.predict(note_doc, "input:client-rollback:new", {"body": "predicted-new"}) + self.assertEqual( + [prediction.key for prediction in session.pending_predictions()], + ["input:client-rollback:existing", "input:client-rollback:new"], + ) + self.assertEqual([prediction.key for prediction in session.resimulation_inputs()], [ + "input:client-rollback:existing", + "input:client-rollback:new", + ]) + + restored = session.rollback_prediction(existing) + self.assertIsNotNone(restored) + assert restored is not None + self.assertEqual(restored.change_kind, "rolled_back") + self.assertEqual(restored.value, {"body": "previous"}) + self.assertEqual(node.get_required(note_doc, "input:client-rollback:existing")["body"], "previous") + + rolled_back = session.rollback_predictions() + self.assertEqual([(change.change_kind, change.record_key, change.value) for change in rolled_back], [ + ("rolled_back", created.key, None), + ]) + self.assertIsNone(node.get(note_doc, created.key)) + self.assertEqual(session.pending_predictions(), ()) + + def test_cultmesh_game_session_authoritative_delete_rolls_back_prediction(self) -> None: + note_doc = define_database_entry_type( + "mesh.deleted_input", + [("body", 0)], + schema_id="mesh.deleted_input.v1", + ) + node = CultMesh.create_node(runtime_id="client-delete") + node.register_document(note_doc) + session = CultMesh.create_game_session( + node, + CultMeshGameSessionOptions( + client_authority_scopes=( + CultNetClientAuthorityScope( + "client-delete", + schema_ids=("mesh.deleted_input.v1",), + key_prefix="input:client-delete", + ), + ) + ), + ) + + prediction = session.predict(note_doc, "input:client-delete:move", {"body": "predicted"}) + delete = document_delete( + message_id="authoritative-delete", + schema_id=prediction.schema_id, + record_key=prediction.key, + shard_id="inputs", + shard_epoch=1, + ) + changes = session.apply_shard_log_response({ + "schemaVersion": "cultnet.shard_log_response.v0", + "messageId": "inputs-delete-log", + "shardId": "inputs", + "shardEpoch": 1, + "entries": [ + { + "sequence": 1, + "committedAt": "2026-06-13T00:00:00Z", + "changeKind": "removed", + "delete": delete.to_wire(), + } + ], + "resyncRequired": False, + }) + + self.assertEqual(changes[0].change_kind, "rolled_back") + self.assertIsNone(node.get(note_doc, prediction.key)) + self.assertEqual(session.pending_predictions(), ()) + + def test_cultmesh_database_watchers_observe_authoritative_shard_log_reconciliation(self) -> None: + note_doc = define_database_entry_type( + "mesh.watched_input", + [("body", 0)], + schema_id="mesh.watched_input.v1", + ) + node = CultMesh.create_node(runtime_id="client-watch") + node.database.register_document(note_doc) + seen: list[CultMeshDatabaseChange] = [] + node.database.watch_record(note_doc, "input:client-watch:move", seen.append) + session = CultMesh.create_game_session( + node, + CultMeshGameSessionOptions( + client_authority_scopes=( + CultNetClientAuthorityScope( + "client-watch", + schema_ids=("mesh.watched_input.v1",), + key_prefix="input:client-watch", + ), + ) + ), + ) + + prediction = session.predict(note_doc, "input:client-watch:move", {"body": "predicted"}) + put = document_put_raw( + message_id="authoritative-watch", + key=prediction.key, + schema_id=prediction.schema_id, + stored_at="2026-06-13T00:00:00Z", + payload=note_doc.encode_payload({"body": "authoritative"}), + shard_id="inputs", + shard_epoch=1, + ) + session_changes = session.apply_shard_log_response({ + "schemaVersion": "cultnet.shard_log_response.v0", + "messageId": "inputs-watch-log", + "shardId": "inputs", + "shardEpoch": 1, + "entries": [ + { + "sequence": 1, + "committedAt": "2026-06-13T00:00:00Z", + "changeKind": "updated", + "put": put.to_wire(), + } + ], + "resyncRequired": False, + }) + + self.assertEqual([change.change_kind for change in seen], ["added", "updated"]) + self.assertEqual(seen[1].value, {"body": "authoritative"}) + self.assertEqual(seen[1].previous_value, {"body": "predicted"}) + self.assertEqual(session_changes[0].change_kind, "reconciled") + + def test_cultmesh_verse_catalog_response_matches_schema_v0_wire_shape(self) -> None: + import msgpack # type: ignore + + catalog = CultMeshVerseCatalog() + catalog.upsert( + CultMeshVerseDescriptor( + verse_id="aetheria-main", + display_name="Aetheria", + authority_model="OperatorCluster", + compatibility=CultMeshVerseCompatibility( + transport_version="cultmesh.v0", + rules_hash="rules", + compatible_verse_ids=("aetheria-modded",), + required_plugin_ids=("core",), + optional_plugin_ids=("skylands",), + ), + discovery_endpoints=("cultmesh://aetheria.example.test:3075",), + authority_runtime_ids=("runtime-a",), + description="main branch", + ) + ) + + response = catalog.create_response(verse_catalog_request("verses-1")) + decoded = msgpack.unpackb(msgpack.packb(response, use_bin_type=True), raw=False) + self.assertEqual(decoded["schemaVersion"], "cultmesh.verse_catalog_response.v0") + self.assertEqual(decoded["messageId"], "verses-1") + self.assertEqual(decoded["verses"][0]["verseId"], "aetheria-main") + self.assertEqual(decoded["verses"][0]["compatibility"]["requiredPluginIds"], ["core"]) + + def test_cultmesh_verse_catalog_watches_updates_and_finds_transfer_targets(self) -> None: + source = CultMeshVerseDescriptor( + verse_id="aetheria-main", + display_name="Aetheria", + authority_model="federated", + compatibility=CultMeshVerseCompatibility( + transport_version="cultmesh.v0", + rules_hash="rules-main", + ), + ) + compatible = CultMeshVerseDescriptor( + verse_id="aetheria-modded", + display_name="Aetheria Modded", + authority_model="federated", + compatibility=CultMeshVerseCompatibility( + transport_version="cultmesh.v0", + rules_hash="rules-modded", + compatible_verse_ids=("aetheria-main",), + ), + ) + incompatible = CultMeshVerseDescriptor( + verse_id="old-world", + display_name="Old World", + authority_model="solo", + compatibility=CultMeshVerseCompatibility( + transport_version="cultmesh.v0", + rules_hash="rules-old", + ), + ) + catalog = CultMeshVerseCatalog() + seen: list[str] = [] + unsubscribe = catalog.watch(lambda verse: seen.append(verse.verse_id)) + + catalog.upsert(source) + catalog.apply_response({ + "schemaVersion": "cultmesh.verse_catalog_response.v0", + "messageId": "verses-watch", + "verses": [compatible.to_wire(), incompatible.to_wire()], + }) + unsubscribe() + catalog.upsert(CultMeshVerseDescriptor( + verse_id="after-unsubscribe", + display_name="After", + authority_model="none", + compatibility=CultMeshVerseCompatibility("cultmesh.v0", "rules-after"), + )) + + self.assertEqual(seen, ["aetheria-main", "aetheria-modded", "old-world"]) + self.assertEqual([verse.verse_id for verse in catalog.verses], ["aetheria-main", "aetheria-modded", "after-unsubscribe", "old-world"]) + self.assertEqual(catalog.get("aetheria-modded"), compatible) + self.assertEqual([verse.verse_id for verse in catalog.find_transfer_targets(source)], ["aetheria-modded"]) + + def test_cultmesh_peer_exchange_response_matches_schema_v0_wire_shape(self) -> None: + import msgpack # type: ignore + + catalog = CultMeshPeerCatalog() + catalog.upsert( + CultMeshPeerCard( + peer_id="peer-a", + verse_id="aetheria-main", + endpoints=("cultnet://peer-a.example.test:3075",), + roles=("discovery", "read-replica"), + shard_ids=("players",), + region="eu-west", + authority_lease_id="lease-1", + expires_at="2026-05-20T12:00:00.0000000Z", + signature="sig", + ) + ) + + response = catalog.create_response( + peer_exchange_request("pex-1", verse_id="aetheria-main", roles=["read-replica"]) + ) + decoded = msgpack.unpackb(msgpack.packb(response, use_bin_type=True), raw=False) + self.assertEqual(decoded["schemaVersion"], "cultmesh.peer_exchange_response.v0") + self.assertEqual(decoded["messageId"], "pex-1") + self.assertEqual(decoded["peers"][0]["peerId"], "peer-a") + self.assertIn("read-replica", decoded["peers"][0]["roles"]) + self.assertEqual(decoded["peers"][0]["authorityLeaseId"], "lease-1") + + def test_cultmesh_peer_catalog_watches_updates_and_gets_peers(self) -> None: + first = CultMeshPeerCard( + peer_id="peer-a", + verse_id="aetheria-main", + endpoints=("cultnet://peer-a.example.test:3075",), + roles=("discovery", "read-replica"), + ) + second = CultMeshPeerCard( + peer_id="peer-b", + verse_id="aetheria-main", + endpoints=("cultnet://peer-b.example.test:3075",), + roles=("shard-primary",), + ) + catalog = CultMeshPeerCatalog() + seen: list[str] = [] + unsubscribe = catalog.watch(lambda peer: seen.append(peer.peer_id)) + + catalog.upsert(first) + catalog.apply_response({ + "schemaVersion": "cultmesh.peer_exchange_response.v0", + "messageId": "peers-watch", + "peers": [second.to_wire()], + }) + unsubscribe() + catalog.upsert(CultMeshPeerCard( + peer_id="peer-c", + verse_id="aetheria-main", + endpoints=("cultnet://peer-c.example.test:3075",), + roles=("read-replica",), + )) + + self.assertEqual(seen, ["peer-a", "peer-b"]) + self.assertTrue(first.has_role("read-replica")) + self.assertEqual([peer.peer_id for peer in catalog.peers], ["peer-a", "peer-b", "peer-c"]) + self.assertEqual(catalog.get("peer-b"), second) + self.assertEqual([peer.peer_id for peer in catalog.find("aetheria-main", role="read-replica")], ["peer-a", "peer-c"]) + + def test_cultmesh_discovery_client_fetches_typed_catalogs_over_cultnet_frames(self) -> None: + import msgpack # type: ignore + from cultnet_py import read_frame, write_frame + + verses = CultMeshVerseCatalog() + verses.upsert( + CultMeshVerseDescriptor( + verse_id="aetheria-main", + display_name="Aetheria", + authority_model="federated", + compatibility=CultMeshVerseCompatibility( + transport_version="cultmesh.v0", + rules_hash="rules-1", + required_plugin_ids=("core",), + ), + discovery_endpoints=("cultmesh://aetheria.example.test:3075",), + authority_runtime_ids=("runtime-a",), + ) + ) + peers = CultMeshPeerCatalog() + peers.upsert( + CultMeshPeerCard( + peer_id="peer-a", + verse_id="aetheria-main", + endpoints=("cultnet://peer-a.example.test:3075",), + roles=("read-replica",), + shard_ids=("players",), + ) + ) + + ready = threading.Event() + server_error: list[BaseException] = [] + + def serve_requests() -> None: + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("127.0.0.1", 0)) + port_holder.append(server.getsockname()[1]) + server.listen(8) + ready.set() + for _ in range(8): + connection, _ = server.accept() + with connection: + stream = connection.makefile("rwb") + request = msgpack.unpackb(read_frame(stream), raw=False) + if request["schemaVersion"] == "cultmesh.verse_catalog_request.v0": + response = verses.create_response(request) + elif request["schemaVersion"] == "cultmesh.peer_exchange_request.v0": + response = peers.create_response(request) + else: + raise AssertionError(f"unexpected request {request['schemaVersion']}") + write_frame(stream, msgpack.packb(response, use_bin_type=True)) + stream.flush() + except BaseException as error: + server_error.append(error) + ready.set() + + port_holder: list[int] = [] + thread = threading.Thread(target=serve_requests, daemon=True) + thread.start() + self.assertTrue(ready.wait(2.0)) + self.assertFalse(server_error) + + client = CultMeshDiscoveryClient("127.0.0.1", port_holder[0], timeout_seconds=2.0) + fetched_verses = client.fetch_verses(transport_version="cultmesh.v0") + fetched_peers = client.fetch_peers(verse_id="aetheria-main", roles=["read-replica"]) + local_verses = CultMeshVerseCatalog() + local_peers = CultMeshPeerCatalog() + synced_verses = client.sync_verse_catalog(local_verses, transport_version="cultmesh.v0") + synced_peers = client.sync_peer_catalog(local_peers, verse_id="aetheria-main", roles=["read-replica"]) + discovered_verses = CultMeshVerseCatalog() + discovered_peers = CultMeshPeerCatalog() + endpoint = f"cultnet://127.0.0.1:{port_holder[0]}" + verse_discovery = CultMeshVerseDiscoveryClient.from_endpoint(endpoint, timeout_seconds=2.0) + peer_discovery = CultMeshPeerExchangeClient.from_endpoint(endpoint, timeout_seconds=2.0) + discovered_verse_count = verse_discovery.discover( + discovered_verses, + endpoints=[endpoint, endpoint, ""], + transport_version="cultmesh.v0", + ) + discovered_peer_count = peer_discovery.discover( + discovered_peers, + verse_id="aetheria-main", + endpoints=[endpoint, endpoint, ""], + roles=["read-replica"], + ) + fanout_verses = CultMeshVerseCatalog() + fanout_verses.upsert( + CultMeshVerseDescriptor( + verse_id="aetheria-main", + display_name="Known Aetheria", + authority_model="federated", + compatibility=CultMeshVerseCompatibility("cultmesh.v0", "rules-1"), + discovery_endpoints=(endpoint, endpoint, ""), + ) + ) + fanout_peers = CultMeshPeerCatalog() + fanout_peers.upsert( + CultMeshPeerCard( + peer_id="known-peer", + verse_id="aetheria-main", + endpoints=(endpoint, endpoint, ""), + roles=("read-replica",), + ) + ) + fanout_verse_count = verse_discovery.fanout(fanout_verses, transport_version="cultmesh.v0") + fanout_peer_count = peer_discovery.fanout( + fanout_peers, + verse_id="aetheria-main", + roles=["read-replica"], + ) + + thread.join(2.0) + self.assertFalse(server_error) + self.assertEqual(fetched_verses[0].verse_id, "aetheria-main") + self.assertEqual(fetched_verses[0].compatibility.required_plugin_ids, ("core",)) + self.assertEqual(fetched_peers[0].peer_id, "peer-a") + self.assertEqual(fetched_peers[0].shard_ids, ("players",)) + self.assertEqual(synced_verses[0].verse_id, "aetheria-main") + self.assertEqual(local_peers.find("aetheria-main", role="read-replica")[0].peer_id, "peer-a") + self.assertEqual(synced_peers[0].roles, ("read-replica",)) + self.assertEqual(discovered_verse_count, 1) + self.assertEqual(discovered_peer_count, 1) + self.assertEqual(discovered_verses.get("aetheria-main").display_name, "Aetheria") + self.assertEqual(discovered_peers.find("aetheria-main", role="read-replica")[0].peer_id, "peer-a") + self.assertEqual(fanout_verse_count, 1) + self.assertEqual(fanout_peer_count, 1) + self.assertEqual(fanout_verses.get("aetheria-main").display_name, "Aetheria") + self.assertIn( + "peer-a", + {peer.peer_id for peer in fanout_peers.find("aetheria-main", role="read-replica")}, + ) + + def test_cultmesh_discovery_client_fans_out_simulation_observations_to_peers(self) -> None: + first_hub = CultNetSimulationObservationHub( + CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0) + ) + second_hub = CultNetSimulationObservationHub( + CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0) + ) + first_server = CultMesh.serve_node( + CultMesh.create_node(runtime_id="sim-peer-a"), + observation_hub=first_hub, + ) + second_server = CultMesh.serve_node( + CultMesh.create_node(runtime_id="sim-peer-b"), + observation_hub=second_hub, + ) + try: + peers = CultMeshPeerCatalog() + peers.upsert(CultMeshPeerCard( + peer_id="sim-peer-a", + verse_id="arena", + endpoints=(f"cultnet://127.0.0.1:{first_server.port}",), + roles=("simulation-witness",), + )) + peers.upsert(CultMeshPeerCard( + peer_id="sim-peer-b", + verse_id="arena", + endpoints=(f"cultnet://127.0.0.1:{second_server.port}",), + roles=("simulation-witness",), + )) + claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:200") + message = simulation_observation( + message_id="fanout-observation", + witness_runtime_id="python-fanout", + shard_id="arena", + shard_epoch=1, + frame=200, + subject_id="bob", + claim_kind="hit", + claim_hash=claim_hash, + claim_summary="alice hit bob", + ).to_wire() + + client = CultMeshDiscoveryClient("127.0.0.1", first_server.port, timeout_seconds=2.0) + candidates = client.fanout_simulation_observation( + peers, + message, + verse_id="arena", + roles=["simulation-witness"], + ) + finally: + first_server.stop() + second_server.stop() + + self.assertEqual([candidate["schemaVersion"] for candidate in candidates], [ + "cultnet.simulation_consensus_candidate.v0", + "cultnet.simulation_consensus_candidate.v0", + ]) + self.assertEqual({candidate["claimHash"] for candidate in candidates}, {claim_hash}) + self.assertEqual({candidate["messageId"] for candidate in candidates}, {"fanout-observation"}) + self.assertEqual({candidate["hasQuorum"] for candidate in candidates}, {True}) + + def test_cultmesh_simulation_observation_fanout_flushes_queued_messages(self) -> None: + hub = CultNetSimulationObservationHub( + CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0) + ) + server = CultMesh.serve_node(CultMesh.create_node(runtime_id="sim-flush-peer"), observation_hub=hub) + try: + peers = CultMeshPeerCatalog() + peers.upsert(CultMeshPeerCard( + peer_id="sim-flush-peer", + verse_id="arena", + endpoints=(f"cultnet://127.0.0.1:{server.port}",), + roles=("simulation-witness",), + )) + claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:210") + seen_candidates: list[dict[str, object]] = [] + fanout = CultMeshSimulationObservationFanout( + CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0), + peers, + verse_id="arena", + roles=["simulation-witness"], + on_candidate=seen_candidates.append, + ) + fanout.enqueue(simulation_observation( + message_id="queued-observation", + witness_runtime_id="python-fanout", + shard_id="arena", + shard_epoch=1, + frame=210, + subject_id="bob", + claim_kind="hit", + claim_hash=claim_hash, + claim_summary="alice hit bob", + ).to_wire()) + + candidates = fanout.flush() + finally: + server.stop() + + self.assertEqual(fanout.pending_count(), 0) + self.assertEqual([candidate["claimHash"] for candidate in candidates], [claim_hash]) + self.assertEqual(seen_candidates[0]["messageId"], "queued-observation") + + def test_cultmesh_simulation_observation_fanout_keeps_failed_messages_pending(self) -> None: + closed_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + closed_socket.bind(("127.0.0.1", 0)) + closed_port = closed_socket.getsockname()[1] + closed_socket.close() + peers = CultMeshPeerCatalog() + peers.upsert(CultMeshPeerCard( + peer_id="closed-simulation-peer", + verse_id="arena", + endpoints=(f"cultnet://127.0.0.1:{closed_port}",), + roles=("simulation-witness",), + )) + errors: list[tuple[str, str]] = [] + fanout = CultMeshSimulationObservationFanout( + CultMeshDiscoveryClient("127.0.0.1", closed_port, timeout_seconds=0.2), + peers, + verse_id="arena", + roles=["simulation-witness"], + on_error=lambda endpoint, error: errors.append((endpoint, str(error))), + ) + fanout.enqueue(simulation_observation( + message_id="retry-observation", + witness_runtime_id="python-fanout", + shard_id="arena", + shard_epoch=1, + frame=212, + subject_id="bob", + claim_kind="miss", + claim_hash=compute_simulation_claim_hash("miss", "alice", "bob", "frame:212"), + claim_summary="alice missed bob", + ).to_wire()) + + candidates = fanout.flush() + + self.assertEqual(candidates, []) + self.assertEqual(fanout.pending_count(), 1) + self.assertEqual(len(errors), 1) + self.assertIn(f"127.0.0.1:{closed_port}", errors[0][0]) + + def test_cultmesh_simulation_observation_fanout_runs_background_loop(self) -> None: + hub = CultNetSimulationObservationHub( + CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0) + ) + server = CultMesh.serve_node(CultMesh.create_node(runtime_id="sim-loop-peer"), observation_hub=hub) + try: + peers = CultMeshPeerCatalog() + peers.upsert(CultMeshPeerCard( + peer_id="sim-loop-peer", + verse_id="arena", + endpoints=(f"cultnet://127.0.0.1:{server.port}",), + roles=("simulation-witness",), + )) + claim_hash = compute_simulation_claim_hash("block", "dana", "eve", "frame:211") + seen_candidates: list[dict[str, object]] = [] + fanout = CultMeshSimulationObservationFanout( + CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0), + peers, + verse_id="arena", + roles=["simulation-witness"], + interval_seconds=0.05, + on_candidate=seen_candidates.append, + ) + fanout.start() + fanout.enqueue(simulation_observation( + message_id="loop-observation", + witness_runtime_id="python-fanout", + shard_id="arena", + shard_epoch=1, + frame=211, + subject_id="eve", + claim_kind="block", + claim_hash=claim_hash, + claim_summary="dana blocked eve", + ).to_wire()) + deadline = time.monotonic() + 2.0 + while not seen_candidates and time.monotonic() < deadline: + time.sleep(0.01) + fanout.stop() + finally: + server.stop() + + self.assertEqual(fanout.pending_count(), 0) + self.assertTrue(seen_candidates) + self.assertEqual(seen_candidates[0]["claimHash"], claim_hash) + self.assertEqual(seen_candidates[0]["messageId"], "loop-observation") + + def test_cultmesh_discovery_client_fans_out_snapshots_to_peers(self) -> None: + document = define_database_entry_type( + "mesh.snapshot_fanout_note", + [("body", 0)], + schema_id="mesh.snapshot_fanout_note.v1", + ) + first = CultMesh.create_node(runtime_id="snapshot-peer-a") + first.database.register_document(document) + first.database.put_raw_message(document, "note:a", {"body": "from-a"}, shard_id="notes", shard_epoch=1) + second = CultMesh.create_node(runtime_id="snapshot-peer-b") + second.database.register_document(document) + second.database.put_raw_message(document, "note:b", {"body": "from-b"}, shard_id="notes", shard_epoch=1) + target = CultMesh.create_node(runtime_id="snapshot-target") + target.database.register_document(document) + first_server = CultMesh.serve_node(first) + second_server = CultMesh.serve_node(second) + try: + peers = CultMeshPeerCatalog() + peers.upsert(CultMeshPeerCard( + peer_id="snapshot-peer-a", + verse_id="mesh", + endpoints=(f"cultnet://127.0.0.1:{first_server.port}", f"cultnet://127.0.0.1:{first_server.port}"), + roles=("read-replica",), + )) + peers.upsert(CultMeshPeerCard( + peer_id="snapshot-peer-b", + verse_id="mesh", + endpoints=(f"cultnet://127.0.0.1:{second_server.port}",), + roles=("read-replica",), + )) + + client = CultMeshDiscoveryClient("127.0.0.1", first_server.port, timeout_seconds=2.0) + responses = client.fanout_snapshot_responses( + peers, + verse_id="mesh", + roles=["read-replica"], + schema_ids=["mesh.snapshot_fanout_note.v1"], + ) + applied = client.sync_snapshots( + target.database, + peers, + verse_id="mesh", + roles=["read-replica"], + schema_ids=["mesh.snapshot_fanout_note.v1"], + ) + finally: + first_server.stop() + second_server.stop() + + self.assertEqual(len(responses), 2) + self.assertTrue(all(isinstance(response, CultNetRawSnapshotResponse) for response in responses)) + self.assertEqual( + sorted(record.record_key for response in responses for record in response.documents), + ["note:a", "note:b"], + ) + self.assertEqual(sorted(record.record_key for record in applied), ["note:a", "note:b"]) + self.assertEqual(target.database.get_required(document, "note:a")["body"], "from-a") + self.assertEqual(target.database.get_required(document, "note:b")["body"], "from-b") + + def test_cultmesh_discovery_client_sync_documents_returns_requested_aliases(self) -> None: + @dataclass + class CanonicalNote: + body: str + + @dataclass + class UiNote: + body: str + + document = define_database_entry_type( + "mesh.snapshot_fanout_alias_note", + [("body", 0)], + cls=CanonicalNote, + schema_id="mesh.snapshot_fanout_alias_note.v1", + schema_name="mesh.snapshot_fanout_alias_note", + schema_version="mesh.snapshot_fanout_alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.snapshot_fanout_alias_note.ui", + [("body", 0)], + cls=UiNote, + schema_id="mesh.snapshot_fanout_alias_note.v1", + schema_name="mesh.snapshot_fanout_alias_note", + schema_version="mesh.snapshot_fanout_alias_note.v1", + ) + source = CultMesh.create_node(runtime_id="snapshot-alias-source") + source.database.register_document(document) + source.database.put_raw_message(document, "note:alias", CanonicalNote("from-source")) + target = CultMesh.create_node(runtime_id="snapshot-alias-target") + target.database.register_document(document) + server = CultMesh.serve_node(source) + try: + peers = CultMeshPeerCatalog() + peers.upsert(CultMeshPeerCard( + peer_id="snapshot-alias-source", + verse_id="mesh", + endpoints=(f"cultnet://127.0.0.1:{server.port}",), + roles=("read-replica",), + )) + + values = CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0).sync_documents( + target.database, + peers, + verse_id="mesh", + roles=["read-replica"], + documents=[(alias, "note:alias")], + ) + finally: + server.stop() + + self.assertEqual(len(values), 1) + self.assertIsInstance(values[0], UiNote) + self.assertEqual(values[0].body, "from-source") + self.assertEqual(target.database.get_required(document, "note:alias").body, "from-source") + self.assertIsInstance(target.database.get_required(alias, "note:alias"), UiNote) + + def test_cultmesh_document_subscription_syncs_alias_snapshot_and_changes(self) -> None: + @dataclass + class CanonicalNote: + body: str + + @dataclass + class UiNote: + body: str + + document = define_database_entry_type( + "mesh.subscription_alias_note", + [("body", 0)], + cls=CanonicalNote, + schema_id="mesh.subscription_alias_note.v1", + schema_name="mesh.subscription_alias_note", + schema_version="mesh.subscription_alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.subscription_alias_note.ui", + [("body", 0)], + cls=UiNote, + schema_id="mesh.subscription_alias_note.v1", + schema_name="mesh.subscription_alias_note", + schema_version="mesh.subscription_alias_note.v1", + ) + source = CultMesh.create_node(runtime_id="subscription-alias-source") + source.database.register_document(document) + source.database.put_raw_message(document, "note:live", CanonicalNote("initial")) + target = CultMesh.create_node(runtime_id="subscription-alias-target") + target.database.register_document(document) + server = CultMesh.serve_node(source) + try: + raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) + with CultMesh.subscribe_document(target, raw_client, alias, "note:live") as subscription: + initial = subscription.sync_initial() + subscription.send(document_put_raw( + message_id="subscription-alias-put", + key="note:live", + schema_id="mesh.subscription_alias_note.v1", + stored_at="2026-06-14T00:00:00Z", + payload=document.encode_payload(CanonicalNote("updated")), + )) + updated = subscription.read_next_change() + subscription.send(document_delete( + message_id="subscription-alias-delete", + schema_id="mesh.subscription_alias_note.v1", + record_key="note:live", + )) + removed = subscription.read_next_change() + finally: + server.stop() + + self.assertIsInstance(initial, UiNote) + self.assertEqual(initial.body, "initial") + self.assertEqual(updated.change_kind, "updated") + self.assertIsInstance(updated.value, UiNote) + self.assertEqual(updated.value.body, "updated") + self.assertIsInstance(updated.previous_value, UiNote) + self.assertEqual(updated.previous_value.body, "initial") + self.assertEqual(removed.change_kind, "removed") + self.assertIsNone(removed.value) + self.assertIsInstance(removed.previous_value, UiNote) + self.assertEqual(removed.previous_value.body, "updated") + self.assertIsNone(target.database.get(alias, "note:live")) + + def test_cultmesh_snapshot_fanout_syncs_once_and_reports_applied_records(self) -> None: + document = define_database_entry_type( + "mesh.snapshot_loop_note", + [("body", 0)], + schema_id="mesh.snapshot_loop_note.v1", + ) + source = CultMesh.create_node(runtime_id="snapshot-loop-source") + source.database.register_document(document) + source.database.put_raw_message(document, "note:sync-once", {"body": "from-source"}) + target = CultMesh.create_node(runtime_id="snapshot-loop-target") + target.database.register_document(document) + server = CultMesh.serve_node(source) + try: + peers = CultMeshPeerCatalog() + peers.upsert(CultMeshPeerCard( + peer_id="snapshot-loop-source", + verse_id="mesh", + endpoints=(f"cultnet://127.0.0.1:{server.port}",), + roles=("read-replica",), + )) + seen: list[str] = [] + fanout = CultMeshSnapshotFanout( + CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0), + target.database, + peers, + verse_id="mesh", + roles=["read-replica"], + schema_ids=["mesh.snapshot_loop_note.v1"], + on_applied=lambda record: seen.append(record.record_key), + ) + + applied = fanout.sync_once() + finally: + server.stop() + + self.assertEqual([record.record_key for record in applied], ["note:sync-once"]) + self.assertEqual(seen, ["note:sync-once"]) + self.assertEqual(target.database.get_required(document, "note:sync-once")["body"], "from-source") + + def test_cultmesh_snapshot_fanout_syncs_typed_documents_and_reports_alias_values(self) -> None: + @dataclass + class CanonicalNote: + body: str + + @dataclass + class UiNote: + body: str + + document = define_database_entry_type( + "mesh.snapshot_loop_alias_note", + [("body", 0)], + cls=CanonicalNote, + schema_id="mesh.snapshot_loop_alias_note.v1", + schema_name="mesh.snapshot_loop_alias_note", + schema_version="mesh.snapshot_loop_alias_note.v1", + ) + alias = define_database_entry_type( + "mesh.snapshot_loop_alias_note.ui", + [("body", 0)], + cls=UiNote, + schema_id="mesh.snapshot_loop_alias_note.v1", + schema_name="mesh.snapshot_loop_alias_note", + schema_version="mesh.snapshot_loop_alias_note.v1", + ) + source = CultMesh.create_node(runtime_id="snapshot-loop-alias-source") + source.database.register_document(document) + source.database.put_raw_message(document, "note:typed-loop", CanonicalNote("from-source")) + target = CultMesh.create_node(runtime_id="snapshot-loop-alias-target") + target.database.register_document(document) + server = CultMesh.serve_node(source) + try: + peers = CultMeshPeerCatalog() + peers.upsert(CultMeshPeerCard( + peer_id="snapshot-loop-alias-source", + verse_id="mesh", + endpoints=(f"cultnet://127.0.0.1:{server.port}",), + roles=("read-replica",), + )) + seen: list[UiNote] = [] + fanout = CultMeshSnapshotFanout( + CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0), + target.database, + peers, + verse_id="mesh", + roles=["read-replica"], + documents=[(alias, "note:typed-loop")], + on_document=seen.append, + ) + + values = fanout.sync_once() + finally: + server.stop() + + self.assertEqual(len(values), 1) + self.assertIsInstance(values[0], UiNote) + self.assertEqual(values[0].body, "from-source") + self.assertEqual(seen, [values[0]]) + self.assertEqual(target.database.get_required(document, "note:typed-loop").body, "from-source") + self.assertIsInstance(target.database.get_required(alias, "note:typed-loop"), UiNote) + + def test_cultmesh_snapshot_fanout_runs_background_loop(self) -> None: + document = define_database_entry_type( + "mesh.snapshot_background_note", + [("body", 0)], + schema_id="mesh.snapshot_background_note.v1", + ) + source = CultMesh.create_node(runtime_id="snapshot-background-source") + source.database.register_document(document) + source.database.put_raw_message(document, "note:background", {"body": "from-background"}) + target = CultMesh.create_node(runtime_id="snapshot-background-target") + target.database.register_document(document) + server = CultMesh.serve_node(source) + try: + peers = CultMeshPeerCatalog() + peers.upsert(CultMeshPeerCard( + peer_id="snapshot-background-source", + verse_id="mesh", + endpoints=(f"cultnet://127.0.0.1:{server.port}",), + roles=("read-replica",), + )) + seen: list[str] = [] + fanout = CultMeshSnapshotFanout( + CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0), + target.database, + peers, + verse_id="mesh", + roles=["read-replica"], + schema_ids=["mesh.snapshot_background_note.v1"], + interval_seconds=0.05, + on_applied=lambda record: seen.append(record.record_key), + ) + fanout.start() + deadline = time.monotonic() + 2.0 + while not seen and time.monotonic() < deadline: + time.sleep(0.01) + fanout.stop() + finally: + server.stop() + + self.assertTrue(seen) + self.assertEqual(target.database.get_required(document, "note:background")["body"], "from-background") + + def test_cultmesh_peer_health_monitor_probes_reachable_and_failed_endpoints(self) -> None: + document = define_database_entry_type( + "mesh.health_note", + [("body", 0)], + schema_id="mesh.health_note.v1", + ) + node = CultMesh.create_node(runtime_id="health-peer") + node.database.register_document(document) + server = CultMesh.serve_node(node) + closed_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + closed_socket.bind(("127.0.0.1", 0)) + closed_port = closed_socket.getsockname()[1] + closed_socket.close() + try: + peers = CultMeshPeerCatalog() + peers.upsert(CultMeshPeerCard( + peer_id="health-peer", + verse_id="mesh", + endpoints=(f"cultnet://127.0.0.1:{server.port}",), + roles=("read-replica",), + )) + peers.upsert(CultMeshPeerCard( + peer_id="closed-peer", + verse_id="mesh", + endpoints=(f"cultnet://127.0.0.1:{closed_port}",), + roles=("read-replica",), + )) + seen: list[tuple[str, bool]] = [] + monitor = CultMeshPeerHealthMonitor( + runtime_id="health-prober", + timeout_seconds=0.2, + on_update=lambda health: seen.append((health.peer_id, health.is_reachable)), + ) + + results = monitor.probe_catalog(peers, verse_id="mesh", roles=["read-replica"]) + finally: + server.stop() + + by_peer = {result.peer_id: result for result in results} + self.assertTrue(by_peer["health-peer"].is_reachable) + self.assertEqual(by_peer["health-peer"].runtime_id, "health-peer") + self.assertEqual(by_peer["health-peer"].runtime_kind, "python") + self.assertEqual(by_peer["health-peer"].display_name, "health-peer") + self.assertEqual(by_peer["health-peer"].supported_document_types, ("mesh.health_note",)) + self.assertIn("cultnet.hello.v0", by_peer["health-peer"].supported_message_versions) + self.assertIn("cultnet.error.v0", by_peer["health-peer"].supported_message_versions) + self.assertEqual(by_peer["health-peer"].supported_mutation_contracts[0]["documentType"], "mesh.health_note") + self.assertIn("shardLog", by_peer["health-peer"].supported_mutation_contracts[0]["operations"]) + self.assertFalse(by_peer["closed-peer"].is_reachable) + self.assertIsNone(by_peer["closed-peer"].runtime_kind) + self.assertEqual(by_peer["closed-peer"].supported_document_types, ()) + self.assertEqual(by_peer["closed-peer"].supported_message_versions, ()) + self.assertEqual(by_peer["closed-peer"].supported_mutation_contracts, ()) + self.assertTrue(by_peer["closed-peer"].error) + self.assertEqual({peer_id for peer_id, _ in seen}, {"health-peer", "closed-peer"}) + self.assertEqual(len(monitor.latest()), 2) + + def test_cultmesh_peer_health_monitor_runs_background_probe_loop(self) -> None: + server = CultMesh.serve_node(CultMesh.create_node(runtime_id="health-loop-peer")) + try: + peers = CultMeshPeerCatalog() + peers.upsert(CultMeshPeerCard( + peer_id="health-loop-peer", + verse_id="mesh", + endpoints=(f"cultnet://127.0.0.1:{server.port}",), + roles=("read-replica",), + )) + seen: list[str] = [] + monitor = CultMeshPeerHealthMonitor( + runtime_id="health-loop-prober", + timeout_seconds=0.2, + interval_seconds=0.05, + on_update=lambda health: seen.append(health.peer_id), + ) + monitor.start(peers, verse_id="mesh", roles=["read-replica"]) + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline: + latest = monitor.latest() + if latest and latest[0].is_reachable: + break + time.sleep(0.01) + monitor.stop() + finally: + server.stop() + + self.assertTrue(seen) + latest = monitor.latest() + self.assertEqual(latest[0].peer_id, "health-loop-peer") + self.assertTrue(latest[0].is_reachable) + + def test_cultmesh_daemon_entrypoint_serves_framed_cultnet_hello(self) -> None: + with tempfile.TemporaryDirectory() as temp: + ready_path = Path(temp) / "ready.json" + package_src = Path(__file__).resolve().parents[1] / "src" + env = dict(os.environ) + existing_pythonpath = env.get("PYTHONPATH") + env["PYTHONPATH"] = ( + str(package_src) + if not existing_pythonpath + else f"{package_src}{os.pathsep}{existing_pythonpath}" + ) + process = subprocess.Popen( + [ + sys.executable, + "-m", + "cultmesh_py.daemon", + "--runtime-id", + "daemon-test-peer", + "--display-name", + "Daemon Test Peer", + "--port", + "0", + "--ready-file", + str(ready_path), + "--seed-interop-note", + "--seed-shard-id", + "daemon-interop", + "--verse-id", + "daemon-verse", + "--verse-display-name", + "Daemon Verse", + "--role", + "shard-primary", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + try: + deadline = time.monotonic() + 5.0 + while not ready_path.exists() and time.monotonic() < deadline: + if process.poll() is not None: + break + time.sleep(0.05) + if not ready_path.exists(): + _, stderr = process.communicate(timeout=2.0) + self.fail(f"cultmesh daemon did not publish readiness: {stderr}") + ready = json.loads(ready_path.read_text(encoding="utf-8")) + self.assertEqual(ready["schemaVersion"], READY_SCHEMA_VERSION) + self.assertEqual(ready["runtimeId"], "daemon-test-peer") + self.assertEqual(ready["runtimeKind"], "python") + self.assertEqual(ready["displayName"], "Daemon Test Peer") + self.assertEqual(ready["endpoint"], f"cultnet://127.0.0.1:{ready['port']}") + self.assertEqual(ready["shardIds"], ["daemon-interop"]) + self.assertEqual(ready["supportedDocumentTypes"], ["cultcache.interop-note"]) + self.assertIn("cultnet.hello.v0", ready["supportedMessageVersions"]) + self.assertIn("cultnet.document_put_raw.v0", ready["supportedMessageVersions"]) + self.assertNotIn("cultnet.simulation_observation.v0", ready["supportedMessageVersions"]) + self.assertEqual(ready["supportedMutationContracts"][0]["documentType"], "cultcache.interop-note") + self.assertIn("shardLog", ready["supportedMutationContracts"][0]["operations"]) + self.assertEqual(ready["snapshotLimits"], {"maxSnapshotBytes": None, "maxSnapshotDocuments": None}) + self.assertEqual(ready["verses"][0]["verseId"], "daemon-verse") + self.assertEqual(ready["verses"][0]["displayName"], "Daemon Verse") + self.assertEqual(ready["verses"][0]["discoveryEndpoints"], [ready["endpoint"]]) + self.assertEqual(ready["verses"][0]["authorityRuntimeIds"], ["daemon-test-peer"]) + self.assertEqual(ready["peers"][0]["peerId"], "daemon-test-peer") + self.assertEqual(ready["peers"][0]["endpoints"], [ready["endpoint"]]) + self.assertEqual(ready["peers"][0]["roles"], ["shard-primary"]) + self.assertEqual(ready["peers"][0]["shardIds"], ["daemon-interop"]) + + client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) + hello_response = client.request( + hello(runtime_id="daemon-test-prober"), + expected_schema_version="cultnet.hello.v0", + ) + + self.assertEqual(hello_response["runtimeId"], "daemon-test-peer") + self.assertEqual(hello_response["displayName"], "Daemon Test Peer") + self.assertIn("cultnet.hello.v0", hello_response["supportedMessageVersions"]) + self.assertIn("cultnet.schema_catalog_request.v0", hello_response["supportedMessageVersions"]) + self.assertNotIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"]) + self.assertEqual(hello_response["supportedDocumentTypes"], ["cultcache.interop-note"]) + self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["protocol"], "tcp_framed") + self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["port"], int(ready["port"])) + self.assertEqual(ready["supportedMessageVersions"], hello_response["supportedMessageVersions"]) + self.assertEqual(ready["supportedMutationContracts"], hello_response["supportedMutationContracts"]) + self.assertEqual(ready["transportProfiles"], hello_response["transportProfiles"]) + + schema_response = client.fetch_schema_catalog(schema_ids=["cultcache.interop-note"], include_schema_json=True) + self.assertEqual(schema_response["schemas"][0]["schemaVersion"], INTEROP_SCHEMA_VERSION) + self.assertEqual(schema_response["schemas"][0]["documentType"], "cultcache.interop-note") + + snapshot_response = client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"]) + self.assertEqual(snapshot_response.documents[0].record_key, "note:daemon-test-peer") + note = interop_note_document.decode_payload(snapshot_response.documents[0].payload) + self.assertEqual(note["authorRuntimeId"], "daemon-test-peer") + self.assertIn("daemon", note["tags"]) + + shard_catalog = client.fetch_shard_catalog(schema_ids=["cultcache.interop-note"]) + self.assertEqual(shard_catalog["shards"][0]["shardId"], "daemon-interop") + self.assertEqual(shard_catalog["shards"][0]["schemaIds"], ["cultcache.interop-note"]) + + shard_log = client.fetch_shard_log_response(shard_id="daemon-interop", shard_epoch=1) + self.assertFalse(shard_log.resync_required) + self.assertEqual(shard_log.entries[0].sequence, 1) + self.assertEqual(shard_log.entries[0].change_kind, "added") + self.assertIsNotNone(shard_log.entries[0].raw_document) + assert shard_log.entries[0].raw_document is not None + self.assertEqual(shard_log.entries[0].raw_document.record_key, "note:daemon-test-peer") + + discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) + verses = discovery_client.fetch_verses(transport_version="cultmesh.v0") + self.assertEqual(verses[0].verse_id, "daemon-verse") + self.assertEqual(verses[0].display_name, "Daemon Verse") + self.assertEqual(verses[0].discovery_endpoints, (ready["endpoint"],)) + self.assertEqual(verses[0].authority_runtime_ids, ("daemon-test-peer",)) + self.assertEqual([verse.to_wire() for verse in verses], ready["verses"]) + + peers = discovery_client.fetch_peers(verse_id="daemon-verse", roles=["shard-primary"]) + self.assertEqual(peers[0].peer_id, "daemon-test-peer") + self.assertEqual(peers[0].endpoints, (ready["endpoint"],)) + self.assertEqual(peers[0].roles, ("shard-primary",)) + self.assertEqual(peers[0].shard_ids, ("daemon-interop",)) + self.assertEqual([peer.to_wire() for peer in peers], ready["peers"]) + + monitor = CultMeshPeerHealthMonitor(runtime_id="daemon-health-prober", timeout_seconds=2.0) + health = monitor.probe_peer(peers[0])[0] + self.assertTrue(health.is_reachable) + self.assertEqual(health.runtime_id, "daemon-test-peer") + self.assertEqual(health.supported_document_types, ("cultcache.interop-note",)) + self.assertEqual(health.supported_mutation_contracts[0]["documentType"], "cultcache.interop-note") + finally: + process.terminate() + try: + process.communicate(timeout=5.0) + except subprocess.TimeoutExpired: + process.kill() + process.communicate(timeout=5.0) + + def test_cultmesh_daemon_restarts_with_persisted_cache_and_shard_log(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + cache_path = root / "mesh.cc" + shard_log_path = root / "shard-log" + env = dict(os.environ) + package_src = Path(__file__).resolve().parents[1] / "src" + existing_pythonpath = env.get("PYTHONPATH") + env["PYTHONPATH"] = ( + str(package_src) + if not existing_pythonpath + else f"{package_src}{os.pathsep}{existing_pythonpath}" + ) + + first_ready = root / "first-ready.json" + first = subprocess.Popen( + [ + sys.executable, + "-m", + "cultmesh_py.daemon", + "--runtime-id", + "durable-daemon-peer", + "--port", + "0", + "--cache-file", + str(cache_path), + "--enable-durable-shard-logs", + "--shard-log-file", + str(shard_log_path), + "--seed-interop-note", + "--seed-shard-id", + "durable-interop", + "--ready-file", + str(first_ready), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + try: + self._wait_for_ready_file(first, first_ready) + finally: + self._terminate_process(first) + + second_ready = root / "second-ready.json" + second = subprocess.Popen( + [ + sys.executable, + "-m", + "cultmesh_py.daemon", + "--runtime-id", + "durable-daemon-peer", + "--port", + "0", + "--cache-file", + str(cache_path), + "--enable-durable-shard-logs", + "--shard-log-file", + str(shard_log_path), + "--register-interop-note", + "--verse-id", + "durable-verse", + "--role", + "shard-primary", + "--ready-file", + str(second_ready), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + try: + ready = self._wait_for_ready_file(second, second_ready) + client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) + + snapshot_response = client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"]) + self.assertEqual(snapshot_response.documents[0].record_key, "note:durable-daemon-peer") + note = interop_note_document.decode_payload(snapshot_response.documents[0].payload) + self.assertEqual(note["authorRuntimeId"], "durable-daemon-peer") + + shard_log = client.fetch_shard_log_response(shard_id="durable-interop", shard_epoch=1) + self.assertFalse(shard_log.resync_required) + self.assertEqual(shard_log.entries[0].sequence, 1) + self.assertIsNotNone(shard_log.entries[0].raw_document) + assert shard_log.entries[0].raw_document is not None + self.assertEqual(shard_log.entries[0].raw_document.record_key, "note:durable-daemon-peer") + + discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) + peers = discovery_client.fetch_peers(verse_id="durable-verse", roles=["shard-primary"]) + self.assertEqual(peers[0].shard_ids, ("durable-interop",)) + finally: + self._terminate_process(second) + + def test_cultmesh_daemon_serves_live_database_subscription_mutations(self) -> None: + with tempfile.TemporaryDirectory() as temp: + ready_path = Path(temp) / "ready.json" + package_src = Path(__file__).resolve().parents[1] / "src" + env = dict(os.environ) + existing_pythonpath = env.get("PYTHONPATH") + env["PYTHONPATH"] = ( + str(package_src) + if not existing_pythonpath + else f"{package_src}{os.pathsep}{existing_pythonpath}" + ) + process = subprocess.Popen( + [ + sys.executable, + "-m", + "cultmesh_py.daemon", + "--runtime-id", + "live-daemon-peer", + "--port", + "0", + "--seed-interop-note", + "--seed-shard-id", + "live-interop", + "--ready-file", + str(ready_path), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + try: + ready = self._wait_for_ready_file(process, ready_path) + client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) + live_note = { + "schemaVersion": INTEROP_SCHEMA_VERSION, + "documentId": "note:daemon-live", + "authorRuntimeId": "daemon-live-client", + "title": "daemon live subscription note", + "body": "live subscription put from Python daemon smoke", + "tags": ["daemon", "subscription", "interop"], + } + + with client.subscribe_database( + subscription_id="daemon-live-sub", + schema_ids=["cultcache.interop-note"], + ) as subscription: + snapshot = subscription.read_next_snapshot_response() + subscription.send(document_put_raw( + message_id="daemon-live-put", + key="note:daemon-live", + schema_id="cultcache.interop-note", + stored_at="2026-06-14T00:00:00Z", + payload=interop_note_document.encode_payload(live_note), + source_runtime_id="daemon-live-client", + shard_id="live-interop", + shard_epoch=1, + )) + change = subscription.read_next_change() + + self.assertEqual(snapshot.documents[0].record_key, "note:live-daemon-peer") + self.assertEqual(change.change_kind, "added") + self.assertEqual(change.record_key, "note:daemon-live") + self.assertIsNotNone(change.raw_document) + assert change.raw_document is not None + self.assertEqual( + interop_note_document.decode_payload(change.raw_document.payload), + live_note, + ) + + shard_log = client.fetch_shard_log_response(shard_id="live-interop", shard_epoch=1) + self.assertFalse(shard_log.resync_required) + self.assertEqual([entry.sequence for entry in shard_log.entries], [1, 2]) + self.assertEqual(shard_log.entries[1].change_kind, "added") + self.assertIsNotNone(shard_log.entries[1].raw_document) + assert shard_log.entries[1].raw_document is not None + self.assertEqual(shard_log.entries[1].raw_document.record_key, "note:daemon-live") + finally: + self._terminate_process(process) + + def test_cultmesh_daemon_serves_compacted_shard_log_resync_boundary(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + cache_path = root / "mesh.cc" + shard_log_path = root / "shard-log" + store = CultNetFileShardMutationLogStore(shard_log_path) + node = CultMesh.create_node( + cache_path, + runtime_id="compacted-daemon-peer", + enable_durable_shard_logs=True, + shard_log_path=shard_log_path, + ) + node.database.register_document(interop_note_document) + node.database.put_raw_message( + interop_note_document, + "note:compacted-one", + { + "schemaVersion": INTEROP_SCHEMA_VERSION, + "documentId": "note:compacted-one", + "authorRuntimeId": "compacted-daemon-peer", + "title": "compacted daemon note one", + "body": "first retained cache value", + "tags": ["daemon", "compaction"], + }, + message_id="compacted-put-1", + shard_id="compacted-interop", + shard_epoch=7, + ) + node.database.put_raw_message( + interop_note_document, + "note:compacted-two", + { + "schemaVersion": INTEROP_SCHEMA_VERSION, + "documentId": "note:compacted-two", + "authorRuntimeId": "compacted-daemon-peer", + "title": "compacted daemon note two", + "body": "second retained cache value", + "tags": ["daemon", "compaction"], + }, + message_id="compacted-put-2", + shard_id="compacted-interop", + shard_epoch=7, + ) + store.compact_through("compacted-interop", 1) + + ready_path = root / "ready.json" + package_src = Path(__file__).resolve().parents[1] / "src" + env = dict(os.environ) + existing_pythonpath = env.get("PYTHONPATH") + env["PYTHONPATH"] = ( + str(package_src) + if not existing_pythonpath + else f"{package_src}{os.pathsep}{existing_pythonpath}" + ) + process = subprocess.Popen( + [ + sys.executable, + "-m", + "cultmesh_py.daemon", + "--runtime-id", + "compacted-daemon-peer", + "--port", + "0", + "--cache-file", + str(cache_path), + "--enable-durable-shard-logs", + "--shard-log-file", + str(shard_log_path), + "--register-interop-note", + "--ready-file", + str(ready_path), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + try: + ready = self._wait_for_ready_file(process, ready_path) + client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) + + snapshot_response = client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"]) + self.assertEqual( + {document.record_key for document in snapshot_response.documents}, + {"note:compacted-one", "note:compacted-two"}, + ) + + compacted = client.fetch_shard_log_response( + shard_id="compacted-interop", + shard_epoch=7, + after_sequence=0, + ) + retained = client.fetch_shard_log_response( + shard_id="compacted-interop", + shard_epoch=7, + after_sequence=1, + ) + + self.assertTrue(compacted.resync_required) + self.assertEqual(compacted.reason, "compacted_history") + self.assertEqual(compacted.compacted_through, 1) + self.assertEqual(compacted.entries, ()) + self.assertFalse(retained.resync_required) + self.assertEqual([entry.sequence for entry in retained.entries], [2]) + self.assertIsNotNone(retained.entries[0].raw_document) + assert retained.entries[0].raw_document is not None + self.assertEqual(retained.entries[0].raw_document.record_key, "note:compacted-two") + finally: + self._terminate_process(process) + + def test_cultmesh_daemon_accepts_schema_write_forwarder_mutations(self) -> None: + with tempfile.TemporaryDirectory() as temp: + ready_path = Path(temp) / "ready.json" + package_src = Path(__file__).resolve().parents[1] / "src" + env = dict(os.environ) + existing_pythonpath = env.get("PYTHONPATH") + env["PYTHONPATH"] = ( + str(package_src) + if not existing_pythonpath + else f"{package_src}{os.pathsep}{existing_pythonpath}" + ) + process = subprocess.Popen( + [ + sys.executable, + "-m", + "cultmesh_py.daemon", + "--runtime-id", + "forwarder-daemon-peer", + "--port", + "0", + "--register-interop-note", + "--ready-file", + str(ready_path), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + try: + ready = self._wait_for_ready_file(process, ready_path) + endpoint = f"cultnet://127.0.0.1:{ready['port']}" + shard = CultNetShardDescriptor( + shard_id="forwarded-interop", + owner_runtime_id="forwarder-daemon-peer", + epoch=3, + is_primary=False, + schema_ids=("cultcache.interop-note",), + primary_endpoints=(endpoint,), + ) + forwarder: CultNetShardWriteForwarder = CultNetSchemaWriteForwarder(timeout_seconds=2.0) + note = { + "schemaVersion": INTEROP_SCHEMA_VERSION, + "documentId": "note:forwarded-daemon", + "authorRuntimeId": "forwarding-client", + "title": "forwarded daemon note", + "body": "forwarded through the schema write-forwarder", + "tags": ["daemon", "forwarded"], + } + + forwarder.forward_put(shard, document_put_raw( + message_id="forwarded-daemon-put", + key="note:forwarded-daemon", + schema_id="cultcache.interop-note", + stored_at="2026-06-14T00:00:00Z", + payload=interop_note_document.encode_payload(note), + source_runtime_id="forwarding-client", + ).to_wire()) + + client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) + self._eventually(lambda: bool(client.fetch_snapshot_response( + schema_ids=["cultcache.interop-note"], + record_keys=["note:forwarded-daemon"], + ).documents)) + snapshot = client.fetch_snapshot_response( + schema_ids=["cultcache.interop-note"], + record_keys=["note:forwarded-daemon"], + ) + self.assertEqual( + interop_note_document.decode_payload(snapshot.documents[0].payload), + note, + ) + + forwarder.forward_delete(shard, document_delete( + message_id="forwarded-daemon-delete", + schema_id="cultcache.interop-note", + record_key="note:forwarded-daemon", + ).to_wire()) + self._eventually(lambda: not client.fetch_snapshot_response( + schema_ids=["cultcache.interop-note"], + record_keys=["note:forwarded-daemon"], + ).documents) + + shard_log = client.fetch_shard_log_response(shard_id="forwarded-interop", shard_epoch=3) + self.assertEqual([entry.change_kind for entry in shard_log.entries], ["added", "removed"]) + self.assertEqual(shard_log.entries[0].put["shardId"], "forwarded-interop") + self.assertEqual(shard_log.entries[0].put["shardEpoch"], 3) + self.assertEqual(shard_log.entries[1].delete["shardId"], "forwarded-interop") + self.assertEqual(shard_log.entries[1].delete["shardEpoch"], 3) + finally: + self._terminate_process(process) + + def test_cultmesh_daemon_enforces_snapshot_document_limit_as_peer_error(self) -> None: + with tempfile.TemporaryDirectory() as temp: + ready_path = Path(temp) / "ready.json" + package_src = Path(__file__).resolve().parents[1] / "src" + env = dict(os.environ) + existing_pythonpath = env.get("PYTHONPATH") + env["PYTHONPATH"] = ( + str(package_src) + if not existing_pythonpath + else f"{package_src}{os.pathsep}{existing_pythonpath}" + ) + process = subprocess.Popen( + [ + sys.executable, + "-m", + "cultmesh_py.daemon", + "--runtime-id", + "limited-daemon-peer", + "--port", + "0", + "--seed-interop-note", + "--max-snapshot-documents", + "0", + "--ready-file", + str(ready_path), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + try: + ready = self._wait_for_ready_file(process, ready_path) + self.assertEqual(ready["snapshotLimits"], {"maxSnapshotBytes": None, "maxSnapshotDocuments": 0}) + client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) + + with self.assertRaisesRegex(CultNetPeerError, "Snapshot document limit exceeded") as raised: + client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"]) + + self.assertEqual(raised.exception.response["schemaVersion"], "cultnet.error.v0") + self.assertEqual(raised.exception.response["code"], "snapshot_document_limit_exceeded") + self.assertEqual(raised.exception.response["details"]["documentCount"], 1) + self.assertEqual(raised.exception.response["details"]["maxSnapshotDocuments"], 0) + finally: + self._terminate_process(process) + + def test_cultmesh_daemon_serves_opt_in_simulation_observations(self) -> None: + with tempfile.TemporaryDirectory() as temp: + ready_path = Path(temp) / "ready.json" + package_src = Path(__file__).resolve().parents[1] / "src" + env = dict(os.environ) + existing_pythonpath = env.get("PYTHONPATH") + env["PYTHONPATH"] = ( + str(package_src) + if not existing_pythonpath + else f"{package_src}{os.pathsep}{existing_pythonpath}" + ) + process = subprocess.Popen( + [ + sys.executable, + "-m", + "cultmesh_py.daemon", + "--runtime-id", + "simulation-daemon-peer", + "--port", + "0", + "--enable-simulation-observations", + "--simulation-minimum-witnesses", + "2", + "--simulation-quorum-ratio", + "1.0", + "--verse-id", + "simulation-verse", + "--ready-file", + str(ready_path), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + try: + ready = self._wait_for_ready_file(process, ready_path) + self.assertIn("cultnet.simulation_observation.v0", ready["supportedMessageVersions"]) + self.assertIn("cultnet.simulation_consensus_candidate.v0", ready["supportedMessageVersions"]) + client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) + hello_response = client.request( + hello(runtime_id="simulation-daemon-prober"), + expected_schema_version="cultnet.hello.v0", + ) + self.assertEqual(ready["supportedMessageVersions"], hello_response["supportedMessageVersions"]) + discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) + peers = discovery_client.fetch_peers(verse_id="simulation-verse", roles=["simulation-observer"]) + self.assertEqual(peers[0].peer_id, "simulation-daemon-peer") + self.assertIn("simulation-observer", peers[0].roles) + self.assertIn("read-replica", peers[0].roles) + + claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100") + first = client.request( + simulation_observation( + message_id="daemon-sim-1", + witness_runtime_id="watcher-1", + shard_id="arena", + shard_epoch=4, + frame=100, + subject_id="bob", + claim_kind="hit", + claim_hash=claim_hash, + claim_summary="alice shot bob first", + ), + expected_schema_version="cultnet.simulation_consensus_candidate.v0", + ) + second = client.request( + simulation_observation( + message_id="daemon-sim-2", + witness_runtime_id="watcher-2", + shard_id="arena", + shard_epoch=4, + frame=100, + subject_id="bob", + claim_kind="hit", + claim_hash=claim_hash, + claim_summary="alice shot bob first", + ), + expected_schema_version="cultnet.simulation_consensus_candidate.v0", + ) + + self.assertEqual(first["messageId"], "daemon-sim-1") + self.assertFalse(first["hasQuorum"]) + self.assertEqual(first["witnessCount"], 1) + self.assertEqual(second["messageId"], "daemon-sim-2") + self.assertTrue(second["hasQuorum"]) + self.assertEqual(second["witnessCount"], 2) + self.assertEqual(second["claimHash"], claim_hash) + finally: + self._terminate_process(process) + + def _wait_for_ready_file(self, process: subprocess.Popen[str], ready_path: Path) -> dict[str, object]: + deadline = time.monotonic() + 5.0 + while not ready_path.exists() and time.monotonic() < deadline: + if process.poll() is not None: + break + time.sleep(0.05) + if not ready_path.exists(): + _, stderr = process.communicate(timeout=2.0) + self.fail(f"cultmesh daemon did not publish readiness: {stderr}") + return json.loads(ready_path.read_text(encoding="utf-8")) + + def _terminate_process(self, process: subprocess.Popen[str]) -> None: + process.terminate() + try: + process.communicate(timeout=5.0) + except subprocess.TimeoutExpired: + process.kill() + process.communicate(timeout=5.0) + + def test_cultmesh_local_server_serves_node_and_catalogs_over_clients(self) -> None: + document = define_database_entry_type( + "mesh.server_note", + [("body", 0)], + schema_id="mesh.server_note.v1", + ) + node = CultMesh.create_node(runtime_id="mesh-server") + node.database.register_document(document) + node.database.put_raw_message(document, "note:1", {"body": "served"}, shard_id="notes", shard_epoch=1) + observation_hub = CultNetSimulationObservationHub( + CultNetSimulationConsensusOptions(minimum_witnesses=2, quorum_ratio=1.0) + ) + seen_candidates: list[CultNetSimulationConsensusCandidate] = [] + unsubscribe_candidates = observation_hub.watch_candidates(seen_candidates.append) + claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100") + verses = CultMeshVerseCatalog() + verses.upsert( + CultMeshVerseDescriptor( + verse_id="server-verse", + display_name="Server Verse", + authority_model="local", + compatibility=CultMeshVerseCompatibility("cultmesh.v0", "rules"), + ) + ) + peers = CultMeshPeerCatalog() + peers.upsert( + CultMeshPeerCard( + peer_id="mesh-server", + verse_id="server-verse", + endpoints=("cultnet://127.0.0.1:0",), + roles=("read-replica",), + ) + ) + + server = CultMesh.serve_node( + node, + verse_catalog=verses, + peer_catalog=peers, + observation_hub=observation_hub, + display_name="Mesh Server", + ) + try: + raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) + hello_response = raw_client.request(hello(runtime_id="probe"), expected_schema_version="cultnet.hello.v0") + schema_response = raw_client.fetch_schema_catalog(schema_ids=["mesh.server_note.v1"], include_schema_json=True) + rudp_client = CultMesh.create_client( + endpoint=f"rudp://127.0.0.1:{server.port}", + timeout_seconds=2.0, + connection_id=server.rudp_connection_id, + runtime_id="rudp-probe", + ) + rudp_hello_response = rudp_client.request( + hello(runtime_id="rudp-probe"), + expected_schema_version="cultnet.hello.v0", + ) + rudp_schema_response = rudp_client.fetch_schema_catalog( + schema_ids=["mesh.server_note.v1"], + include_schema_json=True, + ) + wire_schema_response = raw_client.fetch_schema_catalog(kinds=["wire_message"], include_schema_json=True) + synced_schema_catalog = CultNetSchemaCatalog() + synced_wire_descriptors = raw_client.sync_schema_catalog( + synced_schema_catalog, + kinds=["wire_message"], + include_schema_json=True, + ) + snapshot_response = raw_client.fetch_snapshot(schema_ids=["mesh.server_note.v1"]) + typed_snapshot_response = raw_client.fetch_snapshot_response(schema_ids=["mesh.server_note.v1"]) + shard_catalog = raw_client.fetch_shard_catalog(schema_ids=["mesh.server_note.v1"]) + synced_shard_catalog = CultNetShardCatalog() + synced_shards = raw_client.sync_shard_catalog(synced_shard_catalog, schema_ids=["mesh.server_note.v1"]) + shard_log = raw_client.fetch_shard_log(shard_id="notes", shard_epoch=1) + typed_shard_log = raw_client.fetch_shard_log_response(shard_id="notes", shard_epoch=1) + self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["protocol"], "tcp_framed") + self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["port"], server.port) + stale_shard_log = raw_client.fetch_shard_log(shard_id="notes", shard_epoch=0) + discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", server.port, timeout_seconds=2.0) + fetched_verses = discovery_client.fetch_verses(transport_version="cultmesh.v0") + fetched_peers = discovery_client.fetch_peers(verse_id="server-verse", roles=["read-replica"]) + first_candidate = raw_client.request( + simulation_observation( + message_id="obs-1", + witness_runtime_id="watcher-1", + shard_id="arena", + shard_epoch=4, + frame=100, + subject_id="bob", + claim_kind="hit", + claim_hash=claim_hash, + claim_summary="alice shot bob first", + ), + expected_schema_version="cultnet.simulation_consensus_candidate.v0", + ) + quorum_candidate = raw_client.request( + simulation_observation( + message_id="obs-2", + witness_runtime_id="watcher-2", + shard_id="arena", + shard_epoch=4, + frame=100, + subject_id="bob", + claim_kind="hit", + claim_hash=claim_hash, + claim_summary="alice shot bob first", + ), + expected_schema_version="cultnet.simulation_consensus_candidate.v0", + ) + with raw_client.subscribe_database(subscription_id="sub-server", schema_ids=["mesh.server_note.v1"]) as subscription: + subscription_snapshot = subscription.read_next_snapshot_response() + subscription.send(document_put_raw( + message_id="server-put", + key="note:2", + schema_id="mesh.server_note.v1", + stored_at="2026-06-14T00:00:00Z", + payload=document.encode_payload({"body": "subscribed"}), + shard_id="primary", + shard_epoch=1, + )) + subscription_change = subscription.read_next_change() + subscription.send(document_delete( + message_id="server-delete", + schema_id="mesh.server_note.v1", + record_key="note:2", + shard_id="primary", + shard_epoch=1, + )) + subscription_delete = subscription.read_next_change() + finally: + unsubscribe_candidates() + server.stop() + + self.assertEqual(hello_response["runtimeId"], "mesh-server") + self.assertEqual(hello_response["displayName"], "Mesh Server") + self.assertIn("cultnet.database_subscribe.v0", hello_response["supportedMessageVersions"]) + self.assertIn("cultnet.error.v0", hello_response["supportedMessageVersions"]) + self.assertIn("cultnet.document_put_raw.v0", hello_response["supportedMessageVersions"]) + self.assertIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"]) + self.assertEqual(hello_response["supportedMutationContracts"][0]["documentType"], "mesh.server_note") + self.assertIn("documentDelete", hello_response["supportedMutationContracts"][0]["operations"]) + self.assertIn("shardLog", hello_response["supportedMutationContracts"][0]["operations"]) + advertised_transports = { + transport["protocol"]: transport + for profile in hello_response["transportProfiles"] + for transport in profile["transports"] + } + self.assertEqual(advertised_transports["tcp_framed"]["port"], server.port) + self.assertEqual(advertised_transports["rudp"]["port"], server.port) + self.assertEqual(rudp_hello_response["runtimeId"], "mesh-server") + self.assertEqual(rudp_schema_response["schemas"][0]["schemaId"], "mesh.server_note.v1") + self.assertEqual(schema_response["schemas"][0]["schemaId"], "mesh.server_note.v1") + self.assertIn("schemaJson", schema_response["schemas"][0]) + self.assertIn("cultnet.document_delete.v0", schema_response["schemas"][0]["wireContracts"]) + self.assertIn("cultnet.shard_log_response.v0", schema_response["schemas"][0]["wireContracts"]) + wire_descriptors = {schema["schemaVersion"]: schema for schema in wire_schema_response["schemas"]} + self.assertEqual(wire_descriptors["cultnet.error.v0"]["kind"], "wire_message") + self.assertEqual(wire_descriptors["cultnet.document_put_raw.v0"]["kind"], "wire_message") + self.assertIn("schemaJson", wire_descriptors["cultnet.document_put_raw.v0"]) + 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/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") + self.assertEqual(shard_catalog["shards"][0]["schemaIds"], ["mesh.server_note.v1"]) + self.assertEqual(shard_catalog["shards"][0]["epoch"], 1) + self.assertEqual(shard_catalog["shards"][0]["shardId"], "notes") + self.assertEqual(synced_shards[0].shard_id, "notes") + self.assertEqual(synced_shards[0].epoch, 1) + self.assertEqual(synced_shard_catalog.get("notes"), synced_shards[0]) + self.assertEqual(shard_log["entries"][0]["changeKind"], "added") + self.assertTrue(stale_shard_log["resyncRequired"]) + self.assertEqual(stale_shard_log["reason"], "stale_epoch") + self.assertEqual(stale_shard_log["shardEpoch"], 1) + self.assertEqual(stale_shard_log["entries"], []) + self.assertEqual(typed_shard_log.last_sequence, 1) + self.assertEqual(typed_shard_log.entries[0].put["document"]["recordKey"], "note:1") + self.assertIsInstance(typed_shard_log.entries[0].raw_document, CultNetRawDocumentRecord) + self.assertEqual(typed_shard_log.entries[0].raw_document.record_key, "note:1") + self.assertEqual(fetched_verses[0].verse_id, "server-verse") + self.assertEqual(fetched_peers[0].peer_id, "mesh-server") + self.assertEqual(first_candidate["messageId"], "obs-1") + self.assertFalse(first_candidate["hasQuorum"]) + self.assertEqual(quorum_candidate["messageId"], "obs-2") + self.assertEqual(quorum_candidate["claimHash"], claim_hash) + self.assertTrue(quorum_candidate["hasQuorum"]) + self.assertEqual(quorum_candidate["witnessCount"], 2) + self.assertEqual([candidate.claim_hash for candidate in seen_candidates], [claim_hash, claim_hash]) + self.assertIsInstance(subscription_snapshot, CultNetRawSnapshotResponse) + self.assertEqual(subscription_snapshot.to_wire()["schemaVersion"], "cultnet.snapshot_response_raw.v0") + self.assertEqual(subscription_change.change_kind, "added") + self.assertEqual(subscription_change.record_key, "note:2") + self.assertEqual(subscription_change.document["recordKey"], "note:2") + self.assertIsInstance(subscription_change.raw_document, CultNetRawDocumentRecord) + self.assertEqual(subscription_change.raw_document.schema_id, "mesh.server_note.v1") + self.assertEqual(subscription_delete.change_kind, "removed") + self.assertEqual(subscription_delete.record_key, "note:2") + self.assertIsNone(subscription_delete.raw_document) + self.assertIsNone(node.database.get(document, "note:2")) + + def test_cultmesh_local_server_resolves_snapshot_and_shard_catalog_schema_aliases(self) -> None: + document = define_database_entry_type( + "mesh.alias_server_note", + [("body", 0)], + schema_id="sha256:mesh-alias-server-note", + schema_name="mesh.alias_server_note", + schema_version="mesh.alias_server_note.v1", + ) + node = CultMesh.create_node(runtime_id="mesh-alias-server") + node.database.register_document(document) + node.database.put_raw_message( + document, + "note:alias", + {"body": "served through alias"}, + shard_id="notes", + shard_epoch=1, + ) + + server = CultMesh.serve_node(node) + try: + raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) + snapshot = raw_client.fetch_snapshot(schema_ids=["mesh.alias_server_note.v1"]) + typed_snapshot = raw_client.fetch_snapshot_response(schema_ids=["mesh.alias_server_note.v1"]) + shard_catalog = raw_client.fetch_shard_catalog(schema_ids=["mesh.alias_server_note.v1"]) + finally: + server.stop() + + local_schema_id = document.catalog_entry().schema_id + self.assertEqual(snapshot["documents"][0]["schemaId"], local_schema_id) + self.assertEqual(snapshot["documents"][0]["recordKey"], "note:alias") + self.assertEqual(typed_snapshot.documents[0].schema_id, local_schema_id) + self.assertEqual(shard_catalog["shards"][0]["schemaIds"], [local_schema_id]) + self.assertEqual(shard_catalog["shards"][0]["shardId"], "notes") + + def test_cultmesh_shard_snapshot_keeps_foreign_schema_alias_membership(self) -> None: + document = define_database_entry_type( + "mesh.alias_shard_note", + [ + ("schema_version", 0), + ("body", 1), + ], + schema_id="sha256:mesh-alias-shard-note", + schema_name="mesh.alias_shard_note", + schema_version="mesh.alias_shard_note.v1", + ) + node = CultMesh.create_node(runtime_id="mesh-alias-shard-server") + node.database.register_document(document) + + node.database.apply_raw_put_message(document_put_raw( + message_id="put-foreign-shard-alias", + key="note:foreign-shard", + schema_id="runtime.generated.mesh.alias_shard_note.ui.99", + stored_at="2026-06-27T00:00:00Z", + payload=document.encode_payload({ + "schema_version": "mesh.alias_shard_note.v1", + "body": "visible through shard snapshot", + }), + shard_id="notes", + shard_epoch=1, + ).to_wire()) + node.database.put_raw_message( + document, + "note:other-shard", + { + "schema_version": "mesh.alias_shard_note.v1", + "body": "hidden by shard filter", + }, + shard_id="other", + shard_epoch=1, + ) + + response = node.database.build_snapshot_response( + schema_ids=["mesh.alias_shard_note.v1"], + shard_id="notes", + shard_epoch=1, + ) + + local_schema_id = document.catalog_entry().schema_id + self.assertEqual(response.shard_id, "notes") + self.assertEqual(response.shard_epoch, 1) + self.assertEqual([(record.schema_id, record.record_key) for record in response.documents], [ + (local_schema_id, "note:foreign-shard"), + ]) + self.assertEqual(node.cache.get_required_envelope(document, "note:foreign-shard").schema_id, local_schema_id) + + node.database.apply_raw_delete_message(document_delete( + message_id="delete-foreign-shard-alias", + schema_id="mesh.alias_shard_note.v1", + record_key="note:foreign-shard", + shard_id="notes", + shard_epoch=1, + ).to_wire()) + deleted_response = node.database.build_snapshot_response( + schema_ids=["mesh.alias_shard_note.v1"], + shard_id="notes", + shard_epoch=1, + ) + self.assertEqual(deleted_response.documents, ()) + + def test_cultmesh_local_server_notifies_schema_alias_subscription_for_raw_put(self) -> None: + document = define_database_entry_type( + "mesh.alias_sub_note", + [ + ("schema_version", 0), + ("body", 1), + ], + schema_id="sha256:mesh-alias-sub-note", + schema_name="mesh.alias_sub_note", + schema_version="mesh.alias_sub_note.v1", + ) + node = CultMesh.create_node(runtime_id="mesh-alias-sub-server") + node.database.register_document(document) + + server = CultMesh.serve_node(node) + try: + raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) + with raw_client.subscribe_database( + subscription_id="sub-alias", + schema_ids=["mesh.alias_sub_note.v1"], + include_snapshot=False, + ) as subscription: + subscription.send(document_put_raw( + message_id="put-foreign-alias", + key="note:alias-sub", + schema_id="runtime.generated.mesh.alias_sub_note.ui.99", + stored_at="2026-06-27T00:00:00Z", + payload=document.encode_payload({ + "schema_version": "mesh.alias_sub_note.v1", + "body": "notified", + }), + )) + change = subscription.read_next_change() + finally: + server.stop() + + local_schema_id = document.catalog_entry().schema_id + self.assertEqual(change.subscription_id, "sub-alias") + self.assertEqual(change.change_kind, "added") + self.assertEqual(change.record_key, "note:alias-sub") + self.assertIsInstance(change.raw_document, CultNetRawDocumentRecord) + self.assertEqual(change.raw_document.schema_id, "runtime.generated.mesh.alias_sub_note.ui.99") + self.assertEqual(node.database.get_required(document, "note:alias-sub")["body"], "notified") + self.assertEqual(node.cache.get_required_envelope(document, "note:alias-sub").schema_id, local_schema_id) + + def test_cultmesh_local_server_notifies_schema_alias_subscription_for_delete(self) -> None: + document = define_database_entry_type( + "mesh.alias_delete_note", + [("body", 0)], + schema_id="sha256:mesh-alias-delete-note", + schema_name="mesh.alias_delete_note", + schema_version="mesh.alias_delete_note.v1", + ) + node = CultMesh.create_node(runtime_id="mesh-alias-delete-server") + node.database.register_document(document) + node.database.put_raw_message( + document, + "note:delete-alias", + {"body": "remove me"}, + shard_id="notes", + shard_epoch=1, + ) + + server = CultMesh.serve_node(node) + try: + raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) + with raw_client.subscribe_database( + subscription_id="sub-delete-alias", + schema_ids=[document.catalog_entry().schema_id], + include_snapshot=False, + ) as subscription: + subscription.send(document_delete( + message_id="delete-foreign-alias", + schema_id="mesh.alias_delete_note.v1", + record_key="note:delete-alias", + shard_id="notes", + shard_epoch=1, + )) + change = subscription.read_next_change() + finally: + server.stop() + + self.assertEqual(change.subscription_id, "sub-delete-alias") + self.assertEqual(change.change_kind, "removed") + self.assertEqual(change.schema_id, "mesh.alias_delete_note.v1") + self.assertEqual(change.record_key, "note:delete-alias") + self.assertIsNone(change.raw_document) + self.assertIsNone(node.database.get(document, "note:delete-alias")) + + def test_cultmesh_local_server_rejects_oversized_snapshot_responses(self) -> None: + document = define_database_entry_type( + "mesh.snapshot_limit_note", + [("body", 0)], + schema_id="mesh.snapshot_limit_note.v1", + ) + node = CultMesh.create_node(runtime_id="mesh-snapshot-limits") + node.database.register_document(document) + node.database.put_raw_message(document, "note:1", {"body": "first"}) + node.database.put_raw_message(document, "note:2", {"body": "second"}) + + server = CultMesh.serve_node(node, max_snapshot_documents=1) + try: + raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) + filtered_snapshot = raw_client.fetch_snapshot(record_keys=["note:1"]) + error = raw_client.request( + snapshot_request(schema_ids=["mesh.snapshot_limit_note.v1"]), + expected_schema_version="cultnet.error.v0", + ) + with self.assertRaisesRegex(CultNetPeerError, "Snapshot document limit exceeded"): + raw_client.fetch_snapshot(schema_ids=["mesh.snapshot_limit_note.v1"]) + finally: + server.stop() + + self.assertEqual(filtered_snapshot["schemaVersion"], "cultnet.snapshot_response_raw.v0") + self.assertEqual([document["recordKey"] for document in filtered_snapshot["documents"]], ["note:1"]) + self.assertEqual(error["schemaVersion"], "cultnet.error.v0") + self.assertIn("Snapshot document limit exceeded", error["error"]) + self.assertEqual(error["code"], "snapshot_document_limit_exceeded") + self.assertEqual(error["details"]["documentCount"], 2) + self.assertEqual(error["details"]["maxSnapshotDocuments"], 1) + + def test_cultmesh_local_server_returns_errors_for_bad_requests(self) -> None: + document = define_database_entry_type( + "mesh.error_note", + [("body", 0)], + schema_id="mesh.error_note.v1", + ) + node = CultMesh.create_node(runtime_id="mesh-error-server") + node.database.register_document(document) + + server = CultMesh.serve_node(node) + try: + raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) + unsupported = raw_client.request( + {"schemaVersion": "cultnet.nope.v0", "messageId": "bad-schema"}, + expected_schema_version="cultnet.error.v0", + ) + malformed_put = raw_client.request( + {"schemaVersion": "cultnet.document_put_raw.v0", "messageId": "bad-put"}, + expected_schema_version="cultnet.error.v0", + ) + malformed_delete = raw_client.request( + {"schemaVersion": "cultnet.document_delete.v0", "messageId": "bad-delete", "schemaId": "mesh.error_note.v1"}, + expected_schema_version="cultnet.error.v0", + ) + finally: + server.stop() + + self.assertEqual(unsupported["messageId"], "bad-schema") + self.assertEqual(unsupported["code"], "unsupported_schema_version") + self.assertEqual(unsupported["details"]["schemaVersion"], "cultnet.nope.v0") + self.assertIn("Unsupported CultNet message schema", unsupported["error"]) + self.assertEqual(malformed_put["messageId"], "bad-put") + self.assertEqual(malformed_put["code"], "malformed_document_put") + self.assertEqual(malformed_delete["messageId"], "bad-delete") + self.assertEqual(malformed_delete["code"], "malformed_document_delete") + + def test_cultmesh_authority_lease_requires_live_matching_lease(self) -> None: + peer = CultMeshPeerCard( + peer_id="voidbot-local", + verse_id="local", + endpoints=("cultmesh://localhost",), + roles=("shard-primary",), + authority_lease_id="lease:voidbot-local", + ) + leases = CultMeshAuthorityLeaseCatalog() + peers = CultMeshPeerCatalog() + peers.upsert(peer) + now = datetime.now(UTC) + self.assertFalse(leases.is_authorized(peer, "shard-primary", at=now)) + self.assertEqual(peers.find_authorized("local", "shard-primary", leases, at=now), []) + + leases.upsert( + CultMeshAuthorityLease( + lease_id="lease:voidbot-local", + verse_id="local", + peer_id="voidbot-local", + roles=("shard-primary", "shard-primary", ""), + valid_from=now - timedelta(seconds=1), + expires_at=now + timedelta(seconds=1), + shard_ids=("shard-a",), + issuer_runtime_id="odin", + signature="signed", + ) + ) + + lease = leases.get("lease:voidbot-local") + self.assertIsNotNone(lease) + assert lease is not None + self.assertEqual(leases.leases, (lease,)) + self.assertEqual(lease.roles, ("shard-primary",)) + self.assertEqual(lease.issuer_runtime_id, "odin") + self.assertEqual(lease.signature, "signed") + self.assertTrue(lease.is_valid_at(now)) + self.assertTrue(lease.covers(peer, "shard-primary", shard_id="shard-a", at=now)) + self.assertFalse(lease.covers(peer, "shard-primary", shard_id="shard-b", at=now)) + self.assertTrue(leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now)) + self.assertEqual( + peers.find_authorized("local", "shard-primary", leases, shard_id="shard-a", at=now), + [peer], + ) + self.assertEqual( + peers.first_authorized("local", "shard-primary", leases, shard_id="shard-a", at=now), + peer, + ) + self.assertIsNone(peers.first_authorized("local", "read-replica", leases, at=now)) + self.assertFalse(leases.is_authorized(peer, "shard-primary", shard_id="shard-b", at=now)) + self.assertFalse(leases.is_authorized(peer, "read-replica", at=now)) + self.assertIsNone(leases.get("missing")) + + def test_cultmesh_authority_lease_catalog_can_require_verified_signatures(self) -> None: + peer = CultMeshPeerCard( + peer_id="voidbot-local", + verse_id="local", + endpoints=("cultmesh://localhost",), + roles=("shard-primary",), + shard_ids=("shard-a",), + authority_lease_id="lease:voidbot-local", + ) + now = datetime.now(UTC) + unsigned = CultMeshAuthorityLease( + lease_id="lease:voidbot-local", + verse_id="local", + peer_id="voidbot-local", + roles=("shard-primary",), + valid_from=now - timedelta(seconds=1), + expires_at=now + timedelta(seconds=30), + shard_ids=("shard-a",), + issuer_runtime_id="odin", + ) + verifier = CultMeshHmacAuthorityLeaseVerifier({"odin": b"lease-secret"}) + signed = verifier.issue(unsigned) + strict_leases = CultMesh.create_authority_lease_catalog( + signature_verifier=verifier.verify, + require_verified_signatures=True, + ) + strict_leases.upsert(unsigned) + self.assertFalse(strict_leases.is_verified(unsigned.lease_id)) + self.assertFalse(strict_leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now)) + + strict_leases.upsert(signed) + self.assertTrue(strict_leases.is_verified(signed.lease_id)) + self.assertTrue(strict_leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now)) + + wrong_key = CultMesh.create_authority_lease_catalog( + signature_verifier=CultMeshHmacAuthorityLeaseVerifier({"odin": b"wrong-secret"}).verify, + require_verified_signatures=True, + ) + wrong_key.upsert(signed) + self.assertFalse(wrong_key.is_verified(signed.lease_id)) + self.assertFalse(wrong_key.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now)) + + tampered = CultMeshAuthorityLease( + lease_id=signed.lease_id, + verse_id=signed.verse_id, + peer_id=signed.peer_id, + roles=("shard-primary", "read-replica"), + valid_from=signed.valid_from, + expires_at=signed.expires_at, + shard_ids=signed.shard_ids, + issuer_runtime_id=signed.issuer_runtime_id, + signature=signed.signature, + ) + strict_leases.upsert(tampered) + self.assertFalse(strict_leases.is_verified(tampered.lease_id)) + self.assertFalse(strict_leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now)) + + def test_cultmesh_stream_catalog_negotiates_transport_and_latest_frame(self) -> None: + streams = CultMeshStreamCatalog() + streams.declare( + CultMeshStreamDescriptor( + stream_id="mimir:kiyo-pro", + verse_id="studio", + owner_peer_id="starfire", + kind="video", + label="Kiyo Pro", + clock={"clockDomainId": "starfire-qpc", "confidence": 0.25}, + video={"width": 1920, "height": 1080, "pixelFormat": "YUY2", "framesPerSecond": 30}, + preferred_transports=("shared-d3d12-texture", "shared-memory", "cultcache-page"), + max_in_flight_frames=3, + ) + ) + + negotiation = streams.negotiate( + "mimir:kiyo-pro", + CultMeshStreamConsumerProfile( + peer_id="fensalir", + verse_id="studio", + supported_transports=("shared-d3d12-texture", "cultcache-page"), + accepted_kinds=("video",), + can_import_gpu_handles=True, + max_in_flight_frames=2, + ), + ) + + self.assertEqual(negotiation.transport, "shared-d3d12-texture") + self.assertEqual(negotiation.max_in_flight_frames, 2) + self.assertEqual(negotiation.copy_budget, "zero-copy-target") + + streams.publish_frame( + CultMeshStreamFrameHandle( + stream_id="mimir:kiyo-pro", + sequence=42, + timestamp_ns=1_000_000_000, + duration_ns=33_333_334, + transport="shared-d3d12-texture", + native_handle="0xfeed", + fence_handle="0xbeef", + fence_value=7, + unavoidable_copy_count=0, + ) + ) + self.assertEqual(streams.latest_frame("mimir:kiyo-pro").sequence, 42) + if __name__ == "__main__": unittest.main() diff --git a/packages/cultnet-py/tools/GameCult.Caching.Benchmark/GameCult.Caching.Benchmark.csproj b/packages/cultcache-py/tools/GameCult.Caching.Benchmark/GameCult.Caching.Benchmark.csproj similarity index 100% rename from packages/cultnet-py/tools/GameCult.Caching.Benchmark/GameCult.Caching.Benchmark.csproj rename to packages/cultcache-py/tools/GameCult.Caching.Benchmark/GameCult.Caching.Benchmark.csproj diff --git a/packages/cultnet-py/tools/GameCult.Caching.Benchmark/Program.cs b/packages/cultcache-py/tools/GameCult.Caching.Benchmark/Program.cs similarity index 96% rename from packages/cultnet-py/tools/GameCult.Caching.Benchmark/Program.cs rename to packages/cultcache-py/tools/GameCult.Caching.Benchmark/Program.cs index 14ec8d5e..698b07ec 100644 --- a/packages/cultnet-py/tools/GameCult.Caching.Benchmark/Program.cs +++ b/packages/cultcache-py/tools/GameCult.Caching.Benchmark/Program.cs @@ -1,7 +1,6 @@ using System.Diagnostics; using System.Text.Json; using GameCult.Caching; -using MessagePack; var records = 5000; var emitJson = false; @@ -94,15 +93,12 @@ internal sealed record BenchmarkResult(string Runtime, int Records, IReadOnlyLis internal sealed record BenchmarkMetric(string Name, int Operations, double ElapsedMs, double OpsPerSecond); [CultDocument("bench.item", "bench.item.v1")] -[MessagePackObject(AllowPrivate = true)] internal sealed class BenchItem { - [Key(0)] [CultName] + [CultName] public string Name { get; set; } = string.Empty; - [Key(1)] public string Category { get; set; } = string.Empty; - [Key(2)] public int Value { get; set; } } diff --git a/packages/cultcache-rs/Cargo.toml b/packages/cultcache-rs/Cargo.toml index 23d53801..b11a909e 100644 --- a/packages/cultcache-rs/Cargo.toml +++ b/packages/cultcache-rs/Cargo.toml @@ -1,8 +1,7 @@ [package] name = "cultcache-rs" -version = "0.2.0" +version = "0.1.0" edition = "2024" -license = "MIT" publish = false [workspace] @@ -18,7 +17,6 @@ chrono = { version = "0.4", features = ["serde"] } cultcache-rs-derive = { path = "cultcache-rs-derive" } fs2 = "0.4" rmp-serde = "1.3" -redb = "4.1.0" serde = { version = "1", features = ["derive"] } serde_bytes = "0.11" serde_json = "1" @@ -27,6 +25,3 @@ uuid = { version = "1", features = ["v4"] } [dev-dependencies] pretty_assertions = "1.4" tempfile = "3" - -[target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } diff --git a/packages/cultcache-rs/README.md b/packages/cultcache-rs/README.md index 1ad01eb0..2d75d94e 100644 --- a/packages/cultcache-rs/README.md +++ b/packages/cultcache-rs/README.md @@ -53,7 +53,7 @@ struct Settings { let mut cache = CultCache::new(); cache.register_entry_type::()?; -cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new("cache.cc")); +cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new("cache.msgpack")); cache.pull_all_backing_stores()?; cache.put("app", &Settings { @@ -136,7 +136,7 @@ pub struct PlayerData { let mut cache = CultCache::builder() .with_generated_entries(game_entries()) - .with_generic_store(SingleFileMessagePackBackingStore::new("cache.cc")) + .with_generic_store(SingleFileMessagePackBackingStore::new("cache.msgpack")) .build()?; cache.pull_all_backing_stores()?; @@ -173,71 +173,39 @@ without pretending Rust has runtime reflection hiding under the floorboards. - `get_envelope::` - `get_required_envelope::` - `get_all::` -- `get_all_with_keys::` -- `soa::` - `put::` - `put_envelope::` - `update::` - `delete::` - `snapshot` -- `snapshot_soa` -- `load_soa` - `SingleFileMessagePackBackingStore` -## Structure Of Arrays - -The C# CultCache hot path exposes CPU-local SoA scans as: - -```csharp -Span x = cache.Soa() - .Column(nameof(PlayerTransform.PositionX)) - .Span; -``` - -Rust cannot discover those columns through runtime reflection, so each domain -type declares its column extractors explicitly with `SoaDocument` or the -`cultcache_soa!` helper: - -```rust -cultcache_soa!(PlayerTransform { - "position_x" => |row: &PlayerTransform| row.position_x, - "health" => |row: &PlayerTransform| row.health, -}); - -let table = cache.soa::()?; -let health = table.column::("health")?; -``` - -This is the same public shape as the C# rite: callers ask the cache for a typed -table, then pull contiguous typed columns by name. The Rust version is explicit -about the extractor table because the closed-world schema boundary is a virtue, -not a missing reflection party trick. - ## Backing Store Routing Generic store: ```rust -cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new("cache.cc"))?; +cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new("cache.msgpack")); ``` Type-specific store: ```rust cache.add_backing_store( - SingleFileMessagePackBackingStore::new("players.cc"), + SingleFileMessagePackBackingStore::new("players.msgpack"), ["player"], -)?; +); ``` -The contract, shared with every runtime, is one home store per document type -(`src/GameCult.Caching/Contracts/cultcache-store-composition.md` in CultLib): +When writing a `PlayerData`, the cache checks type-specific stores first. If none +match, it writes to the first generic store. Later matching stores are mirrors. -- a type-specific store owns its types; otherwise the generic store is the home -- a second generic store is an error, and so is a type claimed by a second - store; both registrations return `Err` and attach nothing -- a write touches exactly one store; there are no mirrors -- a write whose type has no home store is an error +This mirrors the C# behavior: + +- specific domain stores own their domain +- the first generic store is the primary generic write target +- later generic stores mirror writes +- this is not multi-master ## Persistence Semantics diff --git a/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs b/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs index 7d613194..f05a23f3 100644 --- a/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs +++ b/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs @@ -75,7 +75,6 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream { }; let mut key: Option = None; let mut default = false; - let mut bytes = false; for attribute in field .attrs .iter() @@ -89,9 +88,6 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream { } else if meta.path.is_ident("default") { default = true; Ok(()) - } else if meta.path.is_ident("bytes") { - bytes = true; - Ok(()) } else { Err(meta.error("unsupported field cultcache attribute")) } @@ -121,7 +117,6 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream { ident: field_ident, ty: field.ty, default, - bytes, }); } slots.sort_by_key(|slot| slot.key); @@ -130,14 +125,8 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream { let serialize_elements = (0..=max_slot).map(|slot| { if let Some(field) = slots.iter().find(|field| field.key == slot) { let field_ident = &field.ident; - if field.bytes { - quote! { - tuple.serialize_element(::serde_bytes::Bytes::new(&self.#field_ident))?; - } - } else { - quote! { - tuple.serialize_element(&self.#field_ident)?; - } + quote! { + tuple.serialize_element(&self.#field_ident)?; } } else { quote! { @@ -149,14 +138,8 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream { let option_declarations = slots.iter().map(|field| { let variable = format_ident!("slot_{}", field.key); let ty = &field.ty; - if field.bytes { - quote! { - let mut #variable: ::std::option::Option<::serde_bytes::ByteBuf> = ::std::option::Option::None; - } - } else { - quote! { - let mut #variable: ::std::option::Option<#ty> = ::std::option::Option::None; - } + quote! { + let mut #variable: ::std::option::Option<#ty> = ::std::option::Option::None; } }); @@ -164,17 +147,9 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream { if let Some(field) = slots.iter().find(|field| field.key == slot) { let variable = format_ident!("slot_{}", field.key); let ty = &field.ty; - if field.bytes { - quote! { - #slot => { - #variable = seq.next_element::<::serde_bytes::ByteBuf>()?; - } - } - } else { - quote! { - #slot => { - #variable = seq.next_element::<#ty>()?; - } + quote! { + #slot => { + #variable = seq.next_element::<#ty>()?; } } } else { @@ -189,16 +164,7 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream { let build_fields = slots.iter().map(|field| { let field_ident = &field.ident; let variable = format_ident!("slot_{}", field.key); - if field.bytes && field.default { - quote! { - #field_ident: #variable.unwrap_or_default().into_vec() - } - } else if field.bytes { - let field_name = field_ident.to_string(); - quote! { - #field_ident: #variable.ok_or_else(|| ::serde::de::Error::missing_field(#field_name))?.into_vec() - } - } else if field.default { + if field.default { quote! { #field_ident: #variable.unwrap_or_default() } @@ -274,5 +240,4 @@ struct CultCacheSlot { ident: syn::Ident, ty: Type, default: bool, - bytes: bool, } diff --git a/packages/cultcache-rs/examples/cultcache_interop.rs b/packages/cultcache-rs/examples/cultcache_interop.rs index e625db9e..4049acf8 100644 --- a/packages/cultcache-rs/examples/cultcache_interop.rs +++ b/packages/cultcache-rs/examples/cultcache_interop.rs @@ -74,7 +74,7 @@ fn read_note(file: &str) -> Result<()> { fn build_cache(file: &str) -> Result { let mut cache = CultCache::new(); cache.register_entry_type::()?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(file))?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(file)); Ok(cache) } diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index ed9cae32..67e88cdf 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -1,22 +1,15 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; -use anyhow::bail; -use anyhow::ensure; use serde::Serialize; use serde::de::DeserializeOwned; -use std::any::Any; use std::collections::BTreeMap; use std::collections::BTreeSet; use std::fs; use std::fs::File; use std::fs::OpenOptions; -use std::marker::PhantomData; use std::path::Path; use std::path::PathBuf; -use std::sync::Arc; - -use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition}; extern crate self as cultcache_rs; @@ -31,83 +24,6 @@ pub trait CultCacheRegistry { fn register_entries(&self, cache: &mut CultCache) -> Result<()>; } -pub trait SoaDocument: DatabaseEntry { - fn soa_columns(rows: &[Self]) -> BTreeMap<&'static str, CultSoaColumnValues>; -} - -pub struct CultSoaColumnValues { - type_name: &'static str, - values: Box, -} - -impl CultSoaColumnValues { - pub fn new(values: Vec) -> Self { - Self { - type_name: std::any::type_name::(), - values: Box::new(values), - } - } -} - -#[derive(Clone, Debug, PartialEq)] -pub struct CultSoaColumn { - name: String, - values: Vec, -} - -impl CultSoaColumn { - pub fn name(&self) -> &str { - &self.name - } - - pub fn values(&self) -> &[TValue] { - &self.values - } -} - -pub struct CultSoaTable { - keys: Vec, - documents: Vec, - columns: BTreeMap, - _document: PhantomData, -} - -impl CultSoaTable { - pub fn keys(&self) -> &[String] { - &self.keys - } - - pub fn documents(&self) -> &[TDocument] { - &self.documents - } - - pub fn len(&self) -> usize { - self.keys.len() - } - - pub fn is_empty(&self) -> bool { - self.keys.is_empty() - } - - pub fn column(&self, name: &str) -> Result> { - let Some(column) = self.columns.get(name) else { - return Err(anyhow!("SoA table has no column {name:?}")); - }; - let Some(values) = column.values.downcast_ref::>() else { - return Err(anyhow!( - "SoA table column {:?} stores {}, not {}", - name, - column.type_name, - std::any::type_name::() - )); - }; - Ok(CultSoaColumn { - name: name.to_string(), - values: values.clone(), - }) - } -} - #[macro_export] macro_rules! cultcache_registry { ($name:ident { $($entry:ty),* $(,)? }) => { @@ -125,22 +41,6 @@ macro_rules! cultcache_registry { }; } -#[macro_export] -macro_rules! cultcache_soa { - ($entry:ty { $($column:literal => $extractor:expr),* $(,)? }) => { - impl $crate::SoaDocument for $entry { - fn soa_columns(rows: &[Self]) -> ::std::collections::BTreeMap<&'static str, $crate::CultSoaColumnValues> { - let mut columns = ::std::collections::BTreeMap::new(); - $( - let values = rows.iter().map($extractor).collect::>(); - columns.insert($column, $crate::CultSoaColumnValues::new(values)); - )* - columns - } - } - }; -} - #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct CultCacheEnvelope { @@ -154,93 +54,6 @@ pub struct CultCacheEnvelope { pub schema_id: Option, } -#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CultCacheSoaTable { - pub keys: Vec, - pub types: Vec, - pub payloads: Vec>, - pub stored_ats: Vec, - pub schema_ids: Vec>, -} - -impl CultCacheSoaTable { - pub fn from_envelopes(entries: impl IntoIterator) -> Self { - let mut table = Self::default(); - for entry in entries { - table.keys.push(entry.key); - table.types.push(entry.r#type); - table.payloads.push(entry.payload); - table.stored_ats.push(entry.stored_at); - table.schema_ids.push(entry.schema_id); - } - table - } - - pub fn len(&self) -> usize { - self.keys.len() - } - - pub fn is_empty(&self) -> bool { - self.keys.is_empty() - } - - pub fn validate(&self) -> Result<()> { - let len = self.keys.len(); - for (name, candidate) in [ - ("types", self.types.len()), - ("payloads", self.payloads.len()), - ("storedAts", self.stored_ats.len()), - ("schemaIds", self.schema_ids.len()), - ] { - if candidate != len { - return Err(anyhow!( - "CultCache SoA column {name} has length {candidate}, expected {len}" - )); - } - } - for (index, key) in self.keys.iter().enumerate() { - if key.trim().is_empty() { - return Err(anyhow!("CultCache SoA row {index} has an empty key")); - } - if self.types[index].trim().is_empty() { - return Err(anyhow!("CultCache SoA row {index} has an empty type")); - } - if self.stored_ats[index].trim().is_empty() { - return Err(anyhow!("CultCache SoA row {index} has an empty stored_at")); - } - } - Ok(()) - } - - pub fn into_envelopes(self) -> Result> { - self.validate()?; - let Self { - keys, - types, - payloads, - stored_ats, - schema_ids, - } = self; - Ok(keys - .into_iter() - .zip(types) - .zip(payloads) - .zip(stored_ats) - .zip(schema_ids) - .map( - |((((key, r#type), payload), stored_at), schema_id)| CultCacheEnvelope { - key, - r#type, - payload, - stored_at, - schema_id, - }, - ) - .collect()) - } -} - #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] struct PersistedStoreSnapshot( String, @@ -284,56 +97,21 @@ pub struct PushAllOptions { pub soft: bool, } -/// One per-entry expectation for [`SingleFileMessagePackBackingStore::compare_exchange`]. -/// `current` is the envelope the caller believes is stored, or `None` when it -/// believes the identity is absent. -#[derive(Clone, Debug, PartialEq)] -pub struct CultCacheExpectedEnvelope { - pub key: String, - pub r#type: String, - pub current: Option, -} - -/// Result of one nonblocking whole-snapshot compare-and-exchange. Contention is -/// reported rather than waited on, so the caller keeps retry and scheduling -/// authority instead of blocking inside the store. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TryCompareExchangeSnapshotOutcome { - Exchanged, - Mismatch, - LockContended, -} - pub trait CacheBackingStore: Send { fn pull_all(&self) -> Result>; fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()>; fn delete(&mut self, entry: &CultCacheEnvelope) -> Result<()>; - /// Replaces everything the store holds with `entries` in one atomic step: - /// on `Err` the store still holds exactly what it held before. A cache - /// batch (`put_prepared_batch`) is all-or-nothing only through this. There - /// is no default, because a replace assembled from `delete` and `push` - /// calls leaves the store emptied or partial when one of them fails; a - /// store without an atomic replace does not compile: - /// - /// ```compile_fail,E0046 - /// use cultcache_rs::{CacheBackingStore, CultCacheEnvelope}; - /// - /// struct PerRecordStore; - /// - /// impl CacheBackingStore for PerRecordStore { - /// fn pull_all(&self) -> anyhow::Result> { - /// Ok(Vec::new()) - /// } - /// fn push(&mut self, _entry: &CultCacheEnvelope) -> anyhow::Result<()> { - /// Ok(()) - /// } - /// fn delete(&mut self, _entry: &CultCacheEnvelope) -> anyhow::Result<()> { - /// Ok(()) - /// } - /// } - /// ``` - fn push_all(&mut self, entries: &[CultCacheEnvelope], options: PushAllOptions) -> Result<()>; + fn push_all(&mut self, entries: &[CultCacheEnvelope], _options: PushAllOptions) -> Result<()> { + let existing = self.pull_all()?; + for entry in existing { + self.delete(&entry)?; + } + for entry in entries { + self.push(entry)?; + } + Ok(()) + } } #[derive(Clone, Debug, PartialEq, Eq)] @@ -350,305 +128,6 @@ impl SingleFileMessagePackBackingStore { &self.path } - /// Holds the pre-created sibling lock shared while a read-only consumer - /// evaluates and acts on one snapshot. The reader never creates or writes - /// either file. Writers using the sibling lock cannot replace the snapshot - /// until `action` returns. - /// - /// Releasing the lock is RAII cleanup. A release failure therefore cannot - /// rewrite the completed action's result; consumers that need to observe - /// that cleanup failure can use - /// [`Self::with_read_only_shared_snapshot_and_unlock_diagnostic`]. - pub fn with_read_only_shared_snapshot( - &self, - action: impl FnOnce(Vec) -> Result, - ) -> Result { - self.with_read_only_shared_snapshot_and_unlock_diagnostic(action, |_| {}) - } - - /// The diagnostic-bearing form of - /// [`Self::with_read_only_shared_snapshot`]. `on_unlock_failure` observes a - /// cleanup failure separately and never changes the action result. - pub fn with_read_only_shared_snapshot_and_unlock_diagnostic( - &self, - action: impl FnOnce(Vec) -> Result, - on_unlock_failure: impl FnOnce(anyhow::Error), - ) -> Result { - self.with_read_only_shared_snapshot_using( - action, - |lock| { - fs2::FileExt::unlock(lock) - .with_context(|| format!("failed to unlock {}", self.lock_path().display())) - }, - on_unlock_failure, - ) - } - - fn with_read_only_shared_snapshot_using( - &self, - action: impl FnOnce(Vec) -> Result, - unlock: U, - on_unlock_failure: D, - ) -> Result - where - U: FnOnce(&File) -> Result<()>, - D: FnOnce(anyhow::Error), - { - let lock_path = self.lock_path(); - let lock = OpenOptions::new() - .read(true) - .open(&lock_path) - .with_context(|| format!("failed to open pre-created {}", lock_path.display()))?; - fs2::FileExt::lock_shared(&lock) - .with_context(|| format!("failed to lock {}", lock_path.display()))?; - let guard = ReadOnlySharedLockGuard::new(lock, unlock, on_unlock_failure); - let result = self.read_all_unlocked().and_then(action); - drop(guard); - result - } - - /// Replaces one exact envelope under the store's cross-process exclusive - /// lock. A changed or missing expected envelope returns false without a write. - pub fn compare_and_swap_entry( - &self, - expected: &CultCacheEnvelope, - replacement: CultCacheEnvelope, - ) -> Result { - self.with_exclusive_lock(|| { - let mut entries = self.read_all_unlocked()?; - let Some(index) = entries - .iter() - .position(|candidate| entry_id(candidate) == entry_id(expected)) - else { - return Ok(false); - }; - if entries[index] != *expected { - return Ok(false); - } - entries[index] = replacement; - entries.sort_by_key(entry_id); - self.write_all_unlocked(&entries)?; - Ok(true) - }) - } - - /// Inserts one envelope only when its polymorphic type/key identity is - /// absent, under the same cross-process lock used by all single-file writes. - pub fn insert_entry_if_absent(&self, entry: CultCacheEnvelope) -> Result { - self.with_exclusive_lock(|| { - let mut entries = self.read_all_unlocked()?; - if entries - .iter() - .any(|candidate| entry_id(candidate) == entry_id(&entry)) - { - return Ok(false); - } - entries.push(entry); - entries.sort_by_key(entry_id); - self.write_all_unlocked(&entries)?; - Ok(true) - }) - } - - /// Atomically replaces an exact set of existing envelopes and inserts any - /// additional companion envelopes. Every precondition is checked beneath - /// the same cross-process lock as the single snapshot write. - pub fn compare_and_swap_batch( - &self, - expected: &[CultCacheEnvelope], - replacements: Vec, - ) -> Result { - if replacements.is_empty() { - return Err(anyhow!( - "conditional batch requires a non-empty replacement set" - )); - } - let expected_ids = unique_batch_ids(expected, "expected")?; - let replacement_ids = unique_batch_ids(&replacements, "replacement")?; - if !expected_ids.is_subset(&replacement_ids) { - return Err(anyhow!( - "conditional batch must replace every expected identity" - )); - } - - self.with_exclusive_lock(|| { - let mut entries = self.read_all_unlocked()?; - for expected_entry in expected { - let Some(current) = entries - .iter() - .find(|candidate| entry_id(candidate) == entry_id(expected_entry)) - else { - return Ok(false); - }; - if current != expected_entry { - return Ok(false); - } - } - for replacement in &replacements { - let identity = entry_id(replacement); - if !expected_ids.contains(&identity) - && entries - .iter() - .any(|candidate| entry_id(candidate) == identity) - { - return Ok(false); - } - } - - entries.retain(|candidate| !expected_ids.contains(&entry_id(candidate))); - entries.extend(replacements); - entries.sort_by_key(entry_id); - self.write_all_unlocked(&entries)?; - Ok(true) - }) - } - - /// Atomically appends companions only when the complete backing-store - /// snapshot is byte-for-byte unchanged. Unlike identity-scoped CAS, this - /// fences predicate cardinality: a concurrently inserted unknown identity - /// makes the expected snapshot stale. - pub fn append_if_snapshot_unchanged( - &self, - expected_snapshot: &[CultCacheEnvelope], - additions: Vec, - ) -> Result { - if additions.is_empty() { - return Err(anyhow!("conditional snapshot append requires additions")); - } - unique_batch_ids(expected_snapshot, "expected snapshot")?; - let addition_ids = unique_batch_ids(&additions, "snapshot additions")?; - self.with_exclusive_lock(|| { - let mut current = self.read_all_unlocked()?; - let mut expected = expected_snapshot.to_vec(); - current.sort_by_key(entry_id); - expected.sort_by_key(entry_id); - if current != expected { - return Ok(false); - } - if current - .iter() - .any(|row| addition_ids.contains(&entry_id(row))) - { - return Ok(false); - } - current.extend(additions); - current.sort_by_key(entry_id); - self.write_all_unlocked(¤t)?; - Ok(true) - }) - } - - /// Atomically deletes an exact set of envelopes. Any changed or missing - /// member refuses the entire deletion without a partial write. - pub fn delete_batch_if_unchanged(&self, expected: &[CultCacheEnvelope]) -> Result { - if expected.is_empty() { - return Err(anyhow!( - "conditional delete requires a non-empty expected set" - )); - } - let expected_ids = unique_batch_ids(expected, "delete expected")?; - self.with_exclusive_lock(|| { - let mut entries = self.read_all_unlocked()?; - for expected_entry in expected { - let Some(current) = entries - .iter() - .find(|candidate| entry_id(candidate) == entry_id(expected_entry)) - else { - return Ok(false); - }; - if current != expected_entry { - return Ok(false); - } - } - entries.retain(|candidate| !expected_ids.contains(&entry_id(candidate))); - self.write_all_unlocked(&entries)?; - Ok(true) - }) - } - - /// Atomically replaces existing rows and appends companion rows only while - /// the complete backing-store snapshot remains byte-exact. - pub fn replace_and_append_if_snapshot_unchanged( - &self, - expected_snapshot: &[CultCacheEnvelope], - replacements: Vec, - ) -> Result { - if replacements.is_empty() { - return Err(anyhow!( - "conditional snapshot replacement requires replacements" - )); - } - unique_batch_ids(expected_snapshot, "expected snapshot")?; - let replacement_ids = unique_batch_ids(&replacements, "snapshot replacements")?; - self.with_exclusive_lock(|| { - let mut current = self.read_all_unlocked()?; - let mut expected = expected_snapshot.to_vec(); - current.sort_by_key(entry_id); - expected.sort_by_key(entry_id); - if current != expected { - return Ok(false); - } - current.retain(|row| !replacement_ids.contains(&entry_id(row))); - current.extend(replacements); - current.sort_by_key(entry_id); - self.write_all_unlocked(¤t)?; - Ok(true) - }) - } - - /// Atomically replaces and deletes rows only while the complete store - /// snapshot remains byte-exact. This is the single-file compaction - /// primitive: summary publication and history deletion share one write. - pub fn replace_and_delete_if_snapshot_unchanged( - &self, - expected_snapshot: &[CultCacheEnvelope], - replacements: Vec, - deletions: &[CultCacheEnvelope], - ) -> Result { - if replacements.is_empty() || deletions.is_empty() { - return Err(anyhow!( - "conditional compaction requires replacements and deletions" - )); - } - let expected_ids = unique_batch_ids(expected_snapshot, "expected snapshot")?; - let replacement_ids = unique_batch_ids(&replacements, "replacement")?; - let deletion_ids = unique_batch_ids(deletions, "deletion")?; - if !deletion_ids.is_subset(&expected_ids) { - return Err(anyhow!( - "compaction deletion is absent from expected snapshot" - )); - } - if !deletion_ids.is_disjoint(&replacement_ids) { - return Err(anyhow!("compaction cannot replace and delete one identity")); - } - self.with_exclusive_lock(|| { - let mut current = self.read_all_unlocked()?; - let mut expected = expected_snapshot.to_vec(); - current.sort_by_key(entry_id); - expected.sort_by_key(entry_id); - if current != expected { - return Ok(false); - } - for row in &replacements { - if !expected_ids.contains(&entry_id(row)) - && current - .iter() - .any(|candidate| entry_id(candidate) == entry_id(row)) - { - return Ok(false); - } - } - current.retain(|row| !deletion_ids.contains(&entry_id(row))); - for row in replacements { - current.retain(|candidate| entry_id(candidate) != entry_id(&row)); - current.push(row); - } - current.sort_by_key(entry_id); - self.write_all_unlocked(¤t)?; - Ok(true) - }) - } - fn read_all_unlocked(&self) -> Result> { if !self.path.exists() { return Ok(Vec::new()); @@ -658,21 +137,10 @@ impl SingleFileMessagePackBackingStore { if bytes.is_empty() { return Ok(Vec::new()); } - 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 - /// CultCache lock file. - /// - /// This is deliberately narrower than `pull_all`: use it only at a - /// provider-owned, read-only crossing where the producer replaces the - /// complete file atomically and the consumer has no authority to mutate - /// the provider directory. An absent file is an empty snapshot. Writers - /// and locally owned stores must continue to use the locked APIs. - pub fn pull_all_read_only_snapshot(&self) -> Result> { - self.read_all_unlocked() + decode_store_snapshot(&bytes).or_else(|_| { + rmp_serde::from_slice(&bytes) + .with_context(|| format!("failed to decode MessagePack {}", self.path.display())) + }) } fn write_all_unlocked(&self, entries: &[CultCacheEnvelope]) -> Result<()> { @@ -680,25 +148,22 @@ impl SingleFileMessagePackBackingStore { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } - remove_abandoned_staging_files(&self.path)?; - let bytes = rmp_serde::to_vec(&encode_store_snapshot(entries)?) + let bytes = rmp_serde::to_vec(&encode_store_snapshot(entries)) .context("failed to encode MessagePack")?; let tmp_path = temporary_path_for(&self.path); - let mut staged = OpenOptions::new() - .create_new(true) - .write(true) - .open(&tmp_path) - .with_context(|| format!("failed to create {}", tmp_path.display()))?; - use std::io::Write; - staged - .write_all(&bytes) + fs::write(&tmp_path, bytes) .with_context(|| format!("failed to write {}", tmp_path.display()))?; - staged - .sync_all() - .with_context(|| format!("failed to sync {}", tmp_path.display()))?; - drop(staged); - replace_file_atomically(&tmp_path, &self.path)?; - sync_parent_directory(&self.path)?; + if self.path.exists() { + fs::remove_file(&self.path) + .with_context(|| format!("failed to replace {}", self.path.display()))?; + } + fs::rename(&tmp_path, &self.path).with_context(|| { + format!( + "failed to move {} to {}", + tmp_path.display(), + self.path.display() + ) + })?; Ok(()) } @@ -722,126 +187,6 @@ impl SingleFileMessagePackBackingStore { result } - /// Replaces the named entries only if each still matches its expectation. - /// Unlike the snapshot form this constrains only the identities named, so - /// unrelated concurrent writes do not lose the race. - pub fn compare_exchange( - &self, - expected: &[CultCacheExpectedEnvelope], - replacements: &[CultCacheEnvelope], - ) -> Result { - self.with_exclusive_lock(|| { - let mut entries = self.read_all_unlocked()?; - for condition in expected { - let current = entries - .iter() - .find(|entry| entry.r#type == condition.r#type && entry.key == condition.key); - if current != condition.current.as_ref() { - return Ok(false); - } - if let Some(envelope) = condition.current.as_ref() - && (envelope.r#type != condition.r#type || envelope.key != condition.key) - { - return Err(anyhow!( - "CultCache compare-exchange expectation identity differs from its envelope" - )); - } - } - let mut replacement_ids = BTreeSet::new(); - for replacement in replacements { - if replacement.key.trim().is_empty() || replacement.r#type.trim().is_empty() { - return Err(anyhow!( - "CultCache compare-exchange replacement identity is empty" - )); - } - if !replacement_ids.insert(entry_id(replacement)) { - return Err(anyhow!( - "CultCache compare-exchange contains a duplicate replacement" - )); - } - } - entries.retain(|entry| !replacement_ids.contains(&entry_id(entry))); - entries.extend_from_slice(replacements); - entries.sort_by_key(entry_id); - self.write_all_unlocked(&entries)?; - Ok(true) - }) - } - - /// Replaces the whole snapshot only if it still equals `expected`, waiting - /// for the exclusive lock. Returns false when another writer has moved the - /// snapshot on, which is a lost race rather than an error. - pub fn compare_exchange_snapshot( - &self, - expected: &[CultCacheEnvelope], - replacements: &[CultCacheEnvelope], - ) -> Result { - self.with_exclusive_lock(|| self.compare_exchange_snapshot_unlocked(expected, replacements)) - } - - /// Makes one nonblocking attempt at the same exchange. Once the lock is - /// held, comparison, validation, ordering and persistence are identical to - /// [`Self::compare_exchange_snapshot`]. - pub fn try_compare_exchange_snapshot( - &self, - expected: &[CultCacheEnvelope], - replacements: &[CultCacheEnvelope], - ) -> Result { - let Some(exchanged) = self.try_with_exclusive_lock(|| { - self.compare_exchange_snapshot_unlocked(expected, replacements) - })? - else { - return Ok(TryCompareExchangeSnapshotOutcome::LockContended); - }; - Ok(if exchanged { - TryCompareExchangeSnapshotOutcome::Exchanged - } else { - TryCompareExchangeSnapshotOutcome::Mismatch - }) - } - - fn compare_exchange_snapshot_unlocked( - &self, - expected: &[CultCacheEnvelope], - replacements: &[CultCacheEnvelope], - ) -> Result { - let current = self.read_all_unlocked()?; - if current != expected { - return Ok(false); - } - let mut replacement_ids = BTreeSet::new(); - for replacement in replacements { - if replacement.key.trim().is_empty() || replacement.r#type.trim().is_empty() { - return Err(anyhow!("CultCache snapshot replacement identity is empty")); - } - if !replacement_ids.insert(entry_id(replacement)) { - return Err(anyhow!( - "CultCache snapshot replacement contains a duplicate identity" - )); - } - } - let mut ordered = replacements.to_vec(); - ordered.sort_by_key(entry_id); - self.write_all_unlocked(&ordered)?; - Ok(true) - } - - fn try_with_exclusive_lock(&self, action: impl FnOnce() -> Result) -> Result> { - let lock = self.open_lock_file()?; - match fs2::FileExt::try_lock_exclusive(&lock) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => return Ok(None), - Err(error) => { - return Err(error) - .with_context(|| format!("failed to lock {}", self.lock_path().display())); - } - } - let result = action(); - fs2::FileExt::unlock(&lock) - .with_context(|| format!("failed to unlock {}", self.lock_path().display()))?; - result.map(Some) - } - fn open_lock_file(&self) -> Result { let lock_path = self.lock_path(); if let Some(parent) = lock_path.parent() { @@ -862,71 +207,14 @@ impl SingleFileMessagePackBackingStore { .path .file_name() .map(|value| value.to_os_string()) - .unwrap_or_else(|| "cultcache.cc".into()); + .unwrap_or_else(|| "cultcache.msgpack".into()); lock_name.push(".lock"); self.path.with_file_name(lock_name) } } -struct ReadOnlySharedLockGuard -where - U: FnOnce(&File) -> Result<()>, - D: FnOnce(anyhow::Error), -{ - lock: Option, - unlock: Option, - on_unlock_failure: Option, -} - -impl ReadOnlySharedLockGuard -where - U: FnOnce(&File) -> Result<()>, - D: FnOnce(anyhow::Error), -{ - fn new(lock: File, unlock: U, on_unlock_failure: D) -> Self { - Self { - lock: Some(lock), - unlock: Some(unlock), - on_unlock_failure: Some(on_unlock_failure), - } - } -} - -impl Drop for ReadOnlySharedLockGuard -where - U: FnOnce(&File) -> Result<()>, - D: FnOnce(anyhow::Error), -{ - fn drop(&mut self) { - let (Some(lock), Some(unlock)) = (self.lock.as_ref(), self.unlock.take()) else { - return; - }; - if let Err(error) = unlock(lock) - && let Some(on_unlock_failure) = self.on_unlock_failure.take() - { - on_unlock_failure(error); - } - } -} - -fn unique_batch_ids( - entries: &[CultCacheEnvelope], - label: &str, -) -> Result> { - let ids = entries.iter().map(entry_id).collect::>(); - if ids.len() != entries.len() { - return Err(anyhow!( - "conditional batch {label} set contains duplicate identities" - )); - } - Ok(ids) -} - impl CacheBackingStore for SingleFileMessagePackBackingStore { fn pull_all(&self) -> Result> { - if !self.path.exists() && !self.lock_path().exists() { - return Ok(Vec::new()); - } self.with_shared_lock(|| self.read_all_unlocked()) } @@ -957,947 +245,6 @@ impl CacheBackingStore for SingleFileMessagePackBackingStore { } } -const REDB_ENVELOPES: TableDefinition<&[u8], &[u8]> = TableDefinition::new("cultcache_envelopes"); - -/// Transactional keyed CultCache storage. Each polymorphic `(type, key)` -/// identity is an independent redb row whose value is the exact MessagePack -/// serialization of its `CultCacheEnvelope`. -#[derive(Clone)] -pub struct RedbMessagePackBackingStore { - path: PathBuf, -} - -impl std::fmt::Debug for RedbMessagePackBackingStore { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("RedbMessagePackBackingStore") - .field("path", &self.path) - .finish_non_exhaustive() - } -} - -impl RedbMessagePackBackingStore { - pub fn new(path: impl Into) -> Result { - let path = path.into(); - if let Some(parent) = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create {}", parent.display()))?; - } - let store = Self { path }; - store.with_database(|database| { - let write = database.begin_write()?; - { - write.open_table(REDB_ENVELOPES)?; - } - write.commit()?; - Ok(()) - })?; - Ok(store) - } - - pub fn path(&self) -> &Path { - &self.path - } - - fn open_database(&self) -> Result { - Database::create(&self.path) - .with_context(|| format!("failed to open redb CultCache {}", self.path.display())) - } - - /// redb permits one open writable database handle per path. The external - /// CultCache lock therefore owns the complete open/transaction/close - /// interval, preserving the fresh-handle and cross-process construction - /// model used by the other backing store. - fn with_database(&self, action: impl FnOnce(&Database) -> Result) -> Result { - let lock_path = self.lock_path(); - if let Some(parent) = lock_path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create {}", parent.display()))?; - } - let lock = OpenOptions::new() - .create(true) - .read(true) - .truncate(false) - .write(true) - .open(&lock_path) - .with_context(|| format!("failed to open {}", lock_path.display()))?; - fs2::FileExt::lock_exclusive(&lock) - .with_context(|| format!("failed to lock {}", lock_path.display()))?; - let result = self.open_database().and_then(|database| action(&database)); - fs2::FileExt::unlock(&lock) - .with_context(|| format!("failed to unlock {}", lock_path.display()))?; - result - } - - fn lock_path(&self) -> PathBuf { - let mut lock_name = self - .path - .file_name() - .map(|value| value.to_os_string()) - .unwrap_or_else(|| "cultcache.cc".into()); - lock_name.push(".lock"); - self.path.with_file_name(lock_name) - } - - pub fn compare_and_swap_entry( - &self, - expected: &CultCacheEnvelope, - replacement: CultCacheEnvelope, - ) -> Result { - if entry_id(expected) != entry_id(&replacement) { - return Err(anyhow!("entry CAS replacement must preserve identity")); - } - self.with_database(|database| { - let write = database.begin_write()?; - let matched = { - let mut table = write.open_table(REDB_ENVELOPES)?; - if read_redb_entry(&table, expected)? != Some(expected.clone()) { - false - } else { - insert_redb_entry(&mut table, &replacement)?; - true - } - }; - if matched { - write.commit()?; - } else { - write.abort()?; - } - Ok(matched) - }) - } - - pub fn insert_entry_if_absent(&self, entry: CultCacheEnvelope) -> Result { - self.with_database(|database| { - let write = database.begin_write()?; - let inserted = { - let mut table = write.open_table(REDB_ENVELOPES)?; - let key = redb_identity(&entry)?; - if table.get(key.as_slice())?.is_some() { - false - } else { - insert_redb_entry(&mut table, &entry)?; - true - } - }; - if inserted { - write.commit()?; - } else { - write.abort()?; - } - Ok(inserted) - }) - } - - pub fn compare_and_swap_batch( - &self, - expected: &[CultCacheEnvelope], - replacements: Vec, - ) -> Result { - if replacements.is_empty() { - return Err(anyhow!( - "conditional batch requires a non-empty replacement set" - )); - } - let expected_ids = unique_batch_ids(expected, "expected")?; - let replacement_ids = unique_batch_ids(&replacements, "replacement")?; - if !expected_ids.is_subset(&replacement_ids) { - return Err(anyhow!( - "conditional batch must replace every expected identity" - )); - } - self.with_database(|database| { - let write = database.begin_write()?; - let matched = { - let mut table = write.open_table(REDB_ENVELOPES)?; - let mut valid = true; - for row in expected { - if read_redb_entry(&table, row)? != Some(row.clone()) { - valid = false; - break; - } - } - if valid { - for row in &replacements { - if !expected_ids.contains(&entry_id(row)) - && read_redb_entry(&table, row)?.is_some() - { - valid = false; - break; - } - } - } - if valid { - for row in &replacements { - insert_redb_entry(&mut table, row)?; - } - } - valid - }; - if matched { - write.commit()?; - } else { - write.abort()?; - } - Ok(matched) - }) - } - - pub fn append_if_snapshot_unchanged( - &self, - expected_snapshot: &[CultCacheEnvelope], - additions: Vec, - ) -> Result { - if additions.is_empty() { - return Err(anyhow!("conditional snapshot append requires additions")); - } - unique_batch_ids(expected_snapshot, "expected snapshot")?; - let addition_ids = unique_batch_ids(&additions, "snapshot additions")?; - self.with_database(|database| { - let write = database.begin_write()?; - let matched = { - let mut table = write.open_table(REDB_ENVELOPES)?; - let mut current = read_all_redb(&table)?; - let mut expected = expected_snapshot.to_vec(); - current.sort_by_key(entry_id); - expected.sort_by_key(entry_id); - let valid = current == expected - && !current - .iter() - .any(|row| addition_ids.contains(&entry_id(row))); - if valid { - for row in &additions { - insert_redb_entry(&mut table, row)?; - } - } - valid - }; - if matched { - write.commit()?; - } else { - write.abort()?; - } - Ok(matched) - }) - } - - pub fn replace_and_append_if_snapshot_unchanged( - &self, - expected_snapshot: &[CultCacheEnvelope], - replacements: Vec, - ) -> Result { - if replacements.is_empty() { - return Err(anyhow!( - "conditional snapshot replacement requires replacements" - )); - } - unique_batch_ids(expected_snapshot, "expected snapshot")?; - unique_batch_ids(&replacements, "snapshot replacements")?; - self.with_database(|database| { - let write = database.begin_write()?; - let matched = { - let mut table = write.open_table(REDB_ENVELOPES)?; - let mut current = read_all_redb(&table)?; - let mut expected = expected_snapshot.to_vec(); - current.sort_by_key(entry_id); - expected.sort_by_key(entry_id); - let valid = current == expected; - if valid { - for row in &replacements { - insert_redb_entry(&mut table, row)?; - } - } - valid - }; - if matched { - write.commit()?; - } else { - write.abort()?; - } - Ok(matched) - }) - } - - /// Atomically replaces and deletes rows only while the complete store - /// snapshot remains byte-exact. This is the compaction primitive for a - /// non-owning keyed handle: a stale reader cannot summarize one history - /// while deleting another. - pub fn replace_and_delete_if_snapshot_unchanged( - &self, - expected_snapshot: &[CultCacheEnvelope], - replacements: Vec, - deletions: &[CultCacheEnvelope], - ) -> Result { - if replacements.is_empty() || deletions.is_empty() { - return Err(anyhow!( - "conditional compaction requires replacements and deletions" - )); - } - let expected_ids = unique_batch_ids(expected_snapshot, "expected snapshot")?; - let replacement_ids = unique_batch_ids(&replacements, "replacement")?; - let deletion_ids = unique_batch_ids(deletions, "deletion")?; - if !deletion_ids.is_subset(&expected_ids) { - return Err(anyhow!( - "compaction deletion is absent from expected snapshot" - )); - } - if !deletion_ids.is_disjoint(&replacement_ids) { - return Err(anyhow!("compaction cannot replace and delete one identity")); - } - self.with_database(|database| { - let write = database.begin_write()?; - let matched = { - let mut table = write.open_table(REDB_ENVELOPES)?; - let mut current = read_all_redb(&table)?; - let mut expected = expected_snapshot.to_vec(); - current.sort_by_key(entry_id); - expected.sort_by_key(entry_id); - let mut valid = current == expected; - if valid { - for row in &replacements { - if !expected_ids.contains(&entry_id(row)) - && read_redb_entry(&table, row)?.is_some() - { - valid = false; - break; - } - } - } - if valid { - for identity in deletion_ids { - let key = rmp_serde::to_vec(&identity) - .context("failed to encode CultCache identity")?; - table.remove(key.as_slice())?; - } - for row in &replacements { - insert_redb_entry(&mut table, row)?; - } - } - valid - }; - if matched { - write.commit()?; - } else { - write.abort()?; - } - Ok(matched) - }) - } - - pub fn delete_batch_if_unchanged(&self, expected: &[CultCacheEnvelope]) -> Result { - if expected.is_empty() { - return Err(anyhow!( - "conditional delete requires a non-empty expected set" - )); - } - unique_batch_ids(expected, "delete expected")?; - self.with_database(|database| { - let write = database.begin_write()?; - let matched = { - let mut table = write.open_table(REDB_ENVELOPES)?; - let mut valid = true; - for row in expected { - if read_redb_entry(&table, row)? != Some(row.clone()) { - valid = false; - break; - } - } - if valid { - for row in expected { - let key = redb_identity(row)?; - table.remove(key.as_slice())?; - } - } - valid - }; - if matched { - write.commit()?; - } else { - write.abort()?; - } - Ok(matched) - }) - } -} - -struct OwnedRedbInner { - database: Database, - _owner_lock: File, - file_identity: String, -} - -/// A pinned redb store for one long-lived service owner. Unlike the transient -/// redb store, this handle holds the CultCache external lock, an open file -/// handle, and the redb database for its entire lifetime. Clones share that -/// exact ownership authority. -#[derive(Clone)] -pub struct OwnedRedbMessagePackBackingStore { - path: PathBuf, - inner: Arc, -} - -impl std::fmt::Debug for OwnedRedbMessagePackBackingStore { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("OwnedRedbMessagePackBackingStore") - .field("path", &self.path) - .field("file_identity", &self.inner.file_identity) - .finish_non_exhaustive() - } -} - -impl OwnedRedbMessagePackBackingStore { - pub fn new(path: impl Into) -> Result { - let path = path.into(); - if let Some(parent) = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create {}", parent.display()))?; - } - let lock_path = redb_lock_path(&path); - let owner_lock = OpenOptions::new() - .create(true) - .read(true) - .truncate(false) - .write(true) - .open(&lock_path) - .with_context(|| format!("failed to open {}", lock_path.display()))?; - fs2::FileExt::try_lock_exclusive(&owner_lock).with_context(|| { - format!( - "redb CultCache {} already has an active owner", - path.display() - ) - })?; - - // Give redb ownership of the already pinned file, structurally closing - // path substitution during database creation. The post-open identity - // comparison separately verifies that the pathname still names it. - let database_file = OpenOptions::new() - .create(true) - .read(true) - .truncate(false) - .write(true) - .open(&path) - .with_context(|| format!("failed to pin redb CultCache {}", path.display()))?; - let held_identity = file_identity_from_file(&database_file)?; - let database = Database::builder() - .create_file(database_file) - .with_context(|| format!("failed to open redb CultCache {}", path.display()))?; - let path_identity_file = File::open(&path)?; - let path_identity = file_identity_from_file(&path_identity_file)?; - if held_identity != path_identity { - bail!( - "redb CultCache path identity changed while opening: held {held_identity}, path {path_identity}" - ); - } - let write = database.begin_write()?; - { - write.open_table(REDB_ENVELOPES)?; - } - write.commit()?; - Ok(Self { - path, - inner: Arc::new(OwnedRedbInner { - database, - _owner_lock: owner_lock, - file_identity: held_identity, - }), - }) - } - - pub fn path(&self) -> &Path { - &self.path - } - - pub fn file_identity(&self) -> &str { - &self.inner.file_identity - } - - pub fn require_file_identity(&self, expected: &str) -> Result<()> { - if expected != self.file_identity() { - bail!( - "redb CultCache file identity mismatch: expected {expected}, owned {}", - self.file_identity() - ); - } - Ok(()) - } - - pub fn validate_path_identity(&self) -> Result<()> { - let current_file = File::open(&self.path) - .with_context(|| format!("owned redb path {} is missing", self.path.display()))?; - let current = file_identity_from_file(¤t_file)?; - self.require_file_identity(¤t).with_context(|| { - format!( - "owned redb path {} no longer names the pinned file", - self.path.display() - ) - }) - } - - pub fn compare_and_swap_entry( - &self, - expected: &CultCacheEnvelope, - replacement: CultCacheEnvelope, - ) -> Result { - if entry_id(expected) != entry_id(&replacement) { - return Err(anyhow!("entry CAS replacement must preserve identity")); - } - let write = self.inner.database.begin_write()?; - let matched = { - let mut table = write.open_table(REDB_ENVELOPES)?; - if read_redb_entry(&table, expected)? != Some(expected.clone()) { - false - } else { - insert_redb_entry(&mut table, &replacement)?; - true - } - }; - if matched { - write.commit()?; - } else { - write.abort()?; - } - Ok(matched) - } - - pub fn insert_entry_if_absent(&self, entry: CultCacheEnvelope) -> Result { - let write = self.inner.database.begin_write()?; - let inserted = { - let mut table = write.open_table(REDB_ENVELOPES)?; - let key = redb_identity(&entry)?; - if table.get(key.as_slice())?.is_some() { - false - } else { - insert_redb_entry(&mut table, &entry)?; - true - } - }; - if inserted { - write.commit()?; - } else { - write.abort()?; - } - Ok(inserted) - } - - pub fn compare_and_swap_batch( - &self, - expected: &[CultCacheEnvelope], - replacements: Vec, - ) -> Result { - if replacements.is_empty() { - return Err(anyhow!( - "conditional batch requires a non-empty replacement set" - )); - } - let expected_ids = unique_batch_ids(expected, "expected")?; - let replacement_ids = unique_batch_ids(&replacements, "replacement")?; - if !expected_ids.is_subset(&replacement_ids) { - return Err(anyhow!( - "conditional batch must replace every expected identity" - )); - } - let write = self.inner.database.begin_write()?; - let matched = { - let mut table = write.open_table(REDB_ENVELOPES)?; - let mut valid = true; - for row in expected { - if read_redb_entry(&table, row)? != Some(row.clone()) { - valid = false; - break; - } - } - if valid { - for row in &replacements { - if !expected_ids.contains(&entry_id(row)) - && read_redb_entry(&table, row)?.is_some() - { - valid = false; - break; - } - } - } - if valid { - for row in &replacements { - insert_redb_entry(&mut table, row)?; - } - } - valid - }; - if matched { - write.commit()?; - } else { - write.abort()?; - } - Ok(matched) - } - - pub fn append_if_snapshot_unchanged( - &self, - expected_snapshot: &[CultCacheEnvelope], - additions: Vec, - ) -> Result { - if additions.is_empty() { - return Err(anyhow!("conditional snapshot append requires additions")); - } - unique_batch_ids(expected_snapshot, "expected snapshot")?; - let addition_ids = unique_batch_ids(&additions, "snapshot additions")?; - let write = self.inner.database.begin_write()?; - let matched = { - let mut table = write.open_table(REDB_ENVELOPES)?; - let mut current = read_all_redb(&table)?; - let mut expected = expected_snapshot.to_vec(); - current.sort_by_key(entry_id); - expected.sort_by_key(entry_id); - let valid = current == expected - && !current - .iter() - .any(|row| addition_ids.contains(&entry_id(row))); - if valid { - for row in &additions { - insert_redb_entry(&mut table, row)?; - } - } - valid - }; - if matched { - write.commit()?; - } else { - write.abort()?; - } - Ok(matched) - } - - pub fn replace_and_append_if_snapshot_unchanged( - &self, - expected_snapshot: &[CultCacheEnvelope], - replacements: Vec, - ) -> Result { - if replacements.is_empty() { - return Err(anyhow!( - "conditional snapshot replacement requires replacements" - )); - } - unique_batch_ids(expected_snapshot, "expected snapshot")?; - unique_batch_ids(&replacements, "snapshot replacements")?; - let write = self.inner.database.begin_write()?; - let matched = { - let mut table = write.open_table(REDB_ENVELOPES)?; - let mut current = read_all_redb(&table)?; - let mut expected = expected_snapshot.to_vec(); - current.sort_by_key(entry_id); - expected.sort_by_key(entry_id); - let valid = current == expected; - if valid { - for row in &replacements { - insert_redb_entry(&mut table, row)?; - } - } - valid - }; - if matched { - write.commit()?; - } else { - write.abort()?; - } - Ok(matched) - } - - pub fn delete_batch_if_unchanged(&self, expected: &[CultCacheEnvelope]) -> Result { - if expected.is_empty() { - return Err(anyhow!( - "conditional delete requires a non-empty expected set" - )); - } - let expected_ids = unique_batch_ids(expected, "delete expected")?; - let write = self.inner.database.begin_write()?; - let matched = { - let mut table = write.open_table(REDB_ENVELOPES)?; - let mut valid = true; - for row in expected { - if read_redb_entry(&table, row)? != Some(row.clone()) { - valid = false; - break; - } - } - if valid { - for identity in expected_ids { - let key = rmp_serde::to_vec(&identity) - .context("failed to encode CultCache identity")?; - table.remove(key.as_slice())?; - } - } - valid - }; - if matched { - write.commit()?; - } else { - write.abort()?; - } - Ok(matched) - } - - /// Atomically replaces and deletes rows only while the complete store - /// snapshot remains byte-exact. This is the compaction primitive: a stale - /// reader cannot summarize one history while deleting another. - pub fn replace_and_delete_if_snapshot_unchanged( - &self, - expected_snapshot: &[CultCacheEnvelope], - replacements: Vec, - deletions: &[CultCacheEnvelope], - ) -> Result { - if replacements.is_empty() || deletions.is_empty() { - return Err(anyhow!( - "conditional compaction requires replacements and deletions" - )); - } - let expected_ids = unique_batch_ids(expected_snapshot, "expected snapshot")?; - let replacement_ids = unique_batch_ids(&replacements, "replacement")?; - let deletion_ids = unique_batch_ids(deletions, "deletion")?; - if !deletion_ids.is_subset(&expected_ids) { - return Err(anyhow!( - "compaction deletion is absent from expected snapshot" - )); - } - if !deletion_ids.is_disjoint(&replacement_ids) { - return Err(anyhow!("compaction cannot replace and delete one identity")); - } - let write = self.inner.database.begin_write()?; - let matched = { - let mut table = write.open_table(REDB_ENVELOPES)?; - let mut current = read_all_redb(&table)?; - let mut expected = expected_snapshot.to_vec(); - current.sort_by_key(entry_id); - expected.sort_by_key(entry_id); - let mut valid = current == expected; - if valid { - for row in &replacements { - if !expected_ids.contains(&entry_id(row)) - && read_redb_entry(&table, row)?.is_some() - { - valid = false; - break; - } - } - } - if valid { - for identity in deletion_ids { - let key = rmp_serde::to_vec(&identity) - .context("failed to encode CultCache identity")?; - table.remove(key.as_slice())?; - } - for row in &replacements { - insert_redb_entry(&mut table, row)?; - } - } - valid - }; - if matched { - write.commit()?; - } else { - write.abort()?; - } - Ok(matched) - } -} - -impl CacheBackingStore for OwnedRedbMessagePackBackingStore { - fn pull_all(&self) -> Result> { - let read = self.inner.database.begin_read()?; - let table = read.open_table(REDB_ENVELOPES)?; - read_all_redb(&table) - } - - fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()> { - let write = self.inner.database.begin_write()?; - { - let mut table = write.open_table(REDB_ENVELOPES)?; - insert_redb_entry(&mut table, entry)?; - } - write.commit()?; - Ok(()) - } - - fn delete(&mut self, entry: &CultCacheEnvelope) -> Result<()> { - let write = self.inner.database.begin_write()?; - { - let mut table = write.open_table(REDB_ENVELOPES)?; - let key = redb_identity(entry)?; - table.remove(key.as_slice())?; - } - write.commit()?; - Ok(()) - } - - fn push_all(&mut self, entries: &[CultCacheEnvelope], _options: PushAllOptions) -> Result<()> { - unique_batch_ids(entries, "push all")?; - let write = self.inner.database.begin_write()?; - { - let mut table = write.open_table(REDB_ENVELOPES)?; - let keys = table - .iter()? - .map(|row| Ok(row?.0.value().to_vec())) - .collect::>>()?; - for key in keys { - table.remove(key.as_slice())?; - } - for entry in entries { - insert_redb_entry(&mut table, entry)?; - } - } - write.commit()?; - Ok(()) - } -} - -fn redb_lock_path(path: &Path) -> PathBuf { - let mut lock_name = path - .file_name() - .map(|value| value.to_os_string()) - .unwrap_or_else(|| "cultcache.cc".into()); - lock_name.push(".lock"); - path.with_file_name(lock_name) -} - -#[cfg(unix)] -fn file_identity_from_file(file: &File) -> Result { - use std::os::unix::fs::MetadataExt; - let metadata = file.metadata()?; - Ok(format!( - "unix:{:016x}:{:016x}", - metadata.dev(), - metadata.ino() - )) -} - -#[cfg(windows)] -fn file_identity_from_file(file: &File) -> Result { - use std::mem::zeroed; - use std::os::windows::io::AsRawHandle; - use windows_sys::Win32::Storage::FileSystem::{ - BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, - }; - let mut information: BY_HANDLE_FILE_INFORMATION = unsafe { zeroed() }; - let success = - unsafe { GetFileInformationByHandle(file.as_raw_handle() as _, &mut information) }; - if success == 0 { - return Err(std::io::Error::last_os_error()) - .context("failed to read Windows file identity"); - } - let index = ((information.nFileIndexHigh as u64) << 32) | information.nFileIndexLow as u64; - Ok(format!( - "windows:{:08x}:{index:016x}", - information.dwVolumeSerialNumber - )) -} - -fn redb_identity(entry: &CultCacheEnvelope) -> Result> { - rmp_serde::to_vec(&entry_id(entry)).context("failed to encode CultCache identity") -} - -fn read_redb_entry( - table: &impl ReadableTable<&'static [u8], &'static [u8]>, - identity: &CultCacheEnvelope, -) -> Result> { - let key = redb_identity(identity)?; - table - .get(key.as_slice())? - .map(|value| { - rmp_serde::from_slice(value.value()).context("failed to decode redb CultCache envelope") - }) - .transpose() -} - -fn insert_redb_entry( - table: &mut redb::Table<&[u8], &[u8]>, - entry: &CultCacheEnvelope, -) -> Result<()> { - let key = redb_identity(entry)?; - let value = rmp_serde::to_vec(entry).context("failed to encode redb CultCache envelope")?; - table.insert(key.as_slice(), value.as_slice())?; - Ok(()) -} - -fn read_all_redb( - table: &impl ReadableTable<&'static [u8], &'static [u8]>, -) -> Result> { - let mut entries = table - .iter()? - .map(|row| { - let (_, value) = row?; - rmp_serde::from_slice(value.value()).context("failed to decode redb CultCache envelope") - }) - .collect::>>()?; - entries.sort_by_key(entry_id); - Ok(entries) -} - -impl CacheBackingStore for RedbMessagePackBackingStore { - fn pull_all(&self) -> Result> { - self.with_database(|database| { - let read = database.begin_read()?; - let table = read.open_table(REDB_ENVELOPES)?; - read_all_redb(&table) - }) - } - - fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()> { - self.with_database(|database| { - let write = database.begin_write()?; - { - let mut table = write.open_table(REDB_ENVELOPES)?; - insert_redb_entry(&mut table, entry)?; - } - write.commit()?; - Ok(()) - }) - } - - fn delete(&mut self, entry: &CultCacheEnvelope) -> Result<()> { - self.with_database(|database| { - let write = database.begin_write()?; - { - let mut table = write.open_table(REDB_ENVELOPES)?; - let key = redb_identity(entry)?; - table.remove(key.as_slice())?; - } - write.commit()?; - Ok(()) - }) - } - - fn push_all(&mut self, entries: &[CultCacheEnvelope], _options: PushAllOptions) -> Result<()> { - unique_batch_ids(entries, "push all")?; - self.with_database(|database| { - let write = database.begin_write()?; - { - let mut table = write.open_table(REDB_ENVELOPES)?; - let keys = table - .iter()? - .map(|row| Ok(row?.0.value().to_vec())) - .collect::>>()?; - for key in keys { - table.remove(key.as_slice())?; - } - for entry in entries { - insert_redb_entry(&mut table, entry)?; - } - } - write.commit()?; - Ok(()) - }) - } -} - struct CultCacheStoreRegistration { store: Box, types: BTreeSet, @@ -1906,7 +253,7 @@ struct CultCacheStoreRegistration { pub struct CultCache { definitions: BTreeMap, schema_name_definitions: BTreeMap, - entries: BTreeMap<(String, String), CultCacheEnvelope>, + entries: BTreeMap, stores: Vec, } @@ -1953,77 +300,31 @@ impl CultCache { self.register_entry_type::() } - pub fn registered_entry_types(&self) -> Vec { - self.definitions.keys().cloned().collect() - } - pub fn register_registry(&mut self, registry: R) -> Result<&mut Self> { registry.register_entries(self)?; Ok(self) } - /// Makes the store home to `types`, or the generic store when `types` is - /// empty. A type has exactly one home store: a second generic store or a - /// type already claimed by another store is an error and attaches nothing. pub fn add_backing_store( &mut self, store: impl CacheBackingStore + 'static, types: impl IntoIterator>, - ) -> Result<()> { - let types: BTreeSet = types.into_iter().map(Into::into).collect(); - if types.is_empty() && self.stores.iter().any(|registration| registration.types.is_empty()) { - return Err(anyhow!( - "Backing store would be a second generic store; name the types it is home to" - )); - } - if let Some(claimed) = types - .iter() - .find(|type_id| self.stores.iter().any(|registration| registration.types.contains(*type_id))) - { - return Err(anyhow!( - "CultCache type {:?} is already routed to another backing store; it cannot also route to this one", - claimed - )); - } + ) { self.stores.push(CultCacheStoreRegistration { store: Box::new(store), - types, + types: types.into_iter().map(Into::into).collect(), }); - // A record's home cannot change after it is admitted. - let candidate = self.stores.len() - 1; - let held: BTreeSet = self.entries.keys().map(|(type_id, _)| type_id.clone()).collect(); - for type_id in &held { - let type_id = type_id.as_str(); - let after = self.resolve_route_index(type_id); - let before = self.stores[..candidate] - .iter() - .position(|registration| registration.types.contains(type_id)) - .or_else(|| self.stores[..candidate].iter().position(|registration| registration.types.is_empty())); - if before != after { - let error = anyhow!( - "Attaching this backing store would move {:?} from {} to {}; attach routed stores before the generic store", - type_id, - self.describe_store(before), - self.describe_store(after) - ); - self.stores.pop(); - return Err(error); - } - } - Ok(()) } - pub fn add_generic_backing_store(&mut self, store: impl CacheBackingStore + 'static) -> Result<()> { - self.add_backing_store(store, Vec::::new()) + pub fn add_generic_backing_store(&mut self, store: impl CacheBackingStore + 'static) { + self.add_backing_store(store, Vec::::new()); } - /// Reads every store into a staged image and replaces the cache's view - /// only when all of them loaded, so a refused load admits nothing. pub fn pull_all_backing_stores(&mut self) -> Result<()> { - let mut loaded = BTreeMap::new(); + self.entries.clear(); let known_types: BTreeSet = self.definitions.keys().cloned().collect(); let schema_name_definitions = self.schema_name_definitions.clone(); - for (index, registration) in self.stores.iter().enumerate() { + for registration in &mut self.stores { for mut entry in registration.store.pull_all()? { let Some(canonical_type) = resolve_registered_type( &known_types, @@ -2035,31 +336,13 @@ impl CultCache { entry.r#type )); }; - let home = self.resolve_route_index(&canonical_type); - if home != Some(index) { - return Err(anyhow!( - "CultCache {:?} record {:?} was loaded from {}, but its home is {}", - canonical_type, - entry.key, - self.describe_store(Some(index)), - self.describe_store(home) - )); - } entry.r#type = canonical_type; - loaded.insert(entry_id(&entry), entry); + self.entries.insert(entry_id(&entry), entry); } } - self.entries = loaded; Ok(()) } - /// Returns the exact envelopes in the currently loaded cache image. - /// Callers that need optimistic concurrency can validate typed documents - /// and capture CAS expectations from one coherent read. - pub fn snapshot_envelopes(&self) -> Vec { - self.entries.values().cloned().collect() - } - pub fn get(&self, key: &str) -> Result> { self.require_entry_type::()?; let Some(entry) = self.entries.get(&entry_id_parts(T::TYPE, key)) else { @@ -2110,57 +393,7 @@ impl CultCache { Ok(values) } - pub fn get_all_with_keys(&self) -> Result> { - self.require_entry_type::()?; - let mut values = Vec::new(); - for entry in self.entries.values() { - if entry.r#type != T::TYPE { - continue; - } - values.push(( - entry.key.clone(), - rmp_serde::from_slice(&entry.payload).with_context(|| { - format!( - "failed to decode CultCache entry {:?} at key {:?} as {}", - T::TYPE, - entry.key, - T::SCHEMA_NAME - ) - })?, - )); - } - Ok(values) - } - - pub fn soa(&self) -> Result> { - let keyed_rows = self.get_all_with_keys::()?; - let (keys, documents): (Vec<_>, Vec<_>) = keyed_rows.into_iter().unzip(); - let columns = T::soa_columns(&documents) - .into_iter() - .map(|(name, values)| (name.to_string(), values)) - .collect(); - Ok(CultSoaTable { - keys, - documents, - columns, - _document: PhantomData, - }) - } - pub fn put(&mut self, key: impl Into, value: &T) -> Result { - let (entry, parsed) = self.prepare_entry(key, value)?; - if let Some(home) = self.home_index(T::TYPE)? { - self.stores[home].store.push(&entry)?; - } - self.entries.insert(entry_id(&entry), entry); - Ok(parsed) - } - - pub fn prepare_entry( - &self, - key: impl Into, - value: &T, - ) -> Result<(CultCacheEnvelope, T)> { self.require_entry_type::()?; let key = key.into(); let payload = rmp_serde::to_vec(value).with_context(|| { @@ -2171,36 +404,6 @@ impl CultCache { T::SCHEMA_NAME ) })?; - self.finish_prepared_entry::(key, payload) - } - - /// Prepares a typed entry while encoding ordinary nested structs as - /// MessagePack maps. DatabaseEntry itself remains the stable numeric-slot - /// tuple; named nested fields prevent serde's omitted optional fields from - /// shifting later values into the wrong positions. - pub fn prepare_entry_named( - &self, - key: impl Into, - value: &T, - ) -> Result<(CultCacheEnvelope, T)> { - self.require_entry_type::()?; - let key = key.into(); - let payload = rmp_serde::to_vec_named(value).with_context(|| { - format!( - "failed to encode named CultCache entry {:?} at key {:?} as {}", - T::TYPE, - key, - T::SCHEMA_NAME - ) - })?; - self.finish_prepared_entry::(key, payload) - } - - fn finish_prepared_entry( - &self, - key: String, - payload: Vec, - ) -> Result<(CultCacheEnvelope, T)> { let parsed: T = rmp_serde::from_slice(&payload).with_context(|| { format!( "failed to validate CultCache entry {:?} at key {:?} as {}", @@ -2216,106 +419,22 @@ impl CultCache { stored_at: now_utc_second(), schema_id: Some(T::TYPE.to_string()), }; - Ok((entry, parsed)) - } - - pub fn put_prepared_batch(&mut self, staged: Vec) -> Result<()> { - if staged.is_empty() { - return Err(anyhow!("CultCache batch must contain at least one entry")); - } - // Validate every staged record before any store is touched. - let mut store_index = None; - for entry in &staged { - if !self.definitions.contains_key(&entry.r#type) { - return Err(anyhow!( - "CultCache batch entry type {:?} is not registered", - entry.r#type - )); - } - if entry.key.trim().is_empty() { - return Err(anyhow!( - "CultCache envelope keys for type {:?} must be non-empty", - entry.r#type - )); - } - if entry.stored_at.trim().is_empty() { - return Err(anyhow!( - "CultCache envelope stored_at for type {:?} must be non-empty", - entry.r#type - )); - } - let Some(home) = self.home_index(&entry.r#type)? else { - continue; - }; - match store_index { - Some(expected) if expected != home => { - return Err(anyhow!("CultCache atomic batch cannot span backing stores")); - } - None => store_index = Some(home), - _ => {} - } - } - let mut next = self.entries.clone(); - for entry in &staged { - next.insert(entry_id(entry), entry.clone()); - } - if let Some(store_index) = store_index { - // The home store receives its own records only: those it already - // holds in this cache plus the staged ones. - let snapshot: Vec<_> = next - .values() - .filter(|entry| self.resolve_route_index(&entry.r#type) == Some(store_index)) - .cloned() - .collect(); - self.stores[store_index] - .store - .push_all(&snapshot, PushAllOptions::default())?; - } - self.entries = next; - Ok(()) - } - - pub fn put_envelope(&mut self, entry: CultCacheEnvelope) -> Result { - let parsed = self.validate_envelope::(&entry)?; - if let Some(home) = self.home_index(T::TYPE)? { - self.stores[home].store.push(&entry)?; + let route = self.resolve_route_indices(T::TYPE); + let Some(primary_index) = route.first().copied() else { + return Err(anyhow!( + "No backing store is registered for entry type {:?}", + T::TYPE + )); + }; + self.stores[primary_index].store.push(&entry)?; + for mirror_index in route.iter().skip(1).copied() { + self.stores[mirror_index].store.push(&entry)?; } self.entries.insert(entry_id(&entry), entry); Ok(parsed) } - /// Publishes an envelope for a registered dynamic document type without - /// claiming compile-time knowledge of its payload schema. This is the - /// CultMesh/schema-registry boundary: callers own payload validation before - /// admission; CultCache still enforces registered type, identity, timestamp, - /// routing, and persistence. - pub fn put_raw_envelope(&mut self, entry: CultCacheEnvelope) -> Result<()> { - if !self.definitions.contains_key(&entry.r#type) { - return Err(anyhow!( - "No entry type registered for CultCache envelope type {:?}", - entry.r#type - )); - } - if entry.key.trim().is_empty() { - return Err(anyhow!( - "CultCache envelope keys for type {:?} must be non-empty", - entry.r#type - )); - } - if entry.stored_at.trim().is_empty() { - return Err(anyhow!( - "CultCache envelope stored_at for type {:?} must be non-empty", - entry.r#type - )); - } - if let Some(home) = self.home_index(&entry.r#type)? { - self.stores[home].store.push(&entry)?; - } - self.entries.insert(entry_id(&entry), entry); - Ok(()) - } - - fn validate_envelope(&self, entry: &CultCacheEnvelope) -> Result { + pub fn put_envelope(&mut self, entry: CultCacheEnvelope) -> Result { self.require_entry_type::()?; if entry.r#type != T::TYPE { return Err(anyhow!( @@ -2337,14 +456,27 @@ impl CultCache { )); } - rmp_serde::from_slice(&entry.payload).with_context(|| { + let parsed: T = rmp_serde::from_slice(&entry.payload).with_context(|| { format!( "failed to validate CultCache envelope {:?} at key {:?} as {}", T::TYPE, entry.key, T::SCHEMA_NAME ) - }) + })?; + let route = self.resolve_route_indices(T::TYPE); + let Some(primary_index) = route.first().copied() else { + return Err(anyhow!( + "No backing store is registered for entry type {:?}", + T::TYPE + )); + }; + self.stores[primary_index].store.push(&entry)?; + for mirror_index in route.iter().skip(1).copied() { + self.stores[mirror_index].store.push(&entry)?; + } + self.entries.insert(entry_id(&entry), entry); + Ok(parsed) } pub fn update(&mut self, key: &str, updater: F) -> Result @@ -2362,8 +494,16 @@ impl CultCache { let Some(entry) = self.entries.get(&id).cloned() else { return Ok(false); }; - if let Some(home) = self.home_index(T::TYPE)? { - self.stores[home].store.delete(&entry)?; + let route = self.resolve_route_indices(T::TYPE); + let Some(primary_index) = route.first().copied() else { + return Err(anyhow!( + "No backing store is registered for entry type {:?}", + T::TYPE + )); + }; + self.stores[primary_index].store.delete(&entry)?; + for mirror_index in route.iter().skip(1).copied() { + self.stores[mirror_index].store.delete(&entry)?; } self.entries.remove(&id); Ok(true) @@ -2373,30 +513,6 @@ impl CultCache { self.entries.values().cloned().collect() } - pub fn snapshot_soa(&self) -> CultCacheSoaTable { - CultCacheSoaTable::from_envelopes(self.snapshot()) - } - - /// Replaces the cache's view with the table's records. Every record is - /// checked (registered type, and a home store once any store is attached) - /// into a local image first, so a refused load admits nothing and leaves - /// the prior view intact. Loading writes no store. - pub fn load_soa(&mut self, table: CultCacheSoaTable) -> Result<()> { - let mut loaded = BTreeMap::new(); - for entry in table.into_envelopes()? { - if !self.definitions.contains_key(&entry.r#type) { - return Err(anyhow!( - "No schema is registered for SoA entry type {:?}", - entry.r#type - )); - } - self.home_index(&entry.r#type)?; - loaded.insert(entry_id(&entry), entry); - } - self.entries = loaded; - Ok(()) - } - fn require_entry_type(&self) -> Result<()> { match self.definitions.get(T::TYPE) { Some(schema_name) if *schema_name == T::SCHEMA_NAME => Ok(()), @@ -2407,40 +523,25 @@ impl CultCache { } } - /// The store that claims the type, else the generic store. Registration - /// guarantees at most one of each. - fn resolve_route_index(&self, type_id: &str) -> Option { + fn resolve_route_indices(&self, type_id: &str) -> Vec { + let type_specific: Vec = self + .stores + .iter() + .enumerate() + .filter_map(|(index, registration)| { + registration.types.contains(type_id).then_some(index) + }) + .collect(); + if !type_specific.is_empty() { + return type_specific; + } self.stores .iter() - .position(|registration| registration.types.contains(type_id)) - .or_else(|| { - self.stores - .iter() - .position(|registration| registration.types.is_empty()) - }) + .enumerate() + .filter_map(|(index, registration)| registration.types.is_empty().then_some(index)) + .collect() } - /// `None` for a cache with no stores, which is in memory. Once any store - /// is attached, a type with no home store is an error. - fn home_index(&self, type_id: &str) -> Result> { - if self.stores.is_empty() { - return Ok(None); - } - self.resolve_route_index(type_id) - .map(Some) - .ok_or_else(|| anyhow!("No backing store is registered for entry type {:?}", type_id)) - } - - fn describe_store(&self, index: Option) -> String { - match index { - None => "memory".to_string(), - Some(index) if self.stores[index].types.is_empty() => "the generic store".to_string(), - Some(index) => format!( - "the store routed to {:?}", - self.stores[index].types.iter().collect::>() - ), - } - } } impl Default for CultCache { @@ -2449,40 +550,47 @@ impl Default for CultCache { } } -fn entry_id(entry: &CultCacheEnvelope) -> (String, String) { +fn entry_id(entry: &CultCacheEnvelope) -> String { entry_id_parts(&entry.r#type, &entry.key) } -fn entry_id_parts(r#type: &str, key: &str) -> (String, String) { - (r#type.to_string(), key.to_string()) +fn entry_id_parts(r#type: &str, key: &str) -> String { + format!("{type}::{key}", type = r#type) +} + +fn resolve_registered_type( + known_types: &BTreeSet, + schema_name_definitions: &BTreeMap, + persisted_type: &str, +) -> Option { + if known_types.contains(persisted_type) { + return Some(persisted_type.to_string()); + } + + schema_name_definitions.get(persisted_type).cloned() } fn now_utc_second() -> String { chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) } -fn encode_store_snapshot(entries: &[CultCacheEnvelope]) -> Result { - let mut schema_types = BTreeMap::::new(); +fn encode_store_snapshot(entries: &[CultCacheEnvelope]) -> PersistedStoreSnapshot { + let mut schema_names = BTreeSet::::new(); for entry in entries { - let schema_id = entry - .schema_id - .clone() - .unwrap_or_else(|| entry.r#type.clone()); - if let Some(existing_type) = schema_types.insert(schema_id.clone(), entry.r#type.clone()) { - ensure!( - existing_type == entry.r#type, - "CultCache schema {schema_id:?} cannot identify both {existing_type:?} and {:?}", - entry.r#type - ); - } + schema_names.insert( + entry + .schema_id + .clone() + .unwrap_or_else(|| entry.r#type.clone()), + ); } - let catalog = schema_types + let catalog = schema_names .into_iter() - .map(|(schema_id, document_type)| { + .map(|schema_id| { PersistedSchemaCatalogEntry( schema_id.clone(), - document_type, + schema_id.clone(), format!("{schema_id}.v1"), schema_id.clone(), format!( @@ -2510,11 +618,7 @@ fn encode_store_snapshot(entries: &[CultCacheEnvelope]) -> Result Result> { @@ -2536,20 +640,13 @@ fn decode_store_snapshot(bytes: &[u8]) -> Result> { let r#type = match catalog.get(&record.1) { Some(schema_name) => schema_name.clone(), None => { - // A record can outlive its catalog entry when another runtime - // wrote it, or when the record is older than the catalog it - // was stamped against. The payload is a MessagePack array - // whose first field is the schema version, so the type can be - // recovered from the record itself rather than failing the - // whole snapshot. - let schema_version = - infer_schema_version_from_payload(&record.3).ok_or_else(|| { - anyhow!( - "CultCache record {:?} references missing schema {:?}", - record.0, - record.1 - ) - })?; + let schema_version = infer_schema_version_from_payload(&record.3).ok_or_else(|| { + anyhow!( + "CultCache record {:?} references missing schema {:?}", + record.0, + record.1 + ) + })?; infer_schema_name(&schema_version).ok_or_else(|| { anyhow!( "CultCache record {:?} references missing schema {:?}", @@ -2570,21 +667,6 @@ fn decode_store_snapshot(bytes: &[u8]) -> Result> { .collect() } -/// A record recovered from its payload carries a schema name, while the -/// registry is keyed by entry type. Accept either, so a snapshot whose catalog -/// entry is gone still resolves to the registered definition. -fn resolve_registered_type( - known_types: &BTreeSet, - schema_name_definitions: &BTreeMap, - persisted_type: &str, -) -> Option { - if known_types.contains(persisted_type) { - return Some(persisted_type.to_string()); - } - - schema_name_definitions.get(persisted_type).cloned() -} - fn infer_schema_version_from_payload(payload: &[u8]) -> Option { let mut offset = 0usize; read_array_header(payload, &mut offset)?; @@ -2655,200 +737,15 @@ fn temporary_path_for(path: &Path) -> PathBuf { let mut file_name = path .file_name() .map(|value| value.to_os_string()) - .unwrap_or_else(|| "cultcache.cc".into()); + .unwrap_or_else(|| "cultcache.msgpack".into()); file_name.push(format!(".{}.tmp", uuid::Uuid::new_v4())); path.with_file_name(file_name) } -fn remove_abandoned_staging_files(destination: &Path) -> Result<()> { - let Some(parent) = destination.parent() else { - return Ok(()); - }; - let Some(destination_name) = destination.file_name().and_then(|value| value.to_str()) else { - return Ok(()); - }; - let prefix = format!("{destination_name}."); - for entry in fs::read_dir(parent).with_context(|| { - format!( - "failed to inspect {} for abandoned staging files", - parent.display() - ) - })? { - let entry = entry?; - let Some(name) = entry.file_name().to_str().map(str::to_owned) else { - continue; - }; - let Some(candidate) = name - .strip_prefix(&prefix) - .and_then(|value| value.strip_suffix(".tmp")) - else { - continue; - }; - if uuid::Uuid::parse_str(candidate).is_err() || !entry.file_type()?.is_file() { - continue; - } - fs::remove_file(entry.path()).with_context(|| { - format!( - "failed to remove abandoned staging file {}", - entry.path().display() - ) - })?; - } - Ok(()) -} - -#[cfg(unix)] -fn replace_file_atomically(staged: &Path, destination: &Path) -> Result<()> { - fs::rename(staged, destination).with_context(|| { - format!( - "failed to atomically replace {} with {}", - destination.display(), - staged.display() - ) - }) -} - -#[cfg(windows)] -fn replace_file_atomically(staged: &Path, destination: &Path) -> Result<()> { - use std::os::windows::ffi::OsStrExt; - use windows_sys::Win32::Storage::FileSystem::{ - MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, - }; - - let staged_wide = staged - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); - let destination_wide = destination - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); - // A concurrent reader of the destination (another process opening the - // store, an indexer, an antivirus scan) makes the replace fail with a - // sharing or access error for as long as it holds the handle. That is - // transient by construction; a daemon that treats it as fatal dies on - // the first overlap (Muninn serve did, 2026-09-11). Retry briefly. - const ERROR_ACCESS_DENIED: i32 = 5; - const ERROR_SHARING_VIOLATION: i32 = 32; - const ERROR_LOCK_VIOLATION: i32 = 33; - const ATTEMPTS: u32 = 25; - const BACKOFF: std::time::Duration = std::time::Duration::from_millis(20); - let mut attempt = 0; - loop { - let success = unsafe { - MoveFileExW( - staged_wide.as_ptr(), - destination_wide.as_ptr(), - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, - ) - }; - if success != 0 { - return Ok(()); - } - let error = std::io::Error::last_os_error(); - attempt += 1; - let transient = matches!( - error.raw_os_error(), - Some(ERROR_ACCESS_DENIED | ERROR_SHARING_VIOLATION | ERROR_LOCK_VIOLATION) - ); - if !transient || attempt >= ATTEMPTS { - return Err(error).with_context(|| { - format!( - "failed to atomically replace {} with {} after {attempt} attempt(s)", - destination.display(), - staged.display() - ) - }); - } - std::thread::sleep(BACKOFF); - } -} - -#[cfg(all(test, windows))] -mod windows_replace_tests { - use super::replace_file_atomically; - use std::os::windows::fs::OpenOptionsExt; - - /// A reader holding the destination open with no sharing makes the - /// replace fail for as long as it holds on; it must succeed once the - /// reader lets go, not report the first refusal as the outcome. - #[test] - fn a_replace_waits_out_a_reader_that_holds_the_destination() { - let dir = std::env::temp_dir().join(format!("cultcache-replace-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let destination = dir.join("store.cc"); - let staged = dir.join("store.cc.tmp"); - std::fs::write(&destination, b"old").unwrap(); - std::fs::write(&staged, b"new").unwrap(); - let held = std::fs::OpenOptions::new() - .read(true) - .share_mode(0) - .open(&destination) - .unwrap(); - let releaser = std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(120)); - drop(held); - }); - replace_file_atomically(&staged, &destination).expect("the replace outlasts the reader"); - releaser.join().unwrap(); - assert_eq!(std::fs::read(&destination).unwrap(), b"new"); - let _ = std::fs::remove_dir_all(&dir); - } -} - -#[cfg(unix)] -fn sync_parent_directory(path: &Path) -> Result<()> { - let Some(parent) = path.parent() else { - return Ok(()); - }; - File::open(parent) - .and_then(|directory| directory.sync_all()) - .with_context(|| format!("failed to sync {}", parent.display())) -} - -#[cfg(windows)] -fn sync_parent_directory(_path: &Path) -> Result<()> { - // MoveFileExW with MOVEFILE_WRITE_THROUGH owns publication durability. - Ok(()) -} - #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; - use std::sync::Arc; - use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::mpsc; - use std::time::Duration; - - struct RefusingBatchStore { - inner: SingleFileMessagePackBackingStore, - refuse_batch: Arc, - } - - impl CacheBackingStore for RefusingBatchStore { - fn pull_all(&self) -> Result> { - self.inner.pull_all() - } - fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()> { - self.inner.push(entry) - } - fn delete(&mut self, entry: &CultCacheEnvelope) -> Result<()> { - self.inner.delete(entry) - } - fn push_all( - &mut self, - entries: &[CultCacheEnvelope], - options: PushAllOptions, - ) -> Result<()> { - if self.refuse_batch.load(Ordering::SeqCst) { - return Err(anyhow!("injected batch refusal")); - } - self.inner.push_all(entries, options) - } - } #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] #[cultcache(type = "settings")] @@ -2868,24 +765,6 @@ mod tests { body: String, } - #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] - #[cultcache(type = "binary-record")] - struct BinaryRecord { - #[cultcache(key = 0, bytes)] - required: Vec, - #[cultcache(key = 1, bytes, default)] - optional: Vec, - } - - #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] - struct NestedOptionalValue { - label: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - count: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - symbol: Option, - } - #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] #[cultcache(type = "runtime-policy", schema = "tests.schema_stamped_entry")] struct SchemaStamped { @@ -2897,94 +776,12 @@ mod tests { value: String, } - #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] - #[cultcache(type = "nested-note")] - struct NestedNote { - #[cultcache(key = 0)] - value: NestedOptionalValue, - } - - cultcache_soa!(Settings { - "theme" => |row: &Settings| row.theme.clone(), - "retries" => |row: &Settings| row.retries, - }); - cultcache_registry!(TestEntries { Settings, Note }); - #[test] - fn captured_snapshot_cannot_outlive_a_shared_consequence_gate() -> Result<()> { - let temp = tempfile::tempdir()?; - let store_path = temp.path().join("authority.cc"); - let mut store = SingleFileMessagePackBackingStore::new(&store_path); - let released = CultCacheEnvelope { - key: "brake".into(), - r#type: "brake".into(), - payload: b"released".to_vec(), - stored_at: "2026-07-20T00:00:00Z".into(), - schema_id: Some("brake".into()), - }; - store.push(&released)?; - - let writer_path = store_path.clone(); - let (started_tx, started_rx) = mpsc::channel(); - let (finished_tx, finished_rx) = mpsc::channel(); - store.with_read_only_shared_snapshot(|captured| { - assert_eq!(captured, vec![released.clone()]); - std::thread::spawn(move || { - started_tx.send(()).unwrap(); - let engaged = CultCacheEnvelope { - payload: b"engaged".to_vec(), - ..released - }; - let mut writer = SingleFileMessagePackBackingStore::new(writer_path); - writer.push(&engaged).unwrap(); - finished_tx.send(()).unwrap(); - }); - started_rx.recv_timeout(Duration::from_secs(1))?; - assert!( - finished_rx - .recv_timeout(Duration::from_millis(100)) - .is_err() - ); - Ok(()) - })?; - finished_rx.recv_timeout(Duration::from_secs(1))?; - assert_eq!(store.pull_all()?[0].payload, b"engaged"); - Ok(()) - } - - #[test] - fn post_action_unlock_failure_is_diagnostic_not_action_result() -> Result<()> { - let temp = tempfile::tempdir()?; - let store_path = temp.path().join("authority.cc"); - let mut store = SingleFileMessagePackBackingStore::new(&store_path); - store.push(&CultCacheEnvelope { - key: "brake".into(), - r#type: "brake".into(), - payload: b"released".to_vec(), - stored_at: "2026-07-20T00:00:00Z".into(), - schema_id: Some("brake".into()), - })?; - - let mut diagnostic = None; - let result = store.with_read_only_shared_snapshot_using( - |_| Ok(42), - |_| Err(anyhow!("injected post-action unlock failure")), - |error| diagnostic = Some(error.to_string()), - ); - - assert_eq!(result?, 42); - assert_eq!( - diagnostic.as_deref(), - Some("injected post-action unlock failure") - ); - Ok(()) - } - #[test] fn familiar_cultcache_flow_persists_and_reloads_typed_documents() -> Result<()> { let temp = tempfile::tempdir()?; - let store_path = temp.path().join("cache.cc"); + let store_path = temp.path().join("cache.msgpack"); let settings = Settings { theme: "ash".to_string(), retries: 3, @@ -2992,14 +789,14 @@ mod tests { let mut cache = CultCache::new(); cache.register_entry_type::()?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path)); cache.pull_all_backing_stores()?; cache.put("app", &settings)?; assert_eq!(cache.get_required::("app")?, settings); let mut reloaded = CultCache::new(); reloaded.register_entry_type::()?; - reloaded.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + reloaded.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path)); reloaded.pull_all_backing_stores()?; assert_eq!(reloaded.get_required::("app")?, settings); Ok(()) @@ -3008,10 +805,10 @@ mod tests { #[test] fn entry_identity_is_polymorphic_by_type_and_key() -> Result<()> { let temp = tempfile::tempdir()?; - let store_path = temp.path().join("cache.cc"); + let store_path = temp.path().join("cache.msgpack"); let mut cache = CultCache::new(); cache.register_registry(TestEntries)?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path)); cache.put( "shared", @@ -3034,759 +831,19 @@ mod tests { Ok(()) } - #[test] - fn prepared_batch_publishes_heterogeneous_entries_in_one_snapshot() -> Result<()> { - let temp = tempfile::tempdir()?; - let store_path = temp.path().join("batch.cc"); - let mut cache = CultCache::new(); - cache.register_registry(TestEntries)?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; - - let (settings, _) = cache.prepare_entry( - "app", - &Settings { - theme: "iron".to_string(), - retries: 4, - }, - )?; - let (note, _) = cache.prepare_entry( - "receipt", - &Note { - title: "committed".to_string(), - body: "same snapshot".to_string(), - }, - )?; - cache.put_prepared_batch(vec![settings, note])?; - - let mut reloaded = CultCache::new(); - reloaded.register_registry(TestEntries)?; - reloaded.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; - reloaded.pull_all_backing_stores()?; - assert_eq!(reloaded.get_required::("app")?.theme, "iron"); - assert_eq!(reloaded.get_required::("receipt")?.title, "committed"); - Ok(()) - } - - #[test] - fn named_preparation_preserves_omitted_nested_optional_slots() -> Result<()> { - let temp = tempfile::tempdir()?; - let store_path = temp.path().join("named.cc"); - let mut cache = CultCache::new(); - cache.register_entry_type::()?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; - cache.pull_all_backing_stores()?; - let note = NestedNote { - value: NestedOptionalValue { - label: "nested".into(), - count: None, - symbol: Some("kept-in-its-own-field".into()), - }, - }; - assert!(cache.prepare_entry("compact", ¬e).is_err()); - let (entry, parsed) = cache.prepare_entry_named("named", ¬e)?; - assert_eq!(parsed, note); - cache.put_prepared_batch(vec![entry])?; - assert_eq!(cache.get_required::("named")?, note); - Ok(()) - } - - #[test] - fn pulling_an_absent_store_does_not_create_its_parent_or_lock() -> Result<()> { - let temp = tempfile::tempdir()?; - let missing_parent = temp.path().join("missing-body"); - let store_path = missing_parent.join("cache.cc"); - let store = SingleFileMessagePackBackingStore::new(&store_path); - - assert!(store.pull_all()?.is_empty()); - assert!(!missing_parent.exists()); - assert!(!store_path.with_file_name("cache.cc.lock").exists()); - Ok(()) - } - - #[test] - fn provider_snapshot_read_does_not_create_or_open_a_lock() -> Result<()> { - let temp = tempfile::tempdir()?; - let store_path = temp.path().join("provider.cc"); - let lock_path = temp.path().join("provider.cc.lock"); - let mut store = SingleFileMessagePackBackingStore::new(&store_path); - let expected = test_envelope("provider.delivery", "one", b"payload"); - store.push(&expected)?; - std::fs::remove_file(&lock_path)?; - - assert_eq!(store.pull_all_read_only_snapshot()?, vec![expected]); - assert!(!lock_path.exists()); - Ok(()) - } - - #[test] - fn next_exclusive_write_removes_only_exact_abandoned_staging_files() -> Result<()> { - let temp = tempfile::tempdir()?; - let store_path = temp.path().join("cache.cc"); - let abandoned = temporary_path_for(&store_path); - let unknown = temp.path().join("cache.cc.not-a-uuid.tmp"); - let foreign = temp - .path() - .join("other.cc.00000000-0000-0000-0000-000000000000.tmp"); - fs::write(&abandoned, b"partial staging bytes")?; - fs::write(&unknown, b"operator-owned unknown file")?; - fs::write(&foreign, b"foreign store staging")?; - - let mut store = SingleFileMessagePackBackingStore::new(&store_path); - let expected = test_envelope("model", "current", b"one"); - store.push(&expected)?; - - assert!(!abandoned.exists()); - assert_eq!(fs::read(&unknown)?, b"operator-owned unknown file"); - assert_eq!(fs::read(&foreign)?, b"foreign store staging"); - assert_eq!(store.pull_all()?, vec![expected]); - Ok(()) - } - - #[test] - fn failed_snapshot_publication_preserves_the_committed_destination() -> Result<()> { - let temp = tempfile::tempdir()?; - let destination = temp.path().join("cache.cc"); - let absent_staging = temp.path().join("absent.tmp"); - let committed = b"committed snapshot"; - fs::write(&destination, committed)?; - - assert!(replace_file_atomically(&absent_staging, &destination).is_err()); - assert_eq!(fs::read(&destination)?, committed); - Ok(()) - } - - #[test] - fn snapshot_publication_atomically_replaces_an_existing_destination() -> Result<()> { - let temp = tempfile::tempdir()?; - let destination = temp.path().join("cache.cc"); - let staged = temp.path().join("cache.cc.staged"); - fs::write(&destination, b"old snapshot")?; - fs::write(&staged, b"new snapshot")?; - - replace_file_atomically(&staged, &destination)?; - - assert_eq!(fs::read(&destination)?, b"new snapshot"); - assert!(!staged.exists()); - Ok(()) - } - - #[test] - #[ignore = "operator SIGKILL probe; requires CULTCACHE_INTERRUPTION_STORE"] - fn snapshot_publication_interruption_probe_writer() -> Result<()> { - let store_path = std::env::var_os("CULTCACHE_INTERRUPTION_STORE") - .map(PathBuf::from) - .ok_or_else(|| anyhow!("CULTCACHE_INTERRUPTION_STORE is required"))?; - let marker_path = std::env::var_os("CULTCACHE_INTERRUPTION_MARKER") - .map(PathBuf::from) - .ok_or_else(|| anyhow!("CULTCACHE_INTERRUPTION_MARKER is required"))?; - let mut store = SingleFileMessagePackBackingStore::new(store_path); - for sequence in 0_u64.. { - store.push(&test_envelope( - "interruption.probe", - "current", - &rmp_serde::to_vec(&sequence)?, - ))?; - fs::write(&marker_path, sequence.to_string())?; - } - unreachable!("the interruption writer is stopped by the operator") - } - - #[test] - #[ignore = "operator SIGKILL probe; requires CULTCACHE_INTERRUPTION_STORE"] - fn snapshot_publication_interruption_probe_reader() -> Result<()> { - let store_path = std::env::var_os("CULTCACHE_INTERRUPTION_STORE") - .map(PathBuf::from) - .ok_or_else(|| anyhow!("CULTCACHE_INTERRUPTION_STORE is required"))?; - let rows = SingleFileMessagePackBackingStore::new(store_path).pull_all()?; - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].r#type, "interruption.probe"); - assert_eq!(rows[0].key, "current"); - let _: u64 = rmp_serde::from_slice(&rows[0].payload)?; - Ok(()) - } - - fn test_envelope(r#type: &str, key: &str, payload: &[u8]) -> CultCacheEnvelope { - CultCacheEnvelope { - key: key.to_string(), - r#type: r#type.to_string(), - payload: payload.to_vec(), - stored_at: "2026-07-13T00:00:00Z".to_string(), - schema_id: Some(r#type.to_string()), - } - } - - #[test] - fn redb_store_crud_and_reopen_preserve_polymorphic_rows() -> Result<()> { - let temp = tempfile::tempdir()?; - let path = temp.path().join("keyed.cc"); - let model = test_envelope("model", "shared", b"one"); - let receipt = test_envelope("receipt", "shared", b"two"); - { - let mut store = RedbMessagePackBackingStore::new(&path)?; - store.push(&model)?; - store.push(&receipt)?; - assert_eq!(store.pull_all()?.len(), 2); - store.delete(&receipt)?; - } - let reopened = RedbMessagePackBackingStore::new(&path)?; - assert_eq!(reopened.pull_all()?, vec![model]); - Ok(()) - } - - #[test] - fn redb_exact_cas_refuses_stale_value_without_mutation() -> Result<()> { - let temp = tempfile::tempdir()?; - let mut store = RedbMessagePackBackingStore::new(temp.path().join("cas.cc"))?; - let current = test_envelope("model", "current", b"one"); - store.push(¤t)?; - let stale = test_envelope("model", "current", b"stale"); - let replacement = test_envelope("model", "current", b"two"); - assert!(!store.compare_and_swap_entry(&stale, replacement.clone())?); - assert_eq!(store.pull_all()?, vec![current.clone()]); - assert!(store.compare_and_swap_entry(¤t, replacement.clone())?); - assert_eq!(store.pull_all()?, vec![replacement]); - Ok(()) - } - - #[test] - fn redb_batch_is_atomic_and_stale_member_refuses_every_write() -> Result<()> { - let temp = tempfile::tempdir()?; - let mut store = RedbMessagePackBackingStore::new(temp.path().join("batch.cc"))?; - let first = test_envelope("model", "first", b"one"); - let second = test_envelope("model", "second", b"one"); - store.push(&first)?; - store.push(&second)?; - let stale_second = test_envelope("model", "second", b"stale"); - let first_two = test_envelope("model", "first", b"two"); - let second_two = test_envelope("model", "second", b"two"); - let receipt = test_envelope("receipt", "batch", b"committed"); - assert!(!store.compare_and_swap_batch( - &[first.clone(), stale_second], - vec![first_two.clone(), second_two.clone(), receipt.clone()], - )?); - assert_eq!(store.pull_all()?, vec![first.clone(), second.clone()]); - assert!(store.compare_and_swap_batch( - &[first, second], - vec![first_two.clone(), second_two.clone(), receipt.clone()], - )?); - assert_eq!(store.pull_all()?, vec![first_two, second_two, receipt]); - Ok(()) - } - - #[test] - fn redb_compaction_refuses_stale_snapshot_and_atomically_replaces_and_deletes() -> Result<()> { - let temp = tempfile::tempdir()?; - let mut store = RedbMessagePackBackingStore::new(temp.path().join("compact.cc"))?; - let old_head = test_envelope("retention", "current", b"one"); - let retired = test_envelope("receipt", "old", b"terminal"); - store.push(&old_head)?; - store.push(&retired)?; - let stale = store.pull_all()?; - let concurrent = test_envelope("receipt", "running", b"active"); - assert!(store.insert_entry_if_absent(concurrent.clone())?); - let new_head = test_envelope("retention", "current", b"two"); - assert!(!store.replace_and_delete_if_snapshot_unchanged( - &stale, - vec![new_head.clone()], - std::slice::from_ref(&retired), - )?); - assert!(store.pull_all()?.contains(&retired)); - let exact = store.pull_all()?; - assert!(store.replace_and_delete_if_snapshot_unchanged( - &exact, - vec![new_head.clone()], - &[retired], - )?); - assert_eq!(store.pull_all()?, vec![concurrent, new_head]); - Ok(()) - } - - #[test] - fn redb_entry_cas_does_not_depend_on_unrelated_snapshot_changes() -> Result<()> { - let temp = tempfile::tempdir()?; - let store = RedbMessagePackBackingStore::new(temp.path().join("independent.cc"))?; - let current = test_envelope("model", "current", b"one"); - assert!(store.insert_entry_if_absent(current.clone())?); - let unrelated = test_envelope("event", "later", b"noise"); - assert!(store.insert_entry_if_absent(unrelated.clone())?); - let replacement = test_envelope("model", "current", b"two"); - assert!(store.compare_and_swap_entry(¤t, replacement.clone())?); - assert_eq!(store.pull_all()?, vec![unrelated, replacement]); - Ok(()) - } - - #[test] - fn redb_fresh_handles_share_one_database_for_the_same_cultcache_path() -> Result<()> { - let temp = tempfile::tempdir()?; - let path = temp.path().join("shared.cc"); - let mut first = RedbMessagePackBackingStore::new(&path)?; - let second = RedbMessagePackBackingStore::new(&path)?; - let initial = test_envelope("model", "current", b"one"); - first.push(&initial)?; - assert_eq!(second.pull_all()?, vec![initial.clone()]); - let replacement = test_envelope("model", "current", b"two"); - assert!(second.compare_and_swap_entry(&initial, replacement.clone())?); - assert_eq!(first.pull_all()?, vec![replacement]); - Ok(()) - } - - #[test] - fn owned_redb_clones_share_authority_and_fresh_owner_is_refused() -> Result<()> { - let temp = tempfile::tempdir()?; - let path = temp.path().join("owned.cc"); - let owner = OwnedRedbMessagePackBackingStore::new(&path)?; - let clone = owner.clone(); - assert_eq!(owner.file_identity(), clone.file_identity()); - assert!(OwnedRedbMessagePackBackingStore::new(&path).is_err()); - drop(owner); - assert!(OwnedRedbMessagePackBackingStore::new(&path).is_err()); - drop(clone); - assert!(OwnedRedbMessagePackBackingStore::new(&path).is_ok()); - Ok(()) - } - - #[test] - fn owned_redb_file_identity_is_stable_across_writes_and_reopen() -> Result<()> { - let temp = tempfile::tempdir()?; - let path = temp.path().join("identity.cc"); - let identity = { - let mut owner = OwnedRedbMessagePackBackingStore::new(&path)?; - let identity = owner.file_identity().to_string(); - owner.push(&test_envelope("model", "current", b"one"))?; - owner.validate_path_identity()?; - owner.require_file_identity(&identity)?; - identity - }; - let reopened = OwnedRedbMessagePackBackingStore::new(&path)?; - assert_eq!(reopened.file_identity(), identity); - assert_eq!(reopened.pull_all()?.len(), 1); - Ok(()) - } - - #[test] - fn owned_redb_identity_helper_refuses_mismatch() -> Result<()> { - let temp = tempfile::tempdir()?; - let owner = OwnedRedbMessagePackBackingStore::new(temp.path().join("mismatch.cc"))?; - assert!(owner.require_file_identity("not-the-owned-file").is_err()); - owner.require_file_identity(owner.file_identity())?; - Ok(()) - } - - #[test] - fn owned_redb_batch_refuses_stale_member_and_commits_success_atomically() -> Result<()> { - let temp = tempfile::tempdir()?; - let mut owner = OwnedRedbMessagePackBackingStore::new(temp.path().join("batch-owned.cc"))?; - let first = test_envelope("model", "first", b"one"); - let second = test_envelope("model", "second", b"one"); - owner.push(&first)?; - owner.push(&second)?; - let first_two = test_envelope("model", "first", b"two"); - let second_two = test_envelope("model", "second", b"two"); - let companion = test_envelope("receipt", "batch", b"committed"); - let stale_second = test_envelope("model", "second", b"stale"); - assert!(!owner.compare_and_swap_batch( - &[first.clone(), stale_second], - vec![first_two.clone(), second_two.clone(), companion.clone()], - )?); - assert_eq!(owner.pull_all()?, vec![first.clone(), second.clone()]); - assert!(owner.compare_and_swap_batch( - &[first, second], - vec![first_two.clone(), second_two.clone(), companion.clone()], - )?); - assert_eq!(owner.pull_all()?, vec![first_two, second_two, companion]); - Ok(()) - } - - #[test] - fn owned_redb_compaction_refuses_stale_snapshot_and_atomically_replaces_and_deletes() - -> Result<()> { - let temp = tempfile::tempdir()?; - let mut owner = - OwnedRedbMessagePackBackingStore::new(temp.path().join("compact-owned.cc"))?; - let old_head = test_envelope("retention", "current", b"one"); - let retired_a = test_envelope("claim", "a", b"terminal"); - let retired_b = test_envelope("receipt", "a", b"terminal"); - owner.push(&old_head)?; - owner.push(&retired_a)?; - owner.push(&retired_b)?; - let stale = owner.pull_all()?; - let concurrent = test_envelope("claim", "concurrent", b"running"); - assert!(owner.insert_entry_if_absent(concurrent.clone())?); - let new_head = test_envelope("retention", "current", b"two"); - assert!(!owner.replace_and_delete_if_snapshot_unchanged( - &stale, - vec![new_head.clone()], - &[retired_a.clone(), retired_b.clone()], - )?); - assert!(owner.pull_all()?.contains(&retired_a)); - let exact = owner.pull_all()?; - assert!(owner.replace_and_delete_if_snapshot_unchanged( - &exact, - vec![new_head.clone()], - &[retired_a, retired_b], - )?); - assert_eq!(owner.pull_all()?, vec![concurrent, new_head]); - Ok(()) - } - - #[test] - fn owned_redb_path_replacement_cannot_redirect_pinned_authority() -> Result<()> { - let temp = tempfile::tempdir()?; - let path = temp.path().join("pinned.cc"); - let displaced = temp.path().join("displaced.cc"); - let mut owner = OwnedRedbMessagePackBackingStore::new(&path)?; - let identity = owner.file_identity().to_string(); - owner.push(&test_envelope("model", "before", b"one"))?; - fs::rename(&path, &displaced)?; - File::create(&path)?; - assert!(owner.validate_path_identity().is_err()); - assert_eq!(owner.file_identity(), identity); - owner.push(&test_envelope("model", "after", b"two"))?; - assert_eq!(owner.pull_all()?.len(), 2); - assert_ne!( - file_identity_from_file(&File::open(&path)?)?, - owner.file_identity() - ); - Ok(()) - } - - #[test] - fn redb_overlapping_fresh_handles_serialize_the_database_open_interval() -> Result<()> { - let temp = tempfile::tempdir()?; - let path = temp.path().join("contended.cc"); - let first = RedbMessagePackBackingStore::new(&path)?; - let second = RedbMessagePackBackingStore::new(&path)?; - let barrier = std::sync::Arc::new(std::sync::Barrier::new(3)); - let first_barrier = barrier.clone(); - let first_thread = std::thread::spawn(move || -> Result<()> { - first_barrier.wait(); - for index in 0..32 { - first.insert_entry_if_absent(test_envelope( - "event", - &format!("first-{index}"), - b"first", - ))?; - } - Ok(()) - }); - let second_barrier = barrier.clone(); - let second_thread = std::thread::spawn(move || -> Result<()> { - second_barrier.wait(); - for index in 0..32 { - second.insert_entry_if_absent(test_envelope( - "event", - &format!("second-{index}"), - b"second", - ))?; - } - Ok(()) - }); - barrier.wait(); - first_thread.join().expect("first redb writer")?; - second_thread.join().expect("second redb writer")?; - assert_eq!( - RedbMessagePackBackingStore::new(path)?.pull_all()?.len(), - 64 - ); - Ok(()) - } - - #[test] - fn redb_accepts_a_bare_relative_cultcache_path() -> Result<()> { - let name = format!("cultcache-redb-relative-{}.cc", uuid::Uuid::new_v4()); - let path = PathBuf::from(&name); - let lock_path = PathBuf::from(format!("{name}.lock")); - let store = RedbMessagePackBackingStore::new(&path)?; - assert!(store.insert_entry_if_absent(test_envelope("model", "current", b"one"))?); - assert_eq!(store.pull_all()?.len(), 1); - fs::remove_file(path)?; - fs::remove_file(lock_path)?; - Ok(()) - } - - #[test] - fn conditional_batch_replaces_expected_and_inserts_companion_atomically() -> Result<()> { - let temp = tempfile::tempdir()?; - let store_path = temp.path().join("conditional-batch.cc"); - let mut store = SingleFileMessagePackBackingStore::new(&store_path); - let current = test_envelope("model", "current", b"revision-1"); - store.push(¤t)?; - let replacement = test_envelope("model", "current", b"revision-2"); - let companion = test_envelope("receipt", "migration-1", b"applied"); - - assert!(store.compare_and_swap_batch( - std::slice::from_ref(¤t), - vec![replacement.clone(), companion.clone()], - )?); - assert_eq!(store.pull_all()?, vec![replacement, companion]); - Ok(()) - } - - #[test] - fn conditional_batch_allows_atomic_first_import_when_identities_are_absent() -> Result<()> { - let temp = tempfile::tempdir()?; - let store_path = temp.path().join("conditional-import.cc"); - let store = SingleFileMessagePackBackingStore::new(&store_path); - let model = test_envelope("model", "current", b"revision-1"); - let receipt = test_envelope("receipt", "migration-1", b"applied"); - - assert!(store.compare_and_swap_batch(&[], vec![model.clone(), receipt.clone()])?); - assert_eq!(store.pull_all()?, vec![model, receipt]); - Ok(()) - } - - #[test] - fn conditional_batch_stale_expected_and_companion_collision_preserve_exact_bytes() -> Result<()> - { - let temp = tempfile::tempdir()?; - let store_path = temp.path().join("conditional-refusal.cc"); - let mut store = SingleFileMessagePackBackingStore::new(&store_path); - let current = test_envelope("model", "current", b"revision-1"); - let occupied = test_envelope("receipt", "migration-1", b"someone-else"); - store.push(¤t)?; - store.push(&occupied)?; - let before = fs::read(&store_path)?; - - let stale = test_envelope("model", "current", b"stale-copy"); - let replacement = test_envelope("model", "current", b"revision-2"); - let fresh_companion = test_envelope("receipt", "migration-2", b"applied"); - assert!( - !store.compare_and_swap_batch(&[stale], vec![replacement.clone(), fresh_companion],)? - ); - assert_eq!(fs::read(&store_path)?, before); - - assert!(!store.compare_and_swap_batch( - &[current], - vec![ - replacement, - test_envelope("receipt", "migration-1", b"collision") - ], - )?); - assert_eq!(fs::read(&store_path)?, before); - Ok(()) - } - - #[test] - fn conditional_batch_rejects_empty_replacements_and_duplicate_identities() -> Result<()> { - let temp = tempfile::tempdir()?; - let store = SingleFileMessagePackBackingStore::new(temp.path().join("invalid-batch.cc")); - let entry = test_envelope("model", "current", b"one"); - - assert!( - store - .compare_and_swap_batch(&[entry.clone()], Vec::new()) - .is_err() - ); - assert!( - store - .compare_and_swap_batch(&[], vec![entry.clone(), entry.clone()]) - .is_err() - ); - assert!( - store - .compare_and_swap_batch( - &[entry.clone(), entry.clone()], - vec![test_envelope("model", "current", b"two")], - ) - .is_err() - ); - assert!(!store.path().exists()); - Ok(()) - } - - #[test] - fn full_snapshot_replacement_and_append_refuses_concurrent_rows() -> Result<()> { - let temp = tempfile::tempdir()?; - let snapshot_path = temp.path().join("snapshot-replace-append.cc"); - let mut snapshot_store = SingleFileMessagePackBackingStore::new(&snapshot_path); - let current = test_envelope("session", "current", b"active"); - snapshot_store.push(¤t)?; - let stale_snapshot = snapshot_store.pull_all()?; - let concurrent = test_envelope("event", "concurrent", b"live"); - snapshot_store.push(&concurrent)?; - let completed = test_envelope("session", "current", b"completed"); - let receipt = test_envelope("receipt", "terminal", b"done"); - assert!(!snapshot_store.replace_and_append_if_snapshot_unchanged( - &stale_snapshot, - vec![completed.clone(), receipt.clone()], - )?); - assert_eq!( - snapshot_store.pull_all()?, - vec![concurrent.clone(), current.clone()] - ); - let exact_snapshot = snapshot_store.pull_all()?; - assert!(snapshot_store.replace_and_append_if_snapshot_unchanged( - &exact_snapshot, - vec![completed.clone(), receipt.clone()], - )?); - assert_eq!( - snapshot_store.pull_all()?, - vec![concurrent.clone(), receipt.clone(), completed.clone()] - ); - - let keyed_path = temp.path().join("keyed-replace-append.redb"); - let mut keyed_store = RedbMessagePackBackingStore::new(&keyed_path)?; - keyed_store.push(¤t)?; - let stale_snapshot = keyed_store.pull_all()?; - keyed_store.push(&concurrent)?; - assert!(!keyed_store.replace_and_append_if_snapshot_unchanged( - &stale_snapshot, - vec![completed.clone(), receipt.clone()], - )?); - let exact_snapshot = keyed_store.pull_all()?; - assert!(keyed_store.replace_and_append_if_snapshot_unchanged( - &exact_snapshot, - vec![completed.clone(), receipt.clone()], - )?); - assert_eq!( - keyed_store.pull_all()?, - vec![concurrent, receipt, completed] - ); - Ok(()) - } - - #[test] - fn single_file_compaction_atomically_replaces_summary_and_deletes_history() -> Result<()> { - let temp = tempfile::tempdir()?; - let store_path = temp.path().join("conditional-compaction.cc"); - let mut store = SingleFileMessagePackBackingStore::new(&store_path); - let history = test_envelope("history", "old", b"closed"); - let head = test_envelope("retention", "head", b"revision-1"); - store.push(&history)?; - store.push(&head)?; - let snapshot = store.pull_all()?; - let next_head = test_envelope("retention", "head", b"revision-2"); - - assert!(store.replace_and_delete_if_snapshot_unchanged( - &snapshot, - vec![next_head.clone()], - std::slice::from_ref(&history), - )?); - assert_eq!(store.pull_all()?, vec![next_head]); - - let before = fs::read(&store_path)?; - assert!(!store.replace_and_delete_if_snapshot_unchanged( - &snapshot, - vec![test_envelope("retention", "head", b"revision-3")], - std::slice::from_ref(&history), - )?); - assert_eq!(fs::read(&store_path)?, before); - Ok(()) - } - - #[test] - fn snapshot_append_fences_concurrent_unknown_identity() -> Result<()> { - let temp = tempfile::tempdir()?; - let store_path = temp.path().join("snapshot-append.cc"); - let mut store = SingleFileMessagePackBackingStore::new(&store_path); - let model = test_envelope("model", "current", b"revision-1"); - store.push(&model)?; - let expected = store.pull_all()?; - let duplicate_admission = test_envelope("admission", "unexpected", b"also-current"); - store.push(&duplicate_admission)?; - let before = fs::read(&store_path)?; - - assert!(!store.append_if_snapshot_unchanged( - &expected, - vec![test_envelope("readiness", "proof", b"ready")], - )?); - assert_eq!(fs::read(&store_path)?, before); - Ok(()) - } - - #[test] - fn snapshot_append_is_order_insensitive_and_refuses_collisions() -> Result<()> { - let temp = tempfile::tempdir()?; - let store_path = temp.path().join("snapshot-order.cc"); - let mut store = SingleFileMessagePackBackingStore::new(&store_path); - let a = test_envelope("a", "one", b"1"); - let b = test_envelope("b", "two", b"2"); - store.push(&a)?; - store.push(&b)?; - assert!(store.append_if_snapshot_unchanged( - &[b.clone(), a.clone()], - vec![test_envelope("proof", "three", b"3")], - )?); - let current = store.pull_all()?; - assert!(!store.append_if_snapshot_unchanged( - ¤t, - vec![test_envelope("a", "one", b"collision")], - )?); - assert!( - store - .append_if_snapshot_unchanged(¤t, Vec::new()) - .is_err() - ); - Ok(()) - } - - #[test] - fn type_and_key_identity_is_not_delimiter_ambiguous() -> Result<()> { - let temp = tempfile::tempdir()?; - let store_path = temp.path().join("delimiter-identity.cc"); - let mut store = SingleFileMessagePackBackingStore::new(&store_path); - let left = test_envelope("a::b", "c", b"left"); - let right = test_envelope("a", "b::c", b"right"); - store.push(&left)?; - store.push(&right)?; - let rows = store.pull_all()?; - assert_eq!(rows.len(), 2); - assert!(rows.contains(&left)); - assert!(rows.contains(&right)); - Ok(()) - } - - #[test] - fn refused_batch_preserves_prior_snapshot() -> Result<()> { - let temp = tempfile::tempdir()?; - let store_path = temp.path().join("refused-batch.cc"); - let refusal = Arc::new(AtomicBool::new(false)); - let mut cache = CultCache::new(); - cache.register_registry(TestEntries)?; - cache.add_generic_backing_store(RefusingBatchStore { - inner: SingleFileMessagePackBackingStore::new(&store_path), - refuse_batch: refusal.clone(), - })?; - cache.put( - "app", - &Settings { - theme: "before".to_string(), - retries: 1, - }, - )?; - let (note, _) = cache.prepare_entry( - "receipt", - &Note { - title: "after".to_string(), - body: "must not land".to_string(), - }, - )?; - refusal.store(true, Ordering::SeqCst); - assert!(cache.put_prepared_batch(vec![note]).is_err()); - assert!(cache.get::("receipt")?.is_none()); - - let mut reloaded = CultCache::new(); - reloaded.register_registry(TestEntries)?; - reloaded.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; - reloaded.pull_all_backing_stores()?; - assert_eq!(reloaded.get_required::("app")?.theme, "before"); - assert!(reloaded.get::("receipt")?.is_none()); - Ok(()) - } - #[test] fn type_specific_store_routes_before_generic_store() -> Result<()> { let temp = tempfile::tempdir()?; - let generic_path = temp.path().join("generic.cc"); - let settings_path = temp.path().join("settings.cc"); + let generic_path = temp.path().join("generic.msgpack"); + let settings_path = temp.path().join("settings.msgpack"); let mut cache = CultCache::new(); cache.register_entry_type::()?; cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path)); cache.add_backing_store( SingleFileMessagePackBackingStore::new(&settings_path), ["settings"], - )?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path))?; + ); cache.put( "app", @@ -3810,232 +867,13 @@ mod tests { Ok(()) } - #[test] - fn second_generic_store_is_rejected() -> Result<()> { - let temp = tempfile::tempdir()?; - let mut cache = CultCache::new(); - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(temp.path().join("a.cc")))?; - let error = cache - .add_generic_backing_store(SingleFileMessagePackBackingStore::new(temp.path().join("b.cc"))) - .unwrap_err(); - assert!(error.to_string().contains("second generic store"), "{error}"); - assert_eq!(cache.stores.len(), 1); - Ok(()) - } - - #[test] - fn type_claimed_twice_is_rejected() -> Result<()> { - let temp = tempfile::tempdir()?; - let mut cache = CultCache::new(); - cache.add_backing_store(SingleFileMessagePackBackingStore::new(temp.path().join("a.cc")), ["settings"])?; - let error = cache - .add_backing_store( - SingleFileMessagePackBackingStore::new(temp.path().join("b.cc")), - ["note", "settings"], - ) - .unwrap_err(); - assert!(error.to_string().contains("already routed"), "{error}"); - // A refused registration claims nothing, so "note" is still free. - cache.add_backing_store(SingleFileMessagePackBackingStore::new(temp.path().join("c.cc")), ["note"])?; - assert_eq!(cache.stores.len(), 2); - Ok(()) - } - - #[test] - fn attach_that_would_move_a_held_record_home_is_refused() -> Result<()> { - let temp = tempfile::tempdir()?; - let generic_path = temp.path().join("generic.cc"); - let settings_path = temp.path().join("settings.cc"); - let mut cache = CultCache::new(); - cache.register_entry_type::()?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path))?; - cache.put("app", &Settings { theme: "v1-generic".to_string(), retries: 1 })?; - - let error = cache - .add_backing_store(SingleFileMessagePackBackingStore::new(&settings_path), ["settings"]) - .unwrap_err(); - assert!( - error - .to_string() - .contains("would move \"settings\" from the generic store to the store routed to [\"settings\"]"), - "{error}" - ); - assert_eq!(cache.stores.len(), 1); - // Nothing attached: the next write still lands in the generic store. - cache.put("app", &Settings { theme: "v2-generic".to_string(), retries: 2 })?; - let generic_entries = SingleFileMessagePackBackingStore::new(&generic_path).pull_all()?; - assert_eq!(generic_entries.len(), 1); - assert_eq!(generic_entries[0].r#type, "settings"); - assert!(!settings_path.exists()); - Ok(()) - } - - #[test] - fn load_from_a_store_that_is_not_the_record_home_is_refused() -> Result<()> { - let temp = tempfile::tempdir()?; - let generic_path = temp.path().join("generic.cc"); - let settings_path = temp.path().join("settings.cc"); - let mut writer = CultCache::new(); - writer.register_entry_type::()?; - writer.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path))?; - writer.put("app", &Settings { theme: "stray".to_string(), retries: 0 })?; - let generic_bytes = fs::read(&generic_path)?; - - let mut cache = CultCache::new(); - cache.register_entry_type::()?; - cache.add_backing_store(SingleFileMessagePackBackingStore::new(&settings_path), ["settings"])?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path))?; - // The cache already holds a record, in its home store, before the refused load. - cache.put("held", &Settings { theme: "home".to_string(), retries: 0 })?; - let before = cache.snapshot(); - let settings_bytes = fs::read(&settings_path)?; - let error = cache.pull_all_backing_stores().unwrap_err(); - assert!( - error.to_string().contains( - "\"settings\" record \"app\" was loaded from the generic store, but its home is the store routed to [\"settings\"]" - ), - "{error}" - ); - assert_eq!(cache.snapshot(), before); - assert_eq!(cache.get_required::("held")?.theme, "home"); - assert_eq!(fs::read(&generic_path)?, generic_bytes); - assert_eq!(fs::read(&settings_path)?, settings_bytes); - Ok(()) - } - - #[test] - fn refused_pull_leaves_prior_view_intact() -> Result<()> { - let temp = tempfile::tempdir()?; - let store_path = temp.path().join("generic.cc"); - let mut cache = CultCache::new(); - cache.register_entry_type::()?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; - cache.put("app", &Settings { theme: "before".to_string(), retries: 1 })?; - let before = cache.snapshot(); - - // A record of a type this cache never registered lands in the store behind its back. - SingleFileMessagePackBackingStore::new(&store_path).push(&CultCacheEnvelope { - key: "stray".into(), - r#type: "unregistered".into(), - payload: rmp_serde::to_vec(&("x",))?, - stored_at: "2026-09-14T00:00:00Z".into(), - schema_id: Some("unregistered".into()), - })?; - let error = cache.pull_all_backing_stores().unwrap_err(); - assert!(error.to_string().contains("\"unregistered\""), "{error}"); - assert_eq!(cache.snapshot(), before); - assert_eq!(cache.get_required::("app")?.theme, "before"); - Ok(()) - } - - #[test] - fn refused_soa_load_leaves_prior_view_intact() -> Result<()> { - let mut source = CultCache::new(); - source.register_registry(TestEntries)?; - source.put("app", &Settings { theme: "ash".to_string(), retries: 3 })?; - source.put("memo", &Note { title: "machine".to_string(), body: "awake".to_string() })?; - let table = source.snapshot_soa(); - - // A type with no registration. - let mut unregistered = CultCache::new(); - unregistered.register_entry_type::()?; - unregistered.put("held", &Settings { theme: "held".to_string(), retries: 0 })?; - let before = unregistered.snapshot(); - let error = unregistered.load_soa(table.clone()).unwrap_err(); - assert!(error.to_string().contains("\"note\""), "{error}"); - assert_eq!(unregistered.snapshot(), before); - - // A registered type with no home once a store is attached. - let temp = tempfile::tempdir()?; - let mut homeless = CultCache::new(); - homeless.register_registry(TestEntries)?; - homeless.add_backing_store(SingleFileMessagePackBackingStore::new(temp.path().join("settings.cc")), ["settings"])?; - homeless.put("held", &Settings { theme: "held".to_string(), retries: 0 })?; - let before = homeless.snapshot(); - let error = homeless.load_soa(table).unwrap_err(); - assert!(error.to_string().contains("No backing store is registered for entry type \"note\""), "{error}"); - assert_eq!(homeless.snapshot(), before); - Ok(()) - } - - #[test] - fn single_home_batch_is_not_refused_by_other_homes_records() -> Result<()> { - let temp = tempfile::tempdir()?; - let generic_path = temp.path().join("generic.cc"); - let settings_path = temp.path().join("settings.cc"); - let mut cache = CultCache::new(); - cache.register_registry(TestEntries)?; - cache.add_backing_store(SingleFileMessagePackBackingStore::new(&settings_path), ["settings"])?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path))?; - cache.put("memo", &Note { title: "generic".to_string(), body: "home".to_string() })?; - cache.put("old", &Settings { theme: "kept".to_string(), retries: 0 })?; - - let (staged, _) = cache.prepare_entry("app", &Settings { theme: "batch".to_string(), retries: 1 })?; - cache.put_prepared_batch(vec![staged])?; - - let mut settings_keys: Vec<_> = SingleFileMessagePackBackingStore::new(&settings_path) - .pull_all()? - .into_iter() - .map(|entry| format!("{}:{}", entry.r#type, entry.key)) - .collect(); - settings_keys.sort(); - assert_eq!(settings_keys, vec!["settings:app".to_string(), "settings:old".to_string()]); - let generic_keys: Vec<_> = SingleFileMessagePackBackingStore::new(&generic_path) - .pull_all()? - .into_iter() - .map(|entry| format!("{}:{}", entry.r#type, entry.key)) - .collect(); - assert_eq!(generic_keys, vec!["note:memo".to_string()]); - assert_eq!(cache.get_required::("app")?.theme, "batch"); - Ok(()) - } - - #[test] - fn batch_with_empty_key_or_stored_at_is_refused_before_any_store() -> Result<()> { - let temp = tempfile::tempdir()?; - let store_path = temp.path().join("generic.cc"); - let mut cache = CultCache::new(); - cache.register_registry(TestEntries)?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; - cache.put("app", &Settings { theme: "before".to_string(), retries: 1 })?; - let before = cache.snapshot(); - let store_bytes = fs::read(&store_path)?; - - let (empty_key, _) = cache.prepare_entry("", &Note { title: "t".to_string(), body: "b".to_string() })?; - let error = cache.put_prepared_batch(vec![empty_key]).unwrap_err(); - assert!(error.to_string().contains("keys for type \"note\" must be non-empty"), "{error}"); - - let (mut empty_stored_at, _) = cache.prepare_entry("n", &Note { title: "t".to_string(), body: "b".to_string() })?; - empty_stored_at.stored_at = " ".to_string(); - let error = cache.put_prepared_batch(vec![empty_stored_at]).unwrap_err(); - assert!(error.to_string().contains("stored_at for type \"note\" must be non-empty"), "{error}"); - - assert_eq!(cache.snapshot(), before); - assert_eq!(fs::read(&store_path)?, store_bytes); - Ok(()) - } - - #[test] - fn zero_store_cache_writes_and_deletes_in_memory() -> Result<()> { - let mut cache = CultCache::new(); - cache.register_entry_type::()?; - cache.put("x", &Settings { theme: "mem".to_string(), retries: 0 })?; - assert_eq!(cache.get_required::("x")?.theme, "mem"); - let (staged, _) = cache.prepare_entry("y", &Settings { theme: "batch".to_string(), retries: 0 })?; - cache.put_prepared_batch(vec![staged])?; - assert_eq!(cache.get_required::("y")?.theme, "batch"); - assert!(cache.delete::("x")?); - assert!(cache.get::("x")?.is_none()); - Ok(()) - } - #[test] fn update_and_delete_follow_the_cache_api() -> Result<()> { let temp = tempfile::tempdir()?; - let store_path = temp.path().join("cache.cc"); + let store_path = temp.path().join("cache.msgpack"); let mut cache = CultCache::new(); cache.register_entry_type::()?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path)); let updated = cache.update::("app", |current| { let mut current = current.unwrap_or(Settings { @@ -4054,7 +892,7 @@ mod tests { #[test] fn pull_rejects_unregistered_persisted_entry_type() -> Result<()> { let temp = tempfile::tempdir()?; - let store_path = temp.path().join("cache.cc"); + let store_path = temp.path().join("cache.msgpack"); let mut store = SingleFileMessagePackBackingStore::new(&store_path); store.push(&CultCacheEnvelope { key: "unknown".to_string(), @@ -4065,7 +903,7 @@ mod tests { })?; let mut cache = CultCache::new(); - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path)); let error = cache.pull_all_backing_stores().unwrap_err(); assert!( error @@ -4078,10 +916,10 @@ mod tests { #[test] fn payload_is_binary_messagepack_not_json_value() -> Result<()> { let temp = tempfile::tempdir()?; - let store_path = temp.path().join("cache.cc"); + let store_path = temp.path().join("cache.msgpack"); let mut cache = CultCache::new(); cache.register_entry_type::()?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path)); cache.put( "app", &Settings { @@ -4097,23 +935,10 @@ mod tests { Ok(()) } - #[test] - fn byte_slots_use_messagepack_binary_and_round_trip() -> Result<()> { - let record = BinaryRecord { - required: vec![1, 2, 3], - optional: Vec::new(), - }; - - let payload = rmp_serde::to_vec(&record)?; - assert_eq!(payload, vec![0x92, 0xc4, 3, 1, 2, 3, 0xc4, 0]); - assert_eq!(rmp_serde::from_slice::(&payload)?, record); - Ok(()) - } - #[test] fn corrupted_payload_fails_during_typed_retrieval() -> Result<()> { let temp = tempfile::tempdir()?; - let store_path = temp.path().join("cache.cc"); + let store_path = temp.path().join("cache.msgpack"); let mut store = SingleFileMessagePackBackingStore::new(&store_path); store.push(&CultCacheEnvelope { key: "app".to_string(), @@ -4125,7 +950,7 @@ mod tests { let mut cache = CultCache::new(); cache.register_entry_type::()?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path)); cache.pull_all_backing_stores()?; let error = cache.get_required::("app").unwrap_err(); assert!( @@ -4139,12 +964,12 @@ mod tests { #[test] fn put_envelope_reuses_existing_messagepack_payload() -> Result<()> { let temp = tempfile::tempdir()?; - let origin_store = temp.path().join("origin.cc"); - let target_store = temp.path().join("target.cc"); + let origin_store = temp.path().join("origin.msgpack"); + let target_store = temp.path().join("target.msgpack"); let mut origin = CultCache::new(); origin.register_entry_type::()?; - origin.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&origin_store))?; + origin.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&origin_store)); origin.put( "app", &Settings { @@ -4157,7 +982,7 @@ mod tests { let mut target = CultCache::new(); target.register_entry_type::()?; - target.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store))?; + target.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store)); let applied = target.put_envelope::(envelope.clone())?; assert_eq!( @@ -4175,145 +1000,6 @@ mod tests { Ok(()) } - #[test] - fn raw_envelope_path_preserves_a_registered_dynamic_payload() -> Result<()> { - let temp = tempfile::tempdir()?; - let store_path = temp.path().join("dynamic.cc"); - let envelope = CultCacheEnvelope { - key: "app".to_string(), - r#type: Settings::TYPE.to_string(), - payload: vec![0xc1], - stored_at: now_utc_second(), - schema_id: Some("dynamic.settings.v1".to_string()), - }; - - let mut cache = CultCache::new(); - cache.register_entry_type::()?; - cache.register_entry_type::()?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; - cache.put_raw_envelope(envelope.clone())?; - - assert_eq!(cache.snapshot(), vec![envelope.clone()]); - assert_eq!( - SingleFileMessagePackBackingStore::new(&store_path).pull_all()?, - vec![envelope] - ); - assert!(cache.get_required::("app").is_err()); - let before_collision = fs::read(&store_path)?; - assert!( - cache - .put_raw_envelope(CultCacheEnvelope { - key: "note".to_string(), - r#type: Note::TYPE.to_string(), - payload: Vec::new(), - stored_at: now_utc_second(), - schema_id: Some("dynamic.settings.v1".to_string()), - }) - .is_err() - ); - assert_eq!(fs::read(&store_path)?, before_collision); - assert!( - cache - .put_raw_envelope(CultCacheEnvelope { - key: "foreign".to_string(), - r#type: "unregistered".to_string(), - payload: Vec::new(), - stored_at: now_utc_second(), - schema_id: None, - }) - .is_err() - ); - Ok(()) - } - - #[test] - fn soa_snapshot_round_trips_registered_entries() -> Result<()> { - let temp = tempfile::tempdir()?; - let store_path = temp.path().join("cache.cc"); - let mut cache = CultCache::new(); - cache.register_registry(TestEntries)?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; - cache.put( - "app", - &Settings { - theme: "ash".to_string(), - retries: 3, - }, - )?; - cache.put( - "memo", - &Note { - title: "machine".to_string(), - body: "awake".to_string(), - }, - )?; - - let soa = cache.snapshot_soa(); - assert_eq!(soa.len(), 2); - assert_eq!(soa.keys.len(), soa.payloads.len()); - - let mut restored = CultCache::new(); - restored.register_registry(TestEntries)?; - restored.add_generic_backing_store(SingleFileMessagePackBackingStore::new( - temp.path().join("restored.cc"), - ))?; - restored.load_soa(soa)?; - assert_eq!(restored.get_required::("app")?.theme, "ash"); - assert_eq!(restored.get_required::("memo")?.body, "awake"); - Ok(()) - } - - #[test] - fn typed_soa_columns_match_csharp_cache_ergonomics() -> Result<()> { - let temp = tempfile::tempdir()?; - let store_path = temp.path().join("cache.cc"); - let mut cache = CultCache::new(); - cache.register_registry(TestEntries)?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; - cache.put( - "settings:a", - &Settings { - theme: "ash".to_string(), - retries: 3, - }, - )?; - cache.put( - "settings:b", - &Settings { - theme: "bone".to_string(), - retries: 5, - }, - )?; - - let table = cache.soa::()?; - assert_eq!( - table.keys(), - &["settings:a".to_string(), "settings:b".to_string()] - ); - assert_eq!( - table.column::("theme")?.values(), - &["ash".to_string(), "bone".to_string()] - ); - assert_eq!(table.column::("retries")?.values(), &[3, 5]); - - let error = table.column::("retries").unwrap_err(); - assert!(error.to_string().contains("not alloc::string::String")); - Ok(()) - } - - #[test] - fn soa_rejects_column_length_drift() { - let table = CultCacheSoaTable { - keys: vec!["app".to_string()], - types: Vec::new(), - payloads: Vec::new(), - stored_ats: Vec::new(), - schema_ids: Vec::new(), - }; - let error = table.validate().unwrap_err(); - assert!(error.to_string().contains("column types has length 0")); - } - #[test] fn messagepack_store_recovers_schema_stamped_records_missing_catalog_entries() -> Result<()> { let temp = tempfile::tempdir()?; @@ -4323,8 +1009,6 @@ mod tests { name: "schema-stamped".to_string(), value: "still readable".to_string(), }; - // An empty catalog with a stale schema id is what a record written by - // another runtime, or one older than its catalog, looks like on disk. let snapshot = PersistedStoreSnapshot( "cultcache.store.v1".to_string(), Vec::new(), @@ -4335,11 +1019,11 @@ mod tests { rmp_serde::to_vec(&expected)?, )], ); - std::fs::write(&store_path, rmp_serde::to_vec(&snapshot)?)?; + fs::write(&store_path, rmp_serde::to_vec(&snapshot)?)?; let mut cache = CultCache::new(); cache.register_entry_type::()?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path)); cache.pull_all_backing_stores()?; assert_eq!(cache.get_required::("record-1")?, expected); @@ -4351,226 +1035,4 @@ mod tests { ); Ok(()) } - - #[test] - fn abandoned_staging_cleanup_removes_only_canonical_candidates() -> Result<()> { - let temp = tempfile::tempdir()?; - let store_path = temp.path().join("store.cc"); - let mut cache = CultCache::new(); - cache.register_entry_type::()?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; - cache.put( - "primary", - &Settings { - theme: "dark".to_string(), - retries: 2, - }, - )?; - - // A crashed writer leaves a canonically named candidate behind. - let abandoned = temp - .path() - .join(format!("store.cc.{}.tmp", uuid::Uuid::new_v4())); - std::fs::write(&abandoned, b"partial snapshot")?; - // These only look like candidates. Cleanup must not touch them: the - // staging name is the store's own file name plus a canonical UUID, and - // anything else is somebody's file. - let lookalike = temp.path().join("store.cc.not-a-uuid.tmp"); - std::fs::write(&lookalike, b"keep me")?; - let bare_suffix = temp.path().join("store.cc.tmp"); - std::fs::write(&bare_suffix, b"keep me too")?; - let neighbour = temp.path().join("other.cc.00000000-0000-0000-0000-000000000000.tmp"); - std::fs::write(&neighbour, b"not this store")?; - - cache.put( - "primary", - &Settings { - theme: "light".to_string(), - retries: 3, - }, - )?; - - assert!(!abandoned.exists(), "canonical abandoned candidate survived"); - assert!(lookalike.exists(), "non-UUID lookalike was deleted"); - assert!(bare_suffix.exists(), "bare .tmp suffix was deleted"); - assert!(neighbour.exists(), "another store's candidate was deleted"); - - let mut reloaded = CultCache::new(); - reloaded.register_entry_type::()?; - reloaded.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; - reloaded.pull_all_backing_stores()?; - assert_eq!(reloaded.get_required::("primary")?.theme, "light"); - Ok(()) - } - - fn snapshot_envelope(key: &str, payload: &[u8]) -> CultCacheEnvelope { - CultCacheEnvelope { - key: key.to_string(), - r#type: "snapshot.record".to_string(), - payload: payload.to_vec(), - stored_at: "2026-01-01T00:00:00Z".to_string(), - // The store stamps the schema id from the type on write, so a - // fixture that omits it would not survive its own round trip. - schema_id: Some("snapshot.record".to_string()), - } - } - - #[test] - fn snapshot_exchange_commits_only_against_the_observed_snapshot() -> Result<()> { - let temp = tempfile::tempdir()?; - let store = SingleFileMessagePackBackingStore::new(temp.path().join("generation.cc")); - - let first = vec![snapshot_envelope("admitted", b"one")]; - assert!(store.compare_exchange_snapshot(&[], &first)?); - assert_eq!(store.pull_all_read_only_snapshot()?, first); - - // A writer holding a stale view loses the race instead of clobbering it. - let stale = vec![snapshot_envelope("admitted", b"stale")]; - assert!(!store.compare_exchange_snapshot(&[], &stale)?); - assert_eq!(store.pull_all_read_only_snapshot()?, first); - - let second = vec![snapshot_envelope("admitted", b"two")]; - assert!(store.compare_exchange_snapshot(&first, &second)?); - assert_eq!(store.pull_all_read_only_snapshot()?, second); - Ok(()) - } - - #[test] - fn try_snapshot_exchange_separates_a_lost_race_from_a_commit() -> Result<()> { - let temp = tempfile::tempdir()?; - let store = SingleFileMessagePackBackingStore::new(temp.path().join("generation.cc")); - - let first = vec![snapshot_envelope("admitted", b"one")]; - assert_eq!( - store.try_compare_exchange_snapshot(&[], &first)?, - TryCompareExchangeSnapshotOutcome::Exchanged - ); - assert_eq!( - store.try_compare_exchange_snapshot(&[], &first)?, - TryCompareExchangeSnapshotOutcome::Mismatch, - "a stale expectation is a lost race, not contention" - ); - assert_eq!(store.pull_all_read_only_snapshot()?, first); - Ok(()) - } - - #[test] - fn snapshot_exchange_refuses_replacements_that_cannot_be_addressed() -> Result<()> { - let temp = tempfile::tempdir()?; - let store = SingleFileMessagePackBackingStore::new(temp.path().join("generation.cc")); - - assert!( - store - .compare_exchange_snapshot(&[], &[snapshot_envelope(" ", b"one")]) - .is_err(), - "an unaddressable record must not reach the store" - ); - assert!( - store - .compare_exchange_snapshot( - &[], - &[ - snapshot_envelope("admitted", b"one"), - snapshot_envelope("admitted", b"two"), - ], - ) - .is_err(), - "a duplicate identity would make the snapshot ambiguous" - ); - assert_eq!(store.pull_all_read_only_snapshot()?, Vec::new()); - Ok(()) - } - - #[test] - fn per_entry_exchange_constrains_only_the_identities_it_names() -> Result<()> { - let temp = tempfile::tempdir()?; - let store = SingleFileMessagePackBackingStore::new(temp.path().join("generation.cc")); - - let admitted = snapshot_envelope("admitted", b"one"); - let unrelated = snapshot_envelope("unrelated", b"other"); - assert!(store.compare_exchange( - &[ - CultCacheExpectedEnvelope { - key: "admitted".into(), - r#type: "snapshot.record".into(), - current: None, - }, - CultCacheExpectedEnvelope { - key: "unrelated".into(), - r#type: "snapshot.record".into(), - current: None, - }, - ], - &[admitted.clone(), unrelated.clone()], - )?); - - // The snapshot moved, but an expectation naming only `admitted` still - // holds. A whole-snapshot exchange would have lost this race. - let next = snapshot_envelope("admitted", b"two"); - assert!(store.compare_exchange( - &[CultCacheExpectedEnvelope { - key: "admitted".into(), - r#type: "snapshot.record".into(), - current: Some(admitted.clone()), - }], - &[next.clone()], - )?); - assert_eq!( - store.pull_all_read_only_snapshot()?, - vec![next.clone(), unrelated.clone()] - ); - - // A stale expectation for that identity now loses. - assert!(!store.compare_exchange( - &[CultCacheExpectedEnvelope { - key: "admitted".into(), - r#type: "snapshot.record".into(), - current: Some(admitted), - }], - &[snapshot_envelope("admitted", b"three")], - )?); - assert_eq!(store.pull_all_read_only_snapshot()?, vec![next, unrelated]); - Ok(()) - } - - #[test] - fn per_entry_exchange_distinguishes_absent_from_stored() -> Result<()> { - let temp = tempfile::tempdir()?; - let store = SingleFileMessagePackBackingStore::new(temp.path().join("generation.cc")); - let admitted = snapshot_envelope("admitted", b"one"); - - // Expecting a stored value where nothing is stored must not commit. - assert!(!store.compare_exchange( - &[CultCacheExpectedEnvelope { - key: "admitted".into(), - r#type: "snapshot.record".into(), - current: Some(admitted.clone()), - }], - &[admitted.clone()], - )?); - assert_eq!(store.pull_all_read_only_snapshot()?, Vec::new()); - - // Expecting absence commits the first writer. - assert!(store.compare_exchange( - &[CultCacheExpectedEnvelope { - key: "admitted".into(), - r#type: "snapshot.record".into(), - current: None, - }], - &[admitted.clone()], - )?); - - // And the second writer expecting absence now loses, which is what - // makes this usable as a create-once claim. - assert!(!store.compare_exchange( - &[CultCacheExpectedEnvelope { - key: "admitted".into(), - r#type: "snapshot.record".into(), - current: None, - }], - &[snapshot_envelope("admitted", b"two")], - )?); - assert_eq!(store.pull_all_read_only_snapshot()?, vec![admitted]); - Ok(()) - } } diff --git a/packages/cultcache-ts/README.md b/packages/cultcache-ts/README.md index d1730958..064cc91b 100644 --- a/packages/cultcache-ts/README.md +++ b/packages/cultcache-ts/README.md @@ -53,7 +53,7 @@ import { SingleFileMessagePackBackingStore, defineDocumentRegistry, defineDocumentType, -} from "@gamecult/cultcache-ts"; +} from "cultcache-ts"; const itemDocument = defineDocumentType({ type: "item", @@ -142,25 +142,6 @@ Document definitions may also carry persistence metadata for cross-language stores: `schemaId`, `schemaName`, `schemaVersion`, `contentHash`, `canonicalSchemaJson`, `compatibleSchemaIds`, and slot `members`. -## Backing Store Routing - -```ts -const cache = CultCache.builder() - .withRegistry(registry) - .withBackingStore(new SingleFileMessagePackBackingStore("settings.cc"), settingsDocument) - .withGenericStore(new SingleFileMessagePackBackingStore("cache.cc")) - .build(); -``` - -A document type has exactly one home store -(`src/GameCult.Caching/Contracts/cultcache-store-composition.md` in CultLib): - -- a store added with types owns those types; otherwise the generic store is the home -- a second generic store throws, and so does a type claimed by a second store; - a refused registration attaches nothing -- `put`, `putEnvelope` and `delete` touch exactly one store; nothing is mirrored -- a write whose type has no home store throws - ## Name, Index, and Global Semantics `name` and `indexes` can be declared on the document definition: @@ -180,19 +161,11 @@ const playerDocument = defineDocumentType({ You can also register them later if you feel like changing the machine after boot: ```ts -await cache.registerNameLookup(playerDocument, "displayName"); -await cache.registerIndex(playerDocument, "faction", "faction"); +cache.registerNameLookup(playerDocument, "displayName"); +cache.registerIndex(playerDocument, "faction", "faction"); ``` -Registration, `addBackingStore`, `pullAllBackingStores` and every write and -delete run one at a time through a per-cache queue and return promises; reads -are synchronous. A write validates the whole record (home store, global key, -name and index accessors) before any store is touched, so a refused write -changes neither store nor cache. Registering an accessor that throws on a held -record installs nothing. - -Global documents are singleton-style per type, always stored under -`CultCache.GLOBAL_KEY` (`__global__`); writing one under any other key throws: +Global documents are singleton-style per type: ```ts await cache.putGlobal(settingsDocument, { theme: "ash", retries: 3 }); diff --git a/packages/cultcache-ts/package-lock.json b/packages/cultcache-ts/package-lock.json new file mode 100644 index 00000000..4b3cf3f6 --- /dev/null +++ b/packages/cultcache-ts/package-lock.json @@ -0,0 +1,70 @@ +{ + "name": "cultcache-ts", + "version": "0.13.5", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cultcache-ts", + "version": "0.13.5", + "license": "MIT", + "dependencies": { + "@msgpack/msgpack": "^3.1.3", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.9.0" + } + }, + "node_modules/@msgpack/msgpack": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz", + "integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==", + "license": "ISC", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/packages/cultcache-ts/package.json b/packages/cultcache-ts/package.json index 67f8d5ed..60731b8f 100644 --- a/packages/cultcache-ts/package.json +++ b/packages/cultcache-ts/package.json @@ -1,6 +1,6 @@ { - "name": "@gamecult/cultcache-ts", - "version": "0.14.0", + "name": "cultcache-ts", + "version": "0.13.5", "description": "CultCache-style typed persistence for TypeScript with schema-backed polymorphic MessagePack storage.", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -20,10 +20,9 @@ "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 --test dist-test/test/cult-cache.test.js" + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --pretty false", + "test": "npm run build && tsc -p tsconfig.test.json && node --test dist-test/test/cult-cache.test.js" }, "keywords": [ "messagepack", @@ -35,18 +34,6 @@ ], "author": "metacratic", "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/GameCult/CultLib.git", - "directory": "packages/cultcache-ts" - }, - "bugs": { - "url": "https://github.com/GameCult/CultLib/issues" - }, - "homepage": "https://github.com/GameCult/CultLib/tree/main/packages/cultcache-ts#readme", - "engines": { - "node": ">=18" - }, "dependencies": { "@msgpack/msgpack": "^3.1.3", "zod": "^3.25.76" diff --git a/packages/cultcache-ts/src/cult-cache.ts b/packages/cultcache-ts/src/cult-cache.ts index 57766be9..157e0934 100644 --- a/packages/cultcache-ts/src/cult-cache.ts +++ b/packages/cultcache-ts/src/cult-cache.ts @@ -13,84 +13,32 @@ import type { CultCacheStoreRegistration, } from "./types"; -type Accessor = (value: unknown) => string | undefined; +type StoreRoute = { + primary?: CacheBackingStore; + mirrors: CacheBackingStore[]; +}; type RegisteredDefinition = { readonly definition: AnyCultCacheDocumentDefinition; readonly global: boolean; readonly formatter: CultCacheDocumentFormatter; readonly catalogEntry: CultCacheSchemaCatalogEntry; - nameAccessor?: Accessor; - indexAccessors: ReadonlyMap; -}; - -// The name and index values a record produced when it was admitted. Removing the record -// reads these and runs no accessor, so removal has no fallible step. -type EntryLookups = { - readonly name?: string; - readonly indexes: ReadonlyMap; + nameAccessor?: (value: unknown) => string | undefined; + readonly indexAccessors: Map string | undefined>; }; type HydratedEntry = CultCacheEnvelope & { value: unknown; - lookups: EntryLookups; }; -// Everything a pull replaces. Built whole, then swapped in with one assignment. -type HydratedState = { - readonly entries: Map; - readonly typeEntryIds: Map>; - readonly nameToKeyMaps: Map>; - readonly indexToKeyMaps: Map>; - readonly globalKeys: Map; - // Load compatibility shim: a global persisted under a key other than GLOBAL_KEY is held as that - // type's global under GLOBAL_KEY, and its persisted envelope is kept here so the first write or - // delete of that global removes it from the home store. Remove once no store holds a global under - // a key other than GLOBAL_KEY. - readonly legacyGlobals: Map; -}; - -// Lookups for every held record of one type under candidate accessors, derived before anything is installed. -type DerivedTypeLookups = { - readonly entryLookups: Array<[HydratedEntry, EntryLookups]>; - readonly nameMap?: Map; - readonly indexMaps: Map>; -}; - -function emptyHydratedState(): HydratedState { - return { - entries: new Map(), - typeEntryIds: new Map(), - nameToKeyMaps: new Map(), - indexToKeyMaps: new Map(), - globalKeys: new Map(), - legacyGlobals: new Map(), - }; -} - -function getOrCreate(map: Map, key: K, create: () => V): V { - let value = map.get(key); - if (value === undefined) { - value = create(); - map.set(key, value); - } - return value; -} - type BackingStoreTypeReference = string | AnyCultCacheDocumentDefinition; type AccessorInput = string | ((value: any) => unknown); -// Assigned in CultCache's static block. The builder's cache is unreachable until build(), so no -// queued operation can be in flight and registration and attach may run synchronously. -let registerNow!: (cache: CultCache, definition: AnyCultCacheDocumentDefinition) => void; -let attachNow!: (cache: CultCache, store: CacheBackingStore, types: BackingStoreTypeReference[]) => void; - export class CultCacheBuilder { readonly #cache = new CultCache(); - #built = false; withDocumentType(definition: TDefinition): this { - registerNow(this.#unbuilt(), definition); + this.#cache.registerDocumentType(definition); return this; } @@ -99,10 +47,7 @@ export class CultCacheBuilder { | CultCacheDocumentRegistry | Iterable, ): this { - const cache = this.#unbuilt(); - for (const definition of registryDefinitions(registry)) { - registerNow(cache, definition); - } + this.#cache.registerRegistry(registry); return this; } @@ -110,201 +55,175 @@ export class CultCacheBuilder { store: CacheBackingStore, ...types: BackingStoreTypeReference[] ): this { - attachNow(this.#unbuilt(), store, types); + this.#cache.addBackingStore(store, ...types); return this; } withGenericStore(store: CacheBackingStore): this { - attachNow(this.#unbuilt(), store, []); + this.#cache.addGenericBackingStore(store); return this; } build(): CultCache { - this.#built = true; return this.#cache; } - - #unbuilt(): CultCache { - if (this.#built) { - throw new Error("CultCacheBuilder has already built its cache; configure the cache through its own methods."); - } - return this.#cache; - } -} - -function registryDefinitions( - registry: CultCacheDocumentRegistry | Iterable, -): Iterable { - return Symbol.iterator in Object(registry) && !("definitions" in Object(registry)) - ? registry as Iterable - : (registry as CultCacheDocumentRegistry).definitions; } export class CultCache { static readonly GLOBAL_KEY = "__global__"; - static { - registerNow = (cache, definition) => cache.#registerNow(definition); - attachNow = (cache, store, types) => cache.#attachNow(store, types); - } - readonly #definitions = new Map(); readonly #schemaIdDefinitions = new Map(); readonly #schemaNameDefinitions = new Map(); - #hydrated = emptyHydratedState(); + readonly #entries = new Map(); + readonly #typeEntryIds = new Map>(); + readonly #nameToKeyMaps = new Map>(); + readonly #indexToKeyMaps = new Map>(); + readonly #globalKeys = new Map(); readonly #stores: CultCacheStoreRegistration[] = []; - #tail: Promise = Promise.resolve(); static builder(): CultCacheBuilder { return new CultCacheBuilder(); } - // Attach, pull, registration and every write and delete run one at a time through this chain, - // so validation, the store call and applying to the cache never interleave with another - // mutation of this cache. Reads are not queued. Observer delivery is not scheduled here. - // The chain advances on its own promise; the caller gets the operation's promise, untouched, so - // an unawaited failure is still an unhandled rejection. A call made from inside an operation - // queues behind it; a store call that waits for that call waits on itself and never finishes. - #serial(operation: () => T | Promise): Promise { - let advance!: () => void; - const previous = this.#tail; - this.#tail = new Promise((resolve) => { - advance = resolve; - }); - return previous.then(async () => { - try { - return await operation(); - } finally { - advance(); - } - }); - } - registerDocumentType( definition: TDefinition, - ): Promise { - return this.#serial(() => { - this.#registerNow(definition); - return definition; - }); + ): TDefinition { + const existing = this.#definitions.get(definition.type); + if (existing && existing.definition !== definition) { + throw new Error(`CultCache already has a different definition registered for type "${definition.type}".`); + } + + const registered = existing ?? { + definition, + global: definition.global === true, + formatter: this.#createFormatter(definition), + catalogEntry: this.#createCatalogEntry(definition), + nameAccessor: undefined, + indexAccessors: new Map string | undefined>(), + }; + + registered.nameAccessor = definition.name + ? this.#compileAccessor(definition.type, "name", definition.name) + : undefined; + registered.indexAccessors.clear(); + for (const [indexName, accessor] of this.#enumerateDefinitionIndexes(definition)) { + registered.indexAccessors.set( + indexName, + this.#compileAccessor(definition.type, `index "${indexName}"`, accessor), + ); + } + + this.#definitions.set(definition.type, registered); + for (const schemaId of registered.catalogEntry.compatibleSchemaIds ?? [registered.catalogEntry.schemaId]) { + const existingBySchema = this.#schemaIdDefinitions.get(schemaId); + if (existingBySchema && existingBySchema !== registered) { + throw new Error(`CultCache schema id "${schemaId}" is already registered for type "${existingBySchema.definition.type}".`); + } + this.#schemaIdDefinitions.set(schemaId, registered); + } + const existingBySchemaName = this.#schemaNameDefinitions.get(registered.catalogEntry.schemaName); + if (existingBySchemaName && existingBySchemaName !== registered) { + throw new Error( + `CultCache schema name "${registered.catalogEntry.schemaName}" is already registered for type "${existingBySchemaName.definition.type}".`, + ); + } + this.#schemaNameDefinitions.set(registered.catalogEntry.schemaName, registered); + this.#rebuildDefinitionLookups(registered); + return definition; } registerRegistry( registry: | CultCacheDocumentRegistry | Iterable, - ): Promise { - return this.#serial(() => { - for (const definition of registryDefinitions(registry)) { - this.#registerNow(definition); - } - }); + ): this { + const definitions = Symbol.iterator in Object(registry) && !("definitions" in Object(registry)) + ? registry as Iterable + : (registry as CultCacheDocumentRegistry).definitions; + + for (const definition of definitions) { + this.registerDocumentType(definition); + } + + return this; } registerNameLookup( definition: TDefinition, accessor: CultCacheDocumentAccessor>, - ): Promise { - return this.#serial(() => { - const registered = this.#requireDefinition(definition); - const nameAccessor = this.#compileAccessor(definition.type, "name", accessor); - const derived = this.#deriveTypeLookups(definition.type, nameAccessor, registered.indexAccessors); - this.#installAccessors(registered, nameAccessor, registered.indexAccessors, derived); - return definition; - }); + ): TDefinition { + const registered = this.#requireDefinition(definition); + registered.nameAccessor = this.#compileAccessor(definition.type, "name", accessor); + this.#rebuildNameLookup(registered); + return definition; } registerIndex( definition: TDefinition, indexName: string, accessor: CultCacheDocumentAccessor>, - ): Promise { - return this.#serial(() => { - if (!indexName || indexName.trim().length === 0) { - throw new Error(`CultCache index names for type "${definition.type}" must be non-empty.`); - } + ): TDefinition { + if (!indexName || indexName.trim().length === 0) { + throw new Error(`CultCache index names for type "${definition.type}" must be non-empty.`); + } - const registered = this.#requireDefinition(definition); - const indexAccessors = new Map(registered.indexAccessors); - indexAccessors.set(indexName, this.#compileAccessor(definition.type, `index "${indexName}"`, accessor)); - const derived = this.#deriveTypeLookups(definition.type, registered.nameAccessor, indexAccessors); - this.#installAccessors(registered, registered.nameAccessor, indexAccessors, derived); - return definition; - }); + const registered = this.#requireDefinition(definition); + registered.indexAccessors.set( + indexName, + this.#compileAccessor(definition.type, `index "${indexName}"`, accessor), + ); + this.#rebuildIndexLookup(registered, indexName); + return definition; } addBackingStore( store: CacheBackingStore, ...types: BackingStoreTypeReference[] - ): Promise { - return this.#serial(() => this.#attachNow(store, types)); - } - - addGenericBackingStore(store: CacheBackingStore): Promise { - return this.addBackingStore(store); - } - - // The complete next state (entries, globals, name and index lookups) is built into a fresh - // HydratedState, running every check and every user accessor, and replaces the cache's state - // in one assignment only once the build finishes. A refused load admits nothing. - pullAllBackingStores(): Promise { - return this.#serial(async () => { - const next = emptyHydratedState(); - const persistedGlobalKeys = new Map(); - - for (const registration of this.#stores) { - const entries = await registration.store.pullAll(); - - for (const entry of entries) { - const registered = this.#resolveDefinitionForEnvelope(entry); - if (!registered) { - throw new Error( - entry.schemaId - ? `No schema is registered for persisted schema id "${entry.schemaId}".` - : `No schema is registered for persisted document type "${entry.type}".`, - ); - } - - const type = registered.definition.type; - const home = this.#resolveRegistration(type); - if (home?.store !== registration.store) { - throw new Error( - `CultCache "${type}" record "${entry.key}" was loaded from ${this.#describe(registration)}, ` + - `but its home is ${this.#describe(home)}.`, - ); - } - - const payload = this.#cloneBytes(entry.payload); - const value = registered.formatter.decode(payload); - let admitted: CultCacheEnvelope = { ...entry, type, payload }; - if (registered.global) { - const persistedKey = persistedGlobalKeys.get(type); - if (persistedKey !== undefined) { - throw new Error( - `CultCache global document type "${type}" has multiple persisted entries: "${persistedKey}" and "${entry.key}".`, - ); - } - persistedGlobalKeys.set(type, entry.key); - if (entry.key !== CultCache.GLOBAL_KEY) { - next.legacyGlobals.set(type, admitted); - admitted = { ...admitted, key: CultCache.GLOBAL_KEY }; - } - } - const hydrated = this.#admitEntry(next, registered, admitted, value); - this.#installEntry(next, registered, hydrated); - } - } - - this.#hydrated = next; + ): void { + this.#stores.push({ + store, + types: [...new Set(types.map((value) => (typeof value === "string" ? value : value.type)))], }); } + addGenericBackingStore(store: CacheBackingStore): void { + this.addBackingStore(store); + } + + async pullAllBackingStores(): Promise { + this.#resetHydratedState(); + + for (const registration of this.#stores) { + const entries = await registration.store.pullAll(); + + for (const entry of entries) { + const registered = this.#resolveDefinitionForEnvelope(entry); + if (!registered) { + throw new Error( + entry.schemaId + ? `No schema is registered for persisted schema id "${entry.schemaId}".` + : `No schema is registered for persisted document type "${entry.type}".`, + ); + } + + const payload = this.#cloneBytes(entry.payload); + const value = registered.formatter.decode(payload); + this.#applyHydratedEntry(registered, { + ...entry, + type: registered.definition.type, + payload, + }, value, "pull"); + } + } + } + get( definition: TDefinition, key: string, ): CultCacheDocumentValue | undefined { this.#requireDefinition(definition); - const entry = this.#hydrated.entries.get(this.#entryId(definition.type, key)); + const entry = this.#entries.get(this.#entryId(definition.type, key)); return entry ? (entry.value as CultCacheDocumentValue) : undefined; } @@ -325,7 +244,7 @@ export class CultCache { key: string, ): CultCacheEnvelope | undefined { this.#requireDefinition(definition); - const entry = this.#hydrated.entries.get(this.#entryId(definition.type, key)); + const entry = this.#entries.get(this.#entryId(definition.type, key)); return entry ? this.#toEnvelope(entry) : undefined; } @@ -344,8 +263,12 @@ export class CultCache { getGlobal( definition: TDefinition, ): CultCacheDocumentValue | undefined { - this.#requireGlobal(definition); - const globalKey = this.#hydrated.globalKeys.get(definition.type); + const registered = this.#requireDefinition(definition); + if (!registered.global) { + throw new Error(`CultCache document type "${definition.type}" is not marked as global.`); + } + + const globalKey = this.#globalKeys.get(definition.type); return globalKey ? this.get(definition, globalKey) : undefined; } @@ -363,8 +286,12 @@ export class CultCache { getGlobalEnvelope( definition: TDefinition, ): CultCacheEnvelope | undefined { - this.#requireGlobal(definition); - const globalKey = this.#hydrated.globalKeys.get(definition.type); + const registered = this.#requireDefinition(definition); + if (!registered.global) { + throw new Error(`CultCache document type "${definition.type}" is not marked as global.`); + } + + const globalKey = this.#globalKeys.get(definition.type); return globalKey ? this.getEnvelope(definition, globalKey) : undefined; } @@ -372,7 +299,21 @@ export class CultCache { definition: TDefinition, ): CultCacheDocumentValue[] { this.#requireDefinition(definition); - return this.#entriesForType(definition.type).map((entry) => entry.value as CultCacheDocumentValue); + const values: CultCacheDocumentValue[] = []; + const entryIds = this.#typeEntryIds.get(definition.type); + + if (!entryIds) { + return values; + } + + for (const entryId of entryIds) { + const entry = this.#entries.get(entryId); + if (entry) { + values.push(entry.value as CultCacheDocumentValue); + } + } + + return values; } getKeyByName( @@ -384,7 +325,7 @@ export class CultCache { return undefined; } - return this.#hydrated.nameToKeyMaps.get(definition.type)?.get(name); + return this.#nameToKeyMaps.get(definition.type)?.get(name); } getIdByName( @@ -408,7 +349,7 @@ export class CultCache { value: string, ): string | undefined { this.#requireDefinition(definition); - return this.#hydrated.indexToKeyMaps.get(this.#indexId(definition.type, indexName))?.get(value); + return this.#indexToKeyMaps.get(this.#indexId(definition.type, indexName))?.get(value); } getIdByIndex( @@ -428,180 +369,12 @@ export class CultCache { return key ? this.get(definition, key) : undefined; } - put( - definition: TDefinition, - key: string, - value: CultCacheDocumentValue, - ): Promise> { - return this.#serial(() => this.#putNow(definition, key, value)); - } - - putEnvelope( - definition: TDefinition, - envelope: CultCacheEnvelope, - ): Promise> { - return this.#serial(async () => { - const registered = this.#requireDefinition(definition); - if (envelope.type !== definition.type) { - throw new Error( - `CultCache envelope type "${envelope.type}" does not match definition "${definition.type}".`, - ); - } - if (!envelope.key || envelope.key.trim().length === 0) { - throw new Error(`CultCache envelope key for type "${definition.type}" must be non-empty.`); - } - if (!envelope.storedAt || envelope.storedAt.trim().length === 0) { - throw new Error(`CultCache envelope storedAt for type "${definition.type}" must be non-empty.`); - } - this.#requireGlobalKey(registered, envelope.key); - - const payload = this.#cloneBytes(envelope.payload); - const parsed = registered.formatter.decode(payload) as CultCacheDocumentValue; - const entry: CultCacheEnvelope = { - key: envelope.key, - type: envelope.type, - payload, - storedAt: envelope.storedAt, - schemaId: envelope.schemaId ?? registered.catalogEntry.schemaId, - catalogEntry: envelope.catalogEntry ?? registered.catalogEntry, - }; - await this.#writeNow(registered, entry, parsed); - return parsed; - }); - } - - putGlobal( - definition: TDefinition, - value: CultCacheDocumentValue, - ): Promise> { - return this.#serial(() => { - this.#requireGlobal(definition); - return this.#putNow(definition, CultCache.GLOBAL_KEY, value); - }); - } - - update( - definition: TDefinition, - key: string, - updater: ( - current: CultCacheDocumentValue | undefined, - ) => CultCacheDocumentValue, - ): Promise> { - return this.#serial(() => this.#putNow(definition, key, updater(this.get(definition, key)))); - } - - updateGlobal( - definition: TDefinition, - updater: ( - current: CultCacheDocumentValue | undefined, - ) => CultCacheDocumentValue, - ): Promise> { - return this.#serial(() => - this.#putNow(definition, CultCache.GLOBAL_KEY, updater(this.getGlobal(definition))), - ); - } - - delete( - definition: TDefinition, - key: string, - ): Promise { - return this.#serial(() => this.#deleteNow(definition, key)); - } - - deleteGlobal( - definition: TDefinition, - ): Promise { - return this.#serial(async () => { - this.#requireGlobal(definition); - const globalKey = this.#hydrated.globalKeys.get(definition.type); - return globalKey ? this.#deleteNow(definition, globalKey) : false; - }); - } - - snapshot(): CultCacheEnvelope[] { - return [...this.#hydrated.entries.values()].map((entry) => this.#toEnvelope(entry)); - } - - #registerNow(definition: AnyCultCacheDocumentDefinition): void { - const existing = this.#definitions.get(definition.type); - if (existing && existing.definition !== definition) { - throw new Error(`CultCache already has a different definition registered for type "${definition.type}".`); - } - - const registered: RegisteredDefinition = existing ?? { - definition, - global: definition.global === true, - formatter: this.#createFormatter(definition), - catalogEntry: this.#createCatalogEntry(definition), - nameAccessor: undefined, - indexAccessors: new Map(), - }; - - const nameAccessor = definition.name - ? this.#compileAccessor(definition.type, "name", definition.name) - : undefined; - const indexAccessors = new Map(); - for (const [indexName, accessor] of this.#enumerateDefinitionIndexes(definition)) { - indexAccessors.set(indexName, this.#compileAccessor(definition.type, `index "${indexName}"`, accessor)); - } - - const schemaIds = registered.catalogEntry.compatibleSchemaIds ?? [registered.catalogEntry.schemaId]; - for (const schemaId of schemaIds) { - const existingBySchema = this.#schemaIdDefinitions.get(schemaId); - if (existingBySchema && existingBySchema !== registered) { - throw new Error(`CultCache schema id "${schemaId}" is already registered for type "${existingBySchema.definition.type}".`); - } - } - const existingBySchemaName = this.#schemaNameDefinitions.get(registered.catalogEntry.schemaName); - if (existingBySchemaName && existingBySchemaName !== registered) { - throw new Error( - `CultCache schema name "${registered.catalogEntry.schemaName}" is already registered for type "${existingBySchemaName.definition.type}".`, - ); - } - const derived = this.#deriveTypeLookups(definition.type, nameAccessor, indexAccessors); - - // Every check and accessor has run; install. - this.#definitions.set(definition.type, registered); - for (const schemaId of schemaIds) { - this.#schemaIdDefinitions.set(schemaId, registered); - } - this.#schemaNameDefinitions.set(registered.catalogEntry.schemaName, registered); - this.#installAccessors(registered, nameAccessor, indexAccessors, derived); - } - - #attachNow(store: CacheBackingStore, types: BackingStoreTypeReference[]): void { - const claimed = [...new Set(types.map((value) => (typeof value === "string" ? value : value.type)))]; - if (claimed.length === 0 && this.#stores.some((registration) => registration.types.length === 0)) { - throw new Error("Backing store would be a second generic store; name the types it is home to."); - } - for (const type of claimed) { - if (this.#stores.some((registration) => registration.types.includes(type))) { - throw new Error(`CultCache type "${type}" is already routed to another backing store; it cannot also route to this one.`); - } - } - const candidate: CultCacheStoreRegistration = { store, types: claimed }; - for (const type of this.#hydrated.typeEntryIds.keys()) { - const before = this.#resolveRegistration(type); - this.#stores.push(candidate); - const after = this.#resolveRegistration(type); - this.#stores.pop(); - if (before !== after) { - throw new Error( - `Attaching this backing store would move "${type}" from ${this.#describe(before)} to ${this.#describe(after)}; ` + - "attach routed stores before the generic store.", - ); - } - } - this.#stores.push(candidate); - } - - async #putNow( + async put( definition: TDefinition, key: string, value: CultCacheDocumentValue, ): Promise> { const registered = this.#requireDefinition(definition); - this.#requireGlobalKey(registered, key); const payload = registered.formatter.encode(value); const parsed = registered.formatter.decode(payload) as CultCacheDocumentValue; const entry: CultCacheEnvelope = { @@ -612,67 +385,140 @@ export class CultCache { schemaId: registered.catalogEntry.schemaId, catalogEntry: registered.catalogEntry, }; - await this.#writeNow(registered, entry, parsed); + + const route = this.#resolveRoute(definition.type); + if (!route.primary) { + throw new Error(`No backing store is registered for document type "${definition.type}".`); + } + + if (registered.global) { + const existingGlobalKey = this.#globalKeys.get(definition.type); + if (existingGlobalKey && existingGlobalKey !== key) { + await this.delete(definition, existingGlobalKey); + } + } + + await route.primary.push(entry); + await Promise.all(route.mirrors.map(async (mirror) => mirror.push(entry))); + this.#applyHydratedEntry(registered, entry, parsed, "put"); return parsed; } - // Validates the whole write (home, global singleton, name and index accessors) before the store - // is touched; once the store accepts, applying to the cache has no fallible step. - async #writeNow(registered: RegisteredDefinition, entry: CultCacheEnvelope, value: unknown): Promise { - const home = this.#homeStore(entry.type); - const hydrated = this.#admitEntry(this.#hydrated, registered, entry, value); - const legacy = this.#hydrated.legacyGlobals.get(entry.type); - await home?.push(entry); - if (home && legacy) { - await this.#retireLegacyGlobal(home, legacy, entry); - } - this.#hydrated.legacyGlobals.delete(entry.type); - this.#installEntry(this.#hydrated, registered, hydrated); - } - - // The first write of a global loaded from a legacy key removes that record in the same write. If the - // removal fails, the new record is taken back out, so the store keeps only the legacy record. - async #retireLegacyGlobal(home: CacheBackingStore, legacy: CultCacheEnvelope, written: CultCacheEnvelope): Promise { - try { - await home.delete(legacy); - } catch (error) { - await home.delete(written).catch(() => undefined); - throw error; - } - } - - async #deleteNow( + async putEnvelope( definition: TDefinition, - key: string, - ): Promise { + envelope: CultCacheEnvelope, + ): Promise> { const registered = this.#requireDefinition(definition); - const entry = this.#hydrated.entries.get(this.#entryId(definition.type, key)); - if (!entry) { - return false; + if (envelope.type !== definition.type) { + throw new Error( + `CultCache envelope type "${envelope.type}" does not match definition "${definition.type}".`, + ); + } + if (!envelope.key || envelope.key.trim().length === 0) { + throw new Error(`CultCache envelope key for type "${definition.type}" must be non-empty.`); + } + if (!envelope.storedAt || envelope.storedAt.trim().length === 0) { + throw new Error(`CultCache envelope storedAt for type "${definition.type}" must be non-empty.`); } - const home = this.#homeStore(definition.type); - await home?.delete(this.#hydrated.legacyGlobals.get(definition.type) ?? this.#toEnvelope(entry)); - this.#hydrated.legacyGlobals.delete(definition.type); - this.#removeEntry(this.#hydrated, registered, entry); - return true; + const payload = this.#cloneBytes(envelope.payload); + const parsed = registered.formatter.decode(payload) as CultCacheDocumentValue; + const entry: CultCacheEnvelope = { + key: envelope.key, + type: envelope.type, + payload, + storedAt: envelope.storedAt, + schemaId: envelope.schemaId ?? registered.catalogEntry.schemaId, + catalogEntry: envelope.catalogEntry ?? registered.catalogEntry, + }; + + const route = this.#resolveRoute(definition.type); + if (!route.primary) { + throw new Error(`No backing store is registered for document type "${definition.type}".`); + } + + if (registered.global) { + const existingGlobalKey = this.#globalKeys.get(definition.type); + if (existingGlobalKey && existingGlobalKey !== entry.key) { + await this.delete(definition, existingGlobalKey); + } + } + + await route.primary.push(entry); + await Promise.all(route.mirrors.map(async (mirror) => mirror.push(entry))); + this.#applyHydratedEntry(registered, entry, parsed, "put"); + return parsed; } - #requireGlobal(definition: AnyCultCacheDocumentDefinition): RegisteredDefinition { + async putGlobal( + definition: TDefinition, + value: CultCacheDocumentValue, + ): Promise> { const registered = this.#requireDefinition(definition); if (!registered.global) { throw new Error(`CultCache document type "${definition.type}" is not marked as global.`); } - return registered; + + return this.put(definition, CultCache.GLOBAL_KEY, value); } - // A global is only ever stored under GLOBAL_KEY; any other key is refused before a store is touched. - #requireGlobalKey(registered: RegisteredDefinition, key: string): void { - if (registered.global && key !== CultCache.GLOBAL_KEY) { - throw new Error( - `CultCache global document type "${registered.definition.type}" must use key "${CultCache.GLOBAL_KEY}", not "${key}".`, - ); + async update( + definition: TDefinition, + key: string, + updater: ( + current: CultCacheDocumentValue | undefined, + ) => CultCacheDocumentValue, + ): Promise> { + const current = this.get(definition, key); + return this.put(definition, key, updater(current)); + } + + async updateGlobal( + definition: TDefinition, + updater: ( + current: CultCacheDocumentValue | undefined, + ) => CultCacheDocumentValue, + ): Promise> { + const current = this.getGlobal(definition); + return this.putGlobal(definition, updater(current)); + } + + async delete( + definition: TDefinition, + key: string, + ): Promise { + const registered = this.#requireDefinition(definition); + const entry = this.#entries.get(this.#entryId(definition.type, key)); + if (!entry) { + return false; } + + const route = this.#resolveRoute(definition.type); + if (!route.primary) { + throw new Error(`No backing store is registered for document type "${definition.type}".`); + } + + const envelope = this.#toEnvelope(entry); + await route.primary.delete(envelope); + await Promise.all(route.mirrors.map(async (mirror) => mirror.delete(envelope))); + this.#removeHydratedEntry(registered, entry); + return true; + } + + async deleteGlobal( + definition: TDefinition, + ): Promise { + const registered = this.#requireDefinition(definition); + if (!registered.global) { + throw new Error(`CultCache document type "${definition.type}" is not marked as global.`); + } + + const globalKey = this.#globalKeys.get(definition.type); + return globalKey ? this.delete(definition, globalKey) : false; + } + + snapshot(): CultCacheEnvelope[] { + return [...this.#entries.values()].map((entry) => this.#toEnvelope(entry)); } #createFormatter(definition: AnyCultCacheDocumentDefinition): CultCacheDocumentFormatter { @@ -757,7 +603,7 @@ export class CultCache { type: string, label: string, accessor: AccessorInput, - ): Accessor { + ): (value: unknown) => string | undefined { if (typeof accessor === "function") { return (value: unknown) => this.#normalizeIndexValue(type, label, accessor(value)); } @@ -814,195 +660,159 @@ export class CultCache { return this.#schemaNameDefinitions.get(entry.type) ?? this.#definitions.get(entry.type); } - // The registration that claims the type, else the generic one. Registration guarantees at most one of each. - #resolveRegistration(type: string): CultCacheStoreRegistration | undefined { - return this.#stores.find((registration) => registration.types.includes(type)) - ?? this.#stores.find((registration) => registration.types.length === 0); + #resolveRoute(type: string): StoreRoute { + const typeSpecific = this.#stores.filter((registration) => registration.types.includes(type)); + if (typeSpecific.length > 0) { + return { + primary: typeSpecific[0]?.store, + mirrors: typeSpecific.slice(1).map((registration) => registration.store), + }; + } + + const generic = this.#stores.filter((registration) => registration.types.length === 0); + return { + primary: generic[0]?.store, + mirrors: generic.slice(1).map((registration) => registration.store), + }; } - // A cache with no stores is in memory: undefined. Once any store is attached, a type with no home is an error. - #homeStore(type: string): CacheBackingStore | undefined { - if (this.#stores.length === 0) { - return undefined; - } - const home = this.#resolveRegistration(type); - if (!home) { - throw new Error(`No backing store is registered for document type "${type}".`); - } - return home.store; - } - - #describe(registration: CultCacheStoreRegistration | undefined): string { - if (!registration) { - return "memory"; - } - return registration.types.length === 0 - ? "the generic store" - : `the store routed to ${registration.types.join(", ")}`; - } - - #lookupsFor( - nameAccessor: Accessor | undefined, - indexAccessors: ReadonlyMap, - value: unknown, - ): EntryLookups { - const name = nameAccessor?.(value); - const indexes = new Map(); - for (const [indexName, accessor] of indexAccessors) { - const indexValue = accessor(value); - if (indexValue !== undefined) { - indexes.set(indexName, indexValue); - } - } - return { name, indexes }; - } - - // Fallible half of admission: the global singleton check and every user accessor. Touches no state. - #admitEntry( - state: HydratedState, + #applyHydratedEntry( registered: RegisteredDefinition, entry: CultCacheEnvelope, value: unknown, - ): HydratedEntry { + source: "pull" | "put", + ): void { + const entryId = this.#entryId(entry.type, entry.key); + const existing = this.#entries.get(entryId); + if (existing) { + this.#removeHydratedEntry(registered, existing); + } + if (registered.global) { - const currentGlobalKey = state.globalKeys.get(entry.type); - if (currentGlobalKey !== undefined && currentGlobalKey !== entry.key) { + const currentGlobalKey = this.#globalKeys.get(entry.type); + if (currentGlobalKey && currentGlobalKey !== entry.key) { throw new Error( - `CultCache global document type "${entry.type}" has multiple persisted entries: "${currentGlobalKey}" and "${entry.key}".`, + source === "pull" + ? `CultCache global document type "${entry.type}" has multiple persisted entries.` + : `CultCache global document type "${entry.type}" already has a different key "${currentGlobalKey}".`, ); } } - return { + const hydrated: HydratedEntry = { ...entry, payload: this.#cloneBytes(entry.payload), schemaId: entry.schemaId ?? registered.catalogEntry.schemaId, catalogEntry: entry.catalogEntry ?? registered.catalogEntry, value, - lookups: this.#lookupsFor(registered.nameAccessor, registered.indexAccessors, value), }; - } + this.#entries.set(entryId, hydrated); + this.#getOrCreateTypeEntrySet(entry.type).add(entryId); - // Infallible half of admission: replaces any held record at the same key and installs the admitted one. - #installEntry(state: HydratedState, registered: RegisteredDefinition, hydrated: HydratedEntry): void { - const entryId = this.#entryId(hydrated.type, hydrated.key); - const existing = state.entries.get(entryId); - if (existing) { - this.#removeEntry(state, registered, existing); - } - - state.entries.set(entryId, hydrated); - getOrCreate(state.typeEntryIds, hydrated.type, () => new Set()).add(entryId); if (registered.global) { - state.globalKeys.set(hydrated.type, hydrated.key); + this.#globalKeys.set(entry.type, entry.key); } - if (hydrated.lookups.name !== undefined) { - getOrCreate(state.nameToKeyMaps, hydrated.type, () => new Map()).set(hydrated.lookups.name, hydrated.key); + + if (registered.nameAccessor) { + const name = registered.nameAccessor(value); + if (name !== undefined) { + this.#getOrCreateNameMap(entry.type).set(name, entry.key); + } } - for (const [indexName, indexValue] of hydrated.lookups.indexes) { - getOrCreate(state.indexToKeyMaps, this.#indexId(hydrated.type, indexName), () => new Map()) - .set(indexValue, hydrated.key); + + for (const [indexName, accessor] of registered.indexAccessors) { + const indexValue = accessor(value); + if (indexValue !== undefined) { + this.#getOrCreateIndexMap(entry.type, indexName).set(indexValue, entry.key); + } } } - // Reads the lookups stored at admission; runs no accessor. - #removeEntry( - state: HydratedState, + #removeHydratedEntry( registered: RegisteredDefinition, entry: HydratedEntry, ): void { const entryId = this.#entryId(entry.type, entry.key); - state.entries.delete(entryId); + this.#entries.delete(entryId); - const typeEntryIds = state.typeEntryIds.get(entry.type); + const typeEntryIds = this.#typeEntryIds.get(entry.type); if (typeEntryIds) { typeEntryIds.delete(entryId); if (typeEntryIds.size === 0) { - state.typeEntryIds.delete(entry.type); + this.#typeEntryIds.delete(entry.type); } } - if (registered.global && state.globalKeys.get(entry.type) === entry.key) { - state.globalKeys.delete(entry.type); + if (registered.global && this.#globalKeys.get(entry.type) === entry.key) { + this.#globalKeys.delete(entry.type); } - if (entry.lookups.name !== undefined) { - const names = state.nameToKeyMaps.get(entry.type); - if (names?.get(entry.lookups.name) === entry.key) { - names.delete(entry.lookups.name); + if (registered.nameAccessor) { + const name = registered.nameAccessor(entry.value); + if (name !== undefined) { + this.#nameToKeyMaps.get(entry.type)?.delete(name); } } - for (const [indexName, indexValue] of entry.lookups.indexes) { - const index = state.indexToKeyMaps.get(this.#indexId(entry.type, indexName)); - if (index?.get(indexValue) === entry.key) { - index.delete(indexValue); + + for (const [indexName, accessor] of registered.indexAccessors) { + const value = accessor(entry.value); + if (value !== undefined) { + this.#indexToKeyMaps.get(this.#indexId(entry.type, indexName))?.delete(value); } } } - // Runs the candidate accessors over every held record of the type into local maps. - #deriveTypeLookups( - type: string, - nameAccessor: Accessor | undefined, - indexAccessors: ReadonlyMap, - ): DerivedTypeLookups { - const entryLookups: Array<[HydratedEntry, EntryLookups]> = []; - const nameMap = nameAccessor ? new Map() : undefined; - const indexMaps = new Map>(); - for (const indexName of indexAccessors.keys()) { - indexMaps.set(indexName, new Map()); - } + #rebuildDefinitionLookups(registered: RegisteredDefinition): void { + this.#rebuildNameLookup(registered); - for (const entry of this.#entriesForType(type)) { - const lookups = this.#lookupsFor(nameAccessor, indexAccessors, entry.value); - entryLookups.push([entry, lookups]); - if (lookups.name !== undefined) { - nameMap?.set(lookups.name, entry.key); - } - for (const [indexName, indexValue] of lookups.indexes) { - indexMaps.get(indexName)?.set(indexValue, entry.key); - } - } - - return { entryLookups, nameMap, indexMaps }; - } - - // Installs accessors and their derived lookups together. Nothing here can throw. - #installAccessors( - registered: RegisteredDefinition, - nameAccessor: Accessor | undefined, - indexAccessors: ReadonlyMap, - derived: DerivedTypeLookups, - ): void { - const type = registered.definition.type; for (const indexName of registered.indexAccessors.keys()) { - this.#hydrated.indexToKeyMaps.delete(this.#indexId(type, indexName)); + this.#rebuildIndexLookup(registered, indexName); + } + } + + #rebuildNameLookup(registered: RegisteredDefinition): void { + this.#nameToKeyMaps.delete(registered.definition.type); + + if (!registered.nameAccessor) { + return; } - registered.nameAccessor = nameAccessor; - registered.indexAccessors = indexAccessors; - for (const [entry, lookups] of derived.entryLookups) { - entry.lookups = lookups; + const map = this.#getOrCreateNameMap(registered.definition.type); + for (const entry of this.#entriesForType(registered.definition.type)) { + const name = registered.nameAccessor(entry.value); + if (name !== undefined) { + map.set(name, entry.key); + } } - if (derived.nameMap) { - this.#hydrated.nameToKeyMaps.set(type, derived.nameMap); - } else { - this.#hydrated.nameToKeyMaps.delete(type); + } + + #rebuildIndexLookup(registered: RegisteredDefinition, indexName: string): void { + const lookupId = this.#indexId(registered.definition.type, indexName); + this.#indexToKeyMaps.delete(lookupId); + + const accessor = registered.indexAccessors.get(indexName); + if (!accessor) { + return; } - for (const [indexName, map] of derived.indexMaps) { - this.#hydrated.indexToKeyMaps.set(this.#indexId(type, indexName), map); + + const map = this.#getOrCreateIndexMap(registered.definition.type, indexName); + for (const entry of this.#entriesForType(registered.definition.type)) { + const value = accessor(entry.value); + if (value !== undefined) { + map.set(value, entry.key); + } } } #entriesForType(type: string): HydratedEntry[] { - const entryIds = this.#hydrated.typeEntryIds.get(type); + const entryIds = this.#typeEntryIds.get(type); if (!entryIds) { return []; } const entries: HydratedEntry[] = []; for (const entryId of entryIds) { - const entry = this.#hydrated.entries.get(entryId); + const entry = this.#entries.get(entryId); if (entry) { entries.push(entry); } @@ -1011,6 +821,34 @@ export class CultCache { return entries; } + #getOrCreateTypeEntrySet(type: string): Set { + let set = this.#typeEntryIds.get(type); + if (!set) { + set = new Set(); + this.#typeEntryIds.set(type, set); + } + return set; + } + + #getOrCreateNameMap(type: string): Map { + let map = this.#nameToKeyMaps.get(type); + if (!map) { + map = new Map(); + this.#nameToKeyMaps.set(type, map); + } + return map; + } + + #getOrCreateIndexMap(type: string, indexName: string): Map { + const lookupId = this.#indexId(type, indexName); + let map = this.#indexToKeyMaps.get(lookupId); + if (!map) { + map = new Map(); + this.#indexToKeyMaps.set(lookupId, map); + } + return map; + } + #toEnvelope(entry: HydratedEntry): CultCacheEnvelope { return { key: entry.key, @@ -1022,6 +860,14 @@ export class CultCache { }; } + #resetHydratedState(): void { + this.#entries.clear(); + this.#typeEntryIds.clear(); + this.#nameToKeyMaps.clear(); + this.#indexToKeyMaps.clear(); + this.#globalKeys.clear(); + } + #entryId(type: string, key: string): string { return `${type}::${key}`; } diff --git a/packages/cultcache-ts/src/index.ts b/packages/cultcache-ts/src/index.ts index 92f7b481..97687de8 100644 --- a/packages/cultcache-ts/src/index.ts +++ b/packages/cultcache-ts/src/index.ts @@ -27,8 +27,3 @@ export type { InspectedCatalogMember, InspectedRecord, } from "./cult-cache-inspector"; -export { - defineSwarmDocuments, - type DefineDocumentType, - type SwarmDocumentCatalog, -} from "./swarm-documents"; diff --git a/packages/cultcache-ts/src/swarm-documents.ts b/packages/cultcache-ts/src/swarm-documents.ts deleted file mode 100644 index b1c0b40d..00000000 --- a/packages/cultcache-ts/src/swarm-documents.ts +++ /dev/null @@ -1,561 +0,0 @@ -// GameCult swarm document-type catalog. -// -// These are CultCache document definitions for documents that cross service -// boundaries: Eve surfaces and provider advertisements, Ghostlight, Heimdall, -// Idunn, Muninn, Sleipnir, Aetheria, VoidBot, Stonks. The catalog lived in Odin -// because Odin was written first, not because Odin owns it; every service that -// decodes another service documents needs the same definitions, and a second -// copy would be a second definition of one wire format. -// -// defineDocumentType is injected rather than imported. That is how it worked in -// Odin, it keeps this file clear of the generic signature in ./document, and it -// lets a caller supply a runtime other than this package. - -/** An undecoded document payload. These definitions predate typing and name fields defensively. */ -export type DocumentValue = any; - -export type DefineDocumentType = (definition: Record) => unknown; -export type SwarmDocumentCatalog = Record; - -function parseObjectDocument(label: string) { - return { - parse(value: unknown) { - if (!value || typeof value !== "object") { - throw new Error(`${label} must be an object`); - } - return value; - }, - }; -} - -export function defineSwarmDocuments(defineDocumentType?: DefineDocumentType | null): SwarmDocumentCatalog { - if (!defineDocumentType) { - return { - interfaceBindingDefinition: null, - evePluginAdvertisementDefinition: null, - ghostlightSchemaCatalogDefinition: null, - heimdallCommandBoundaryDefinition: null, - heimdallTransportProfileDefinition: null, - idunnCommandBoundaryDefinition: null, - idunnDaemonHealthDefinition: null, - idunnTransportProfileDefinition: null, - interfaceLayoutDefinition: null, - muninnCaptureStreamCommandDefinition: null, - muninnCaptureStreamDefinition: null, - muninnCommandBoundaryDefinition: null, - muninnMediaAudioPacketDefinition: null, - muninnMediaReceiverFeedbackDefinition: null, - muninnMediaVideoAccessUnitDefinition: null, - muninnHidControllerStateDefinition: null, - muninnMoveControllerStateDefinition: null, - muninnMoveIdentityDefinition: null, - muninnMoveLightCommandDefinition: null, - muninnMoveMarkerCandidateDefinition: null, - muninnObsStreamCatalogDefinition: null, - muninnQuestAccessDefinition: null, - muninnTelemetrySurfaceDefinition: null, - muninnTransportProfileDefinition: null, - SleipnirInputMappingDefinition: null, - aetheriaAssetManifestDefinition: null, - aetheriaGravityViewportDefinition: null, - aetheriaObjectsViewportDefinition: null, - aetheriaRenderSplatsViewportDefinition: null, - operatorStateDefinition: null, - providerAdvertisementDefinition: null, - voidbotProviderCatalogDefinition: null, - stonksCommandBoundaryDefinition: null, - stonksMarketSnapshotDefinition: null, - stonksRequestEventDefinition: null, - stonksTransportProfileDefinition: null, - streamPixelsCommandBoundaryDefinition: null, - streamPixelsTransportProfileDefinition: null, - surfaceDefinition: null, - viliCommandBoundaryDefinition: null, - viliTransportProfileDefinition: null, - weksaCommandBoundaryDefinition: null, - weksaOperatorStateDefinition: null, - weksaTransportProfileDefinition: null, - voidbotSwarmSnapshotDefinition: null, - }; - } - - const surfaceDefinition = defineDocumentType({ - type: "gamecult.eve.surface_state", - schemaName: "gamecult.eve.surface_state", - schemaId: "gamecult.eve.surface_state.v1", - schemaVersion: "gamecult.eve.surface_state.v1", - global: false, - name: (value: DocumentValue) => value?.providerId || "surface", - schema: { parse: (value: DocumentValue) => value }, - members: [ - { slot: 0, memberName: "providerId", typeName: "string", isName: true }, - { slot: 1, memberName: "title", typeName: "string" }, - { slot: 2, memberName: "version", typeName: "long" }, - { slot: 3, memberName: "updatedAt", typeName: "string" }, - { slot: 4, memberName: "surface", typeName: "object" }, - ], - }); - const interfaceBindingDefinition = defineDocumentType({ - type: "gamecult.eve.interface_binding", - schemaName: "gamecult.eve.interface_binding", - schemaId: "gamecult.eve.interface_binding.v1", - schemaVersion: "gamecult.eve.interface_binding.v1", - global: false, - name: (value: DocumentValue) => value?.bindingId || value?.providerId || "interface", - schema: parseObjectDocument("Eve interface binding"), - }); - const providerAdvertisementDefinition = defineDocumentType({ - type: "gamecult.eve.provider_advertisement", - schemaName: "gamecult.eve.provider_advertisement", - schemaId: "gamecult.eve.provider_advertisement.v1", - schemaVersion: "gamecult.eve.provider_advertisement.v1", - global: false, - name: (value: DocumentValue) => value?.providerId || value?.provider?.id || "provider", - schema: parseObjectDocument("Eve provider advertisement"), - }); - const evePluginAdvertisementDefinition = defineDocumentType({ - type: "gamecult.eve.plugin_advertisement", - schemaName: "gamecult.eve.plugin_advertisement", - schemaId: "gamecult.eve.plugin_advertisement.v1", - schemaVersion: "gamecult.eve.plugin_advertisement.v1", - global: false, - name: (value: DocumentValue) => value?.pluginId || value?.plugin?.id || "plugin", - schema: parseObjectDocument("Eve plugin advertisement"), - }); - const ghostlightSchemaCatalogDefinition = defineDocumentType({ - type: "ghostlight.schema_catalog", - schemaName: "ghostlight.schema_catalog", - schemaId: "ghostlight.schema_catalog.v1", - schemaVersion: "ghostlight.schema_catalog.v1", - global: false, - name: (value: DocumentValue) => value?.providerId || "gamecult.ghostlight.dungeon", - schema: parseObjectDocument("Ghostlight public schema catalog"), - }); - const heimdallCommandBoundaryDefinition = defineDocumentType({ - type: "heimdall.command_boundary", - schemaName: "heimdall.command_boundary", - schemaId: "heimdall.command_boundary.v1", - schemaVersion: "heimdall.command_boundary.v1", - global: false, - name: (value: DocumentValue) => value?.boundaryId || value?.daemonId || "heimdall", - schema: parseObjectDocument("Heimdall command boundary"), - }); - const heimdallTransportProfileDefinition = defineDocumentType({ - type: "heimdall.transport_profile", - schemaName: "heimdall.transport_profile", - schemaId: "heimdall.transport_profile.v1", - schemaVersion: "heimdall.transport_profile.v1", - global: false, - name: (value: DocumentValue) => value?.profileId || value?.daemonId || "heimdall", - schema: parseObjectDocument("Heimdall transport profile"), - }); - const interfaceLayoutDefinition = defineDocumentType({ - type: "odin.interface_layout", - schemaName: "odin.interface_layout", - schemaId: "odin.interface_layout.v1", - schemaVersion: "odin.interface_layout.v1", - global: false, - name: (value: DocumentValue) => value?.layoutId || value?.providerId || "odin.providers", - schema: parseObjectDocument("Odin interface layout"), - members: [ - { slot: 0, memberName: "layoutId", typeName: "string", isName: true }, - { slot: 1, memberName: "updatedAt", typeName: "string" }, - { slot: 2, memberName: "tiles", typeName: "object" }, - ], - }); - const idunnDaemonHealthDefinition = defineDocumentType({ - type: "idunn.daemon_health", - schemaName: "idunn.daemon_health", - schemaId: "idunn.daemon_health", - schemaVersion: "idunn.daemon_health.v1", - global: false, - name: (value: DocumentValue) => value?.daemonId || value?.daemon_id || "daemon", - schema: parseObjectDocument("Idunn daemon health"), - }); - const idunnCommandBoundaryDefinition = defineDocumentType({ - type: "idunn.command_boundary", - schemaName: "idunn.command_boundary", - schemaId: "idunn.command_boundary.v1", - schemaVersion: "idunn.command_boundary.v1", - global: false, - name: (value: DocumentValue) => value?.boundary_id || value?.boundaryId || value?.daemon_id || value?.daemonId || "daemon", - schema: parseObjectDocument("Idunn command boundary"), - }); - const idunnTransportProfileDefinition = defineDocumentType({ - type: "idunn.daemon_transport_profile", - schemaName: "idunn.daemon_transport_profile", - schemaId: "idunn.daemon_transport_profile.v1", - schemaVersion: "idunn.daemon_transport_profile.v1", - global: false, - name: (value: DocumentValue) => value?.profile_id || value?.profileId || value?.daemon_id || value?.daemonId || "daemon", - schema: parseObjectDocument("Idunn daemon transport profile"), - }); - const muninnCaptureStreamDefinition = defineDocumentType({ - type: "muninn.capture_stream", - schemaName: "muninn.capture_stream", - schemaId: "muninn.capture_stream.v1", - schemaVersion: "muninn.capture_stream.v1", - global: false, - name: (value: DocumentValue) => value?.stream_id || value?.streamId || "capture-stream", - schema: parseObjectDocument("Muninn capture stream"), - }); - const muninnCaptureStreamCommandDefinition = defineDocumentType({ - type: "muninn.capture_stream_command", - schemaName: "muninn.capture_stream_command", - schemaId: "muninn.capture_stream_command.v1", - schemaVersion: "muninn.capture_stream_command.v1", - global: false, - name: (value: DocumentValue) => value?.command_id || value?.commandId || value?.stream_id || value?.streamId || "capture-stream-command", - schema: parseObjectDocument("Muninn capture stream command"), - }); - const muninnCommandBoundaryDefinition = defineDocumentType({ - type: "muninn.command_boundary", - schemaName: "muninn.command_boundary", - schemaId: "muninn.command_boundary.v1", - schemaVersion: "muninn.command_boundary.v1", - global: false, - name: (value: DocumentValue) => value?.boundary_id || value?.boundaryId || value?.daemon_id || value?.daemonId || "muninn", - schema: parseObjectDocument("Muninn command boundary"), - }); - const muninnMediaVideoAccessUnitDefinition = defineDocumentType({ - type: "muninn.media_video_access_unit", - schemaName: "muninn.media_video_access_unit", - schemaId: "muninn.media_video_access_unit.v1", - schemaVersion: "muninn.media_video_access_unit.v1", - global: false, - name: (value: DocumentValue) => { - const streamId = value?.stream_id || value?.streamId || "stream"; - const sessionId = value?.session_id || value?.sessionId || "session"; - const frameId = value?.frame_id ?? value?.frameId ?? "frame"; - const chunkIndex = value?.chunk_index ?? value?.chunkIndex ?? "chunk"; - return `${streamId}:${sessionId}:video:${frameId}:${chunkIndex}`; - }, - schema: parseObjectDocument("Muninn media video access unit"), - }); - const muninnMediaAudioPacketDefinition = defineDocumentType({ - type: "muninn.media_audio_packet", - schemaName: "muninn.media_audio_packet", - schemaId: "muninn.media_audio_packet.v1", - schemaVersion: "muninn.media_audio_packet.v1", - global: false, - name: (value: DocumentValue) => { - const streamId = value?.stream_id || value?.streamId || "stream"; - const sessionId = value?.session_id || value?.sessionId || "session"; - const packetId = value?.packet_id ?? value?.packetId ?? "packet"; - return `${streamId}:${sessionId}:audio:${packetId}`; - }, - schema: parseObjectDocument("Muninn media audio packet"), - }); - const muninnMediaReceiverFeedbackDefinition = defineDocumentType({ - type: "muninn.media_receiver_feedback", - schemaName: "muninn.media_receiver_feedback", - schemaId: "muninn.media_receiver_feedback.v1", - schemaVersion: "muninn.media_receiver_feedback.v1", - global: false, - name: (value: DocumentValue) => { - const streamId = value?.stream_id || value?.streamId || "stream"; - const sessionId = value?.session_id || value?.sessionId || "session"; - const receiverId = value?.receiver_id || value?.receiverId || "receiver"; - return `${streamId}:${sessionId}:feedback:${receiverId}`; - }, - schema: parseObjectDocument("Muninn media receiver feedback"), - }); - const muninnMoveControllerStateDefinition = defineDocumentType({ - type: "muninn.move_controller_state", - schemaName: "muninn.move_controller_state", - schemaId: "muninn.move_controller_state.v1", - schemaVersion: "muninn.move_controller_state.v1", - global: false, - name: (value: DocumentValue) => value?.stream_id || value?.streamId || value?.move_id || value?.moveId || "move-controller-state", - schema: parseObjectDocument("Muninn Move controller state"), - }); - const muninnHidControllerStateDefinition = defineDocumentType({ - type: "muninn.hid_controller_state", - schemaName: "muninn.hid_controller_state", - schemaId: "muninn.hid_controller_state.v1", - schemaVersion: "muninn.hid_controller_state.v1", - global: false, - name: (value: DocumentValue) => value?.stream_id || value?.streamId || value?.device_id || value?.deviceId || "hid-controller-state", - schema: parseObjectDocument("Muninn HID controller state"), - }); - const muninnMoveIdentityDefinition = defineDocumentType({ - type: "muninn.move_identity", - schemaName: "muninn.move_identity", - schemaId: "muninn.move_identity.v1", - schemaVersion: "muninn.move_identity.v1", - global: false, - name: (value: DocumentValue) => value?.identity_id || value?.identityId || value?.move_id || value?.moveId || "move-identity", - schema: parseObjectDocument("Muninn Move identity"), - }); - const muninnMoveLightCommandDefinition = defineDocumentType({ - type: "muninn.move_light_command", - schemaName: "muninn.move_light_command", - schemaId: "muninn.move_light_command.v1", - schemaVersion: "muninn.move_light_command.v1", - global: false, - name: (value: DocumentValue) => value?.command_id || value?.commandId || value?.move_id || value?.moveId || "move-light-command", - schema: parseObjectDocument("Muninn Move light command"), - }); - const muninnMoveMarkerCandidateDefinition = defineDocumentType({ - type: "muninn.move_marker_candidate", - schemaName: "muninn.move_marker_candidate", - schemaId: "muninn.move_marker_candidate.v1", - schemaVersion: "muninn.move_marker_candidate.v1", - global: false, - name: (value: DocumentValue) => value?.stream_id || value?.streamId || value?.camera_id || value?.cameraId || "move-marker-candidate", - schema: parseObjectDocument("Muninn Move marker candidate"), - }); - const muninnObsStreamCatalogDefinition = defineDocumentType({ - type: "muninn.obs_stream_catalog", - schemaName: "muninn.obs_stream_catalog", - schemaId: "muninn.obs_stream_catalog.v1", - schemaVersion: "muninn.obs_stream_catalog.v1", - global: false, - name: (value: DocumentValue) => value?.catalog_id || value?.catalogId || value?.host_id || value?.hostId || "muninn", - schema: parseObjectDocument("Muninn OBS stream catalog"), - }); - const muninnQuestAccessDefinition = defineDocumentType({ - type: "muninn.quest_access", - schemaName: "muninn.quest_access", - schemaId: "muninn.quest_access.v1", - schemaVersion: "muninn.quest_access.v1", - global: false, - name: (value: DocumentValue) => value?.access_id || value?.accessId || value?.serial || "quest-access", - schema: parseObjectDocument("Muninn Quest access"), - }); - const muninnTelemetrySurfaceDefinition = defineDocumentType({ - type: "muninn.telemetry_surface", - schemaName: "muninn.telemetry_surface", - schemaId: "muninn.telemetry_surface.v1", - schemaVersion: "muninn.telemetry_surface.v1", - global: false, - name: (value: DocumentValue) => value?.surface_id || value?.surfaceId || value?.host_id || value?.hostId || "muninn-telemetry", - schema: parseObjectDocument("Muninn telemetry surface"), - }); - const muninnTransportProfileDefinition = defineDocumentType({ - type: "muninn.transport_profile", - schemaName: "muninn.transport_profile", - schemaId: "muninn.transport_profile.v1", - schemaVersion: "muninn.transport_profile.v1", - global: false, - name: (value: DocumentValue) => value?.profile_id || value?.profileId || value?.daemon_id || value?.daemonId || "muninn", - schema: parseObjectDocument("Muninn transport profile"), - }); - const SleipnirInputMappingDefinition = defineDocumentType({ - type: "sleipnir.input_mapping", - schemaName: "sleipnir.input_mapping", - schemaId: "sleipnir.input_mapping.v1", - schemaVersion: "sleipnir.input_mapping.v1", - global: false, - name: (value: DocumentValue) => value?.providerId || value?.provider_id || "sleipnir", - schema: parseObjectDocument("Sleipnir input mapping"), - }); - const aetheriaAssetManifestDefinition = defineDocumentType({ - type: "gamecult.aetheria.asset_manifest", - schemaName: "gamecult.aetheria.asset_manifest", - schemaId: "gamecult.aetheria.asset_manifest.v1", - schemaVersion: "gamecult.aetheria.asset_manifest.v1", - global: false, - name: (value: DocumentValue) => value?.runId || value?.run_id || "aetheria-assets", - schema: parseObjectDocument("Aetheria asset manifest"), - }); - const aetheriaGravityViewportDefinition = defineDocumentType({ - type: "gamecult.fields.gravity", - schemaName: "gamecult.fields.gravity", - schemaId: "gamecult.fields.gravity.v1", - schemaVersion: "gamecult.fields.gravity.v1", - global: false, - name: (value: DocumentValue) => value?.viewportId || value?.viewport_id || "aetheria-gravity", - schema: parseObjectDocument("Aetheria gravity viewport"), - }); - const aetheriaObjectsViewportDefinition = defineDocumentType({ - type: "gamecult.fields.objects", - schemaName: "gamecult.fields.objects", - schemaId: "gamecult.fields.objects.v1", - schemaVersion: "gamecult.fields.objects.v1", - global: false, - name: (value: DocumentValue) => value?.viewportId || value?.viewport_id || "aetheria-objects", - schema: parseObjectDocument("Aetheria objects viewport"), - }); - const aetheriaRenderSplatsViewportDefinition = defineDocumentType({ - type: "gamecult.fields.splats", - schemaName: "gamecult.fields.splats", - schemaId: "gamecult.fields.splats.v1", - schemaVersion: "gamecult.fields.splats.v1", - global: false, - name: (value: DocumentValue) => value?.viewportId || value?.viewport_id || "aetheria-render-splats", - schema: parseObjectDocument("Aetheria render splats viewport"), - }); - const operatorStateDefinition = defineDocumentType({ - type: "gamecult.vili.operator_state", - schemaName: "gamecult.vili.operator_state", - schemaId: "gamecult.vili.operator_state.v1", - schemaVersion: "gamecult.vili.operator_state.v1", - global: false, - name: (value: DocumentValue) => value?.service || value?.providerId || "operator", - schema: parseObjectDocument("Vili operator state"), - }); - const viliCommandBoundaryDefinition = defineDocumentType({ - type: "gamecult.vili.command_boundary", - schemaName: "gamecult.vili.command_boundary", - schemaId: "gamecult.vili.command_boundary.v1", - schemaVersion: "gamecult.vili.command_boundary.v1", - global: false, - name: (value: DocumentValue) => value?.boundaryId || value?.daemonId || "vili", - schema: parseObjectDocument("Vili command boundary"), - }); - const viliTransportProfileDefinition = defineDocumentType({ - type: "gamecult.vili.transport_profile", - schemaName: "gamecult.vili.transport_profile", - schemaId: "gamecult.vili.transport_profile.v1", - schemaVersion: "gamecult.vili.transport_profile.v1", - global: false, - name: (value: DocumentValue) => value?.profileId || value?.daemonId || "vili", - schema: parseObjectDocument("Vili transport profile"), - }); - const weksaOperatorStateDefinition = defineDocumentType({ - type: "weksa.operator_state", - schemaName: "weksa.operator_state", - schemaId: "weksa.operator_state.v0", - schemaVersion: "weksa.operator_state.v0", - global: false, - name: (value: DocumentValue) => value?.daemon_id || value?.daemonId || value?.provider_id || value?.providerId || "weksa", - schema: parseObjectDocument("Weksa operator state"), - }); - const weksaCommandBoundaryDefinition = defineDocumentType({ - type: "weksa.command_boundary", - schemaName: "weksa.command_boundary", - schemaId: "weksa.command_boundary.v1", - schemaVersion: "weksa.command_boundary.v1", - global: false, - name: (value: DocumentValue) => value?.boundary_id || value?.boundaryId || value?.daemon_id || value?.daemonId || "weksa", - schema: parseObjectDocument("Weksa command boundary"), - }); - const weksaTransportProfileDefinition = defineDocumentType({ - type: "weksa.transport_profile", - schemaName: "weksa.transport_profile", - schemaId: "weksa.transport_profile.v1", - schemaVersion: "weksa.transport_profile.v1", - global: false, - name: (value: DocumentValue) => value?.profile_id || value?.profileId || value?.daemon_id || value?.daemonId || "weksa", - schema: parseObjectDocument("Weksa transport profile"), - }); - const stonksRequestEventDefinition = defineDocumentType({ - type: "stonks.request_event", - schemaName: "stonks.request_event", - schemaId: "stonks.request_event.v1", - schemaVersion: "stonks.request_event.v1", - global: false, - name: (value: DocumentValue) => value?.id || "request", - schema: parseObjectDocument("Stonks request event"), - }); - const stonksMarketSnapshotDefinition = defineDocumentType({ - type: "stonks.market_snapshot", - schemaName: "stonks.market_snapshot", - schemaId: "stonks.market_snapshot.v1", - schemaVersion: "stonks.market_snapshot.v1", - global: true, - schema: parseObjectDocument("Stonks market snapshot"), - }); - const stonksCommandBoundaryDefinition = defineDocumentType({ - type: "stonks.command_boundary", - schemaName: "stonks.command_boundary", - schemaId: "stonks.command_boundary.v1", - schemaVersion: "stonks.command_boundary.v1", - global: false, - name: (value: DocumentValue) => value?.boundaryId || value?.daemonId || "stonks", - schema: parseObjectDocument("Stonks command boundary"), - }); - const stonksTransportProfileDefinition = defineDocumentType({ - type: "stonks.transport_profile", - schemaName: "stonks.transport_profile", - schemaId: "stonks.transport_profile.v1", - schemaVersion: "stonks.transport_profile.v1", - global: false, - name: (value: DocumentValue) => value?.profileId || value?.daemonId || "stonks", - schema: parseObjectDocument("Stonks transport profile"), - }); - const streamPixelsCommandBoundaryDefinition = defineDocumentType({ - type: "streampixels.command_boundary", - schemaName: "streampixels.command_boundary", - schemaId: "streampixels.command_boundary.v1", - schemaVersion: "streampixels.command_boundary.v1", - global: false, - name: (value: DocumentValue) => value?.boundaryId || value?.daemonId || "streampixels", - schema: parseObjectDocument("StreamPixels command boundary"), - }); - const streamPixelsTransportProfileDefinition = defineDocumentType({ - type: "streampixels.transport_profile", - schemaName: "streampixels.transport_profile", - schemaId: "streampixels.transport_profile.v1", - schemaVersion: "streampixels.transport_profile.v1", - global: false, - name: (value: DocumentValue) => value?.profileId || value?.daemonId || "streampixels", - schema: parseObjectDocument("StreamPixels transport profile"), - }); - const voidbotSwarmSnapshotDefinition = defineDocumentType({ - type: "voidbot.swarm_state_snapshot", - schemaName: "voidbot.swarm_state_snapshot", - schemaId: "voidbot.swarm_state_snapshot.v1", - schemaVersion: "voidbot.swarm_state_snapshot.v1", - global: true, - schema: parseObjectDocument("VoidBot swarm snapshot"), - }); - const voidbotProviderCatalogDefinition = defineDocumentType({ - type: "voidbot.provider_advertisement_catalog", - schemaName: "voidbot.provider_advertisement_catalog", - schemaId: "voidbot.provider_advertisement_catalog.v0", - schemaVersion: "voidbot.provider_advertisement_catalog.v0", - global: true, - schema: parseObjectDocument("VoidBot provider advertisement catalog"), - }); - - return { - interfaceBindingDefinition, - evePluginAdvertisementDefinition, - ghostlightSchemaCatalogDefinition, - heimdallCommandBoundaryDefinition, - heimdallTransportProfileDefinition, - interfaceLayoutDefinition, - idunnCommandBoundaryDefinition, - idunnDaemonHealthDefinition, - idunnTransportProfileDefinition, - muninnCaptureStreamCommandDefinition, - muninnCaptureStreamDefinition, - muninnCommandBoundaryDefinition, - muninnMediaAudioPacketDefinition, - muninnMediaReceiverFeedbackDefinition, - muninnMediaVideoAccessUnitDefinition, - muninnHidControllerStateDefinition, - muninnMoveControllerStateDefinition, - muninnMoveIdentityDefinition, - muninnMoveLightCommandDefinition, - muninnMoveMarkerCandidateDefinition, - muninnObsStreamCatalogDefinition, - muninnQuestAccessDefinition, - muninnTelemetrySurfaceDefinition, - muninnTransportProfileDefinition, - SleipnirInputMappingDefinition, - aetheriaAssetManifestDefinition, - aetheriaGravityViewportDefinition, - aetheriaObjectsViewportDefinition, - aetheriaRenderSplatsViewportDefinition, - operatorStateDefinition, - providerAdvertisementDefinition, - stonksCommandBoundaryDefinition, - stonksMarketSnapshotDefinition, - stonksRequestEventDefinition, - stonksTransportProfileDefinition, - streamPixelsCommandBoundaryDefinition, - streamPixelsTransportProfileDefinition, - surfaceDefinition, - viliCommandBoundaryDefinition, - viliTransportProfileDefinition, - weksaCommandBoundaryDefinition, - weksaOperatorStateDefinition, - weksaTransportProfileDefinition, - voidbotProviderCatalogDefinition, - voidbotSwarmSnapshotDefinition, - }; -} - diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index 5932b2f8..fb206eb6 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -1,10 +1,9 @@ import assert from "node:assert/strict"; import { exec, execFile } from "node:child_process"; -import { EventEmitter } from "node:events"; import { existsSync } from "node:fs"; import { access, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; -import { dirname, join, resolve, delimiter } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { test } from "node:test"; import { promisify } from "node:util"; @@ -25,9 +24,7 @@ const pythonCommand = process.env.PYTHON ?? "python"; const cultCacheTsRoot = resolve(__dirname, "../.."); const cultLibRoot = findAncestor(cultCacheTsRoot, "CultLib.sln") ?? resolve(cultCacheTsRoot, "..", "CultLib"); const cultcachePyRoot = resolve(cultLibRoot, "packages", "cultcache-py"); -const cultcachePySrc = ["cultcache-py", "cultnet-py", "cultmesh-py"] - .map((name) => resolve(cultLibRoot, "packages", name, "src")) - .join(delimiter); +const cultcachePySrc = resolve(cultcachePyRoot, "src"); const cultcacheRsRoot = existsSync(resolve(cultLibRoot, "packages", "cultcache-rs")) ? resolve(cultLibRoot, "packages", "cultcache-rs") : resolve(cultCacheTsRoot, "..", "cultcache-rs"); @@ -164,8 +161,8 @@ test("CultCache can register name and index lookups after entries already exist" body: "world", }); - await cache.registerNameLookup(noteDocument, "title"); - await cache.registerIndex(noteDocument, "author", "author"); + cache.registerNameLookup(noteDocument, "title"); + cache.registerIndex(noteDocument, "author", "author"); assert.equal(cache.getIdByName(noteDocument, "Hello"), "note:hello"); assert.equal(cache.getIdByIndex(noteDocument, "author", "ari"), "note:hello"); @@ -534,536 +531,6 @@ test("SingleFileMessagePackBackingStore heals legacy envelopes whose payload was assert.ok(records[0]?.[3] instanceof Uint8Array); }); -test("CultCache rejects a second generic backing store", async () => { - const cache = new CultCache(); - await cache.addGenericBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-a.cc"))); - await assert.rejects( - cache.addGenericBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-b.cc"))), - /second generic store/u, - ); -}); - -test("CultCache rejects a type registered to two stores", async () => { - const cache = new CultCache(); - await cache.addBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-a.cc")), "item"); - await assert.rejects( - cache.addBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-b.cc")), "settings", "item"), - /"item" is already routed/u, - ); - // A refused registration claims nothing, so "settings" is still free. - await cache.addBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-c.cc")), "settings"); -}); - -test("CultCache routes each type to its home store", async () => { - const itemDocument = defineDocumentType({ - type: "item", - schema: z.object({ name: z.string() }), - }); - const settingsDocument = defineDocumentType({ - type: "settings", - schema: z.object({ theme: z.string() }), - }); - const tempDir = await mkdtemp(join(tmpdir(), "cultcache-routes-")); - const genericPath = join(tempDir, "generic.cc"); - const settingsPath = join(tempDir, "settings.cc"); - const build = () => CultCache.builder() - .withRegistry(defineDocumentRegistry(itemDocument, settingsDocument)) - .withBackingStore(new SingleFileMessagePackBackingStore(settingsPath), settingsDocument) - .withGenericStore(new SingleFileMessagePackBackingStore(genericPath)) - .build(); - const keysIn = async (path: string) => - (await new SingleFileMessagePackBackingStore(path).pullAll()).map((entry) => `${entry.type}:${entry.key}`); - - const cache = build(); - await cache.put(itemDocument, "potion", { name: "Potion" }); - await cache.put(settingsDocument, "app", { theme: "ash" }); - assert.deepEqual(await keysIn(genericPath), ["item:potion"]); - assert.deepEqual(await keysIn(settingsPath), ["settings:app"]); - - await cache.delete(settingsDocument, "app"); - assert.deepEqual(await keysIn(settingsPath), []); - assert.deepEqual(await keysIn(genericPath), ["item:potion"]); - - const reloaded = build(); - await reloaded.pullAllBackingStores(); - assert.deepEqual(reloaded.getRequired(itemDocument, "potion"), { name: "Potion" }); - assert.equal(reloaded.get(settingsDocument, "app"), undefined); -}); - -test("CultCache refuses an attach that would move a held record's home", async () => { - const settingsDocument = defineDocumentType({ type: "settings", schema: z.object({ theme: z.string() }) }); - const tempDir = await mkdtemp(join(tmpdir(), "cultcache-home-move-")); - const genericPath = join(tempDir, "generic.cc"); - const settingsPath = join(tempDir, "settings.cc"); - const cache = CultCache.builder() - .withRegistry(defineDocumentRegistry(settingsDocument)) - .withGenericStore(new SingleFileMessagePackBackingStore(genericPath)) - .build(); - await cache.put(settingsDocument, "app", { theme: "v1-generic" }); - - await assert.rejects( - async () => cache.addBackingStore(new SingleFileMessagePackBackingStore(settingsPath), settingsDocument), - /would move "settings" from the generic store to the store routed to settings/u, - ); - // Nothing attached: the next write still lands in the generic store, and the refused store stays untouched. - await cache.put(settingsDocument, "app", { theme: "v2-generic" }); - const keysIn = async (path: string) => - (await new SingleFileMessagePackBackingStore(path).pullAll()).map((entry) => `${entry.type}:${entry.key}`); - assert.deepEqual(await keysIn(genericPath), ["settings:app"]); - assert.equal(existsSync(settingsPath), false); -}); - -test("CultCache refuses at load a record delivered by a store that is not its home", async () => { - const settingsDocument = defineDocumentType({ type: "settings", schema: z.object({ theme: z.string() }) }); - const tempDir = await mkdtemp(join(tmpdir(), "cultcache-load-home-")); - const genericPath = join(tempDir, "generic.cc"); - const settingsPath = join(tempDir, "settings.cc"); - const writer = CultCache.builder() - .withRegistry(defineDocumentRegistry(settingsDocument)) - .withGenericStore(new SingleFileMessagePackBackingStore(genericPath)) - .build(); - await writer.put(settingsDocument, "app", { theme: "stray" }); - const genericBytes = await readFile(genericPath); - - const cache = CultCache.builder() - .withRegistry(defineDocumentRegistry(settingsDocument)) - .withBackingStore(new SingleFileMessagePackBackingStore(settingsPath), settingsDocument) - .withGenericStore(new SingleFileMessagePackBackingStore(genericPath)) - .build(); - // The cache already holds a record, in its home store, before the refused load. - await cache.put(settingsDocument, "held", { theme: "home" }); - const before = cache.snapshot(); - const settingsBytes = await readFile(settingsPath); - await assert.rejects( - cache.pullAllBackingStores(), - /"settings" record "app" was loaded from the generic store, but its home is the store routed to settings/u, - ); - assert.equal(cache.get(settingsDocument, "app"), undefined); - assert.deepEqual(cache.snapshot(), before); - assert.deepEqual(cache.getRequired(settingsDocument, "held"), { theme: "home" }); - assert.deepEqual(await readFile(genericPath), genericBytes); - assert.deepEqual(await readFile(settingsPath), settingsBytes); -}); - -async function storeState(path: string): Promise { - if (!existsSync(path)) { - return []; - } - return (await new SingleFileMessagePackBackingStore(path).pullAll()) - .map((entry) => `${entry.type}:${entry.key}:${Buffer.from(entry.payload).toString("hex")}`) - .sort(); -} - -test("putWithThrowingAccessorChangesNeitherStoreNorCache", async () => { - const itemDocument = defineDocumentType({ - type: "item", - schema: z.object({ n: z.string() }), - name: (value: { n: string }) => { - if (value.n === "bad") { - throw new Error("accessor refuses bad"); - } - return value.n; - }, - }); - const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-put-accessor-")), "generic.cc"); - const cache = CultCache.builder() - .withRegistry(defineDocumentRegistry(itemDocument)) - .withGenericStore(new SingleFileMessagePackBackingStore(storePath)) - .build(); - await cache.put(itemDocument, "k", { n: "good" }); - const storeBefore = await storeState(storePath); - const cacheBefore = cache.snapshot(); - - await assert.rejects(cache.put(itemDocument, "k", { n: "bad" }), /accessor refuses bad/u); - assert.deepEqual(await storeState(storePath), storeBefore); - assert.deepEqual(cache.snapshot(), cacheBefore); - assert.deepEqual(cache.getRequired(itemDocument, "k"), { n: "good" }); - assert.equal(cache.getKeyByName(itemDocument, "good"), "k"); -}); - -test("overwriteUnderRegisteredAccessorThatNowThrowsChangesNeitherStoreNorCache", async () => { - const itemDocument = defineDocumentType({ type: "item", schema: z.object({ c: z.string() }) }); - const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-overwrite-accessor-")), "generic.cc"); - const cache = CultCache.builder() - .withRegistry(defineDocumentRegistry(itemDocument)) - .withGenericStore(new SingleFileMessagePackBackingStore(storePath)) - .build(); - await cache.put(itemDocument, "k", { c: "old" }); - let refusing = false; - await cache.registerIndex(itemDocument, "category", (value: { c: string }) => { - if (refusing) { - throw new Error("accessor refuses now"); - } - return value.c; - }); - refusing = true; - const storeBefore = await storeState(storePath); - const cacheBefore = cache.snapshot(); - - await assert.rejects(cache.put(itemDocument, "k", { c: "new" }), /accessor refuses now/u); - assert.deepEqual(await storeState(storePath), storeBefore); - assert.deepEqual(cache.snapshot(), cacheBefore); - assert.equal(cache.getKeyByIndex(itemDocument, "category", "old"), "k"); - // Deleting runs no accessor: it uses the lookup values stored when the record was admitted. - assert.equal(await cache.delete(itemDocument, "k"), true); - assert.equal(cache.getKeyByIndex(itemDocument, "category", "old"), undefined); -}); - -test("registeringAccessorThatThrowsOnHeldRecordInstallsNothing", async () => { - const itemDocument = defineDocumentType({ type: "item", schema: z.object({ n: z.string() }) }); - const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-register-accessor-")), "generic.cc"); - const cache = CultCache.builder() - .withRegistry(defineDocumentRegistry(itemDocument)) - .withGenericStore(new SingleFileMessagePackBackingStore(storePath)) - .build(); - await cache.put(itemDocument, "old", { n: "bad" }); - await assert.rejects( - async () => cache.registerIndex(itemDocument, "i", (value: { n: string }) => { - if (value.n === "bad") { - throw new Error("index refuses bad"); - } - return value.n; - }), - /index refuses bad/u, - ); - await cache.put(itemDocument, "old", { n: "fine" }); - assert.deepEqual(cache.getRequired(itemDocument, "old"), { n: "fine" }); - assert.equal(cache.getKeyByIndex(itemDocument, "i", "fine"), undefined); -}); - -test("globalReplaceWithFailingStorePushKeepsOldGlobal", async () => { - const settingsDocument = defineDocumentType({ - type: "settings", - schema: z.object({ t: z.string() }), - global: true, - }); - const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-global-push-")), "generic.cc"); - const inner = new SingleFileMessagePackBackingStore(storePath); - let failPush = false; - const store: CacheBackingStore = { - pullAll: () => inner.pullAll(), - delete: (entry) => inner.delete(entry), - pushAll: (entries, options) => inner.pushAll(entries, options), - push: async (entry) => { - if (failPush) { - throw new Error("disk full"); - } - return inner.push(entry); - }, - }; - const cache = CultCache.builder() - .withRegistry(defineDocumentRegistry(settingsDocument)) - .withGenericStore(store) - .build(); - await cache.putGlobal(settingsDocument, { t: "base" }); - const storeBefore = await storeState(storePath); - const cacheBefore = cache.snapshot(); - failPush = true; - - await assert.rejects(cache.putGlobal(settingsDocument, { t: "new" }), /disk full/u); - await assert.rejects(cache.put(settingsDocument, "other", { t: "new" }), /must use key "__global__"/u); - assert.deepEqual(await storeState(storePath), storeBefore); - assert.deepEqual(cache.snapshot(), cacheBefore); - assert.deepEqual(cache.getRequiredGlobal(settingsDocument), { t: "base" }); -}); - -test("concurrentGlobalPutsCannotBothReachDisk", async () => { - const settingsDocument = defineDocumentType({ - type: "settings", - schema: z.object({ t: z.string() }), - global: true, - }); - const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-global-race-")), "generic.cc"); - const inner = new SingleFileMessagePackBackingStore(storePath); - // The first push after arming writes, then pauses until released. A second write that reaches the - // store during that pause is recorded: serialized writes cannot. - let armed = false; - let paused = false; - let reachedStoreWhilePaused = false; - let release!: () => void; - let reportPaused!: () => void; - const pausedSignal = new Promise((resolve) => { - reportPaused = resolve; - }); - const store: CacheBackingStore = { - pullAll: () => inner.pullAll(), - delete: (entry) => inner.delete(entry), - push: async (entry) => { - reachedStoreWhilePaused ||= paused; - await inner.push(entry); - if (armed) { - armed = false; - paused = true; - await new Promise((resolve) => { - release = resolve; - reportPaused(); - }); - paused = false; - } - }, - }; - const build = (backing: CacheBackingStore) => CultCache.builder() - .withRegistry(defineDocumentRegistry(settingsDocument)) - .withGenericStore(backing) - .build(); - const cache = build(store); - await cache.putGlobal(settingsDocument, { t: "base" }); - - armed = true; - const first = cache.putGlobal(settingsDocument, { t: "first" }); - await pausedSignal; - const second = cache.putGlobal(settingsDocument, { t: "second" }); - await new Promise((resolve) => setTimeout(resolve, 50)); - release(); - await Promise.all([first, second]); - - assert.equal(reachedStoreWhilePaused, false); - const onDisk = await inner.pullAll(); - assert.deepEqual(onDisk.map((entry) => entry.key), [CultCache.GLOBAL_KEY]); - assert.deepEqual( - Buffer.from(onDisk[0].payload), - Buffer.from(cache.getRequiredEnvelope(settingsDocument, CultCache.GLOBAL_KEY).payload), - ); - assert.deepEqual(cache.getRequiredGlobal(settingsDocument), { t: "second" }); - const reloaded = build(new SingleFileMessagePackBackingStore(storePath)); - await reloaded.pullAllBackingStores(); - assert.deepEqual(reloaded.getRequiredGlobal(settingsDocument), { t: "second" }); -}); - -test("unawaitedFailingPutIsAnUnhandledRejection", async () => { - const itemDocument = defineDocumentType({ type: "item", schema: z.object({ n: z.string() }) }); - const store: CacheBackingStore = { - pullAll: async () => [], - push: async () => { - throw new Error("disk full"); - }, - delete: async () => undefined, - }; - const cache = CultCache.builder() - .withRegistry(defineDocumentRegistry(itemDocument)) - .withGenericStore(store) - .build(); - - const runnerListeners = process.listeners("unhandledRejection"); - process.removeAllListeners("unhandledRejection"); - const unhandled: unknown[] = []; - const listener = (reason: unknown) => { - unhandled.push(reason); - }; - process.on("unhandledRejection", listener); - try { - void cache.put(itemDocument, "k", { n: "x" }); - await new Promise((resolve) => setTimeout(resolve, 50)); - } finally { - process.off("unhandledRejection", listener); - for (const runnerListener of runnerListeners) { - process.on("unhandledRejection", runnerListener); - } - } - - assert.equal(unhandled.length, 1); - assert.match(String((unhandled[0] as Error).message), /disk full/u); - // The chain survived the rejection: the next operation still runs. - await assert.rejects(cache.put(itemDocument, "k", { n: "y" }), /disk full/u); -}); - -test("storeChangeHandlerWritingBackQueuesAfterTheOperation", async () => { - const itemDocument = defineDocumentType({ type: "item", schema: z.object({ n: z.string() }) }); - const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-writeback-")), "generic.cc"); - const inner = new SingleFileMessagePackBackingStore(storePath); - const changes = new EventEmitter(); - const pushed: string[] = []; - const store: CacheBackingStore = { - pullAll: () => inner.pullAll(), - delete: (entry) => inner.delete(entry), - push: async (entry) => { - await inner.push(entry); - pushed.push(entry.key); - changes.emit("pushed", entry.key); - }, - }; - const cache = CultCache.builder() - .withRegistry(defineDocumentRegistry(itemDocument)) - .withGenericStore(store) - .build(); - // The handler writes back and the store does not wait for it: that write queues behind the put. - const writeBacks: Promise[] = []; - changes.on("pushed", (key: string) => { - if (key === "k") { - writeBacks.push(cache.put(itemDocument, "side", { n: "side" })); - } - }); - - await cache.put(itemDocument, "k", { n: "x" }); - await Promise.all(writeBacks); - - assert.equal(writeBacks.length, 1); - assert.deepEqual(pushed, ["k", "side"]); - assert.deepEqual(cache.get(itemDocument, "k"), { n: "x" }); - assert.deepEqual(cache.get(itemDocument, "side"), { n: "side" }); - assert.deepEqual( - (await inner.pullAll()).map((entry) => entry.key).sort(), - cache.snapshot().map((entry) => entry.key).sort(), - ); -}); - -test("legacyKeyGlobalLoadsWithoutWritingAndFirstWriteLeavesOneGlobal", async () => { - const settingsDocument = defineDocumentType({ - type: "settings", - schema: z.object({ t: z.string() }), - global: true, - }); - const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-legacy-global-")), "generic.cc"); - const build = () => CultCache.builder() - .withRegistry(defineDocumentRegistry(settingsDocument)) - .withGenericStore(new SingleFileMessagePackBackingStore(storePath)) - .build(); - const seed = CultCache.builder().withRegistry(defineDocumentRegistry(settingsDocument)).build(); - await seed.putGlobal(settingsDocument, { t: "old" }); - const legacy = { ...seed.getRequiredEnvelope(settingsDocument, CultCache.GLOBAL_KEY), key: "legacy" }; - await new SingleFileMessagePackBackingStore(storePath).push(legacy); - const bytes = await readFile(storePath); - - const cache = build(); - await cache.pullAllBackingStores(); - assert.deepEqual(cache.getRequiredGlobal(settingsDocument), { t: "old" }); - assert.deepEqual(cache.get(settingsDocument, CultCache.GLOBAL_KEY), { t: "old" }); - assert.deepEqual(await readFile(storePath), bytes); - - await cache.putGlobal(settingsDocument, { t: "new" }); - assert.deepEqual( - (await new SingleFileMessagePackBackingStore(storePath).pullAll()).map((entry) => entry.key), - [CultCache.GLOBAL_KEY], - ); - const reloaded = build(); - await reloaded.pullAllBackingStores(); - assert.deepEqual(reloaded.getRequiredGlobal(settingsDocument), { t: "new" }); - - // Deleting an adopted global removes the legacy record. - await new SingleFileMessagePackBackingStore(storePath).pushAll([legacy]); - const deleting = build(); - await deleting.pullAllBackingStores(); - assert.equal(await deleting.deleteGlobal(settingsDocument), true); - assert.deepEqual(await new SingleFileMessagePackBackingStore(storePath).pullAll(), []); - - // Two globals of one type on disk, under any keys, are refused. - await new SingleFileMessagePackBackingStore(storePath).pushAll([legacy, { ...legacy, key: CultCache.GLOBAL_KEY }]); - await assert.rejects(build().pullAllBackingStores(), /has multiple persisted entries/u); -}); - -test("concurrentPutAndAttachCannotLandRecordInNonHomeStore", async () => { - const settingsDocument = defineDocumentType({ type: "settings", schema: z.object({ t: z.string() }) }); - const tempDir = await mkdtemp(join(tmpdir(), "cultcache-put-attach-race-")); - const genericPath = join(tempDir, "generic.cc"); - const settingsPath = join(tempDir, "settings.cc"); - const cache = CultCache.builder() - .withRegistry(defineDocumentRegistry(settingsDocument)) - .withGenericStore(new SingleFileMessagePackBackingStore(genericPath)) - .build(); - - const pending = cache.put(settingsDocument, "app", { t: "x" }); - const attach = Promise.resolve().then(() => - cache.addBackingStore(new SingleFileMessagePackBackingStore(settingsPath), settingsDocument), - ); - await pending; - await assert.rejects(attach, /would move "settings" from the generic store/u); - - assert.deepEqual( - (await new SingleFileMessagePackBackingStore(genericPath).pullAll()).map((entry) => entry.key), - ["app"], - ); - assert.equal(existsSync(settingsPath), false); - const reloaded = CultCache.builder() - .withRegistry(defineDocumentRegistry(settingsDocument)) - .withGenericStore(new SingleFileMessagePackBackingStore(genericPath)) - .build(); - await reloaded.pullAllBackingStores(); - assert.deepEqual(reloaded.getRequired(settingsDocument, "app"), { t: "x" }); -}); - -test("pullWithDuplicateGlobalLeavesCacheUnchanged", async () => { - const itemDocument = defineDocumentType({ type: "item", schema: z.object({ name: z.string() }) }); - const settingsDocument = defineDocumentType({ - type: "settings", - schema: z.object({ theme: z.string() }), - global: true, - }); - const tempDir = await mkdtemp(join(tmpdir(), "cultcache-duplicate-global-")); - const storePath = join(tempDir, "generic.cc"); - const cache = CultCache.builder() - .withRegistry(defineDocumentRegistry(itemDocument, settingsDocument)) - .withGenericStore(new SingleFileMessagePackBackingStore(storePath)) - .build(); - await cache.put(itemDocument, "potion", { name: "Potion" }); - await cache.putGlobal(settingsDocument, { theme: "ash" }); - const before = cache.snapshot(); - - // A second record of the global type lands in the store file behind the cache's back. - const stray = cache.getRequiredEnvelope(settingsDocument, CultCache.GLOBAL_KEY); - await new SingleFileMessagePackBackingStore(storePath).push({ ...stray, key: "stray" }); - - await assert.rejects(cache.pullAllBackingStores(), /has multiple persisted entries/u); - assert.deepEqual(cache.snapshot(), before); - assert.deepEqual(cache.getRequired(itemDocument, "potion"), { name: "Potion" }); - assert.deepEqual(cache.getRequiredGlobal(settingsDocument), { theme: "ash" }); -}); - -test("pullWithThrowingIndexAccessorLeavesCacheUnchanged", async () => { - const itemDocument = defineDocumentType({ - type: "item", - schema: z.object({ name: z.string(), category: z.string() }), - name: "name", - indexes: { - category: (item: { name: string; category: string }) => { - if (item.name === "Bomb") { - throw new Error("category accessor refuses Bomb"); - } - return item.category; - }, - }, - }); - const settingsDocument = defineDocumentType({ - type: "settings", - schema: z.object({ theme: z.string() }), - global: true, - }); - const tempDir = await mkdtemp(join(tmpdir(), "cultcache-throwing-accessor-")); - const storePath = join(tempDir, "generic.cc"); - const cache = CultCache.builder() - .withRegistry(defineDocumentRegistry(itemDocument, settingsDocument)) - .withGenericStore(new SingleFileMessagePackBackingStore(storePath)) - .build(); - await cache.put(itemDocument, "potion", { name: "Potion", category: "consumable" }); - await cache.putGlobal(settingsDocument, { theme: "ash" }); - const before = cache.snapshot(); - - // A record whose index accessor throws lands in the store file behind the cache's back. - const potion = cache.getRequiredEnvelope(itemDocument, "potion"); - await new SingleFileMessagePackBackingStore(storePath).push({ - ...potion, - key: "bomb", - payload: encode({ name: "Bomb", category: "explosive" }), - }); - - await assert.rejects(cache.pullAllBackingStores(), /category accessor refuses Bomb/u); - assert.deepEqual(cache.snapshot(), before); - assert.deepEqual(cache.getRequired(itemDocument, "potion"), { name: "Potion", category: "consumable" }); - assert.equal(cache.get(itemDocument, "bomb"), undefined); - assert.equal(cache.getKeyByName(itemDocument, "Potion"), "potion"); - assert.equal(cache.getKeyByName(itemDocument, "Bomb"), undefined); - assert.equal(cache.getKeyByIndex(itemDocument, "category", "consumable"), "potion"); - assert.deepEqual(cache.getRequiredGlobal(settingsDocument), { theme: "ash" }); -}); - -test("CultCache with zero stores writes and deletes in memory", async () => { - const settingsDocument = defineDocumentType({ type: "settings", schema: z.object({ theme: z.string() }) }); - const cache = CultCache.builder().withRegistry(defineDocumentRegistry(settingsDocument)).build(); - await cache.put(settingsDocument, "x", { theme: "mem" }); - assert.deepEqual(cache.getRequired(settingsDocument, "x"), { theme: "mem" }); - assert.equal(await cache.delete(settingsDocument, "x"), true); - assert.equal(cache.get(settingsDocument, "x"), undefined); -}); - test("CultCache v1 MessagePack stores are readable across TS, Rust, C#, and Python", async () => { await buildInteropPeers(); const tempDir = await mkdtemp(join(tmpdir(), "cultcache-interop-")); @@ -1145,32 +612,6 @@ test("CultCache v1 MessagePack stores are readable across TS, Rust, C#, and Pyth assert.ok(read.tags.includes("interop")); } } - - // C# write-routed attaches one home store per type: each file is a complete v1 snapshot holding only its own records. - const catalogFile = join(tempDir, "catalog.cc"); - const runFile = join(tempDir, "run.cc"); - const routed = await runJsonCommand("csharp-write-routed", dotnetCommand, [ - csharpInteropDll, - "write-routed", - catalogFile, - runFile, - ], cultLibRoot); - const recordsIn = async (file: string) => { - const decoded = decode(await readFile(file)) as unknown[]; - assert.equal(decoded[0], "cultcache.store.v1"); - assert.ok(Array.isArray(decoded[1]), `${file} has no schema catalog`); - return (decoded[2] as unknown[][]).map((record) => ({ schemaId: record[1], key: record[0] })); - }; - const catalogRecords = await recordsIn(catalogFile); - const runRecords = await recordsIn(runFile); - assert.deepEqual(catalogRecords.map((record) => record.key), ["note:csharp-routed"]); - assert.deepEqual(runRecords.map((record) => record.key), ["run-note:csharp-routed"]); - assert.notEqual(catalogRecords[0]?.schemaId, runRecords[0]?.schemaId); - for (const reader of readers) { - const read = await reader.read(catalogFile); - assert.equal(read.documentId, routed.documentId, `${reader.name} failed to read the routed catalog store`); - assert.equal(read.body, "One home store per document type."); - } }); test("CultCache interop reader accepts missing compatible trailing slots and rejects mismatched slots", async () => { diff --git a/packages/cultmath/.gitignore b/packages/cultmath/.gitignore deleted file mode 100644 index c9b141d0..00000000 --- a/packages/cultmath/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -bin/ -obj/ -**/bin/ -**/obj/ -target/ -**/target/ -/native/cultmath-core/target.meta -.tools/ -/artifacts/ diff --git a/packages/cultmath/README.md b/packages/cultmath/README.md deleted file mode 100644 index 0be46738..00000000 --- a/packages/cultmath/README.md +++ /dev/null @@ -1,136 +0,0 @@ -# CultMath - -CultMath is the project name. Its clean .NET package identity is -`GameCult.Math`, owned and released from the CultLib monorepo. - -Clean-room math shaped for people who think in HLSL. - -CultMath provides lowercase vector types, shader-style intrinsics, and native -hot-path kernels so math-heavy renderer code can move between C#, Rust, and HLSL -without turning every line into a translation exercise. It is not a -Unity.Mathematics clone. It is a small, Aquarium-born library built from public -shader semantics. - -## Current Surface - -- `float2`, `float3`, `float4` -- component-wise arithmetic -- scalar-to-vector splats -- every HLSL swizzle (`xyzw` and `rgba`, repeats readable, non-repeating - masks writable) and every HLSL mixed constructor, generated by - `tools/generate-swizzles.ps1` -- conversions to and from `System.Numerics` -- `Random`, a compact deterministic XORShift stream with scalar and vector draws -- `BatchMath` SoA kernels over `Span` / `ReadOnlySpan` using - `System.Numerics.Vector` when available, including radial falloff - 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`, 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, 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. - -## Native Core - -The canonical hot-path body lives in `native/cultmath-core`. It exports a C ABI -for batch kernels such as `cultmath_apollonian_voronoi_tones`. - -Runtime wrappers own syntax and integration. Rust owns optimized primitive -kernel behavior and parity fixtures. - -## Example - -```csharp -using CultMath; - -float3 normal = math.normalize(new float3(0.25f, 1.0f, -0.1f)); -float rim = math.saturate(1.0f - math.dot(normal, new float3(0.0f, 0.0f, -1.0f))); -float glow = math.smoothstep(0.2f, 1.0f, rim); -float grain = math.value_noise_bicubic(math.float2(12.0f, 3.5f)); -``` - -```hlsl -#include "CultMath.hlsl" - -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)); -``` - -## Provenance - -The small spline, damping, and pursuit helpers were promoted from Aetheria's -local `AetheriaMath` surface. The value-noise family follows public shader -literature from Inigo Quilez's noise and mini-spline articles, expressed in -CultMath names with tests instead of copied project-local helper drift. - -## Build - -```powershell -dotnet build packages/cultmath/src/CultMath/CultMath.csproj -dotnet test packages/cultmath/tests/CultMath.Tests/CultMath.Tests.csproj -cargo test --manifest-path packages/cultmath/native/cultmath-core/Cargo.toml -cargo build --release --manifest-path packages/cultmath/native/cultmath-core/Cargo.toml -.\packages\cultmath\scripts\build-unity-package.ps1 -``` - -## Unity package - -CultMath's Unity 2021.3 surface is the tracked precompiled package at -`packages/cultmath/unity/org.gamecult.cultmath`. Unity consumes `CultMath.dll` and the numeric -`CultMath.hlsl`; it does not compile the repository's current-language C# source -tree and does not require a consumer `csc.rsp` workaround. - -```json -"org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath" -``` - -The repository root is not a Unity package. Build the tracked package with -`packages/cultmath/scripts/build-unity-package.ps1`; the script fresh-builds the netstandard2.1 -assembly and rejects stale binaries, missing metadata, unexpected assemblies, -or leaked C# source. - -## Shader Tooling - -CultMath keeps portable DXC outside git under `packages/cultmath/.tools/`: - -```powershell -.\packages\cultmath\tools\get-dxc.ps1 -.\packages\cultmath\tools\get-spirv-cross.ps1 -.\packages\cultmath\tools\compile-hlsl-spirv.ps1 ` - -ShaderPath E:\Projects\Odin\crates\muninn-move-tracker\shaders\MoveSphereCandidate.comp.hlsl ` - -OutputPath E:\Projects\Odin\crates\muninn-move-tracker\artifacts\shader\MoveSphereCandidate.comp.spv ` - -IncludePath E:\Projects\CultLib\packages\cultmath\shaders -.\packages\cultmath\tools\compile-spirv-metal.ps1 ` - -SpirvPath E:\Projects\Odin\crates\muninn-move-tracker\artifacts\shader\MoveSphereCandidate.comp.spv ` - -OutputPath E:\Projects\Odin\crates\muninn-move-tracker\artifacts\shader\MoveSphereCandidate.comp.metal -``` - -DXC emits Vulkan SPIR-V with `-spirv`. `-TargetEnv vulkan1.2` is exposed by the -script, but the current Windows DXC release compiled this shader successfully -with DXC's default Vulkan target while rejecting the dotted target-env value. -The SPIR-V script applies default Vulkan register shifts for cbuffers, textures, -UAVs, and samplers so cross-compiled Metal buffers do not alias by resource -class. SPIRV-Cross emits Metal Shading Language from SPIR-V on Windows; building -an iPad-ready `.metallib` still belongs to Apple's Metal compiler on an Apple -toolchain. - -## Doctrine - -The goal is mechanical sympathy between CPU-side authoring, native kernels, and -GPU-side shader math. If an intrinsic exists here, it should behave like the -shader concept unless a runtime's numeric rules make that impossible. Weird -deviations get tests and documentation, not folklore. - -CultMath is still not a shader generator. Projects should include -`shaders/CultMath.hlsl` directly or add that directory to their shader include -path. If a shared operation needs CPU/GPU parity, put the primitive here and -test it here before copying local math helpers into a renderer or daemon. diff --git a/packages/cultmath/README.md.meta b/packages/cultmath/README.md.meta deleted file mode 100644 index 4411da72..00000000 --- a/packages/cultmath/README.md.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 867daebafe0ce48479cf043461ee6bdf -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/THIRD-PARTY-NOTICES.md b/packages/cultmath/THIRD-PARTY-NOTICES.md deleted file mode 100644 index 06ebb6c3..00000000 --- a/packages/cultmath/THIRD-PARTY-NOTICES.md +++ /dev/null @@ -1,11 +0,0 @@ -# Third-Party Notices - -## 2D and 3D Simplex Noise - -`math.snoise` and `cultmath_snoise` port the textureless 2D and 3D -simplex-noise functions by Ian McEwan and Ashima Arts, as distributed by Stefan -Gustavson's `webgl-noise` project and Unity Mathematics. - -- Copyright 2011 Ashima Arts. -- Upstream: -- License: MIT. diff --git a/packages/cultmath/THIRD-PARTY-NOTICES.md.meta b/packages/cultmath/THIRD-PARTY-NOTICES.md.meta deleted file mode 100644 index 4b43ddaa..00000000 --- a/packages/cultmath/THIRD-PARTY-NOTICES.md.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 7c28c8631c7d4054eb751d7ce952df33 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/docs.meta b/packages/cultmath/docs.meta deleted file mode 100644 index ee5fab29..00000000 --- a/packages/cultmath/docs.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: de535b89253410b41b5989f712b80af4 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/docs/design.md b/packages/cultmath/docs/design.md deleted file mode 100644 index de09ee26..00000000 --- a/packages/cultmath/docs/design.md +++ /dev/null @@ -1,143 +0,0 @@ -# Design - -CultMath exists to make renderer math readable in every place it needs to live: -Rust hot kernels, C# host code, and HLSL shader code. - -## Parity Target: HLSL - -HLSL is the semantic authority for every type and intrinsic HLSL defines. The -goal is source compatibility: HLSL-shaped math compiles as C# under -`using static CultMath.math;`. C# forces float literal suffixes (`0.5f`); the -rest of the spelling should survive the move. - -That makes these HLSL rules, not options: - -- Every swizzle exists. `float2/3/4`, `int2/3/4`, and `bool2/3/4` expose every - read swizzle of length 2 to 4, repeats included (`v.wwxy`), in both `xyzw` and - `rgba` naming (never mixed), plus single-component `r/g/b/a`. `double2/3` get - lengths 2 and 3 only, because there is no `double4`. Components and swizzles are - writable: `v.xz = f2;`, `c.rgb *= 0.5f;`, and `v[1] = s;` work. Setters exist - only for non-repeating masks, because HLSL rejects `v.xx = ...`. -- Every HLSL mixed constructor exists, as a struct constructor and as a `math` - function: `float4(float2, float2)`, `float4(float, float2, float)`, - `float3(float, float2)`, the copy form `float3(float3)`, and the rest, for the - float, int, and bool families (double gets constructors only; a `math.double3` - function would hide the `double3` type name). -- Swizzles and mixed constructors are generated. `tools/generate-swizzles.ps1` - writes the checked-in `src/CultMath/Swizzles.g.cs` (partial structs plus a - partial `math`). Edit the generator, rerun it, and commit both; never edit the - output by hand. -- Comparison operators (`<`, `>`, `<=`, `>=`, `==`, `!=`) are component-wise and - return `bool2`/`bool3`/`bool4`. Reduce them with `any` and `all`. - `Equals`/`GetHashCode` stay value-based so vectors still work as dictionary - keys. -- `select(condition, whenTrue, whenFalse)` uses HLSL's argument order. -- Matrices are row-major: constructors take rows, `m[i]` returns a reference to - row i (so `m[1][2] = s;` writes one element), `_mRC` is row R column C, and - products go through `mul` (`mul(m, v)` treats `v` as a column, `mul(v, m)` as a - row). `*` on matrices would be component-wise in HLSL, so CultMath does not - define it. -- `m[r][c] = v` writes through locals, array elements, fields of classes, and - ref locals. Through a readonly field, an `in` parameter, a property - (auto-properties included), or a static readonly value such as - `float3x3.identity`, C# takes a defensive copy: the write compiles and is - silently lost. Copy the matrix to a local, write to it, and assign it back. -- Scalar/vector arithmetic and comparisons have explicit overloads on both - sides rather than leaning on implicit scalar splat. -- Intrinsics return HLSL's types and edge cases: `sign` returns `int`/`intN` - (NaN gives 0); `step(y, x)` is `x < y ? 0 : 1` (NaN gives 1), which is how dxc - lowers it, in C# and in the HLSL mirror; float and double `min` and `max`, and - float `clamp` and `saturate`, follow the DXIL `FMin`/`FMax`/`Saturate` operations dxc emits for - them: `min(a, b)` is `a < b ? a : b`, `max(a, b)` is `a >= b ? a : b`, a NaN - 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. - -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 -GPUs are not guaranteed. CultMath defines the CPU result by dxc's lowering and -the DXIL operation spec (DirectXShaderCompiler `docs/DXIL.rst`). - -NaN does not reliably propagate through CultMath, just as it does not on the -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; 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`, -and HLSL's one-based `_11`..`_33` element names. - -### HLSL Target: Source Transformations - -`HlslSourceCompatibilityTests` compiles the real `shaders/CultMath.hlsl` with -Roslyn against CultMath. These transformations are the complete list; if a body -needs anything else, the test fails: - -1. Include guards (`#ifndef`/`#define`/`#endif`) are dropped. -2. Functions taking `Texture2D`/`SamplerState` are dropped; GPU resources have - no CultMath analog. -3. File-scope `static const` becomes `const`. -4. Floating literals gain `f` (`0.5` becomes `0.5f`). -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: -`const` locals of vector type (C# `const` is primitives only; write a plain -local) and writes through a swizzle component (`v.xy.x = s`, which C# rejects as -modifying a temporary). - -The HLSL side is checked too: `tools/get-dxc.ps1` fetches Microsoft's -DirectXShaderCompiler, and -`dxc -T lib_6_3 -HV 2021 shaders/CultMath.hlsl` must compile cleanly. - -The mirror test compares bit patterns (any NaN equals any NaN; -0 differs from -0). It proves the text of `CultMath.hlsl` computes the same float32 results as -C# `math` on the CPU, not that a GPU agrees: driver `sin` precision alone makes -`cultmath_hash` and value noise differ bit for bit on hardware. The mirror is -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. - -Where HLSL is silent, CultMath keeps its own decisions and does not defer to -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. - -Known C# friction: under `using static CultMath.math;` the constructor -functions (`float3(...)`, `float3x3(...)`) hide the type names in member access, -so static members need a qualified type (`CultMath.float3x3.Euler(...)`). - -## Rules - -- Keep type names HLSL-shaped: `float2`, `float3`, `float4`, `float3x3`, and `math`. -- Keep Rust as the owner of native hot-path kernels and parity fixtures. -- Keep C#, shader, and scripting surfaces as runtime wrappers, not competing - math authorities. -- Keep `shaders/CultMath.hlsl` as the canonical shader mirror for shared - CultMath functions HLSL does not already provide (HLSL intrinsics such as - `mul`, `any`, and `log` need no mirror). Project-local shader helpers should - move here once more than one organ needs them or once CPU/GPU parity matters. -- Implement public shader semantics directly; do not copy Unity.Mathematics. -- Prefer plain mutable value types with public component fields, as HLSL does. -- Prefer component-wise overloads over clever generic machinery until the API - earns that complexity. -- Keep the surface boring and exact. Fill what a consumer uses, guided by HLSL - semantics; noise primitives, matrices, quaternions, and packing helpers need an - owner before they enter. -- Keep the core assembly engine-free. Unity conversions live in the Unity - package's `CultMath.UnityBridge` assembly. - -## Non-Goals - -- Compiling C# to shaders. -- Generating HLSL from Rust or Rust from HLSL. -- Replacing `System.Numerics` for general .NET work. -- Recreating every Unity.Mathematics type because a spreadsheet somewhere says - “coverage.” That way lies decorative obesity. diff --git a/packages/cultmath/docs/design.md.meta b/packages/cultmath/docs/design.md.meta deleted file mode 100644 index 0d31ca6a..00000000 --- a/packages/cultmath/docs/design.md.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 07021a8be816e3448a9490c10845b0aa -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/native.meta b/packages/cultmath/native.meta deleted file mode 100644 index f4135336..00000000 --- a/packages/cultmath/native.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 9d1cb2ac88c530b4d9da456523115e12 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/native/cultmath-core.meta b/packages/cultmath/native/cultmath-core.meta deleted file mode 100644 index 48a2ef76..00000000 --- a/packages/cultmath/native/cultmath-core.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: a42d65478346c354c8b4f353c5581b49 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/native/cultmath-core/Cargo.lock b/packages/cultmath/native/cultmath-core/Cargo.lock deleted file mode 100644 index 09598b97..00000000 --- a/packages/cultmath/native/cultmath-core/Cargo.lock +++ /dev/null @@ -1,7 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "cultmath-core" -version = "0.1.0" diff --git a/packages/cultmath/native/cultmath-core/Cargo.lock.meta b/packages/cultmath/native/cultmath-core/Cargo.lock.meta deleted file mode 100644 index 9ca3ec60..00000000 --- a/packages/cultmath/native/cultmath-core/Cargo.lock.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: f1c365f7eead2684ba621124e653bdab -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/native/cultmath-core/Cargo.toml b/packages/cultmath/native/cultmath-core/Cargo.toml deleted file mode 100644 index cf7819a0..00000000 --- a/packages/cultmath/native/cultmath-core/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "cultmath-core" -version = "0.1.0" -edition = "2021" - -[lib] -name = "cultmath_core" -crate-type = ["cdylib", "staticlib"] - -[profile.release] -lto = "thin" -codegen-units = 1 -panic = "abort" diff --git a/packages/cultmath/native/cultmath-core/Cargo.toml.meta b/packages/cultmath/native/cultmath-core/Cargo.toml.meta deleted file mode 100644 index 8875de09..00000000 --- a/packages/cultmath/native/cultmath-core/Cargo.toml.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: cdd2d4eab053b764b9a06d36b080e43e -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/native/cultmath-core/src.meta b/packages/cultmath/native/cultmath-core/src.meta deleted file mode 100644 index 8e5232db..00000000 --- a/packages/cultmath/native/cultmath-core/src.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 7de448b7e258d2c448bb7391051cc628 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/native/cultmath-core/src/lib.rs b/packages/cultmath/native/cultmath-core/src/lib.rs deleted file mode 100644 index 46e7ced6..00000000 --- a/packages/cultmath/native/cultmath-core/src/lib.rs +++ /dev/null @@ -1,213 +0,0 @@ -#[repr(C)] -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct CultMathColor32 { - pub r: u8, - pub g: u8, - pub b: u8, - pub a: u8, -} - -#[no_mangle] -pub unsafe extern "C" fn cultmath_apollonian_voronoi_tones( - xs: *const f32, - ys: *const f32, - tones: *const u8, - spans: *const f32, - count: usize, - resolution_y: f32, - frame_index: i32, - out_colors: *mut CultMathColor32, -) -> i32 { - if xs.is_null() || ys.is_null() || tones.is_null() || spans.is_null() || out_colors.is_null() { - return -1; - } - - let xs = unsafe { std::slice::from_raw_parts(xs, count) }; - let ys = unsafe { std::slice::from_raw_parts(ys, count) }; - let tones = unsafe { std::slice::from_raw_parts(tones, count) }; - let spans = unsafe { std::slice::from_raw_parts(spans, count) }; - let out_colors = unsafe { std::slice::from_raw_parts_mut(out_colors, count) }; - for index in 0..count { - out_colors[index] = tone_color( - xs[index], - ys[index], - resolution_y, - frame_index, - tones[index], - spans[index], - ); - } - - 0 -} - -fn tone_color(px: f32, py: f32, resolution_y: f32, frame_index: i32, tone: u8, span: f32) -> CultMathColor32 { - let sample = stable_sample(px, py, resolution_y, frame_index, tone, span); - let luminance = saturate(sample.0 * 0.299 + sample.1 * 0.587 + sample.2 * 0.114); - match tone { - 3 => { - let glow = 0.62 + luminance * 0.38; - color(255.0 * glow, 96.0 * glow, 14.0 * glow) - } - 4 => { - let glow = 0.68 + luminance * 0.32; - color(245.0 * glow, 248.0 * glow, 252.0 * glow) - } - _ => { - let (gain, lift) = match tone { - 1 => (0.42, 14.0), - 2 => (1.05, 24.0), - _ => (0.24, 4.0), - }; - color( - lift + sample.0 * 255.0 * gain, - lift + sample.1 * 255.0 * gain, - lift + sample.2 * 255.0 * gain, - ) - } - } -} - -fn stable_sample(px: f32, py: f32, resolution_y: f32, frame_index: i32, tone: u8, span: f32) -> (f32, f32, f32) { - let seed = px * 12.9898 + py * 78.233 + hash1((tone as f32 + 1.0) * 19.19); - let jitter_x = (hash1(seed) - 0.5) * span; - let jitter_y = (hash1(seed + 37.17) - 0.5) * span; - voronoi(px + jitter_x, py + jitter_y, resolution_y, frame_index) -} - -fn voronoi(px: f32, py: f32, resolution_y: f32, frame_index: i32) -> (f32, f32, f32) { - let time = frame_index as f32 / 120.0; - let scale = 6.0; - let x = scale * px / resolution_y.max(1.0); - let y = scale * py / resolution_y.max(1.0); - let nx = x.floor(); - let ny = y.floor(); - let fx = frac(x); - let fy = frac(y); - let mut distance = 8.0; - let mut red = 0.0; - let mut green = 0.0; - let mut blue = 0.0; - let smoothness = 0.005; - - for j in -2..=2 { - for i in -2..=2 { - let gx = i as f32; - let gy = j as f32; - let (mut ox, mut oy) = hash2(nx + gx, ny + gy); - let weight = ox * 0.5 + 0.5; - ox = 0.5 + 0.5 * (time + 6.2831 * ox).sin(); - oy = 0.5 + 0.5 * (time + 6.2831 * oy).sin(); - let dx = (gx - fx + ox).abs(); - let dy = (gy - fy + oy).abs(); - let d = dx.max(dy) * weight; - let seed = hash1((nx + gx) * 7.0 + (ny + gy) * 113.0); - let hue = frac((nx + gx) * 0.173 + (ny + gy) * 0.379 + seed * 0.431); - let (candidate_red, candidate_green, candidate_blue) = pastel_spectrum(hue); - let h = smoothstep(0.0, 1.0, 0.5 + 0.5 * (distance - d) / smoothness); - let correction = h * (1.0 - h) * smoothness / (1.0 + 3.0 * smoothness); - distance = lerp(distance, d, h) - correction; - red = lerp(red, candidate_red, h) - correction; - green = lerp(green, candidate_green, h) - correction; - blue = lerp(blue, candidate_blue, h) - correction; - } - } - - let edge_dimming = 1.0 - 0.1 * smoothstep(0.04, 0.05, distance); - ( - (red * edge_dimming).max(0.0), - (green * edge_dimming).max(0.0), - (blue * edge_dimming).max(0.0), - ) -} - -fn hash1(value: f32) -> f32 { - frac(value.sin() * 43758.5453) -} - -fn hash2(x: f32, y: f32) -> (f32, f32) { - ( - frac((x * 127.1 + y * 311.7).sin() * 43758.5453), - frac((x * 269.5 + y * 183.3).sin() * 43758.5453), - ) -} - -fn frac(value: f32) -> f32 { - value - value.floor() -} - -fn saturate(value: f32) -> f32 { - value.clamp(0.0, 1.0) -} - -fn smoothstep(edge0: f32, edge1: f32, value: f32) -> f32 { - let t = saturate((value - edge0) / (edge1 - edge0)); - t * t * (3.0 - 2.0 * t) -} - -fn lerp(start: f32, end: f32, amount: f32) -> f32 { - start + (end - start) * amount -} - -fn pastel_spectrum(hue: f32) -> (f32, f32, f32) { - hsv_to_rgb(hue, 0.52, 0.98) -} - -fn hsv_to_rgb(hue: f32, saturation: f32, value: f32) -> (f32, f32, f32) { - let h = frac(hue) * 6.0; - let c = value * saturation; - let x = c * (1.0 - ((h % 2.0) - 1.0).abs()); - let m = value - c; - let (r, g, b) = if h < 1.0 { - (c, x, 0.0) - } else if h < 2.0 { - (x, c, 0.0) - } else if h < 3.0 { - (0.0, c, x) - } else if h < 4.0 { - (0.0, x, c) - } else if h < 5.0 { - (x, 0.0, c) - } else { - (c, 0.0, x) - }; - - (r + m, g + m, b + m) -} - -fn color(r: f32, g: f32, b: f32) -> CultMathColor32 { - CultMathColor32 { - r: r.clamp(0.0, 255.0) as u8, - g: g.clamp(0.0, 255.0) as u8, - b: b.clamp(0.0, 255.0) as u8, - a: 255, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn batch_sampler_writes_colors() { - let xs = [0.0, 20.0, 100.0]; - let ys = [0.0, 30.0, 140.0]; - let tones = [0, 3, 4]; - let spans = [1920.0, 8.0, 8.0]; - let mut out = [CultMathColor32::default(); 3]; - let rc = unsafe { - cultmath_apollonian_voronoi_tones( - xs.as_ptr(), - ys.as_ptr(), - tones.as_ptr(), - spans.as_ptr(), - xs.len(), - 1080.0, - 12, - out.as_mut_ptr(), - ) - }; - assert_eq!(rc, 0); - assert!(out.iter().any(|color| color.r != 0 || color.g != 0 || color.b != 0)); - } -} diff --git a/packages/cultmath/native/cultmath-core/src/lib.rs.meta b/packages/cultmath/native/cultmath-core/src/lib.rs.meta deleted file mode 100644 index 2fa8aee8..00000000 --- a/packages/cultmath/native/cultmath-core/src/lib.rs.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: b06c69c432a633f4584830d6b28d4473 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/scripts.meta b/packages/cultmath/scripts.meta deleted file mode 100644 index 6f03c566..00000000 --- a/packages/cultmath/scripts.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 0ddff5b782cf14e43ae5cda048e040d8 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/scripts/build-unity-package.ps1 b/packages/cultmath/scripts/build-unity-package.ps1 deleted file mode 100644 index b64e9446..00000000 --- a/packages/cultmath/scripts/build-unity-package.ps1 +++ /dev/null @@ -1,159 +0,0 @@ -param( - [string] $Configuration = "Release", - [string] $OutputDirectory = "artifacts\unity\org.gamecult.cultmath" -) - -$ErrorActionPreference = "Stop" - -$repoRoot = Split-Path -Parent $PSScriptRoot -$projectPath = Join-Path $repoRoot "src\CultMath\CultMath.csproj" -$templateRoot = Join-Path $repoRoot "unity\org.gamecult.cultmath" -$buildRoot = Join-Path $repoRoot "artifacts\cultmath-unity-build" -$outputRoot = if ([System.IO.Path]::IsPathRooted($OutputDirectory)) { - $OutputDirectory -} else { - Join-Path $repoRoot $OutputDirectory -} -$freshAssembly = Join-Path $buildRoot "CultMath.dll" -$freshSymbols = Join-Path $buildRoot "CultMath.pdb" -$trackedPluginRoot = Join-Path $templateRoot "Runtime\Plugins" -$trackedAssembly = Join-Path $trackedPluginRoot "CultMath.dll" -$trackedSymbols = Join-Path $trackedPluginRoot "CultMath.pdb" - -if (Test-Path -LiteralPath $buildRoot) { - Remove-Item -LiteralPath $buildRoot -Recurse -Force -} -if (Test-Path -LiteralPath $outputRoot) { - Remove-Item -LiteralPath $outputRoot -Recurse -Force -} - -# 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 ` - --disable-build-servers -p:UseSharedCompilation=false -p:ContinuousIntegrationBuild=true -p:EnableSourceLink=false -m:1 -if ($LASTEXITCODE -ne 0) { - throw "CultMath Unity assembly build failed with exit code $LASTEXITCODE" -} - -foreach ($path in @($freshAssembly, $freshSymbols)) { - if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { - throw "CultMath Unity build is missing $path" - } -} - -$requiredTemplateFiles = @( - "LICENSE.md", - "LICENSE.md.meta", - "README.md", - "README.md.meta", - "THIRD-PARTY-NOTICES.md", - "THIRD-PARTY-NOTICES.md.meta", - "package.json", - "package.json.meta", - "Runtime\Plugins\CultMath.dll", - "Runtime\Plugins\CultMath.dll.meta", - "Runtime\Plugins\CultMath.pdb", - "Runtime\Plugins\CultMath.pdb.meta", - "Runtime\UnityBridge\CultMath.UnityBridge.asmdef", - "Runtime\UnityBridge\CultMath.UnityBridge.asmdef.meta", - "Runtime\UnityBridge\UnityConversions.cs", - "Runtime\UnityBridge\UnityConversions.cs.meta", - "Shaders\CultMath.hlsl", - "Shaders\CultMath.hlsl.meta" -) -foreach ($relativePath in $requiredTemplateFiles) { - if (-not (Test-Path -LiteralPath (Join-Path $templateRoot $relativePath) -PathType Leaf)) { - throw "Tracked CultMath Unity package is incomplete: missing $relativePath" - } -} - -$unityAssetFiles = @(Get-ChildItem -LiteralPath $templateRoot -Recurse -File | Where-Object { - $_.Extension -in @(".asmdef", ".cs", ".dll", ".pdb", ".hlsl") -}) -$missingAssetMetas = @($unityAssetFiles | Where-Object { -not (Test-Path -LiteralPath ($_.FullName + ".meta")) }) -$missingDirectoryMetas = @(Get-ChildItem -LiteralPath $templateRoot -Recurse -Directory | Where-Object { - -not (Test-Path -LiteralPath ($_.FullName + ".meta")) -}) -if ($missingAssetMetas.Count -ne 0 -or $missingDirectoryMetas.Count -ne 0) { - $missing = @($missingAssetMetas.FullName) + @($missingDirectoryMetas.FullName) - throw "Tracked CultMath Unity package has assets without stable .meta files: $($missing -join ', ')" -} - -$manifest = Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json -$pluginMeta = Get-Content -LiteralPath ($trackedAssembly + ".meta") -Raw -if ($pluginMeta -notmatch '(?m)^\s*isExplicitlyReferenced:\s*0\s*$') { - throw "CultMath.dll must be auto-referenced; explicit-only plugins do not reach dependent Unity asmdefs" -} -# The precompiled DLL is the only authority for CultMath's numeric types. The one -# compiled assembly allowed beside it is the UnityEngine conversion bridge, which -# references that DLL and must not re-declare core sources. -$bridgeRoot = Join-Path $templateRoot "Runtime\UnityBridge" -$bridgeDefinition = Join-Path $bridgeRoot "CultMath.UnityBridge.asmdef" -$definitions = @(Get-ChildItem -LiteralPath $templateRoot -Recurse -File -Filter "*.asmdef") -if ($definitions.Count -ne 1 -or $definitions[0].FullName -ne $bridgeDefinition) { - throw "CultMath's Unity package may define only the Runtime\UnityBridge assembly; found: $($definitions.FullName -join ', ')" -} -$bridge = Get-Content -LiteralPath $bridgeDefinition -Raw | ConvertFrom-Json -if ($bridge.noEngineReferences -or -not $bridge.overrideReferences -or @($bridge.precompiledReferences) -notcontains "CultMath.dll") { - throw "CultMath.UnityBridge must reference UnityEngine and the precompiled CultMath.dll" -} -$expectedFileVersion = "$($manifest.version).0" -$trackedFileVersion = [Diagnostics.FileVersionInfo]::GetVersionInfo($trackedAssembly).FileVersion -$freshFileVersion = [Diagnostics.FileVersionInfo]::GetVersionInfo($freshAssembly).FileVersion -if ($trackedFileVersion -ne $expectedFileVersion -or $freshFileVersion -ne $expectedFileVersion) { - throw "CultMath Unity assemblies must match package version $expectedFileVersion; tracked=$trackedFileVersion fresh=$freshFileVersion" -} - -foreach ($pair in @(@($freshAssembly, $trackedAssembly), @($freshSymbols, $trackedSymbols))) { - $freshHash = (Get-FileHash -LiteralPath $pair[0] -Algorithm SHA256).Hash - $trackedHash = (Get-FileHash -LiteralPath $pair[1] -Algorithm SHA256).Hash - if ($freshHash -ne $trackedHash) { - throw "Tracked Unity artifact is stale: $($pair[1]) differs from a fresh deterministic build" - } -} - -$trackedSourceFiles = @(Get-ChildItem -LiteralPath $templateRoot -Recurse -File -Filter "*.cs") -$straySources = @($trackedSourceFiles | Where-Object { $_.DirectoryName -ne $bridgeRoot }) -if ($trackedSourceFiles.Count -eq 0 -or $straySources.Count -ne 0) { - throw "CultMath Unity package may compile only bridge sources under Runtime\UnityBridge: $($straySources.FullName -join ', ')" -} - -# Check what the bridge declares, not what its files are called: it may declare types only in -# CultMath.UnityBridge and never a type name the core CultMath sources declare. -$declarationPattern = '\b(?:struct|class|enum|interface|record|delegate\s+[\w<>.,\s]+?)\s+(\w+)' -function Get-CodeWithoutComments([string]$path) { - $text = Get-Content -LiteralPath $path -Raw - return [regex]::Replace($text, '(?s)/\*.*?\*/|//[^\r\n]*', '') -} -$coreTypeNames = @(Get-ChildItem -LiteralPath (Join-Path $repoRoot "src\CultMath") -File -Filter "*.cs" | ForEach-Object { - [regex]::Matches((Get-CodeWithoutComments $_.FullName), $declarationPattern) | ForEach-Object { $_.Groups[1].Value } -} | Sort-Object -Unique) -if ($coreTypeNames -notcontains "float3" -or $coreTypeNames -notcontains "math") { - throw "Could not read CultMath core type declarations; the bridge check would be vacuous" -} -foreach ($source in $trackedSourceFiles) { - $code = Get-CodeWithoutComments $source.FullName - $redeclared = @([regex]::Matches($code, $declarationPattern) | ForEach-Object { $_.Groups[1].Value } | Where-Object { $coreTypeNames -contains $_ }) - $foreignNamespaces = @([regex]::Matches($code, '\bnamespace\s+([\w.]+)') | ForEach-Object { $_.Groups[1].Value } | Where-Object { $_ -ne "CultMath.UnityBridge" }) - if ($redeclared.Count -ne 0 -or $foreignNamespaces.Count -ne 0) { - throw "CultMath.UnityBridge source $($source.Name) re-declares core types [$($redeclared -join ', ')] or uses namespaces [$($foreignNamespaces -join ', ')]; the precompiled DLL owns CultMath's types" - } -} - -New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null -Copy-Item -Path (Join-Path $templateRoot "*") -Destination $outputRoot -Recurse -Force - -$stagedAssemblies = @(Get-ChildItem -LiteralPath (Join-Path $outputRoot "Runtime\Plugins") -Filter "*.dll") -$stagedBridgeRoot = Join-Path $outputRoot "Runtime\UnityBridge" -$stagedSources = @(Get-ChildItem -LiteralPath $outputRoot -Recurse -File -Filter "*.cs") -$stagedDefinitions = @(Get-ChildItem -LiteralPath $outputRoot -Recurse -File -Filter "*.asmdef") -$stagedStraySources = @($stagedSources | Where-Object { $_.DirectoryName -ne $stagedBridgeRoot }) -if ($stagedAssemblies.Count -ne 1 -or $stagedAssemblies[0].Name -ne "CultMath.dll" -or $stagedStraySources.Count -ne 0 -or $stagedDefinitions.Count -ne 1) { - throw "CultMath package must contain one owned auto-referenced DLL and only the UnityBridge assembly as source" -} - -Write-Host "CultMath Unity package: $outputRoot" -Write-Host "Package: $($manifest.name)@$($manifest.version)" -Write-Host "Managed assemblies: $($stagedAssemblies.Count)" -Write-Host "Bridge C# source files: $($stagedSources.Count)" -Write-Host "Asmdef definitions: $($stagedDefinitions.Count)" diff --git a/packages/cultmath/shaders.meta b/packages/cultmath/shaders.meta deleted file mode 100644 index 46f9d445..00000000 --- a/packages/cultmath/shaders.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 3bed74ebcba445543800218dbe185034 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/shaders/CultMath.hlsl b/packages/cultmath/shaders/CultMath.hlsl deleted file mode 100644 index 365705f5..00000000 --- a/packages/cultmath/shaders/CultMath.hlsl +++ /dev/null @@ -1,356 +0,0 @@ -#ifndef CULTMATH_HLSL -#define CULTMATH_HLSL - -// CultMath HLSL mirror. -// -// HLSL already owns the float2/float3/float4 type names and most primitive -// intrinsics. This include names the CultMath semantic surface explicitly so -// shader code, C# wrappers, and Rust/native parity fixtures can share the same -// small vocabulary without local helper drift. - -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); } - -float cultmath_frac(float value) { return value - floor(value); } -float2 cultmath_frac(float2 value) { return value - floor(value); } -float3 cultmath_frac(float3 value) { return value - floor(value); } -float4 cultmath_frac(float4 value) { return value - floor(value); } - -float cultmath_clamp(float value, float minimum, float maximum) { return min(max(value, minimum), maximum); } -float2 cultmath_clamp(float2 value, float2 minimum, float2 maximum) { return min(max(value, minimum), maximum); } -float3 cultmath_clamp(float3 value, float3 minimum, float3 maximum) { return min(max(value, minimum), maximum); } -float4 cultmath_clamp(float4 value, float4 minimum, float4 maximum) { return min(max(value, minimum), maximum); } - -// DXIL Saturate is FMin(1, FMax(0, x)); dxc lowers min/max to the same FMin/FMax, NaN rules included. -float cultmath_saturate(float value) { return min(1.0, max(0.0, value)); } -float2 cultmath_saturate(float2 value) { return min(float2(1.0, 1.0), max(float2(0.0, 0.0), value)); } -float3 cultmath_saturate(float3 value) { return min(float3(1.0, 1.0, 1.0), max(float3(0.0, 0.0, 0.0), value)); } -float4 cultmath_saturate(float4 value) { return min(float4(1.0, 1.0, 1.0, 1.0), max(float4(0.0, 0.0, 0.0, 0.0), value)); } - -float cultmath_lerp(float start, float end, float amount) { return start + (end - start) * amount; } -float2 cultmath_lerp(float2 start, float2 end, float2 amount) { return start + (end - start) * amount; } -float3 cultmath_lerp(float3 start, float3 end, float3 amount) { return start + (end - start) * amount; } -float4 cultmath_lerp(float4 start, float4 end, float4 amount) { return start + (end - start) * amount; } - -float cultmath_step(float edge, float value) { return value < edge ? 0.0 : 1.0; } -float2 cultmath_step(float2 edge, float2 value) { return float2(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y)); } -float3 cultmath_step(float3 edge, float3 value) { return float3(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z)); } -float4 cultmath_step(float4 edge, float4 value) { return float4(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z), cultmath_step(edge.w, value.w)); } - -float cultmath_smoothstep(float minimum, float maximum, float value) -{ - float t = cultmath_saturate((value - minimum) / (maximum - minimum)); - return t * t * (3.0 - 2.0 * t); -} - -float2 cultmath_smoothstep(float2 minimum, float2 maximum, float2 value) -{ - float2 t = cultmath_saturate((value - minimum) / (maximum - minimum)); - return t * t * (float2(3.0, 3.0) - float2(2.0, 2.0) * t); -} - -float3 cultmath_smoothstep(float3 minimum, float3 maximum, float3 value) -{ - float3 t = cultmath_saturate((value - minimum) / (maximum - minimum)); - return t * t * (float3(3.0, 3.0, 3.0) - float3(2.0, 2.0, 2.0) * t); -} - -float4 cultmath_smoothstep(float4 minimum, float4 maximum, float4 value) -{ - float4 t = cultmath_saturate((value - minimum) / (maximum - minimum)); - return t * t * (float4(3.0, 3.0, 3.0, 3.0) - float4(2.0, 2.0, 2.0, 2.0) * t); -} - -float cultmath_smoothstep01(float value) { return cultmath_smoothstep(0.0, 1.0, value); } - -float cultmath_smootherstep(float value) -{ - value = cultmath_saturate(value); - return value * value * value * (value * (value * 6.0 - 15.0) + 10.0); -} - -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) + 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. -float cultmath_snoise(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 m = max(0.6 - float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); - m = m * m; - return 42.0 * dot(m * m, float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3))); -} - -float cultmath_snoise(float2 value) -{ - float4 c = float4( - 0.211324865405187, - 0.366025403784439, - -0.577350269189626, - 0.024390243902439); - float2 i = floor(value + dot(value, c.yy)); - float2 x0 = value - i + dot(i, c.xx); - float2 i1 = x0.x > x0.y ? float2(1.0, 0.0) : float2(0.0, 1.0); - float4 x12 = float4(x0.x + c.x - i1.x, x0.y + c.x - i1.y, x0.x + c.z, x0.y + c.z); - - i = cultmath_snoise_mod289(i); - float3 p = cultmath_snoise_permute( - cultmath_snoise_permute(i.y + float3(0.0, i1.y, 1.0)) + i.x + float3(0.0, i1.x, 1.0)); - float3 m = max(0.5 - float3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0); - m *= m; - m *= m; - - float3 x = 2.0 * frac(p * c.w) - 1.0; - float3 h = abs(x) - 0.5; - float3 ox = floor(x + 0.5); - float3 a0 = x - ox; - m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h); - - float3 g = float3( - a0.x * x0.x + h.x * x0.y, - a0.y * x12.x + h.y * x12.y, - a0.z * x12.z + h.z * x12.w); - return 130.0 * dot(m, g); -} - -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); } - -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; -} - -float3 cultmath_reflect(float3 incident, float3 normal_value) -{ - return incident - 2.0 * dot(normal_value, incident) * normal_value; -} - -float2 cultmath_rotate(float2 value, float radians_value) -{ - float s = sin(radians_value); - float c = cos(radians_value); - return float2(value.x * c - value.y * s, value.x * s + value.y * c); -} - -float2 cultmath_rotate_degrees(float2 value, float degrees_value) -{ - return cultmath_rotate(value, cultmath_radians(degrees_value)); -} - -float cultmath_csum(float2 value) { return value.x + value.y; } -float cultmath_csum(float3 value) { return value.x + value.y + value.z; } -float cultmath_csum(float4 value) { return value.x + value.y + value.z + value.w; } - -float cultmath_decay(float source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } -float2 cultmath_decay(float2 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } -float3 cultmath_decay(float3 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } -float4 cultmath_decay(float4 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } - -float cultmath_damp(float start, float end, float lambda_value, float dt) -{ - return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); -} - -float2 cultmath_damp(float2 start, float2 end, float lambda_value, float dt) -{ - return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); -} - -float3 cultmath_damp(float3 start, float3 end, float lambda_value, float dt) -{ - return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); -} - -float4 cultmath_damp(float4 start, float4 end, float lambda_value, float dt) -{ - return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); -} - -float cultmath_catmullrom(float p0, float p1, float p2, float p3, float t) -{ - float t2 = t * t; - float t3 = t2 * t; - return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); -} - -float2 cultmath_catmullrom(float2 p0, float2 p1, float2 p2, float2 p3, float t) -{ - float t2 = t * t; - float t3 = t2 * t; - return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); -} - -float3 cultmath_catmullrom(float3 p0, float3 p1, float3 p2, float3 p3, float t) -{ - float t2 = t * t; - float t3 = t2 * t; - return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); -} - -float4 cultmath_catmullrom(float4 p0, float4 p1, float4 p2, float4 p3, float t) -{ - float t2 = t * t; - float t3 = t2 * t; - return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); -} - -float cultmath_quadratic_bezier(float p0, float p1, float p2, float t) -{ - t = cultmath_saturate(t); - float inv = 1.0 - t; - return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2; -} - -float3 cultmath_quadratic_bezier(float3 p0, float3 p1, float3 p2, float t) -{ - t = cultmath_saturate(t); - float inv = 1.0 - t; - return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2; -} - -float cultmath_cubic_bezier(float p0, float p1, float p2, float p3, float t) -{ - t = cultmath_saturate(t); - float inv = 1.0 - t; - return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3; -} - -float3 cultmath_cubic_bezier(float3 p0, float3 p1, float3 p2, float3 p3, float t) -{ - t = cultmath_saturate(t); - float inv = 1.0 - t; - return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3; -} - -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))); } - -float cultmath_value_noise(float2 position) -{ - float2 cell = floor(position); - float2 local = cultmath_frac(position); - float2 u = local * local * (float2(3.0, 3.0) - float2(2.0, 2.0) * local); - - float a = cultmath_hash(cell); - float b = cultmath_hash(cell + float2(1.0, 0.0)); - float c = cultmath_hash(cell + float2(0.0, 1.0)); - float d = cultmath_hash(cell + float2(1.0, 1.0)); - - return cultmath_lerp(cultmath_lerp(a, b, u.x), cultmath_lerp(c, d, u.x), u.y); -} - -float cultmath_value_noise_bicubic(float2 position) -{ - float2 cell = floor(position); - float2 local = cultmath_frac(position); - - float y0 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, -1.0)), cultmath_hash(cell + float2(0.0, -1.0)), cultmath_hash(cell + float2(1.0, -1.0)), cultmath_hash(cell + float2(2.0, -1.0)), local.x); - float y1 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, 0.0)), cultmath_hash(cell + float2(0.0, 0.0)), cultmath_hash(cell + float2(1.0, 0.0)), cultmath_hash(cell + float2(2.0, 0.0)), local.x); - float y2 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, 1.0)), cultmath_hash(cell + float2(0.0, 1.0)), cultmath_hash(cell + float2(1.0, 1.0)), cultmath_hash(cell + float2(2.0, 1.0)), local.x); - float y3 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, 2.0)), cultmath_hash(cell + float2(0.0, 2.0)), cultmath_hash(cell + float2(1.0, 2.0)), cultmath_hash(cell + float2(2.0, 2.0)), local.x); - - return cultmath_catmullrom(y0, y1, y2, y3, local.y); -} - -float cultmath_value_noise_texture(Texture2D noise_texture, SamplerState noise_sampler, float2 uv, float scale) -{ - return noise_texture.SampleLevel(noise_sampler, uv * scale, 0.0).r; -} - -float cultmath_value_noise_texture_bicubic(Texture2D noise_texture, SamplerState noise_sampler, float2 uv, float scale, float2 texel_size) -{ - float2 position = uv * scale; - float2 cell = floor(position); - float2 local = cultmath_frac(position); - float2 base_uv = cell / scale; - - float row0 = cultmath_catmullrom( - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, -1.0), 0.0).r, - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, -1.0), 0.0).r, - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, -1.0), 0.0).r, - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, -1.0), 0.0).r, - local.x); - float row1 = cultmath_catmullrom( - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, 0.0), 0.0).r, - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, 0.0), 0.0).r, - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, 0.0), 0.0).r, - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, 0.0), 0.0).r, - local.x); - float row2 = cultmath_catmullrom( - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, 1.0), 0.0).r, - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, 1.0), 0.0).r, - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, 1.0), 0.0).r, - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, 1.0), 0.0).r, - local.x); - float row3 = cultmath_catmullrom( - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, 2.0), 0.0).r, - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, 2.0), 0.0).r, - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, 2.0), 0.0).r, - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, 2.0), 0.0).r, - local.x); - - return cultmath_catmullrom(row0, row1, row2, row3, local.y); -} - -#endif diff --git a/packages/cultmath/shaders/CultMath.hlsl.meta b/packages/cultmath/shaders/CultMath.hlsl.meta deleted file mode 100644 index d6b8542e..00000000 --- a/packages/cultmath/shaders/CultMath.hlsl.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 33174674795143c4a905408718f956f8 -ShaderIncludeImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/src.meta b/packages/cultmath/src.meta deleted file mode 100644 index 3e88fcb9..00000000 --- a/packages/cultmath/src.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 64f48d0cae0810e4ba9903e9094fe4c6 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/src/CultMath.meta b/packages/cultmath/src/CultMath.meta deleted file mode 100644 index 381b726d..00000000 --- a/packages/cultmath/src/CultMath.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: e857b457b93397d4fb4ad8481cb72e4c -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/src/CultMath/BatchMath.cs b/packages/cultmath/src/CultMath/BatchMath.cs deleted file mode 100644 index 04c042d9..00000000 --- a/packages/cultmath/src/CultMath/BatchMath.cs +++ /dev/null @@ -1,245 +0,0 @@ -using System; -using System.Numerics; - -namespace CultMath; - -public static class BatchMath -{ - private const float MinimumDistanceSquared = 1.0e-8f; - - public static int LaneCount => Vector.Count; - - public static bool IsHardwareAccelerated => Vector.IsHardwareAccelerated; - - public static void Clear(Span values) => values.Clear(); - - public static void AddRadialFalloffAcceleration2D( - ReadOnlySpan positionX, - ReadOnlySpan positionY, - float centerX, - float centerY, - float strength, - float radius, - Span accelerationX, - Span accelerationY) - { - ValidateEqualLengths(positionX.Length, positionY.Length, accelerationX.Length, accelerationY.Length); - - if (radius <= 0.0f || strength == 0.0f || positionX.Length == 0) - { - return; - } - - var radiusVector = new Vector(radius); - var centerXVector = new Vector(centerX); - var centerYVector = new Vector(centerY); - var strengthVector = new Vector(strength); - var one = Vector.One; - var zero = Vector.Zero; - var minimumDistanceSquared = new Vector(MinimumDistanceSquared); - - var i = 0; - var lanes = Vector.Count; - var pxBuffer = new float[lanes]; - var pyBuffer = new float[lanes]; - var axBuffer = new float[lanes]; - var ayBuffer = new float[lanes]; - for (; i <= positionX.Length - lanes; i += lanes) - { - CopySpanToArray(positionX.Slice(i, lanes), pxBuffer); - CopySpanToArray(positionY.Slice(i, lanes), pyBuffer); - CopySpanToArray(accelerationX.Slice(i, lanes), axBuffer); - CopySpanToArray(accelerationY.Slice(i, lanes), ayBuffer); - - var px = new Vector(pxBuffer); - var py = new Vector(pyBuffer); - var dx = centerXVector - px; - var dy = centerYVector - py; - var distanceSquared = Vector.Max(dx * dx + dy * dy, minimumDistanceSquared); - var distance = Vector.SquareRoot(distanceSquared); - var inRange = Vector.LessThanOrEqual(distance, radiusVector); - var falloff = Vector.Max(zero, one - distance / radiusVector); - var magnitude = strengthVector * falloff; - var ax = Vector.ConditionalSelect(inRange, dx / distance * magnitude, zero); - var ay = Vector.ConditionalSelect(inRange, dy / distance * magnitude, zero); - - (new Vector(axBuffer) + ax).CopyTo(axBuffer); - (new Vector(ayBuffer) + ay).CopyTo(ayBuffer); - CopyArrayToSpan(axBuffer, accelerationX.Slice(i, lanes)); - CopyArrayToSpan(ayBuffer, accelerationY.Slice(i, lanes)); - } - - for (; i < positionX.Length; i++) - { - var dx = centerX - positionX[i]; - var dy = centerY - positionY[i]; - var distanceSquared = MathF.Max(dx * dx + dy * dy, MinimumDistanceSquared); - var distance = MathF.Sqrt(distanceSquared); - if (distance > radius) - { - continue; - } - - var falloff = MathF.Max(0.0f, 1.0f - distance / radius); - var magnitude = strength * falloff; - accelerationX[i] += dx / distance * magnitude; - accelerationY[i] += dy / distance * magnitude; - } - } - - public static void AddRadialFalloffAcceleration2D( - ReadOnlySpan positions, - float2 center, - float strength, - float radius, - Span acceleration) - { - ValidateEqualLengths(positions.Length, acceleration.Length); - - if (radius <= 0.0f || strength == 0.0f || positions.Length == 0) - { - return; - } - - for (var i = 0; i < positions.Length; i++) - { - var delta = center - positions[i]; - var distanceSquared = MathF.Max(delta.x * delta.x + delta.y * delta.y, MinimumDistanceSquared); - var distance = MathF.Sqrt(distanceSquared); - if (distance > radius) - { - continue; - } - - var falloff = MathF.Max(0.0f, 1.0f - distance / radius); - acceleration[i] += delta / distance * (strength * falloff); - } - } - - public static void IntegrateSemiImplicitEuler2D( - float deltaTime, - ReadOnlySpan dynamicMask, - Span positionX, - Span positionY, - Span velocityX, - Span velocityY, - ReadOnlySpan accelerationX, - ReadOnlySpan accelerationY) - { - if (deltaTime <= 0.0f) - { - throw new ArgumentOutOfRangeException(nameof(deltaTime), "Delta time must be positive."); - } - - ValidateEqualLengths( - dynamicMask.Length, - positionX.Length, - positionY.Length, - velocityX.Length, - velocityY.Length, - accelerationX.Length, - accelerationY.Length); - - var delta = new Vector(deltaTime); - var i = 0; - var lanes = Vector.Count; - var maskBuffer = new float[lanes]; - var pxBuffer = new float[lanes]; - var pyBuffer = new float[lanes]; - var vxBuffer = new float[lanes]; - var vyBuffer = new float[lanes]; - var axBuffer = new float[lanes]; - var ayBuffer = new float[lanes]; - for (; i <= positionX.Length - lanes; i += lanes) - { - CopySpanToArray(dynamicMask.Slice(i, lanes), maskBuffer); - CopySpanToArray(velocityX.Slice(i, lanes), vxBuffer); - CopySpanToArray(velocityY.Slice(i, lanes), vyBuffer); - CopySpanToArray(positionX.Slice(i, lanes), pxBuffer); - CopySpanToArray(positionY.Slice(i, lanes), pyBuffer); - CopySpanToArray(accelerationX.Slice(i, lanes), axBuffer); - CopySpanToArray(accelerationY.Slice(i, lanes), ayBuffer); - - var mask = new Vector(maskBuffer); - var vx = new Vector(vxBuffer); - var vy = new Vector(vyBuffer); - var px = new Vector(pxBuffer); - var py = new Vector(pyBuffer); - var ax = new Vector(axBuffer); - var ay = new Vector(ayBuffer); - - vx += ax * delta * mask; - vy += ay * delta * mask; - px += vx * delta * mask; - py += vy * delta * mask; - - vx.CopyTo(vxBuffer); - vy.CopyTo(vyBuffer); - px.CopyTo(pxBuffer); - py.CopyTo(pyBuffer); - CopyArrayToSpan(vxBuffer, velocityX.Slice(i, lanes)); - CopyArrayToSpan(vyBuffer, velocityY.Slice(i, lanes)); - CopyArrayToSpan(pxBuffer, positionX.Slice(i, lanes)); - CopyArrayToSpan(pyBuffer, positionY.Slice(i, lanes)); - } - - for (; i < positionX.Length; i++) - { - var mask = dynamicMask[i]; - velocityX[i] += accelerationX[i] * deltaTime * mask; - velocityY[i] += accelerationY[i] * deltaTime * mask; - positionX[i] += velocityX[i] * deltaTime * mask; - positionY[i] += velocityY[i] * deltaTime * mask; - } - } - - public static void IntegrateSemiImplicitEuler2D( - float deltaTime, - ReadOnlySpan dynamicMask, - Span position, - Span velocity, - ReadOnlySpan acceleration) - { - if (deltaTime <= 0.0f) - { - throw new ArgumentOutOfRangeException(nameof(deltaTime), "Delta time must be positive."); - } - - ValidateEqualLengths(dynamicMask.Length, position.Length, velocity.Length, acceleration.Length); - - for (var i = 0; i < position.Length; i++) - { - var mask = dynamicMask[i]; - velocity[i] += acceleration[i] * deltaTime * mask; - position[i] += velocity[i] * deltaTime * mask; - } - } - - private static void ValidateEqualLengths(params int[] lengths) - { - var length = lengths[0]; - for (var i = 1; i < lengths.Length; i++) - { - if (lengths[i] != length) - { - throw new ArgumentException("CultMath batch spans must have equal length."); - } - } - } - - private static void CopySpanToArray(ReadOnlySpan source, float[] destination) - { - for (var i = 0; i < source.Length; i++) - { - destination[i] = source[i]; - } - } - - private static void CopyArrayToSpan(float[] source, Span destination) - { - for (var i = 0; i < destination.Length; i++) - { - destination[i] = source[i]; - } - } -} diff --git a/packages/cultmath/src/CultMath/BatchMath.cs.meta b/packages/cultmath/src/CultMath/BatchMath.cs.meta deleted file mode 100644 index fe7c6033..00000000 --- a/packages/cultmath/src/CultMath/BatchMath.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: d4771320e190bdd408dd296c293a9d0e \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/Color32.cs b/packages/cultmath/src/CultMath/Color32.cs deleted file mode 100644 index e9a2b481..00000000 --- a/packages/cultmath/src/CultMath/Color32.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace CultMath; - -[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] -public readonly record struct Color32(byte r, byte g, byte b, byte a = 255); diff --git a/packages/cultmath/src/CultMath/Color32.cs.meta b/packages/cultmath/src/CultMath/Color32.cs.meta deleted file mode 100644 index 71e1ba55..00000000 --- a/packages/cultmath/src/CultMath/Color32.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: bfa14a9aacb8ac4448761568643f2bc6 \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/CultMath.asmdef b/packages/cultmath/src/CultMath/CultMath.asmdef deleted file mode 100644 index 449549f6..00000000 --- a/packages/cultmath/src/CultMath/CultMath.asmdef +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "CultMath", - "rootNamespace": "CultMath", - "references": [], - "includePlatforms": [], - "excludePlatforms": [], - "allowUnsafeCode": true, - "overrideReferences": false, - "precompiledReferences": [], - "autoReferenced": true, - "defineConstraints": [], - "versionDefines": [], - "noEngineReferences": true -} diff --git a/packages/cultmath/src/CultMath/CultMath.asmdef.meta b/packages/cultmath/src/CultMath/CultMath.asmdef.meta deleted file mode 100644 index 5a26d2c5..00000000 --- a/packages/cultmath/src/CultMath/CultMath.asmdef.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: f0b66b909d13e18468f7aa56221531e9 -AssemblyDefinitionImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/src/CultMath/CultMath.csproj b/packages/cultmath/src/CultMath/CultMath.csproj deleted file mode 100644 index 7c567105..00000000 --- a/packages/cultmath/src/CultMath/CultMath.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - net8.0;netstandard2.1 - enable - enable - latest - CultMath - CultMath - GameCult.Math - 0.2.0 - Clean-room C# math shaped for HLSL-style renderer code. - GameCult - README.md - $(NoWarn);CS8981 - true - - false - - - - - - - - diff --git a/packages/cultmath/src/CultMath/CultMath.csproj.meta b/packages/cultmath/src/CultMath/CultMath.csproj.meta deleted file mode 100644 index 78fc304d..00000000 --- a/packages/cultmath/src/CultMath/CultMath.csproj.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 77e1a0a2512910f41885650a7d0d6f60 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/src/CultMath/CultMathTone.cs b/packages/cultmath/src/CultMath/CultMathTone.cs deleted file mode 100644 index 25dfffeb..00000000 --- a/packages/cultmath/src/CultMath/CultMathTone.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace CultMath; - -public enum CultMathTone : byte -{ - Background = 0, - Panel = 1, - Edge = 2, - Header = 3, - Body = 4, -} diff --git a/packages/cultmath/src/CultMath/CultMathTone.cs.meta b/packages/cultmath/src/CultMath/CultMathTone.cs.meta deleted file mode 100644 index f41d1475..00000000 --- a/packages/cultmath/src/CultMath/CultMathTone.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: d4bfc66f9f7e992448f884c2c338d50a \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/IsExternalInit.cs b/packages/cultmath/src/CultMath/IsExternalInit.cs deleted file mode 100644 index b05eb4a1..00000000 --- a/packages/cultmath/src/CultMath/IsExternalInit.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace System.Runtime.CompilerServices; - -internal static class IsExternalInit -{ -} diff --git a/packages/cultmath/src/CultMath/IsExternalInit.cs.meta b/packages/cultmath/src/CultMath/IsExternalInit.cs.meta deleted file mode 100644 index ca761bcb..00000000 --- a/packages/cultmath/src/CultMath/IsExternalInit.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: de2b3c3e7f03c354aa2b84fbbc55b497 \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/Random.cs b/packages/cultmath/src/CultMath/Random.cs deleted file mode 100644 index 6892e20c..00000000 --- a/packages/cultmath/src/CultMath/Random.cs +++ /dev/null @@ -1,66 +0,0 @@ -namespace CultMath; - -public struct Random -{ - private const uint DefaultSeed = 0x6E624EB7u; - - public uint state; - - public Random(uint seed) - { - state = seed == 0 ? DefaultSeed : seed; - } - - public static Random CreateFromIndex(uint index) => new(index + DefaultSeed); - - public void InitState(uint seed) - { - state = seed == 0 ? DefaultSeed : seed; - } - - public uint NextUInt() - { - var x = state == 0 ? DefaultSeed : state; - x ^= x << 13; - x ^= x >> 17; - x ^= x << 5; - state = x == 0 ? DefaultSeed : x; - return state; - } - - public int NextInt(int max) => NextInt(0, max); - - public int NextInt(int min, int max) - { - if (max <= min) - { - return min; - } - - return min + (int)(NextUInt() % (uint)(max - min)); - } - - public float NextFloat() => (NextUInt() >> 8) * (1.0f / 16777216.0f); - - public float NextFloat(float max) => NextFloat() * max; - - public float NextFloat(float min, float max) => min + NextFloat() * (max - min); - - public float2 NextFloat2() => new(NextFloat(), NextFloat()); - - public float2 NextFloat2(float min, float max) => new(NextFloat(min, max), NextFloat(min, max)); - - public float2 NextFloat2(float2 min, float2 max) => min + NextFloat2() * (max - min); - - public float2 NextFloat2Direction() - { - var angle = NextFloat(0.0f, math.TAU); - return new float2(math.cos(angle), math.sin(angle)); - } - - public float3 NextFloat3() => new(NextFloat(), NextFloat(), NextFloat()); - - public float3 NextFloat3(float3 max) => NextFloat3() * max; - - public float3 NextFloat3(float3 min, float3 max) => min + NextFloat3() * (max - min); -} diff --git a/packages/cultmath/src/CultMath/Random.cs.meta b/packages/cultmath/src/CultMath/Random.cs.meta deleted file mode 100644 index 55032ded..00000000 --- a/packages/cultmath/src/CultMath/Random.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 7c1a557259026da4ca20b54264852c13 \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/Swizzles.g.cs b/packages/cultmath/src/CultMath/Swizzles.g.cs deleted file mode 100644 index ce84bbc4..00000000 --- a/packages/cultmath/src/CultMath/Swizzles.g.cs +++ /dev/null @@ -1,3137 +0,0 @@ -// -// Generated by tools/generate-swizzles.ps1. Do not edit by hand. -// - -namespace CultMath; - -public partial struct float2 -{ - public float2(float2 xy) : this(xy.x, xy.y) { } - 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); - public float2 xy { readonly get => new(x, y); set { x = value.x; y = value.y; } } - public float2 yx { readonly get => new(y, x); set { y = value.x; x = value.y; } } - public readonly float2 yy => new(y, y); - public readonly float3 xxx => new(x, x, x); - public readonly float3 xxy => new(x, x, y); - public readonly float3 xyx => new(x, y, x); - public readonly float3 xyy => new(x, y, y); - public readonly float3 yxx => new(y, x, x); - public readonly float3 yxy => new(y, x, y); - public readonly float3 yyx => new(y, y, x); - public readonly float3 yyy => new(y, y, y); - public readonly float4 xxxx => new(x, x, x, x); - public readonly float4 xxxy => new(x, x, x, y); - public readonly float4 xxyx => new(x, x, y, x); - public readonly float4 xxyy => new(x, x, y, y); - public readonly float4 xyxx => new(x, y, x, x); - public readonly float4 xyxy => new(x, y, x, y); - public readonly float4 xyyx => new(x, y, y, x); - public readonly float4 xyyy => new(x, y, y, y); - public readonly float4 yxxx => new(y, x, x, x); - public readonly float4 yxxy => new(y, x, x, y); - public readonly float4 yxyx => new(y, x, y, x); - public readonly float4 yxyy => new(y, x, y, y); - public readonly float4 yyxx => new(y, y, x, x); - public readonly float4 yyxy => new(y, y, x, y); - public readonly float4 yyyx => new(y, y, y, x); - public readonly float4 yyyy => new(y, y, y, y); - public readonly float2 rr => new(x, x); - public float2 rg { readonly get => new(x, y); set { x = value.x; y = value.y; } } - public float2 gr { readonly get => new(y, x); set { y = value.x; x = value.y; } } - public readonly float2 gg => new(y, y); - public readonly float3 rrr => new(x, x, x); - public readonly float3 rrg => new(x, x, y); - public readonly float3 rgr => new(x, y, x); - public readonly float3 rgg => new(x, y, y); - public readonly float3 grr => new(y, x, x); - public readonly float3 grg => new(y, x, y); - public readonly float3 ggr => new(y, y, x); - public readonly float3 ggg => new(y, y, y); - public readonly float4 rrrr => new(x, x, x, x); - public readonly float4 rrrg => new(x, x, x, y); - public readonly float4 rrgr => new(x, x, y, x); - public readonly float4 rrgg => new(x, x, y, y); - public readonly float4 rgrr => new(x, y, x, x); - public readonly float4 rgrg => new(x, y, x, y); - public readonly float4 rggr => new(x, y, y, x); - public readonly float4 rggg => new(x, y, y, y); - public readonly float4 grrr => new(y, x, x, x); - public readonly float4 grrg => new(y, x, x, y); - public readonly float4 grgr => new(y, x, y, x); - public readonly float4 grgg => new(y, x, y, y); - public readonly float4 ggrr => new(y, y, x, x); - public readonly float4 ggrg => new(y, y, x, y); - public readonly float4 gggr => new(y, y, y, x); - public readonly float4 gggg => new(y, y, y, y); -} - -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 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; } - public readonly float2 xx => new(x, x); - public float2 xy { readonly get => new(x, y); set { x = value.x; y = value.y; } } - public float2 xz { readonly get => new(x, z); set { x = value.x; z = value.y; } } - public float2 yx { readonly get => new(y, x); set { y = value.x; x = value.y; } } - public readonly float2 yy => new(y, y); - public float2 yz { readonly get => new(y, z); set { y = value.x; z = value.y; } } - public float2 zx { readonly get => new(z, x); set { z = value.x; x = value.y; } } - public float2 zy { readonly get => new(z, y); set { z = value.x; y = value.y; } } - public readonly float2 zz => new(z, z); - public readonly float3 xxx => new(x, x, x); - public readonly float3 xxy => new(x, x, y); - public readonly float3 xxz => new(x, x, z); - public readonly float3 xyx => new(x, y, x); - public readonly float3 xyy => new(x, y, y); - public float3 xyz { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } - public readonly float3 xzx => new(x, z, x); - public float3 xzy { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } - public readonly float3 xzz => new(x, z, z); - public readonly float3 yxx => new(y, x, x); - public readonly float3 yxy => new(y, x, y); - public float3 yxz { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } - public readonly float3 yyx => new(y, y, x); - public readonly float3 yyy => new(y, y, y); - public readonly float3 yyz => new(y, y, z); - public float3 yzx { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } - public readonly float3 yzy => new(y, z, y); - public readonly float3 yzz => new(y, z, z); - public readonly float3 zxx => new(z, x, x); - public float3 zxy { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } - public readonly float3 zxz => new(z, x, z); - public float3 zyx { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } - public readonly float3 zyy => new(z, y, y); - public readonly float3 zyz => new(z, y, z); - public readonly float3 zzx => new(z, z, x); - public readonly float3 zzy => new(z, z, y); - public readonly float3 zzz => new(z, z, z); - public readonly float4 xxxx => new(x, x, x, x); - public readonly float4 xxxy => new(x, x, x, y); - public readonly float4 xxxz => new(x, x, x, z); - public readonly float4 xxyx => new(x, x, y, x); - public readonly float4 xxyy => new(x, x, y, y); - public readonly float4 xxyz => new(x, x, y, z); - public readonly float4 xxzx => new(x, x, z, x); - public readonly float4 xxzy => new(x, x, z, y); - public readonly float4 xxzz => new(x, x, z, z); - public readonly float4 xyxx => new(x, y, x, x); - public readonly float4 xyxy => new(x, y, x, y); - public readonly float4 xyxz => new(x, y, x, z); - public readonly float4 xyyx => new(x, y, y, x); - public readonly float4 xyyy => new(x, y, y, y); - public readonly float4 xyyz => new(x, y, y, z); - public readonly float4 xyzx => new(x, y, z, x); - public readonly float4 xyzy => new(x, y, z, y); - public readonly float4 xyzz => new(x, y, z, z); - public readonly float4 xzxx => new(x, z, x, x); - public readonly float4 xzxy => new(x, z, x, y); - public readonly float4 xzxz => new(x, z, x, z); - public readonly float4 xzyx => new(x, z, y, x); - public readonly float4 xzyy => new(x, z, y, y); - public readonly float4 xzyz => new(x, z, y, z); - public readonly float4 xzzx => new(x, z, z, x); - public readonly float4 xzzy => new(x, z, z, y); - public readonly float4 xzzz => new(x, z, z, z); - public readonly float4 yxxx => new(y, x, x, x); - public readonly float4 yxxy => new(y, x, x, y); - public readonly float4 yxxz => new(y, x, x, z); - public readonly float4 yxyx => new(y, x, y, x); - public readonly float4 yxyy => new(y, x, y, y); - public readonly float4 yxyz => new(y, x, y, z); - public readonly float4 yxzx => new(y, x, z, x); - public readonly float4 yxzy => new(y, x, z, y); - public readonly float4 yxzz => new(y, x, z, z); - public readonly float4 yyxx => new(y, y, x, x); - public readonly float4 yyxy => new(y, y, x, y); - public readonly float4 yyxz => new(y, y, x, z); - public readonly float4 yyyx => new(y, y, y, x); - public readonly float4 yyyy => new(y, y, y, y); - public readonly float4 yyyz => new(y, y, y, z); - public readonly float4 yyzx => new(y, y, z, x); - public readonly float4 yyzy => new(y, y, z, y); - public readonly float4 yyzz => new(y, y, z, z); - public readonly float4 yzxx => new(y, z, x, x); - public readonly float4 yzxy => new(y, z, x, y); - public readonly float4 yzxz => new(y, z, x, z); - public readonly float4 yzyx => new(y, z, y, x); - public readonly float4 yzyy => new(y, z, y, y); - public readonly float4 yzyz => new(y, z, y, z); - public readonly float4 yzzx => new(y, z, z, x); - public readonly float4 yzzy => new(y, z, z, y); - public readonly float4 yzzz => new(y, z, z, z); - public readonly float4 zxxx => new(z, x, x, x); - public readonly float4 zxxy => new(z, x, x, y); - public readonly float4 zxxz => new(z, x, x, z); - public readonly float4 zxyx => new(z, x, y, x); - public readonly float4 zxyy => new(z, x, y, y); - public readonly float4 zxyz => new(z, x, y, z); - public readonly float4 zxzx => new(z, x, z, x); - public readonly float4 zxzy => new(z, x, z, y); - public readonly float4 zxzz => new(z, x, z, z); - public readonly float4 zyxx => new(z, y, x, x); - public readonly float4 zyxy => new(z, y, x, y); - public readonly float4 zyxz => new(z, y, x, z); - public readonly float4 zyyx => new(z, y, y, x); - public readonly float4 zyyy => new(z, y, y, y); - public readonly float4 zyyz => new(z, y, y, z); - public readonly float4 zyzx => new(z, y, z, x); - public readonly float4 zyzy => new(z, y, z, y); - public readonly float4 zyzz => new(z, y, z, z); - public readonly float4 zzxx => new(z, z, x, x); - public readonly float4 zzxy => new(z, z, x, y); - public readonly float4 zzxz => new(z, z, x, z); - public readonly float4 zzyx => new(z, z, y, x); - public readonly float4 zzyy => new(z, z, y, y); - public readonly float4 zzyz => new(z, z, y, z); - public readonly float4 zzzx => new(z, z, z, x); - public readonly float4 zzzy => new(z, z, z, y); - public readonly float4 zzzz => new(z, z, z, z); - public readonly float2 rr => new(x, x); - public float2 rg { readonly get => new(x, y); set { x = value.x; y = value.y; } } - public float2 rb { readonly get => new(x, z); set { x = value.x; z = value.y; } } - public float2 gr { readonly get => new(y, x); set { y = value.x; x = value.y; } } - public readonly float2 gg => new(y, y); - public float2 gb { readonly get => new(y, z); set { y = value.x; z = value.y; } } - public float2 br { readonly get => new(z, x); set { z = value.x; x = value.y; } } - public float2 bg { readonly get => new(z, y); set { z = value.x; y = value.y; } } - public readonly float2 bb => new(z, z); - public readonly float3 rrr => new(x, x, x); - public readonly float3 rrg => new(x, x, y); - public readonly float3 rrb => new(x, x, z); - public readonly float3 rgr => new(x, y, x); - public readonly float3 rgg => new(x, y, y); - public float3 rgb { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } - public readonly float3 rbr => new(x, z, x); - public float3 rbg { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } - public readonly float3 rbb => new(x, z, z); - public readonly float3 grr => new(y, x, x); - public readonly float3 grg => new(y, x, y); - public float3 grb { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } - public readonly float3 ggr => new(y, y, x); - public readonly float3 ggg => new(y, y, y); - public readonly float3 ggb => new(y, y, z); - public float3 gbr { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } - public readonly float3 gbg => new(y, z, y); - public readonly float3 gbb => new(y, z, z); - public readonly float3 brr => new(z, x, x); - public float3 brg { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } - public readonly float3 brb => new(z, x, z); - public float3 bgr { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } - public readonly float3 bgg => new(z, y, y); - public readonly float3 bgb => new(z, y, z); - public readonly float3 bbr => new(z, z, x); - public readonly float3 bbg => new(z, z, y); - public readonly float3 bbb => new(z, z, z); - public readonly float4 rrrr => new(x, x, x, x); - public readonly float4 rrrg => new(x, x, x, y); - public readonly float4 rrrb => new(x, x, x, z); - public readonly float4 rrgr => new(x, x, y, x); - public readonly float4 rrgg => new(x, x, y, y); - public readonly float4 rrgb => new(x, x, y, z); - public readonly float4 rrbr => new(x, x, z, x); - public readonly float4 rrbg => new(x, x, z, y); - public readonly float4 rrbb => new(x, x, z, z); - public readonly float4 rgrr => new(x, y, x, x); - public readonly float4 rgrg => new(x, y, x, y); - public readonly float4 rgrb => new(x, y, x, z); - public readonly float4 rggr => new(x, y, y, x); - public readonly float4 rggg => new(x, y, y, y); - public readonly float4 rggb => new(x, y, y, z); - public readonly float4 rgbr => new(x, y, z, x); - public readonly float4 rgbg => new(x, y, z, y); - public readonly float4 rgbb => new(x, y, z, z); - public readonly float4 rbrr => new(x, z, x, x); - public readonly float4 rbrg => new(x, z, x, y); - public readonly float4 rbrb => new(x, z, x, z); - public readonly float4 rbgr => new(x, z, y, x); - public readonly float4 rbgg => new(x, z, y, y); - public readonly float4 rbgb => new(x, z, y, z); - public readonly float4 rbbr => new(x, z, z, x); - public readonly float4 rbbg => new(x, z, z, y); - public readonly float4 rbbb => new(x, z, z, z); - public readonly float4 grrr => new(y, x, x, x); - public readonly float4 grrg => new(y, x, x, y); - public readonly float4 grrb => new(y, x, x, z); - public readonly float4 grgr => new(y, x, y, x); - public readonly float4 grgg => new(y, x, y, y); - public readonly float4 grgb => new(y, x, y, z); - public readonly float4 grbr => new(y, x, z, x); - public readonly float4 grbg => new(y, x, z, y); - public readonly float4 grbb => new(y, x, z, z); - public readonly float4 ggrr => new(y, y, x, x); - public readonly float4 ggrg => new(y, y, x, y); - public readonly float4 ggrb => new(y, y, x, z); - public readonly float4 gggr => new(y, y, y, x); - public readonly float4 gggg => new(y, y, y, y); - public readonly float4 gggb => new(y, y, y, z); - public readonly float4 ggbr => new(y, y, z, x); - public readonly float4 ggbg => new(y, y, z, y); - public readonly float4 ggbb => new(y, y, z, z); - public readonly float4 gbrr => new(y, z, x, x); - public readonly float4 gbrg => new(y, z, x, y); - public readonly float4 gbrb => new(y, z, x, z); - public readonly float4 gbgr => new(y, z, y, x); - public readonly float4 gbgg => new(y, z, y, y); - public readonly float4 gbgb => new(y, z, y, z); - public readonly float4 gbbr => new(y, z, z, x); - public readonly float4 gbbg => new(y, z, z, y); - public readonly float4 gbbb => new(y, z, z, z); - public readonly float4 brrr => new(z, x, x, x); - public readonly float4 brrg => new(z, x, x, y); - public readonly float4 brrb => new(z, x, x, z); - public readonly float4 brgr => new(z, x, y, x); - public readonly float4 brgg => new(z, x, y, y); - public readonly float4 brgb => new(z, x, y, z); - public readonly float4 brbr => new(z, x, z, x); - public readonly float4 brbg => new(z, x, z, y); - public readonly float4 brbb => new(z, x, z, z); - public readonly float4 bgrr => new(z, y, x, x); - public readonly float4 bgrg => new(z, y, x, y); - public readonly float4 bgrb => new(z, y, x, z); - public readonly float4 bggr => new(z, y, y, x); - public readonly float4 bggg => new(z, y, y, y); - public readonly float4 bggb => new(z, y, y, z); - public readonly float4 bgbr => new(z, y, z, x); - public readonly float4 bgbg => new(z, y, z, y); - public readonly float4 bgbb => new(z, y, z, z); - public readonly float4 bbrr => new(z, z, x, x); - public readonly float4 bbrg => new(z, z, x, y); - public readonly float4 bbrb => new(z, z, x, z); - public readonly float4 bbgr => new(z, z, y, x); - public readonly float4 bbgg => new(z, z, y, y); - public readonly float4 bbgb => new(z, z, y, z); - public readonly float4 bbbr => new(z, z, z, x); - public readonly float4 bbbg => new(z, z, z, y); - public readonly float4 bbbb => new(z, z, z, z); -} - -public partial struct float4 -{ - public float4(float x, float y, float2 zw) : this(x, y, zw.x, zw.y) { } - public float4(float x, float2 yz, float w) : this(x, yz.x, yz.y, w) { } - public float4(float x, float3 yzw) : this(x, yzw.x, yzw.y, yzw.z) { } - public float4(float2 xy, float z, float w) : this(xy.x, xy.y, z, w) { } - 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 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; } - public float a { readonly get => w; set => w = value; } - public readonly float2 xx => new(x, x); - public float2 xy { readonly get => new(x, y); set { x = value.x; y = value.y; } } - public float2 xz { readonly get => new(x, z); set { x = value.x; z = value.y; } } - public float2 xw { readonly get => new(x, w); set { x = value.x; w = value.y; } } - public float2 yx { readonly get => new(y, x); set { y = value.x; x = value.y; } } - public readonly float2 yy => new(y, y); - public float2 yz { readonly get => new(y, z); set { y = value.x; z = value.y; } } - public float2 yw { readonly get => new(y, w); set { y = value.x; w = value.y; } } - public float2 zx { readonly get => new(z, x); set { z = value.x; x = value.y; } } - public float2 zy { readonly get => new(z, y); set { z = value.x; y = value.y; } } - public readonly float2 zz => new(z, z); - public float2 zw { readonly get => new(z, w); set { z = value.x; w = value.y; } } - public float2 wx { readonly get => new(w, x); set { w = value.x; x = value.y; } } - public float2 wy { readonly get => new(w, y); set { w = value.x; y = value.y; } } - public float2 wz { readonly get => new(w, z); set { w = value.x; z = value.y; } } - public readonly float2 ww => new(w, w); - public readonly float3 xxx => new(x, x, x); - public readonly float3 xxy => new(x, x, y); - public readonly float3 xxz => new(x, x, z); - public readonly float3 xxw => new(x, x, w); - public readonly float3 xyx => new(x, y, x); - public readonly float3 xyy => new(x, y, y); - public float3 xyz { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } - public float3 xyw { readonly get => new(x, y, w); set { x = value.x; y = value.y; w = value.z; } } - public readonly float3 xzx => new(x, z, x); - public float3 xzy { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } - public readonly float3 xzz => new(x, z, z); - public float3 xzw { readonly get => new(x, z, w); set { x = value.x; z = value.y; w = value.z; } } - public readonly float3 xwx => new(x, w, x); - public float3 xwy { readonly get => new(x, w, y); set { x = value.x; w = value.y; y = value.z; } } - public float3 xwz { readonly get => new(x, w, z); set { x = value.x; w = value.y; z = value.z; } } - public readonly float3 xww => new(x, w, w); - public readonly float3 yxx => new(y, x, x); - public readonly float3 yxy => new(y, x, y); - public float3 yxz { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } - public float3 yxw { readonly get => new(y, x, w); set { y = value.x; x = value.y; w = value.z; } } - public readonly float3 yyx => new(y, y, x); - public readonly float3 yyy => new(y, y, y); - public readonly float3 yyz => new(y, y, z); - public readonly float3 yyw => new(y, y, w); - public float3 yzx { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } - public readonly float3 yzy => new(y, z, y); - public readonly float3 yzz => new(y, z, z); - public float3 yzw { readonly get => new(y, z, w); set { y = value.x; z = value.y; w = value.z; } } - public float3 ywx { readonly get => new(y, w, x); set { y = value.x; w = value.y; x = value.z; } } - public readonly float3 ywy => new(y, w, y); - public float3 ywz { readonly get => new(y, w, z); set { y = value.x; w = value.y; z = value.z; } } - public readonly float3 yww => new(y, w, w); - public readonly float3 zxx => new(z, x, x); - public float3 zxy { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } - public readonly float3 zxz => new(z, x, z); - public float3 zxw { readonly get => new(z, x, w); set { z = value.x; x = value.y; w = value.z; } } - public float3 zyx { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } - public readonly float3 zyy => new(z, y, y); - public readonly float3 zyz => new(z, y, z); - public float3 zyw { readonly get => new(z, y, w); set { z = value.x; y = value.y; w = value.z; } } - public readonly float3 zzx => new(z, z, x); - public readonly float3 zzy => new(z, z, y); - public readonly float3 zzz => new(z, z, z); - public readonly float3 zzw => new(z, z, w); - public float3 zwx { readonly get => new(z, w, x); set { z = value.x; w = value.y; x = value.z; } } - public float3 zwy { readonly get => new(z, w, y); set { z = value.x; w = value.y; y = value.z; } } - public readonly float3 zwz => new(z, w, z); - public readonly float3 zww => new(z, w, w); - public readonly float3 wxx => new(w, x, x); - public float3 wxy { readonly get => new(w, x, y); set { w = value.x; x = value.y; y = value.z; } } - public float3 wxz { readonly get => new(w, x, z); set { w = value.x; x = value.y; z = value.z; } } - public readonly float3 wxw => new(w, x, w); - public float3 wyx { readonly get => new(w, y, x); set { w = value.x; y = value.y; x = value.z; } } - public readonly float3 wyy => new(w, y, y); - public float3 wyz { readonly get => new(w, y, z); set { w = value.x; y = value.y; z = value.z; } } - public readonly float3 wyw => new(w, y, w); - public float3 wzx { readonly get => new(w, z, x); set { w = value.x; z = value.y; x = value.z; } } - public float3 wzy { readonly get => new(w, z, y); set { w = value.x; z = value.y; y = value.z; } } - public readonly float3 wzz => new(w, z, z); - public readonly float3 wzw => new(w, z, w); - public readonly float3 wwx => new(w, w, x); - public readonly float3 wwy => new(w, w, y); - public readonly float3 wwz => new(w, w, z); - public readonly float3 www => new(w, w, w); - public readonly float4 xxxx => new(x, x, x, x); - public readonly float4 xxxy => new(x, x, x, y); - public readonly float4 xxxz => new(x, x, x, z); - public readonly float4 xxxw => new(x, x, x, w); - public readonly float4 xxyx => new(x, x, y, x); - public readonly float4 xxyy => new(x, x, y, y); - public readonly float4 xxyz => new(x, x, y, z); - public readonly float4 xxyw => new(x, x, y, w); - public readonly float4 xxzx => new(x, x, z, x); - public readonly float4 xxzy => new(x, x, z, y); - public readonly float4 xxzz => new(x, x, z, z); - public readonly float4 xxzw => new(x, x, z, w); - public readonly float4 xxwx => new(x, x, w, x); - public readonly float4 xxwy => new(x, x, w, y); - public readonly float4 xxwz => new(x, x, w, z); - public readonly float4 xxww => new(x, x, w, w); - public readonly float4 xyxx => new(x, y, x, x); - public readonly float4 xyxy => new(x, y, x, y); - public readonly float4 xyxz => new(x, y, x, z); - public readonly float4 xyxw => new(x, y, x, w); - public readonly float4 xyyx => new(x, y, y, x); - public readonly float4 xyyy => new(x, y, y, y); - public readonly float4 xyyz => new(x, y, y, z); - public readonly float4 xyyw => new(x, y, y, w); - public readonly float4 xyzx => new(x, y, z, x); - public readonly float4 xyzy => new(x, y, z, y); - public readonly float4 xyzz => new(x, y, z, z); - public float4 xyzw { readonly get => new(x, y, z, w); set { x = value.x; y = value.y; z = value.z; w = value.w; } } - public readonly float4 xywx => new(x, y, w, x); - public readonly float4 xywy => new(x, y, w, y); - public float4 xywz { readonly get => new(x, y, w, z); set { x = value.x; y = value.y; w = value.z; z = value.w; } } - public readonly float4 xyww => new(x, y, w, w); - public readonly float4 xzxx => new(x, z, x, x); - public readonly float4 xzxy => new(x, z, x, y); - public readonly float4 xzxz => new(x, z, x, z); - public readonly float4 xzxw => new(x, z, x, w); - public readonly float4 xzyx => new(x, z, y, x); - public readonly float4 xzyy => new(x, z, y, y); - public readonly float4 xzyz => new(x, z, y, z); - public float4 xzyw { readonly get => new(x, z, y, w); set { x = value.x; z = value.y; y = value.z; w = value.w; } } - public readonly float4 xzzx => new(x, z, z, x); - public readonly float4 xzzy => new(x, z, z, y); - public readonly float4 xzzz => new(x, z, z, z); - public readonly float4 xzzw => new(x, z, z, w); - public readonly float4 xzwx => new(x, z, w, x); - public float4 xzwy { readonly get => new(x, z, w, y); set { x = value.x; z = value.y; w = value.z; y = value.w; } } - public readonly float4 xzwz => new(x, z, w, z); - public readonly float4 xzww => new(x, z, w, w); - public readonly float4 xwxx => new(x, w, x, x); - public readonly float4 xwxy => new(x, w, x, y); - public readonly float4 xwxz => new(x, w, x, z); - public readonly float4 xwxw => new(x, w, x, w); - public readonly float4 xwyx => new(x, w, y, x); - public readonly float4 xwyy => new(x, w, y, y); - public float4 xwyz { readonly get => new(x, w, y, z); set { x = value.x; w = value.y; y = value.z; z = value.w; } } - public readonly float4 xwyw => new(x, w, y, w); - public readonly float4 xwzx => new(x, w, z, x); - public float4 xwzy { readonly get => new(x, w, z, y); set { x = value.x; w = value.y; z = value.z; y = value.w; } } - public readonly float4 xwzz => new(x, w, z, z); - public readonly float4 xwzw => new(x, w, z, w); - public readonly float4 xwwx => new(x, w, w, x); - public readonly float4 xwwy => new(x, w, w, y); - public readonly float4 xwwz => new(x, w, w, z); - public readonly float4 xwww => new(x, w, w, w); - public readonly float4 yxxx => new(y, x, x, x); - public readonly float4 yxxy => new(y, x, x, y); - public readonly float4 yxxz => new(y, x, x, z); - public readonly float4 yxxw => new(y, x, x, w); - public readonly float4 yxyx => new(y, x, y, x); - public readonly float4 yxyy => new(y, x, y, y); - public readonly float4 yxyz => new(y, x, y, z); - public readonly float4 yxyw => new(y, x, y, w); - public readonly float4 yxzx => new(y, x, z, x); - public readonly float4 yxzy => new(y, x, z, y); - public readonly float4 yxzz => new(y, x, z, z); - public float4 yxzw { readonly get => new(y, x, z, w); set { y = value.x; x = value.y; z = value.z; w = value.w; } } - public readonly float4 yxwx => new(y, x, w, x); - public readonly float4 yxwy => new(y, x, w, y); - public float4 yxwz { readonly get => new(y, x, w, z); set { y = value.x; x = value.y; w = value.z; z = value.w; } } - public readonly float4 yxww => new(y, x, w, w); - public readonly float4 yyxx => new(y, y, x, x); - public readonly float4 yyxy => new(y, y, x, y); - public readonly float4 yyxz => new(y, y, x, z); - public readonly float4 yyxw => new(y, y, x, w); - public readonly float4 yyyx => new(y, y, y, x); - public readonly float4 yyyy => new(y, y, y, y); - public readonly float4 yyyz => new(y, y, y, z); - public readonly float4 yyyw => new(y, y, y, w); - public readonly float4 yyzx => new(y, y, z, x); - public readonly float4 yyzy => new(y, y, z, y); - public readonly float4 yyzz => new(y, y, z, z); - public readonly float4 yyzw => new(y, y, z, w); - public readonly float4 yywx => new(y, y, w, x); - public readonly float4 yywy => new(y, y, w, y); - public readonly float4 yywz => new(y, y, w, z); - public readonly float4 yyww => new(y, y, w, w); - public readonly float4 yzxx => new(y, z, x, x); - public readonly float4 yzxy => new(y, z, x, y); - public readonly float4 yzxz => new(y, z, x, z); - public float4 yzxw { readonly get => new(y, z, x, w); set { y = value.x; z = value.y; x = value.z; w = value.w; } } - public readonly float4 yzyx => new(y, z, y, x); - public readonly float4 yzyy => new(y, z, y, y); - public readonly float4 yzyz => new(y, z, y, z); - public readonly float4 yzyw => new(y, z, y, w); - public readonly float4 yzzx => new(y, z, z, x); - public readonly float4 yzzy => new(y, z, z, y); - public readonly float4 yzzz => new(y, z, z, z); - public readonly float4 yzzw => new(y, z, z, w); - public float4 yzwx { readonly get => new(y, z, w, x); set { y = value.x; z = value.y; w = value.z; x = value.w; } } - public readonly float4 yzwy => new(y, z, w, y); - public readonly float4 yzwz => new(y, z, w, z); - public readonly float4 yzww => new(y, z, w, w); - public readonly float4 ywxx => new(y, w, x, x); - public readonly float4 ywxy => new(y, w, x, y); - public float4 ywxz { readonly get => new(y, w, x, z); set { y = value.x; w = value.y; x = value.z; z = value.w; } } - public readonly float4 ywxw => new(y, w, x, w); - public readonly float4 ywyx => new(y, w, y, x); - public readonly float4 ywyy => new(y, w, y, y); - public readonly float4 ywyz => new(y, w, y, z); - public readonly float4 ywyw => new(y, w, y, w); - public float4 ywzx { readonly get => new(y, w, z, x); set { y = value.x; w = value.y; z = value.z; x = value.w; } } - public readonly float4 ywzy => new(y, w, z, y); - public readonly float4 ywzz => new(y, w, z, z); - public readonly float4 ywzw => new(y, w, z, w); - public readonly float4 ywwx => new(y, w, w, x); - public readonly float4 ywwy => new(y, w, w, y); - public readonly float4 ywwz => new(y, w, w, z); - public readonly float4 ywww => new(y, w, w, w); - public readonly float4 zxxx => new(z, x, x, x); - public readonly float4 zxxy => new(z, x, x, y); - public readonly float4 zxxz => new(z, x, x, z); - public readonly float4 zxxw => new(z, x, x, w); - public readonly float4 zxyx => new(z, x, y, x); - public readonly float4 zxyy => new(z, x, y, y); - public readonly float4 zxyz => new(z, x, y, z); - public float4 zxyw { readonly get => new(z, x, y, w); set { z = value.x; x = value.y; y = value.z; w = value.w; } } - public readonly float4 zxzx => new(z, x, z, x); - public readonly float4 zxzy => new(z, x, z, y); - public readonly float4 zxzz => new(z, x, z, z); - public readonly float4 zxzw => new(z, x, z, w); - public readonly float4 zxwx => new(z, x, w, x); - public float4 zxwy { readonly get => new(z, x, w, y); set { z = value.x; x = value.y; w = value.z; y = value.w; } } - public readonly float4 zxwz => new(z, x, w, z); - public readonly float4 zxww => new(z, x, w, w); - public readonly float4 zyxx => new(z, y, x, x); - public readonly float4 zyxy => new(z, y, x, y); - public readonly float4 zyxz => new(z, y, x, z); - public float4 zyxw { readonly get => new(z, y, x, w); set { z = value.x; y = value.y; x = value.z; w = value.w; } } - public readonly float4 zyyx => new(z, y, y, x); - public readonly float4 zyyy => new(z, y, y, y); - public readonly float4 zyyz => new(z, y, y, z); - public readonly float4 zyyw => new(z, y, y, w); - public readonly float4 zyzx => new(z, y, z, x); - public readonly float4 zyzy => new(z, y, z, y); - public readonly float4 zyzz => new(z, y, z, z); - public readonly float4 zyzw => new(z, y, z, w); - public float4 zywx { readonly get => new(z, y, w, x); set { z = value.x; y = value.y; w = value.z; x = value.w; } } - public readonly float4 zywy => new(z, y, w, y); - public readonly float4 zywz => new(z, y, w, z); - public readonly float4 zyww => new(z, y, w, w); - public readonly float4 zzxx => new(z, z, x, x); - public readonly float4 zzxy => new(z, z, x, y); - public readonly float4 zzxz => new(z, z, x, z); - public readonly float4 zzxw => new(z, z, x, w); - public readonly float4 zzyx => new(z, z, y, x); - public readonly float4 zzyy => new(z, z, y, y); - public readonly float4 zzyz => new(z, z, y, z); - public readonly float4 zzyw => new(z, z, y, w); - public readonly float4 zzzx => new(z, z, z, x); - public readonly float4 zzzy => new(z, z, z, y); - public readonly float4 zzzz => new(z, z, z, z); - public readonly float4 zzzw => new(z, z, z, w); - public readonly float4 zzwx => new(z, z, w, x); - public readonly float4 zzwy => new(z, z, w, y); - public readonly float4 zzwz => new(z, z, w, z); - public readonly float4 zzww => new(z, z, w, w); - public readonly float4 zwxx => new(z, w, x, x); - public float4 zwxy { readonly get => new(z, w, x, y); set { z = value.x; w = value.y; x = value.z; y = value.w; } } - public readonly float4 zwxz => new(z, w, x, z); - public readonly float4 zwxw => new(z, w, x, w); - public float4 zwyx { readonly get => new(z, w, y, x); set { z = value.x; w = value.y; y = value.z; x = value.w; } } - public readonly float4 zwyy => new(z, w, y, y); - public readonly float4 zwyz => new(z, w, y, z); - public readonly float4 zwyw => new(z, w, y, w); - public readonly float4 zwzx => new(z, w, z, x); - public readonly float4 zwzy => new(z, w, z, y); - public readonly float4 zwzz => new(z, w, z, z); - public readonly float4 zwzw => new(z, w, z, w); - public readonly float4 zwwx => new(z, w, w, x); - public readonly float4 zwwy => new(z, w, w, y); - public readonly float4 zwwz => new(z, w, w, z); - public readonly float4 zwww => new(z, w, w, w); - public readonly float4 wxxx => new(w, x, x, x); - public readonly float4 wxxy => new(w, x, x, y); - public readonly float4 wxxz => new(w, x, x, z); - public readonly float4 wxxw => new(w, x, x, w); - public readonly float4 wxyx => new(w, x, y, x); - public readonly float4 wxyy => new(w, x, y, y); - public float4 wxyz { readonly get => new(w, x, y, z); set { w = value.x; x = value.y; y = value.z; z = value.w; } } - public readonly float4 wxyw => new(w, x, y, w); - public readonly float4 wxzx => new(w, x, z, x); - public float4 wxzy { readonly get => new(w, x, z, y); set { w = value.x; x = value.y; z = value.z; y = value.w; } } - public readonly float4 wxzz => new(w, x, z, z); - public readonly float4 wxzw => new(w, x, z, w); - public readonly float4 wxwx => new(w, x, w, x); - public readonly float4 wxwy => new(w, x, w, y); - public readonly float4 wxwz => new(w, x, w, z); - public readonly float4 wxww => new(w, x, w, w); - public readonly float4 wyxx => new(w, y, x, x); - public readonly float4 wyxy => new(w, y, x, y); - public float4 wyxz { readonly get => new(w, y, x, z); set { w = value.x; y = value.y; x = value.z; z = value.w; } } - public readonly float4 wyxw => new(w, y, x, w); - public readonly float4 wyyx => new(w, y, y, x); - public readonly float4 wyyy => new(w, y, y, y); - public readonly float4 wyyz => new(w, y, y, z); - public readonly float4 wyyw => new(w, y, y, w); - public float4 wyzx { readonly get => new(w, y, z, x); set { w = value.x; y = value.y; z = value.z; x = value.w; } } - public readonly float4 wyzy => new(w, y, z, y); - public readonly float4 wyzz => new(w, y, z, z); - public readonly float4 wyzw => new(w, y, z, w); - public readonly float4 wywx => new(w, y, w, x); - public readonly float4 wywy => new(w, y, w, y); - public readonly float4 wywz => new(w, y, w, z); - public readonly float4 wyww => new(w, y, w, w); - public readonly float4 wzxx => new(w, z, x, x); - public float4 wzxy { readonly get => new(w, z, x, y); set { w = value.x; z = value.y; x = value.z; y = value.w; } } - public readonly float4 wzxz => new(w, z, x, z); - public readonly float4 wzxw => new(w, z, x, w); - public float4 wzyx { readonly get => new(w, z, y, x); set { w = value.x; z = value.y; y = value.z; x = value.w; } } - public readonly float4 wzyy => new(w, z, y, y); - public readonly float4 wzyz => new(w, z, y, z); - public readonly float4 wzyw => new(w, z, y, w); - public readonly float4 wzzx => new(w, z, z, x); - public readonly float4 wzzy => new(w, z, z, y); - public readonly float4 wzzz => new(w, z, z, z); - public readonly float4 wzzw => new(w, z, z, w); - public readonly float4 wzwx => new(w, z, w, x); - public readonly float4 wzwy => new(w, z, w, y); - public readonly float4 wzwz => new(w, z, w, z); - public readonly float4 wzww => new(w, z, w, w); - public readonly float4 wwxx => new(w, w, x, x); - public readonly float4 wwxy => new(w, w, x, y); - public readonly float4 wwxz => new(w, w, x, z); - public readonly float4 wwxw => new(w, w, x, w); - public readonly float4 wwyx => new(w, w, y, x); - public readonly float4 wwyy => new(w, w, y, y); - public readonly float4 wwyz => new(w, w, y, z); - public readonly float4 wwyw => new(w, w, y, w); - public readonly float4 wwzx => new(w, w, z, x); - public readonly float4 wwzy => new(w, w, z, y); - public readonly float4 wwzz => new(w, w, z, z); - public readonly float4 wwzw => new(w, w, z, w); - public readonly float4 wwwx => new(w, w, w, x); - public readonly float4 wwwy => new(w, w, w, y); - public readonly float4 wwwz => new(w, w, w, z); - public readonly float4 wwww => new(w, w, w, w); - public readonly float2 rr => new(x, x); - public float2 rg { readonly get => new(x, y); set { x = value.x; y = value.y; } } - public float2 rb { readonly get => new(x, z); set { x = value.x; z = value.y; } } - public float2 ra { readonly get => new(x, w); set { x = value.x; w = value.y; } } - public float2 gr { readonly get => new(y, x); set { y = value.x; x = value.y; } } - public readonly float2 gg => new(y, y); - public float2 gb { readonly get => new(y, z); set { y = value.x; z = value.y; } } - public float2 ga { readonly get => new(y, w); set { y = value.x; w = value.y; } } - public float2 br { readonly get => new(z, x); set { z = value.x; x = value.y; } } - public float2 bg { readonly get => new(z, y); set { z = value.x; y = value.y; } } - public readonly float2 bb => new(z, z); - public float2 ba { readonly get => new(z, w); set { z = value.x; w = value.y; } } - public float2 ar { readonly get => new(w, x); set { w = value.x; x = value.y; } } - public float2 ag { readonly get => new(w, y); set { w = value.x; y = value.y; } } - public float2 ab { readonly get => new(w, z); set { w = value.x; z = value.y; } } - public readonly float2 aa => new(w, w); - public readonly float3 rrr => new(x, x, x); - public readonly float3 rrg => new(x, x, y); - public readonly float3 rrb => new(x, x, z); - public readonly float3 rra => new(x, x, w); - public readonly float3 rgr => new(x, y, x); - public readonly float3 rgg => new(x, y, y); - public float3 rgb { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } - public float3 rga { readonly get => new(x, y, w); set { x = value.x; y = value.y; w = value.z; } } - public readonly float3 rbr => new(x, z, x); - public float3 rbg { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } - public readonly float3 rbb => new(x, z, z); - public float3 rba { readonly get => new(x, z, w); set { x = value.x; z = value.y; w = value.z; } } - public readonly float3 rar => new(x, w, x); - public float3 rag { readonly get => new(x, w, y); set { x = value.x; w = value.y; y = value.z; } } - public float3 rab { readonly get => new(x, w, z); set { x = value.x; w = value.y; z = value.z; } } - public readonly float3 raa => new(x, w, w); - public readonly float3 grr => new(y, x, x); - public readonly float3 grg => new(y, x, y); - public float3 grb { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } - public float3 gra { readonly get => new(y, x, w); set { y = value.x; x = value.y; w = value.z; } } - public readonly float3 ggr => new(y, y, x); - public readonly float3 ggg => new(y, y, y); - public readonly float3 ggb => new(y, y, z); - public readonly float3 gga => new(y, y, w); - public float3 gbr { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } - public readonly float3 gbg => new(y, z, y); - public readonly float3 gbb => new(y, z, z); - public float3 gba { readonly get => new(y, z, w); set { y = value.x; z = value.y; w = value.z; } } - public float3 gar { readonly get => new(y, w, x); set { y = value.x; w = value.y; x = value.z; } } - public readonly float3 gag => new(y, w, y); - public float3 gab { readonly get => new(y, w, z); set { y = value.x; w = value.y; z = value.z; } } - public readonly float3 gaa => new(y, w, w); - public readonly float3 brr => new(z, x, x); - public float3 brg { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } - public readonly float3 brb => new(z, x, z); - public float3 bra { readonly get => new(z, x, w); set { z = value.x; x = value.y; w = value.z; } } - public float3 bgr { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } - public readonly float3 bgg => new(z, y, y); - public readonly float3 bgb => new(z, y, z); - public float3 bga { readonly get => new(z, y, w); set { z = value.x; y = value.y; w = value.z; } } - public readonly float3 bbr => new(z, z, x); - public readonly float3 bbg => new(z, z, y); - public readonly float3 bbb => new(z, z, z); - public readonly float3 bba => new(z, z, w); - public float3 bar { readonly get => new(z, w, x); set { z = value.x; w = value.y; x = value.z; } } - public float3 bag { readonly get => new(z, w, y); set { z = value.x; w = value.y; y = value.z; } } - public readonly float3 bab => new(z, w, z); - public readonly float3 baa => new(z, w, w); - public readonly float3 arr => new(w, x, x); - public float3 arg { readonly get => new(w, x, y); set { w = value.x; x = value.y; y = value.z; } } - public float3 arb { readonly get => new(w, x, z); set { w = value.x; x = value.y; z = value.z; } } - public readonly float3 ara => new(w, x, w); - public float3 agr { readonly get => new(w, y, x); set { w = value.x; y = value.y; x = value.z; } } - public readonly float3 agg => new(w, y, y); - public float3 agb { readonly get => new(w, y, z); set { w = value.x; y = value.y; z = value.z; } } - public readonly float3 aga => new(w, y, w); - public float3 abr { readonly get => new(w, z, x); set { w = value.x; z = value.y; x = value.z; } } - public float3 abg { readonly get => new(w, z, y); set { w = value.x; z = value.y; y = value.z; } } - public readonly float3 abb => new(w, z, z); - public readonly float3 aba => new(w, z, w); - public readonly float3 aar => new(w, w, x); - public readonly float3 aag => new(w, w, y); - public readonly float3 aab => new(w, w, z); - public readonly float3 aaa => new(w, w, w); - public readonly float4 rrrr => new(x, x, x, x); - public readonly float4 rrrg => new(x, x, x, y); - public readonly float4 rrrb => new(x, x, x, z); - public readonly float4 rrra => new(x, x, x, w); - public readonly float4 rrgr => new(x, x, y, x); - public readonly float4 rrgg => new(x, x, y, y); - public readonly float4 rrgb => new(x, x, y, z); - public readonly float4 rrga => new(x, x, y, w); - public readonly float4 rrbr => new(x, x, z, x); - public readonly float4 rrbg => new(x, x, z, y); - public readonly float4 rrbb => new(x, x, z, z); - public readonly float4 rrba => new(x, x, z, w); - public readonly float4 rrar => new(x, x, w, x); - public readonly float4 rrag => new(x, x, w, y); - public readonly float4 rrab => new(x, x, w, z); - public readonly float4 rraa => new(x, x, w, w); - public readonly float4 rgrr => new(x, y, x, x); - public readonly float4 rgrg => new(x, y, x, y); - public readonly float4 rgrb => new(x, y, x, z); - public readonly float4 rgra => new(x, y, x, w); - public readonly float4 rggr => new(x, y, y, x); - public readonly float4 rggg => new(x, y, y, y); - public readonly float4 rggb => new(x, y, y, z); - public readonly float4 rgga => new(x, y, y, w); - public readonly float4 rgbr => new(x, y, z, x); - public readonly float4 rgbg => new(x, y, z, y); - public readonly float4 rgbb => new(x, y, z, z); - public float4 rgba { readonly get => new(x, y, z, w); set { x = value.x; y = value.y; z = value.z; w = value.w; } } - public readonly float4 rgar => new(x, y, w, x); - public readonly float4 rgag => new(x, y, w, y); - public float4 rgab { readonly get => new(x, y, w, z); set { x = value.x; y = value.y; w = value.z; z = value.w; } } - public readonly float4 rgaa => new(x, y, w, w); - public readonly float4 rbrr => new(x, z, x, x); - public readonly float4 rbrg => new(x, z, x, y); - public readonly float4 rbrb => new(x, z, x, z); - public readonly float4 rbra => new(x, z, x, w); - public readonly float4 rbgr => new(x, z, y, x); - public readonly float4 rbgg => new(x, z, y, y); - public readonly float4 rbgb => new(x, z, y, z); - public float4 rbga { readonly get => new(x, z, y, w); set { x = value.x; z = value.y; y = value.z; w = value.w; } } - public readonly float4 rbbr => new(x, z, z, x); - public readonly float4 rbbg => new(x, z, z, y); - public readonly float4 rbbb => new(x, z, z, z); - public readonly float4 rbba => new(x, z, z, w); - public readonly float4 rbar => new(x, z, w, x); - public float4 rbag { readonly get => new(x, z, w, y); set { x = value.x; z = value.y; w = value.z; y = value.w; } } - public readonly float4 rbab => new(x, z, w, z); - public readonly float4 rbaa => new(x, z, w, w); - public readonly float4 rarr => new(x, w, x, x); - public readonly float4 rarg => new(x, w, x, y); - public readonly float4 rarb => new(x, w, x, z); - public readonly float4 rara => new(x, w, x, w); - public readonly float4 ragr => new(x, w, y, x); - public readonly float4 ragg => new(x, w, y, y); - public float4 ragb { readonly get => new(x, w, y, z); set { x = value.x; w = value.y; y = value.z; z = value.w; } } - public readonly float4 raga => new(x, w, y, w); - public readonly float4 rabr => new(x, w, z, x); - public float4 rabg { readonly get => new(x, w, z, y); set { x = value.x; w = value.y; z = value.z; y = value.w; } } - public readonly float4 rabb => new(x, w, z, z); - public readonly float4 raba => new(x, w, z, w); - public readonly float4 raar => new(x, w, w, x); - public readonly float4 raag => new(x, w, w, y); - public readonly float4 raab => new(x, w, w, z); - public readonly float4 raaa => new(x, w, w, w); - public readonly float4 grrr => new(y, x, x, x); - public readonly float4 grrg => new(y, x, x, y); - public readonly float4 grrb => new(y, x, x, z); - public readonly float4 grra => new(y, x, x, w); - public readonly float4 grgr => new(y, x, y, x); - public readonly float4 grgg => new(y, x, y, y); - public readonly float4 grgb => new(y, x, y, z); - public readonly float4 grga => new(y, x, y, w); - public readonly float4 grbr => new(y, x, z, x); - public readonly float4 grbg => new(y, x, z, y); - public readonly float4 grbb => new(y, x, z, z); - public float4 grba { readonly get => new(y, x, z, w); set { y = value.x; x = value.y; z = value.z; w = value.w; } } - public readonly float4 grar => new(y, x, w, x); - public readonly float4 grag => new(y, x, w, y); - public float4 grab { readonly get => new(y, x, w, z); set { y = value.x; x = value.y; w = value.z; z = value.w; } } - public readonly float4 graa => new(y, x, w, w); - public readonly float4 ggrr => new(y, y, x, x); - public readonly float4 ggrg => new(y, y, x, y); - public readonly float4 ggrb => new(y, y, x, z); - public readonly float4 ggra => new(y, y, x, w); - public readonly float4 gggr => new(y, y, y, x); - public readonly float4 gggg => new(y, y, y, y); - public readonly float4 gggb => new(y, y, y, z); - public readonly float4 ggga => new(y, y, y, w); - public readonly float4 ggbr => new(y, y, z, x); - public readonly float4 ggbg => new(y, y, z, y); - public readonly float4 ggbb => new(y, y, z, z); - public readonly float4 ggba => new(y, y, z, w); - public readonly float4 ggar => new(y, y, w, x); - public readonly float4 ggag => new(y, y, w, y); - public readonly float4 ggab => new(y, y, w, z); - public readonly float4 ggaa => new(y, y, w, w); - public readonly float4 gbrr => new(y, z, x, x); - public readonly float4 gbrg => new(y, z, x, y); - public readonly float4 gbrb => new(y, z, x, z); - public float4 gbra { readonly get => new(y, z, x, w); set { y = value.x; z = value.y; x = value.z; w = value.w; } } - public readonly float4 gbgr => new(y, z, y, x); - public readonly float4 gbgg => new(y, z, y, y); - public readonly float4 gbgb => new(y, z, y, z); - public readonly float4 gbga => new(y, z, y, w); - public readonly float4 gbbr => new(y, z, z, x); - public readonly float4 gbbg => new(y, z, z, y); - public readonly float4 gbbb => new(y, z, z, z); - public readonly float4 gbba => new(y, z, z, w); - public float4 gbar { readonly get => new(y, z, w, x); set { y = value.x; z = value.y; w = value.z; x = value.w; } } - public readonly float4 gbag => new(y, z, w, y); - public readonly float4 gbab => new(y, z, w, z); - public readonly float4 gbaa => new(y, z, w, w); - public readonly float4 garr => new(y, w, x, x); - public readonly float4 garg => new(y, w, x, y); - public float4 garb { readonly get => new(y, w, x, z); set { y = value.x; w = value.y; x = value.z; z = value.w; } } - public readonly float4 gara => new(y, w, x, w); - public readonly float4 gagr => new(y, w, y, x); - public readonly float4 gagg => new(y, w, y, y); - public readonly float4 gagb => new(y, w, y, z); - public readonly float4 gaga => new(y, w, y, w); - public float4 gabr { readonly get => new(y, w, z, x); set { y = value.x; w = value.y; z = value.z; x = value.w; } } - public readonly float4 gabg => new(y, w, z, y); - public readonly float4 gabb => new(y, w, z, z); - public readonly float4 gaba => new(y, w, z, w); - public readonly float4 gaar => new(y, w, w, x); - public readonly float4 gaag => new(y, w, w, y); - public readonly float4 gaab => new(y, w, w, z); - public readonly float4 gaaa => new(y, w, w, w); - public readonly float4 brrr => new(z, x, x, x); - public readonly float4 brrg => new(z, x, x, y); - public readonly float4 brrb => new(z, x, x, z); - public readonly float4 brra => new(z, x, x, w); - public readonly float4 brgr => new(z, x, y, x); - public readonly float4 brgg => new(z, x, y, y); - public readonly float4 brgb => new(z, x, y, z); - public float4 brga { readonly get => new(z, x, y, w); set { z = value.x; x = value.y; y = value.z; w = value.w; } } - public readonly float4 brbr => new(z, x, z, x); - public readonly float4 brbg => new(z, x, z, y); - public readonly float4 brbb => new(z, x, z, z); - public readonly float4 brba => new(z, x, z, w); - public readonly float4 brar => new(z, x, w, x); - public float4 brag { readonly get => new(z, x, w, y); set { z = value.x; x = value.y; w = value.z; y = value.w; } } - public readonly float4 brab => new(z, x, w, z); - public readonly float4 braa => new(z, x, w, w); - public readonly float4 bgrr => new(z, y, x, x); - public readonly float4 bgrg => new(z, y, x, y); - public readonly float4 bgrb => new(z, y, x, z); - public float4 bgra { readonly get => new(z, y, x, w); set { z = value.x; y = value.y; x = value.z; w = value.w; } } - public readonly float4 bggr => new(z, y, y, x); - public readonly float4 bggg => new(z, y, y, y); - public readonly float4 bggb => new(z, y, y, z); - public readonly float4 bgga => new(z, y, y, w); - public readonly float4 bgbr => new(z, y, z, x); - public readonly float4 bgbg => new(z, y, z, y); - public readonly float4 bgbb => new(z, y, z, z); - public readonly float4 bgba => new(z, y, z, w); - public float4 bgar { readonly get => new(z, y, w, x); set { z = value.x; y = value.y; w = value.z; x = value.w; } } - public readonly float4 bgag => new(z, y, w, y); - public readonly float4 bgab => new(z, y, w, z); - public readonly float4 bgaa => new(z, y, w, w); - public readonly float4 bbrr => new(z, z, x, x); - public readonly float4 bbrg => new(z, z, x, y); - public readonly float4 bbrb => new(z, z, x, z); - public readonly float4 bbra => new(z, z, x, w); - public readonly float4 bbgr => new(z, z, y, x); - public readonly float4 bbgg => new(z, z, y, y); - public readonly float4 bbgb => new(z, z, y, z); - public readonly float4 bbga => new(z, z, y, w); - public readonly float4 bbbr => new(z, z, z, x); - public readonly float4 bbbg => new(z, z, z, y); - public readonly float4 bbbb => new(z, z, z, z); - public readonly float4 bbba => new(z, z, z, w); - public readonly float4 bbar => new(z, z, w, x); - public readonly float4 bbag => new(z, z, w, y); - public readonly float4 bbab => new(z, z, w, z); - public readonly float4 bbaa => new(z, z, w, w); - public readonly float4 barr => new(z, w, x, x); - public float4 barg { readonly get => new(z, w, x, y); set { z = value.x; w = value.y; x = value.z; y = value.w; } } - public readonly float4 barb => new(z, w, x, z); - public readonly float4 bara => new(z, w, x, w); - public float4 bagr { readonly get => new(z, w, y, x); set { z = value.x; w = value.y; y = value.z; x = value.w; } } - public readonly float4 bagg => new(z, w, y, y); - public readonly float4 bagb => new(z, w, y, z); - public readonly float4 baga => new(z, w, y, w); - public readonly float4 babr => new(z, w, z, x); - public readonly float4 babg => new(z, w, z, y); - public readonly float4 babb => new(z, w, z, z); - public readonly float4 baba => new(z, w, z, w); - public readonly float4 baar => new(z, w, w, x); - public readonly float4 baag => new(z, w, w, y); - public readonly float4 baab => new(z, w, w, z); - public readonly float4 baaa => new(z, w, w, w); - public readonly float4 arrr => new(w, x, x, x); - public readonly float4 arrg => new(w, x, x, y); - public readonly float4 arrb => new(w, x, x, z); - public readonly float4 arra => new(w, x, x, w); - public readonly float4 argr => new(w, x, y, x); - public readonly float4 argg => new(w, x, y, y); - public float4 argb { readonly get => new(w, x, y, z); set { w = value.x; x = value.y; y = value.z; z = value.w; } } - public readonly float4 arga => new(w, x, y, w); - public readonly float4 arbr => new(w, x, z, x); - public float4 arbg { readonly get => new(w, x, z, y); set { w = value.x; x = value.y; z = value.z; y = value.w; } } - public readonly float4 arbb => new(w, x, z, z); - public readonly float4 arba => new(w, x, z, w); - public readonly float4 arar => new(w, x, w, x); - public readonly float4 arag => new(w, x, w, y); - public readonly float4 arab => new(w, x, w, z); - public readonly float4 araa => new(w, x, w, w); - public readonly float4 agrr => new(w, y, x, x); - public readonly float4 agrg => new(w, y, x, y); - public float4 agrb { readonly get => new(w, y, x, z); set { w = value.x; y = value.y; x = value.z; z = value.w; } } - public readonly float4 agra => new(w, y, x, w); - public readonly float4 aggr => new(w, y, y, x); - public readonly float4 aggg => new(w, y, y, y); - public readonly float4 aggb => new(w, y, y, z); - public readonly float4 agga => new(w, y, y, w); - public float4 agbr { readonly get => new(w, y, z, x); set { w = value.x; y = value.y; z = value.z; x = value.w; } } - public readonly float4 agbg => new(w, y, z, y); - public readonly float4 agbb => new(w, y, z, z); - public readonly float4 agba => new(w, y, z, w); - public readonly float4 agar => new(w, y, w, x); - public readonly float4 agag => new(w, y, w, y); - public readonly float4 agab => new(w, y, w, z); - public readonly float4 agaa => new(w, y, w, w); - public readonly float4 abrr => new(w, z, x, x); - public float4 abrg { readonly get => new(w, z, x, y); set { w = value.x; z = value.y; x = value.z; y = value.w; } } - public readonly float4 abrb => new(w, z, x, z); - public readonly float4 abra => new(w, z, x, w); - public float4 abgr { readonly get => new(w, z, y, x); set { w = value.x; z = value.y; y = value.z; x = value.w; } } - public readonly float4 abgg => new(w, z, y, y); - public readonly float4 abgb => new(w, z, y, z); - public readonly float4 abga => new(w, z, y, w); - public readonly float4 abbr => new(w, z, z, x); - public readonly float4 abbg => new(w, z, z, y); - public readonly float4 abbb => new(w, z, z, z); - public readonly float4 abba => new(w, z, z, w); - public readonly float4 abar => new(w, z, w, x); - public readonly float4 abag => new(w, z, w, y); - public readonly float4 abab => new(w, z, w, z); - public readonly float4 abaa => new(w, z, w, w); - public readonly float4 aarr => new(w, w, x, x); - public readonly float4 aarg => new(w, w, x, y); - public readonly float4 aarb => new(w, w, x, z); - public readonly float4 aara => new(w, w, x, w); - public readonly float4 aagr => new(w, w, y, x); - public readonly float4 aagg => new(w, w, y, y); - public readonly float4 aagb => new(w, w, y, z); - public readonly float4 aaga => new(w, w, y, w); - public readonly float4 aabr => new(w, w, z, x); - public readonly float4 aabg => new(w, w, z, y); - public readonly float4 aabb => new(w, w, z, z); - public readonly float4 aaba => new(w, w, z, w); - public readonly float4 aaar => new(w, w, w, x); - public readonly float4 aaag => new(w, w, w, y); - public readonly float4 aaab => new(w, w, w, z); - public readonly float4 aaaa => new(w, w, w, w); -} - -public partial struct int2 -{ - public int2(int2 xy) : this(xy.x, xy.y) { } - 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); - public int2 xy { readonly get => new(x, y); set { x = value.x; y = value.y; } } - public int2 yx { readonly get => new(y, x); set { y = value.x; x = value.y; } } - public readonly int2 yy => new(y, y); - public readonly int3 xxx => new(x, x, x); - public readonly int3 xxy => new(x, x, y); - public readonly int3 xyx => new(x, y, x); - public readonly int3 xyy => new(x, y, y); - public readonly int3 yxx => new(y, x, x); - public readonly int3 yxy => new(y, x, y); - public readonly int3 yyx => new(y, y, x); - public readonly int3 yyy => new(y, y, y); - public readonly int4 xxxx => new(x, x, x, x); - public readonly int4 xxxy => new(x, x, x, y); - public readonly int4 xxyx => new(x, x, y, x); - public readonly int4 xxyy => new(x, x, y, y); - public readonly int4 xyxx => new(x, y, x, x); - public readonly int4 xyxy => new(x, y, x, y); - public readonly int4 xyyx => new(x, y, y, x); - public readonly int4 xyyy => new(x, y, y, y); - public readonly int4 yxxx => new(y, x, x, x); - public readonly int4 yxxy => new(y, x, x, y); - public readonly int4 yxyx => new(y, x, y, x); - public readonly int4 yxyy => new(y, x, y, y); - public readonly int4 yyxx => new(y, y, x, x); - public readonly int4 yyxy => new(y, y, x, y); - public readonly int4 yyyx => new(y, y, y, x); - public readonly int4 yyyy => new(y, y, y, y); - public readonly int2 rr => new(x, x); - public int2 rg { readonly get => new(x, y); set { x = value.x; y = value.y; } } - public int2 gr { readonly get => new(y, x); set { y = value.x; x = value.y; } } - public readonly int2 gg => new(y, y); - public readonly int3 rrr => new(x, x, x); - public readonly int3 rrg => new(x, x, y); - public readonly int3 rgr => new(x, y, x); - public readonly int3 rgg => new(x, y, y); - public readonly int3 grr => new(y, x, x); - public readonly int3 grg => new(y, x, y); - public readonly int3 ggr => new(y, y, x); - public readonly int3 ggg => new(y, y, y); - public readonly int4 rrrr => new(x, x, x, x); - public readonly int4 rrrg => new(x, x, x, y); - public readonly int4 rrgr => new(x, x, y, x); - public readonly int4 rrgg => new(x, x, y, y); - public readonly int4 rgrr => new(x, y, x, x); - public readonly int4 rgrg => new(x, y, x, y); - public readonly int4 rggr => new(x, y, y, x); - public readonly int4 rggg => new(x, y, y, y); - public readonly int4 grrr => new(y, x, x, x); - public readonly int4 grrg => new(y, x, x, y); - public readonly int4 grgr => new(y, x, y, x); - public readonly int4 grgg => new(y, x, y, y); - public readonly int4 ggrr => new(y, y, x, x); - public readonly int4 ggrg => new(y, y, x, y); - public readonly int4 gggr => new(y, y, y, x); - public readonly int4 gggg => new(y, y, y, y); -} - -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 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; } - public readonly int2 xx => new(x, x); - public int2 xy { readonly get => new(x, y); set { x = value.x; y = value.y; } } - public int2 xz { readonly get => new(x, z); set { x = value.x; z = value.y; } } - public int2 yx { readonly get => new(y, x); set { y = value.x; x = value.y; } } - public readonly int2 yy => new(y, y); - public int2 yz { readonly get => new(y, z); set { y = value.x; z = value.y; } } - public int2 zx { readonly get => new(z, x); set { z = value.x; x = value.y; } } - public int2 zy { readonly get => new(z, y); set { z = value.x; y = value.y; } } - public readonly int2 zz => new(z, z); - public readonly int3 xxx => new(x, x, x); - public readonly int3 xxy => new(x, x, y); - public readonly int3 xxz => new(x, x, z); - public readonly int3 xyx => new(x, y, x); - public readonly int3 xyy => new(x, y, y); - public int3 xyz { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } - public readonly int3 xzx => new(x, z, x); - public int3 xzy { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } - public readonly int3 xzz => new(x, z, z); - public readonly int3 yxx => new(y, x, x); - public readonly int3 yxy => new(y, x, y); - public int3 yxz { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } - public readonly int3 yyx => new(y, y, x); - public readonly int3 yyy => new(y, y, y); - public readonly int3 yyz => new(y, y, z); - public int3 yzx { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } - public readonly int3 yzy => new(y, z, y); - public readonly int3 yzz => new(y, z, z); - public readonly int3 zxx => new(z, x, x); - public int3 zxy { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } - public readonly int3 zxz => new(z, x, z); - public int3 zyx { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } - public readonly int3 zyy => new(z, y, y); - public readonly int3 zyz => new(z, y, z); - public readonly int3 zzx => new(z, z, x); - public readonly int3 zzy => new(z, z, y); - public readonly int3 zzz => new(z, z, z); - public readonly int4 xxxx => new(x, x, x, x); - public readonly int4 xxxy => new(x, x, x, y); - public readonly int4 xxxz => new(x, x, x, z); - public readonly int4 xxyx => new(x, x, y, x); - public readonly int4 xxyy => new(x, x, y, y); - public readonly int4 xxyz => new(x, x, y, z); - public readonly int4 xxzx => new(x, x, z, x); - public readonly int4 xxzy => new(x, x, z, y); - public readonly int4 xxzz => new(x, x, z, z); - public readonly int4 xyxx => new(x, y, x, x); - public readonly int4 xyxy => new(x, y, x, y); - public readonly int4 xyxz => new(x, y, x, z); - public readonly int4 xyyx => new(x, y, y, x); - public readonly int4 xyyy => new(x, y, y, y); - public readonly int4 xyyz => new(x, y, y, z); - public readonly int4 xyzx => new(x, y, z, x); - public readonly int4 xyzy => new(x, y, z, y); - public readonly int4 xyzz => new(x, y, z, z); - public readonly int4 xzxx => new(x, z, x, x); - public readonly int4 xzxy => new(x, z, x, y); - public readonly int4 xzxz => new(x, z, x, z); - public readonly int4 xzyx => new(x, z, y, x); - public readonly int4 xzyy => new(x, z, y, y); - public readonly int4 xzyz => new(x, z, y, z); - public readonly int4 xzzx => new(x, z, z, x); - public readonly int4 xzzy => new(x, z, z, y); - public readonly int4 xzzz => new(x, z, z, z); - public readonly int4 yxxx => new(y, x, x, x); - public readonly int4 yxxy => new(y, x, x, y); - public readonly int4 yxxz => new(y, x, x, z); - public readonly int4 yxyx => new(y, x, y, x); - public readonly int4 yxyy => new(y, x, y, y); - public readonly int4 yxyz => new(y, x, y, z); - public readonly int4 yxzx => new(y, x, z, x); - public readonly int4 yxzy => new(y, x, z, y); - public readonly int4 yxzz => new(y, x, z, z); - public readonly int4 yyxx => new(y, y, x, x); - public readonly int4 yyxy => new(y, y, x, y); - public readonly int4 yyxz => new(y, y, x, z); - public readonly int4 yyyx => new(y, y, y, x); - public readonly int4 yyyy => new(y, y, y, y); - public readonly int4 yyyz => new(y, y, y, z); - public readonly int4 yyzx => new(y, y, z, x); - public readonly int4 yyzy => new(y, y, z, y); - public readonly int4 yyzz => new(y, y, z, z); - public readonly int4 yzxx => new(y, z, x, x); - public readonly int4 yzxy => new(y, z, x, y); - public readonly int4 yzxz => new(y, z, x, z); - public readonly int4 yzyx => new(y, z, y, x); - public readonly int4 yzyy => new(y, z, y, y); - public readonly int4 yzyz => new(y, z, y, z); - public readonly int4 yzzx => new(y, z, z, x); - public readonly int4 yzzy => new(y, z, z, y); - public readonly int4 yzzz => new(y, z, z, z); - public readonly int4 zxxx => new(z, x, x, x); - public readonly int4 zxxy => new(z, x, x, y); - public readonly int4 zxxz => new(z, x, x, z); - public readonly int4 zxyx => new(z, x, y, x); - public readonly int4 zxyy => new(z, x, y, y); - public readonly int4 zxyz => new(z, x, y, z); - public readonly int4 zxzx => new(z, x, z, x); - public readonly int4 zxzy => new(z, x, z, y); - public readonly int4 zxzz => new(z, x, z, z); - public readonly int4 zyxx => new(z, y, x, x); - public readonly int4 zyxy => new(z, y, x, y); - public readonly int4 zyxz => new(z, y, x, z); - public readonly int4 zyyx => new(z, y, y, x); - public readonly int4 zyyy => new(z, y, y, y); - public readonly int4 zyyz => new(z, y, y, z); - public readonly int4 zyzx => new(z, y, z, x); - public readonly int4 zyzy => new(z, y, z, y); - public readonly int4 zyzz => new(z, y, z, z); - public readonly int4 zzxx => new(z, z, x, x); - public readonly int4 zzxy => new(z, z, x, y); - public readonly int4 zzxz => new(z, z, x, z); - public readonly int4 zzyx => new(z, z, y, x); - public readonly int4 zzyy => new(z, z, y, y); - public readonly int4 zzyz => new(z, z, y, z); - public readonly int4 zzzx => new(z, z, z, x); - public readonly int4 zzzy => new(z, z, z, y); - public readonly int4 zzzz => new(z, z, z, z); - public readonly int2 rr => new(x, x); - public int2 rg { readonly get => new(x, y); set { x = value.x; y = value.y; } } - public int2 rb { readonly get => new(x, z); set { x = value.x; z = value.y; } } - public int2 gr { readonly get => new(y, x); set { y = value.x; x = value.y; } } - public readonly int2 gg => new(y, y); - public int2 gb { readonly get => new(y, z); set { y = value.x; z = value.y; } } - public int2 br { readonly get => new(z, x); set { z = value.x; x = value.y; } } - public int2 bg { readonly get => new(z, y); set { z = value.x; y = value.y; } } - public readonly int2 bb => new(z, z); - public readonly int3 rrr => new(x, x, x); - public readonly int3 rrg => new(x, x, y); - public readonly int3 rrb => new(x, x, z); - public readonly int3 rgr => new(x, y, x); - public readonly int3 rgg => new(x, y, y); - public int3 rgb { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } - public readonly int3 rbr => new(x, z, x); - public int3 rbg { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } - public readonly int3 rbb => new(x, z, z); - public readonly int3 grr => new(y, x, x); - public readonly int3 grg => new(y, x, y); - public int3 grb { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } - public readonly int3 ggr => new(y, y, x); - public readonly int3 ggg => new(y, y, y); - public readonly int3 ggb => new(y, y, z); - public int3 gbr { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } - public readonly int3 gbg => new(y, z, y); - public readonly int3 gbb => new(y, z, z); - public readonly int3 brr => new(z, x, x); - public int3 brg { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } - public readonly int3 brb => new(z, x, z); - public int3 bgr { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } - public readonly int3 bgg => new(z, y, y); - public readonly int3 bgb => new(z, y, z); - public readonly int3 bbr => new(z, z, x); - public readonly int3 bbg => new(z, z, y); - public readonly int3 bbb => new(z, z, z); - public readonly int4 rrrr => new(x, x, x, x); - public readonly int4 rrrg => new(x, x, x, y); - public readonly int4 rrrb => new(x, x, x, z); - public readonly int4 rrgr => new(x, x, y, x); - public readonly int4 rrgg => new(x, x, y, y); - public readonly int4 rrgb => new(x, x, y, z); - public readonly int4 rrbr => new(x, x, z, x); - public readonly int4 rrbg => new(x, x, z, y); - public readonly int4 rrbb => new(x, x, z, z); - public readonly int4 rgrr => new(x, y, x, x); - public readonly int4 rgrg => new(x, y, x, y); - public readonly int4 rgrb => new(x, y, x, z); - public readonly int4 rggr => new(x, y, y, x); - public readonly int4 rggg => new(x, y, y, y); - public readonly int4 rggb => new(x, y, y, z); - public readonly int4 rgbr => new(x, y, z, x); - public readonly int4 rgbg => new(x, y, z, y); - public readonly int4 rgbb => new(x, y, z, z); - public readonly int4 rbrr => new(x, z, x, x); - public readonly int4 rbrg => new(x, z, x, y); - public readonly int4 rbrb => new(x, z, x, z); - public readonly int4 rbgr => new(x, z, y, x); - public readonly int4 rbgg => new(x, z, y, y); - public readonly int4 rbgb => new(x, z, y, z); - public readonly int4 rbbr => new(x, z, z, x); - public readonly int4 rbbg => new(x, z, z, y); - public readonly int4 rbbb => new(x, z, z, z); - public readonly int4 grrr => new(y, x, x, x); - public readonly int4 grrg => new(y, x, x, y); - public readonly int4 grrb => new(y, x, x, z); - public readonly int4 grgr => new(y, x, y, x); - public readonly int4 grgg => new(y, x, y, y); - public readonly int4 grgb => new(y, x, y, z); - public readonly int4 grbr => new(y, x, z, x); - public readonly int4 grbg => new(y, x, z, y); - public readonly int4 grbb => new(y, x, z, z); - public readonly int4 ggrr => new(y, y, x, x); - public readonly int4 ggrg => new(y, y, x, y); - public readonly int4 ggrb => new(y, y, x, z); - public readonly int4 gggr => new(y, y, y, x); - public readonly int4 gggg => new(y, y, y, y); - public readonly int4 gggb => new(y, y, y, z); - public readonly int4 ggbr => new(y, y, z, x); - public readonly int4 ggbg => new(y, y, z, y); - public readonly int4 ggbb => new(y, y, z, z); - public readonly int4 gbrr => new(y, z, x, x); - public readonly int4 gbrg => new(y, z, x, y); - public readonly int4 gbrb => new(y, z, x, z); - public readonly int4 gbgr => new(y, z, y, x); - public readonly int4 gbgg => new(y, z, y, y); - public readonly int4 gbgb => new(y, z, y, z); - public readonly int4 gbbr => new(y, z, z, x); - public readonly int4 gbbg => new(y, z, z, y); - public readonly int4 gbbb => new(y, z, z, z); - public readonly int4 brrr => new(z, x, x, x); - public readonly int4 brrg => new(z, x, x, y); - public readonly int4 brrb => new(z, x, x, z); - public readonly int4 brgr => new(z, x, y, x); - public readonly int4 brgg => new(z, x, y, y); - public readonly int4 brgb => new(z, x, y, z); - public readonly int4 brbr => new(z, x, z, x); - public readonly int4 brbg => new(z, x, z, y); - public readonly int4 brbb => new(z, x, z, z); - public readonly int4 bgrr => new(z, y, x, x); - public readonly int4 bgrg => new(z, y, x, y); - public readonly int4 bgrb => new(z, y, x, z); - public readonly int4 bggr => new(z, y, y, x); - public readonly int4 bggg => new(z, y, y, y); - public readonly int4 bggb => new(z, y, y, z); - public readonly int4 bgbr => new(z, y, z, x); - public readonly int4 bgbg => new(z, y, z, y); - public readonly int4 bgbb => new(z, y, z, z); - public readonly int4 bbrr => new(z, z, x, x); - public readonly int4 bbrg => new(z, z, x, y); - public readonly int4 bbrb => new(z, z, x, z); - public readonly int4 bbgr => new(z, z, y, x); - public readonly int4 bbgg => new(z, z, y, y); - public readonly int4 bbgb => new(z, z, y, z); - public readonly int4 bbbr => new(z, z, z, x); - public readonly int4 bbbg => new(z, z, z, y); - public readonly int4 bbbb => new(z, z, z, z); -} - -public partial struct int4 -{ - public int4(int x, int y, int2 zw) : this(x, y, zw.x, zw.y) { } - public int4(int x, int2 yz, int w) : this(x, yz.x, yz.y, w) { } - public int4(int x, int3 yzw) : this(x, yzw.x, yzw.y, yzw.z) { } - public int4(int2 xy, int z, int w) : this(xy.x, xy.y, z, w) { } - 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 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; } - public int a { readonly get => w; set => w = value; } - public readonly int2 xx => new(x, x); - public int2 xy { readonly get => new(x, y); set { x = value.x; y = value.y; } } - public int2 xz { readonly get => new(x, z); set { x = value.x; z = value.y; } } - public int2 xw { readonly get => new(x, w); set { x = value.x; w = value.y; } } - public int2 yx { readonly get => new(y, x); set { y = value.x; x = value.y; } } - public readonly int2 yy => new(y, y); - public int2 yz { readonly get => new(y, z); set { y = value.x; z = value.y; } } - public int2 yw { readonly get => new(y, w); set { y = value.x; w = value.y; } } - public int2 zx { readonly get => new(z, x); set { z = value.x; x = value.y; } } - public int2 zy { readonly get => new(z, y); set { z = value.x; y = value.y; } } - public readonly int2 zz => new(z, z); - public int2 zw { readonly get => new(z, w); set { z = value.x; w = value.y; } } - public int2 wx { readonly get => new(w, x); set { w = value.x; x = value.y; } } - public int2 wy { readonly get => new(w, y); set { w = value.x; y = value.y; } } - public int2 wz { readonly get => new(w, z); set { w = value.x; z = value.y; } } - public readonly int2 ww => new(w, w); - public readonly int3 xxx => new(x, x, x); - public readonly int3 xxy => new(x, x, y); - public readonly int3 xxz => new(x, x, z); - public readonly int3 xxw => new(x, x, w); - public readonly int3 xyx => new(x, y, x); - public readonly int3 xyy => new(x, y, y); - public int3 xyz { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } - public int3 xyw { readonly get => new(x, y, w); set { x = value.x; y = value.y; w = value.z; } } - public readonly int3 xzx => new(x, z, x); - public int3 xzy { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } - public readonly int3 xzz => new(x, z, z); - public int3 xzw { readonly get => new(x, z, w); set { x = value.x; z = value.y; w = value.z; } } - public readonly int3 xwx => new(x, w, x); - public int3 xwy { readonly get => new(x, w, y); set { x = value.x; w = value.y; y = value.z; } } - public int3 xwz { readonly get => new(x, w, z); set { x = value.x; w = value.y; z = value.z; } } - public readonly int3 xww => new(x, w, w); - public readonly int3 yxx => new(y, x, x); - public readonly int3 yxy => new(y, x, y); - public int3 yxz { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } - public int3 yxw { readonly get => new(y, x, w); set { y = value.x; x = value.y; w = value.z; } } - public readonly int3 yyx => new(y, y, x); - public readonly int3 yyy => new(y, y, y); - public readonly int3 yyz => new(y, y, z); - public readonly int3 yyw => new(y, y, w); - public int3 yzx { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } - public readonly int3 yzy => new(y, z, y); - public readonly int3 yzz => new(y, z, z); - public int3 yzw { readonly get => new(y, z, w); set { y = value.x; z = value.y; w = value.z; } } - public int3 ywx { readonly get => new(y, w, x); set { y = value.x; w = value.y; x = value.z; } } - public readonly int3 ywy => new(y, w, y); - public int3 ywz { readonly get => new(y, w, z); set { y = value.x; w = value.y; z = value.z; } } - public readonly int3 yww => new(y, w, w); - public readonly int3 zxx => new(z, x, x); - public int3 zxy { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } - public readonly int3 zxz => new(z, x, z); - public int3 zxw { readonly get => new(z, x, w); set { z = value.x; x = value.y; w = value.z; } } - public int3 zyx { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } - public readonly int3 zyy => new(z, y, y); - public readonly int3 zyz => new(z, y, z); - public int3 zyw { readonly get => new(z, y, w); set { z = value.x; y = value.y; w = value.z; } } - public readonly int3 zzx => new(z, z, x); - public readonly int3 zzy => new(z, z, y); - public readonly int3 zzz => new(z, z, z); - public readonly int3 zzw => new(z, z, w); - public int3 zwx { readonly get => new(z, w, x); set { z = value.x; w = value.y; x = value.z; } } - public int3 zwy { readonly get => new(z, w, y); set { z = value.x; w = value.y; y = value.z; } } - public readonly int3 zwz => new(z, w, z); - public readonly int3 zww => new(z, w, w); - public readonly int3 wxx => new(w, x, x); - public int3 wxy { readonly get => new(w, x, y); set { w = value.x; x = value.y; y = value.z; } } - public int3 wxz { readonly get => new(w, x, z); set { w = value.x; x = value.y; z = value.z; } } - public readonly int3 wxw => new(w, x, w); - public int3 wyx { readonly get => new(w, y, x); set { w = value.x; y = value.y; x = value.z; } } - public readonly int3 wyy => new(w, y, y); - public int3 wyz { readonly get => new(w, y, z); set { w = value.x; y = value.y; z = value.z; } } - public readonly int3 wyw => new(w, y, w); - public int3 wzx { readonly get => new(w, z, x); set { w = value.x; z = value.y; x = value.z; } } - public int3 wzy { readonly get => new(w, z, y); set { w = value.x; z = value.y; y = value.z; } } - public readonly int3 wzz => new(w, z, z); - public readonly int3 wzw => new(w, z, w); - public readonly int3 wwx => new(w, w, x); - public readonly int3 wwy => new(w, w, y); - public readonly int3 wwz => new(w, w, z); - public readonly int3 www => new(w, w, w); - public readonly int4 xxxx => new(x, x, x, x); - public readonly int4 xxxy => new(x, x, x, y); - public readonly int4 xxxz => new(x, x, x, z); - public readonly int4 xxxw => new(x, x, x, w); - public readonly int4 xxyx => new(x, x, y, x); - public readonly int4 xxyy => new(x, x, y, y); - public readonly int4 xxyz => new(x, x, y, z); - public readonly int4 xxyw => new(x, x, y, w); - public readonly int4 xxzx => new(x, x, z, x); - public readonly int4 xxzy => new(x, x, z, y); - public readonly int4 xxzz => new(x, x, z, z); - public readonly int4 xxzw => new(x, x, z, w); - public readonly int4 xxwx => new(x, x, w, x); - public readonly int4 xxwy => new(x, x, w, y); - public readonly int4 xxwz => new(x, x, w, z); - public readonly int4 xxww => new(x, x, w, w); - public readonly int4 xyxx => new(x, y, x, x); - public readonly int4 xyxy => new(x, y, x, y); - public readonly int4 xyxz => new(x, y, x, z); - public readonly int4 xyxw => new(x, y, x, w); - public readonly int4 xyyx => new(x, y, y, x); - public readonly int4 xyyy => new(x, y, y, y); - public readonly int4 xyyz => new(x, y, y, z); - public readonly int4 xyyw => new(x, y, y, w); - public readonly int4 xyzx => new(x, y, z, x); - public readonly int4 xyzy => new(x, y, z, y); - public readonly int4 xyzz => new(x, y, z, z); - public int4 xyzw { readonly get => new(x, y, z, w); set { x = value.x; y = value.y; z = value.z; w = value.w; } } - public readonly int4 xywx => new(x, y, w, x); - public readonly int4 xywy => new(x, y, w, y); - public int4 xywz { readonly get => new(x, y, w, z); set { x = value.x; y = value.y; w = value.z; z = value.w; } } - public readonly int4 xyww => new(x, y, w, w); - public readonly int4 xzxx => new(x, z, x, x); - public readonly int4 xzxy => new(x, z, x, y); - public readonly int4 xzxz => new(x, z, x, z); - public readonly int4 xzxw => new(x, z, x, w); - public readonly int4 xzyx => new(x, z, y, x); - public readonly int4 xzyy => new(x, z, y, y); - public readonly int4 xzyz => new(x, z, y, z); - public int4 xzyw { readonly get => new(x, z, y, w); set { x = value.x; z = value.y; y = value.z; w = value.w; } } - public readonly int4 xzzx => new(x, z, z, x); - public readonly int4 xzzy => new(x, z, z, y); - public readonly int4 xzzz => new(x, z, z, z); - public readonly int4 xzzw => new(x, z, z, w); - public readonly int4 xzwx => new(x, z, w, x); - public int4 xzwy { readonly get => new(x, z, w, y); set { x = value.x; z = value.y; w = value.z; y = value.w; } } - public readonly int4 xzwz => new(x, z, w, z); - public readonly int4 xzww => new(x, z, w, w); - public readonly int4 xwxx => new(x, w, x, x); - public readonly int4 xwxy => new(x, w, x, y); - public readonly int4 xwxz => new(x, w, x, z); - public readonly int4 xwxw => new(x, w, x, w); - public readonly int4 xwyx => new(x, w, y, x); - public readonly int4 xwyy => new(x, w, y, y); - public int4 xwyz { readonly get => new(x, w, y, z); set { x = value.x; w = value.y; y = value.z; z = value.w; } } - public readonly int4 xwyw => new(x, w, y, w); - public readonly int4 xwzx => new(x, w, z, x); - public int4 xwzy { readonly get => new(x, w, z, y); set { x = value.x; w = value.y; z = value.z; y = value.w; } } - public readonly int4 xwzz => new(x, w, z, z); - public readonly int4 xwzw => new(x, w, z, w); - public readonly int4 xwwx => new(x, w, w, x); - public readonly int4 xwwy => new(x, w, w, y); - public readonly int4 xwwz => new(x, w, w, z); - public readonly int4 xwww => new(x, w, w, w); - public readonly int4 yxxx => new(y, x, x, x); - public readonly int4 yxxy => new(y, x, x, y); - public readonly int4 yxxz => new(y, x, x, z); - public readonly int4 yxxw => new(y, x, x, w); - public readonly int4 yxyx => new(y, x, y, x); - public readonly int4 yxyy => new(y, x, y, y); - public readonly int4 yxyz => new(y, x, y, z); - public readonly int4 yxyw => new(y, x, y, w); - public readonly int4 yxzx => new(y, x, z, x); - public readonly int4 yxzy => new(y, x, z, y); - public readonly int4 yxzz => new(y, x, z, z); - public int4 yxzw { readonly get => new(y, x, z, w); set { y = value.x; x = value.y; z = value.z; w = value.w; } } - public readonly int4 yxwx => new(y, x, w, x); - public readonly int4 yxwy => new(y, x, w, y); - public int4 yxwz { readonly get => new(y, x, w, z); set { y = value.x; x = value.y; w = value.z; z = value.w; } } - public readonly int4 yxww => new(y, x, w, w); - public readonly int4 yyxx => new(y, y, x, x); - public readonly int4 yyxy => new(y, y, x, y); - public readonly int4 yyxz => new(y, y, x, z); - public readonly int4 yyxw => new(y, y, x, w); - public readonly int4 yyyx => new(y, y, y, x); - public readonly int4 yyyy => new(y, y, y, y); - public readonly int4 yyyz => new(y, y, y, z); - public readonly int4 yyyw => new(y, y, y, w); - public readonly int4 yyzx => new(y, y, z, x); - public readonly int4 yyzy => new(y, y, z, y); - public readonly int4 yyzz => new(y, y, z, z); - public readonly int4 yyzw => new(y, y, z, w); - public readonly int4 yywx => new(y, y, w, x); - public readonly int4 yywy => new(y, y, w, y); - public readonly int4 yywz => new(y, y, w, z); - public readonly int4 yyww => new(y, y, w, w); - public readonly int4 yzxx => new(y, z, x, x); - public readonly int4 yzxy => new(y, z, x, y); - public readonly int4 yzxz => new(y, z, x, z); - public int4 yzxw { readonly get => new(y, z, x, w); set { y = value.x; z = value.y; x = value.z; w = value.w; } } - public readonly int4 yzyx => new(y, z, y, x); - public readonly int4 yzyy => new(y, z, y, y); - public readonly int4 yzyz => new(y, z, y, z); - public readonly int4 yzyw => new(y, z, y, w); - public readonly int4 yzzx => new(y, z, z, x); - public readonly int4 yzzy => new(y, z, z, y); - public readonly int4 yzzz => new(y, z, z, z); - public readonly int4 yzzw => new(y, z, z, w); - public int4 yzwx { readonly get => new(y, z, w, x); set { y = value.x; z = value.y; w = value.z; x = value.w; } } - public readonly int4 yzwy => new(y, z, w, y); - public readonly int4 yzwz => new(y, z, w, z); - public readonly int4 yzww => new(y, z, w, w); - public readonly int4 ywxx => new(y, w, x, x); - public readonly int4 ywxy => new(y, w, x, y); - public int4 ywxz { readonly get => new(y, w, x, z); set { y = value.x; w = value.y; x = value.z; z = value.w; } } - public readonly int4 ywxw => new(y, w, x, w); - public readonly int4 ywyx => new(y, w, y, x); - public readonly int4 ywyy => new(y, w, y, y); - public readonly int4 ywyz => new(y, w, y, z); - public readonly int4 ywyw => new(y, w, y, w); - public int4 ywzx { readonly get => new(y, w, z, x); set { y = value.x; w = value.y; z = value.z; x = value.w; } } - public readonly int4 ywzy => new(y, w, z, y); - public readonly int4 ywzz => new(y, w, z, z); - public readonly int4 ywzw => new(y, w, z, w); - public readonly int4 ywwx => new(y, w, w, x); - public readonly int4 ywwy => new(y, w, w, y); - public readonly int4 ywwz => new(y, w, w, z); - public readonly int4 ywww => new(y, w, w, w); - public readonly int4 zxxx => new(z, x, x, x); - public readonly int4 zxxy => new(z, x, x, y); - public readonly int4 zxxz => new(z, x, x, z); - public readonly int4 zxxw => new(z, x, x, w); - public readonly int4 zxyx => new(z, x, y, x); - public readonly int4 zxyy => new(z, x, y, y); - public readonly int4 zxyz => new(z, x, y, z); - public int4 zxyw { readonly get => new(z, x, y, w); set { z = value.x; x = value.y; y = value.z; w = value.w; } } - public readonly int4 zxzx => new(z, x, z, x); - public readonly int4 zxzy => new(z, x, z, y); - public readonly int4 zxzz => new(z, x, z, z); - public readonly int4 zxzw => new(z, x, z, w); - public readonly int4 zxwx => new(z, x, w, x); - public int4 zxwy { readonly get => new(z, x, w, y); set { z = value.x; x = value.y; w = value.z; y = value.w; } } - public readonly int4 zxwz => new(z, x, w, z); - public readonly int4 zxww => new(z, x, w, w); - public readonly int4 zyxx => new(z, y, x, x); - public readonly int4 zyxy => new(z, y, x, y); - public readonly int4 zyxz => new(z, y, x, z); - public int4 zyxw { readonly get => new(z, y, x, w); set { z = value.x; y = value.y; x = value.z; w = value.w; } } - public readonly int4 zyyx => new(z, y, y, x); - public readonly int4 zyyy => new(z, y, y, y); - public readonly int4 zyyz => new(z, y, y, z); - public readonly int4 zyyw => new(z, y, y, w); - public readonly int4 zyzx => new(z, y, z, x); - public readonly int4 zyzy => new(z, y, z, y); - public readonly int4 zyzz => new(z, y, z, z); - public readonly int4 zyzw => new(z, y, z, w); - public int4 zywx { readonly get => new(z, y, w, x); set { z = value.x; y = value.y; w = value.z; x = value.w; } } - public readonly int4 zywy => new(z, y, w, y); - public readonly int4 zywz => new(z, y, w, z); - public readonly int4 zyww => new(z, y, w, w); - public readonly int4 zzxx => new(z, z, x, x); - public readonly int4 zzxy => new(z, z, x, y); - public readonly int4 zzxz => new(z, z, x, z); - public readonly int4 zzxw => new(z, z, x, w); - public readonly int4 zzyx => new(z, z, y, x); - public readonly int4 zzyy => new(z, z, y, y); - public readonly int4 zzyz => new(z, z, y, z); - public readonly int4 zzyw => new(z, z, y, w); - public readonly int4 zzzx => new(z, z, z, x); - public readonly int4 zzzy => new(z, z, z, y); - public readonly int4 zzzz => new(z, z, z, z); - public readonly int4 zzzw => new(z, z, z, w); - public readonly int4 zzwx => new(z, z, w, x); - public readonly int4 zzwy => new(z, z, w, y); - public readonly int4 zzwz => new(z, z, w, z); - public readonly int4 zzww => new(z, z, w, w); - public readonly int4 zwxx => new(z, w, x, x); - public int4 zwxy { readonly get => new(z, w, x, y); set { z = value.x; w = value.y; x = value.z; y = value.w; } } - public readonly int4 zwxz => new(z, w, x, z); - public readonly int4 zwxw => new(z, w, x, w); - public int4 zwyx { readonly get => new(z, w, y, x); set { z = value.x; w = value.y; y = value.z; x = value.w; } } - public readonly int4 zwyy => new(z, w, y, y); - public readonly int4 zwyz => new(z, w, y, z); - public readonly int4 zwyw => new(z, w, y, w); - public readonly int4 zwzx => new(z, w, z, x); - public readonly int4 zwzy => new(z, w, z, y); - public readonly int4 zwzz => new(z, w, z, z); - public readonly int4 zwzw => new(z, w, z, w); - public readonly int4 zwwx => new(z, w, w, x); - public readonly int4 zwwy => new(z, w, w, y); - public readonly int4 zwwz => new(z, w, w, z); - public readonly int4 zwww => new(z, w, w, w); - public readonly int4 wxxx => new(w, x, x, x); - public readonly int4 wxxy => new(w, x, x, y); - public readonly int4 wxxz => new(w, x, x, z); - public readonly int4 wxxw => new(w, x, x, w); - public readonly int4 wxyx => new(w, x, y, x); - public readonly int4 wxyy => new(w, x, y, y); - public int4 wxyz { readonly get => new(w, x, y, z); set { w = value.x; x = value.y; y = value.z; z = value.w; } } - public readonly int4 wxyw => new(w, x, y, w); - public readonly int4 wxzx => new(w, x, z, x); - public int4 wxzy { readonly get => new(w, x, z, y); set { w = value.x; x = value.y; z = value.z; y = value.w; } } - public readonly int4 wxzz => new(w, x, z, z); - public readonly int4 wxzw => new(w, x, z, w); - public readonly int4 wxwx => new(w, x, w, x); - public readonly int4 wxwy => new(w, x, w, y); - public readonly int4 wxwz => new(w, x, w, z); - public readonly int4 wxww => new(w, x, w, w); - public readonly int4 wyxx => new(w, y, x, x); - public readonly int4 wyxy => new(w, y, x, y); - public int4 wyxz { readonly get => new(w, y, x, z); set { w = value.x; y = value.y; x = value.z; z = value.w; } } - public readonly int4 wyxw => new(w, y, x, w); - public readonly int4 wyyx => new(w, y, y, x); - public readonly int4 wyyy => new(w, y, y, y); - public readonly int4 wyyz => new(w, y, y, z); - public readonly int4 wyyw => new(w, y, y, w); - public int4 wyzx { readonly get => new(w, y, z, x); set { w = value.x; y = value.y; z = value.z; x = value.w; } } - public readonly int4 wyzy => new(w, y, z, y); - public readonly int4 wyzz => new(w, y, z, z); - public readonly int4 wyzw => new(w, y, z, w); - public readonly int4 wywx => new(w, y, w, x); - public readonly int4 wywy => new(w, y, w, y); - public readonly int4 wywz => new(w, y, w, z); - public readonly int4 wyww => new(w, y, w, w); - public readonly int4 wzxx => new(w, z, x, x); - public int4 wzxy { readonly get => new(w, z, x, y); set { w = value.x; z = value.y; x = value.z; y = value.w; } } - public readonly int4 wzxz => new(w, z, x, z); - public readonly int4 wzxw => new(w, z, x, w); - public int4 wzyx { readonly get => new(w, z, y, x); set { w = value.x; z = value.y; y = value.z; x = value.w; } } - public readonly int4 wzyy => new(w, z, y, y); - public readonly int4 wzyz => new(w, z, y, z); - public readonly int4 wzyw => new(w, z, y, w); - public readonly int4 wzzx => new(w, z, z, x); - public readonly int4 wzzy => new(w, z, z, y); - public readonly int4 wzzz => new(w, z, z, z); - public readonly int4 wzzw => new(w, z, z, w); - public readonly int4 wzwx => new(w, z, w, x); - public readonly int4 wzwy => new(w, z, w, y); - public readonly int4 wzwz => new(w, z, w, z); - public readonly int4 wzww => new(w, z, w, w); - public readonly int4 wwxx => new(w, w, x, x); - public readonly int4 wwxy => new(w, w, x, y); - public readonly int4 wwxz => new(w, w, x, z); - public readonly int4 wwxw => new(w, w, x, w); - public readonly int4 wwyx => new(w, w, y, x); - public readonly int4 wwyy => new(w, w, y, y); - public readonly int4 wwyz => new(w, w, y, z); - public readonly int4 wwyw => new(w, w, y, w); - public readonly int4 wwzx => new(w, w, z, x); - public readonly int4 wwzy => new(w, w, z, y); - public readonly int4 wwzz => new(w, w, z, z); - public readonly int4 wwzw => new(w, w, z, w); - public readonly int4 wwwx => new(w, w, w, x); - public readonly int4 wwwy => new(w, w, w, y); - public readonly int4 wwwz => new(w, w, w, z); - public readonly int4 wwww => new(w, w, w, w); - public readonly int2 rr => new(x, x); - public int2 rg { readonly get => new(x, y); set { x = value.x; y = value.y; } } - public int2 rb { readonly get => new(x, z); set { x = value.x; z = value.y; } } - public int2 ra { readonly get => new(x, w); set { x = value.x; w = value.y; } } - public int2 gr { readonly get => new(y, x); set { y = value.x; x = value.y; } } - public readonly int2 gg => new(y, y); - public int2 gb { readonly get => new(y, z); set { y = value.x; z = value.y; } } - public int2 ga { readonly get => new(y, w); set { y = value.x; w = value.y; } } - public int2 br { readonly get => new(z, x); set { z = value.x; x = value.y; } } - public int2 bg { readonly get => new(z, y); set { z = value.x; y = value.y; } } - public readonly int2 bb => new(z, z); - public int2 ba { readonly get => new(z, w); set { z = value.x; w = value.y; } } - public int2 ar { readonly get => new(w, x); set { w = value.x; x = value.y; } } - public int2 ag { readonly get => new(w, y); set { w = value.x; y = value.y; } } - public int2 ab { readonly get => new(w, z); set { w = value.x; z = value.y; } } - public readonly int2 aa => new(w, w); - public readonly int3 rrr => new(x, x, x); - public readonly int3 rrg => new(x, x, y); - public readonly int3 rrb => new(x, x, z); - public readonly int3 rra => new(x, x, w); - public readonly int3 rgr => new(x, y, x); - public readonly int3 rgg => new(x, y, y); - public int3 rgb { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } - public int3 rga { readonly get => new(x, y, w); set { x = value.x; y = value.y; w = value.z; } } - public readonly int3 rbr => new(x, z, x); - public int3 rbg { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } - public readonly int3 rbb => new(x, z, z); - public int3 rba { readonly get => new(x, z, w); set { x = value.x; z = value.y; w = value.z; } } - public readonly int3 rar => new(x, w, x); - public int3 rag { readonly get => new(x, w, y); set { x = value.x; w = value.y; y = value.z; } } - public int3 rab { readonly get => new(x, w, z); set { x = value.x; w = value.y; z = value.z; } } - public readonly int3 raa => new(x, w, w); - public readonly int3 grr => new(y, x, x); - public readonly int3 grg => new(y, x, y); - public int3 grb { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } - public int3 gra { readonly get => new(y, x, w); set { y = value.x; x = value.y; w = value.z; } } - public readonly int3 ggr => new(y, y, x); - public readonly int3 ggg => new(y, y, y); - public readonly int3 ggb => new(y, y, z); - public readonly int3 gga => new(y, y, w); - public int3 gbr { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } - public readonly int3 gbg => new(y, z, y); - public readonly int3 gbb => new(y, z, z); - public int3 gba { readonly get => new(y, z, w); set { y = value.x; z = value.y; w = value.z; } } - public int3 gar { readonly get => new(y, w, x); set { y = value.x; w = value.y; x = value.z; } } - public readonly int3 gag => new(y, w, y); - public int3 gab { readonly get => new(y, w, z); set { y = value.x; w = value.y; z = value.z; } } - public readonly int3 gaa => new(y, w, w); - public readonly int3 brr => new(z, x, x); - public int3 brg { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } - public readonly int3 brb => new(z, x, z); - public int3 bra { readonly get => new(z, x, w); set { z = value.x; x = value.y; w = value.z; } } - public int3 bgr { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } - public readonly int3 bgg => new(z, y, y); - public readonly int3 bgb => new(z, y, z); - public int3 bga { readonly get => new(z, y, w); set { z = value.x; y = value.y; w = value.z; } } - public readonly int3 bbr => new(z, z, x); - public readonly int3 bbg => new(z, z, y); - public readonly int3 bbb => new(z, z, z); - public readonly int3 bba => new(z, z, w); - public int3 bar { readonly get => new(z, w, x); set { z = value.x; w = value.y; x = value.z; } } - public int3 bag { readonly get => new(z, w, y); set { z = value.x; w = value.y; y = value.z; } } - public readonly int3 bab => new(z, w, z); - public readonly int3 baa => new(z, w, w); - public readonly int3 arr => new(w, x, x); - public int3 arg { readonly get => new(w, x, y); set { w = value.x; x = value.y; y = value.z; } } - public int3 arb { readonly get => new(w, x, z); set { w = value.x; x = value.y; z = value.z; } } - public readonly int3 ara => new(w, x, w); - public int3 agr { readonly get => new(w, y, x); set { w = value.x; y = value.y; x = value.z; } } - public readonly int3 agg => new(w, y, y); - public int3 agb { readonly get => new(w, y, z); set { w = value.x; y = value.y; z = value.z; } } - public readonly int3 aga => new(w, y, w); - public int3 abr { readonly get => new(w, z, x); set { w = value.x; z = value.y; x = value.z; } } - public int3 abg { readonly get => new(w, z, y); set { w = value.x; z = value.y; y = value.z; } } - public readonly int3 abb => new(w, z, z); - public readonly int3 aba => new(w, z, w); - public readonly int3 aar => new(w, w, x); - public readonly int3 aag => new(w, w, y); - public readonly int3 aab => new(w, w, z); - public readonly int3 aaa => new(w, w, w); - public readonly int4 rrrr => new(x, x, x, x); - public readonly int4 rrrg => new(x, x, x, y); - public readonly int4 rrrb => new(x, x, x, z); - public readonly int4 rrra => new(x, x, x, w); - public readonly int4 rrgr => new(x, x, y, x); - public readonly int4 rrgg => new(x, x, y, y); - public readonly int4 rrgb => new(x, x, y, z); - public readonly int4 rrga => new(x, x, y, w); - public readonly int4 rrbr => new(x, x, z, x); - public readonly int4 rrbg => new(x, x, z, y); - public readonly int4 rrbb => new(x, x, z, z); - public readonly int4 rrba => new(x, x, z, w); - public readonly int4 rrar => new(x, x, w, x); - public readonly int4 rrag => new(x, x, w, y); - public readonly int4 rrab => new(x, x, w, z); - public readonly int4 rraa => new(x, x, w, w); - public readonly int4 rgrr => new(x, y, x, x); - public readonly int4 rgrg => new(x, y, x, y); - public readonly int4 rgrb => new(x, y, x, z); - public readonly int4 rgra => new(x, y, x, w); - public readonly int4 rggr => new(x, y, y, x); - public readonly int4 rggg => new(x, y, y, y); - public readonly int4 rggb => new(x, y, y, z); - public readonly int4 rgga => new(x, y, y, w); - public readonly int4 rgbr => new(x, y, z, x); - public readonly int4 rgbg => new(x, y, z, y); - public readonly int4 rgbb => new(x, y, z, z); - public int4 rgba { readonly get => new(x, y, z, w); set { x = value.x; y = value.y; z = value.z; w = value.w; } } - public readonly int4 rgar => new(x, y, w, x); - public readonly int4 rgag => new(x, y, w, y); - public int4 rgab { readonly get => new(x, y, w, z); set { x = value.x; y = value.y; w = value.z; z = value.w; } } - public readonly int4 rgaa => new(x, y, w, w); - public readonly int4 rbrr => new(x, z, x, x); - public readonly int4 rbrg => new(x, z, x, y); - public readonly int4 rbrb => new(x, z, x, z); - public readonly int4 rbra => new(x, z, x, w); - public readonly int4 rbgr => new(x, z, y, x); - public readonly int4 rbgg => new(x, z, y, y); - public readonly int4 rbgb => new(x, z, y, z); - public int4 rbga { readonly get => new(x, z, y, w); set { x = value.x; z = value.y; y = value.z; w = value.w; } } - public readonly int4 rbbr => new(x, z, z, x); - public readonly int4 rbbg => new(x, z, z, y); - public readonly int4 rbbb => new(x, z, z, z); - public readonly int4 rbba => new(x, z, z, w); - public readonly int4 rbar => new(x, z, w, x); - public int4 rbag { readonly get => new(x, z, w, y); set { x = value.x; z = value.y; w = value.z; y = value.w; } } - public readonly int4 rbab => new(x, z, w, z); - public readonly int4 rbaa => new(x, z, w, w); - public readonly int4 rarr => new(x, w, x, x); - public readonly int4 rarg => new(x, w, x, y); - public readonly int4 rarb => new(x, w, x, z); - public readonly int4 rara => new(x, w, x, w); - public readonly int4 ragr => new(x, w, y, x); - public readonly int4 ragg => new(x, w, y, y); - public int4 ragb { readonly get => new(x, w, y, z); set { x = value.x; w = value.y; y = value.z; z = value.w; } } - public readonly int4 raga => new(x, w, y, w); - public readonly int4 rabr => new(x, w, z, x); - public int4 rabg { readonly get => new(x, w, z, y); set { x = value.x; w = value.y; z = value.z; y = value.w; } } - public readonly int4 rabb => new(x, w, z, z); - public readonly int4 raba => new(x, w, z, w); - public readonly int4 raar => new(x, w, w, x); - public readonly int4 raag => new(x, w, w, y); - public readonly int4 raab => new(x, w, w, z); - public readonly int4 raaa => new(x, w, w, w); - public readonly int4 grrr => new(y, x, x, x); - public readonly int4 grrg => new(y, x, x, y); - public readonly int4 grrb => new(y, x, x, z); - public readonly int4 grra => new(y, x, x, w); - public readonly int4 grgr => new(y, x, y, x); - public readonly int4 grgg => new(y, x, y, y); - public readonly int4 grgb => new(y, x, y, z); - public readonly int4 grga => new(y, x, y, w); - public readonly int4 grbr => new(y, x, z, x); - public readonly int4 grbg => new(y, x, z, y); - public readonly int4 grbb => new(y, x, z, z); - public int4 grba { readonly get => new(y, x, z, w); set { y = value.x; x = value.y; z = value.z; w = value.w; } } - public readonly int4 grar => new(y, x, w, x); - public readonly int4 grag => new(y, x, w, y); - public int4 grab { readonly get => new(y, x, w, z); set { y = value.x; x = value.y; w = value.z; z = value.w; } } - public readonly int4 graa => new(y, x, w, w); - public readonly int4 ggrr => new(y, y, x, x); - public readonly int4 ggrg => new(y, y, x, y); - public readonly int4 ggrb => new(y, y, x, z); - public readonly int4 ggra => new(y, y, x, w); - public readonly int4 gggr => new(y, y, y, x); - public readonly int4 gggg => new(y, y, y, y); - public readonly int4 gggb => new(y, y, y, z); - public readonly int4 ggga => new(y, y, y, w); - public readonly int4 ggbr => new(y, y, z, x); - public readonly int4 ggbg => new(y, y, z, y); - public readonly int4 ggbb => new(y, y, z, z); - public readonly int4 ggba => new(y, y, z, w); - public readonly int4 ggar => new(y, y, w, x); - public readonly int4 ggag => new(y, y, w, y); - public readonly int4 ggab => new(y, y, w, z); - public readonly int4 ggaa => new(y, y, w, w); - public readonly int4 gbrr => new(y, z, x, x); - public readonly int4 gbrg => new(y, z, x, y); - public readonly int4 gbrb => new(y, z, x, z); - public int4 gbra { readonly get => new(y, z, x, w); set { y = value.x; z = value.y; x = value.z; w = value.w; } } - public readonly int4 gbgr => new(y, z, y, x); - public readonly int4 gbgg => new(y, z, y, y); - public readonly int4 gbgb => new(y, z, y, z); - public readonly int4 gbga => new(y, z, y, w); - public readonly int4 gbbr => new(y, z, z, x); - public readonly int4 gbbg => new(y, z, z, y); - public readonly int4 gbbb => new(y, z, z, z); - public readonly int4 gbba => new(y, z, z, w); - public int4 gbar { readonly get => new(y, z, w, x); set { y = value.x; z = value.y; w = value.z; x = value.w; } } - public readonly int4 gbag => new(y, z, w, y); - public readonly int4 gbab => new(y, z, w, z); - public readonly int4 gbaa => new(y, z, w, w); - public readonly int4 garr => new(y, w, x, x); - public readonly int4 garg => new(y, w, x, y); - public int4 garb { readonly get => new(y, w, x, z); set { y = value.x; w = value.y; x = value.z; z = value.w; } } - public readonly int4 gara => new(y, w, x, w); - public readonly int4 gagr => new(y, w, y, x); - public readonly int4 gagg => new(y, w, y, y); - public readonly int4 gagb => new(y, w, y, z); - public readonly int4 gaga => new(y, w, y, w); - public int4 gabr { readonly get => new(y, w, z, x); set { y = value.x; w = value.y; z = value.z; x = value.w; } } - public readonly int4 gabg => new(y, w, z, y); - public readonly int4 gabb => new(y, w, z, z); - public readonly int4 gaba => new(y, w, z, w); - public readonly int4 gaar => new(y, w, w, x); - public readonly int4 gaag => new(y, w, w, y); - public readonly int4 gaab => new(y, w, w, z); - public readonly int4 gaaa => new(y, w, w, w); - public readonly int4 brrr => new(z, x, x, x); - public readonly int4 brrg => new(z, x, x, y); - public readonly int4 brrb => new(z, x, x, z); - public readonly int4 brra => new(z, x, x, w); - public readonly int4 brgr => new(z, x, y, x); - public readonly int4 brgg => new(z, x, y, y); - public readonly int4 brgb => new(z, x, y, z); - public int4 brga { readonly get => new(z, x, y, w); set { z = value.x; x = value.y; y = value.z; w = value.w; } } - public readonly int4 brbr => new(z, x, z, x); - public readonly int4 brbg => new(z, x, z, y); - public readonly int4 brbb => new(z, x, z, z); - public readonly int4 brba => new(z, x, z, w); - public readonly int4 brar => new(z, x, w, x); - public int4 brag { readonly get => new(z, x, w, y); set { z = value.x; x = value.y; w = value.z; y = value.w; } } - public readonly int4 brab => new(z, x, w, z); - public readonly int4 braa => new(z, x, w, w); - public readonly int4 bgrr => new(z, y, x, x); - public readonly int4 bgrg => new(z, y, x, y); - public readonly int4 bgrb => new(z, y, x, z); - public int4 bgra { readonly get => new(z, y, x, w); set { z = value.x; y = value.y; x = value.z; w = value.w; } } - public readonly int4 bggr => new(z, y, y, x); - public readonly int4 bggg => new(z, y, y, y); - public readonly int4 bggb => new(z, y, y, z); - public readonly int4 bgga => new(z, y, y, w); - public readonly int4 bgbr => new(z, y, z, x); - public readonly int4 bgbg => new(z, y, z, y); - public readonly int4 bgbb => new(z, y, z, z); - public readonly int4 bgba => new(z, y, z, w); - public int4 bgar { readonly get => new(z, y, w, x); set { z = value.x; y = value.y; w = value.z; x = value.w; } } - public readonly int4 bgag => new(z, y, w, y); - public readonly int4 bgab => new(z, y, w, z); - public readonly int4 bgaa => new(z, y, w, w); - public readonly int4 bbrr => new(z, z, x, x); - public readonly int4 bbrg => new(z, z, x, y); - public readonly int4 bbrb => new(z, z, x, z); - public readonly int4 bbra => new(z, z, x, w); - public readonly int4 bbgr => new(z, z, y, x); - public readonly int4 bbgg => new(z, z, y, y); - public readonly int4 bbgb => new(z, z, y, z); - public readonly int4 bbga => new(z, z, y, w); - public readonly int4 bbbr => new(z, z, z, x); - public readonly int4 bbbg => new(z, z, z, y); - public readonly int4 bbbb => new(z, z, z, z); - public readonly int4 bbba => new(z, z, z, w); - public readonly int4 bbar => new(z, z, w, x); - public readonly int4 bbag => new(z, z, w, y); - public readonly int4 bbab => new(z, z, w, z); - public readonly int4 bbaa => new(z, z, w, w); - public readonly int4 barr => new(z, w, x, x); - public int4 barg { readonly get => new(z, w, x, y); set { z = value.x; w = value.y; x = value.z; y = value.w; } } - public readonly int4 barb => new(z, w, x, z); - public readonly int4 bara => new(z, w, x, w); - public int4 bagr { readonly get => new(z, w, y, x); set { z = value.x; w = value.y; y = value.z; x = value.w; } } - public readonly int4 bagg => new(z, w, y, y); - public readonly int4 bagb => new(z, w, y, z); - public readonly int4 baga => new(z, w, y, w); - public readonly int4 babr => new(z, w, z, x); - public readonly int4 babg => new(z, w, z, y); - public readonly int4 babb => new(z, w, z, z); - public readonly int4 baba => new(z, w, z, w); - public readonly int4 baar => new(z, w, w, x); - public readonly int4 baag => new(z, w, w, y); - public readonly int4 baab => new(z, w, w, z); - public readonly int4 baaa => new(z, w, w, w); - public readonly int4 arrr => new(w, x, x, x); - public readonly int4 arrg => new(w, x, x, y); - public readonly int4 arrb => new(w, x, x, z); - public readonly int4 arra => new(w, x, x, w); - public readonly int4 argr => new(w, x, y, x); - public readonly int4 argg => new(w, x, y, y); - public int4 argb { readonly get => new(w, x, y, z); set { w = value.x; x = value.y; y = value.z; z = value.w; } } - public readonly int4 arga => new(w, x, y, w); - public readonly int4 arbr => new(w, x, z, x); - public int4 arbg { readonly get => new(w, x, z, y); set { w = value.x; x = value.y; z = value.z; y = value.w; } } - public readonly int4 arbb => new(w, x, z, z); - public readonly int4 arba => new(w, x, z, w); - public readonly int4 arar => new(w, x, w, x); - public readonly int4 arag => new(w, x, w, y); - public readonly int4 arab => new(w, x, w, z); - public readonly int4 araa => new(w, x, w, w); - public readonly int4 agrr => new(w, y, x, x); - public readonly int4 agrg => new(w, y, x, y); - public int4 agrb { readonly get => new(w, y, x, z); set { w = value.x; y = value.y; x = value.z; z = value.w; } } - public readonly int4 agra => new(w, y, x, w); - public readonly int4 aggr => new(w, y, y, x); - public readonly int4 aggg => new(w, y, y, y); - public readonly int4 aggb => new(w, y, y, z); - public readonly int4 agga => new(w, y, y, w); - public int4 agbr { readonly get => new(w, y, z, x); set { w = value.x; y = value.y; z = value.z; x = value.w; } } - public readonly int4 agbg => new(w, y, z, y); - public readonly int4 agbb => new(w, y, z, z); - public readonly int4 agba => new(w, y, z, w); - public readonly int4 agar => new(w, y, w, x); - public readonly int4 agag => new(w, y, w, y); - public readonly int4 agab => new(w, y, w, z); - public readonly int4 agaa => new(w, y, w, w); - public readonly int4 abrr => new(w, z, x, x); - public int4 abrg { readonly get => new(w, z, x, y); set { w = value.x; z = value.y; x = value.z; y = value.w; } } - public readonly int4 abrb => new(w, z, x, z); - public readonly int4 abra => new(w, z, x, w); - public int4 abgr { readonly get => new(w, z, y, x); set { w = value.x; z = value.y; y = value.z; x = value.w; } } - public readonly int4 abgg => new(w, z, y, y); - public readonly int4 abgb => new(w, z, y, z); - public readonly int4 abga => new(w, z, y, w); - public readonly int4 abbr => new(w, z, z, x); - public readonly int4 abbg => new(w, z, z, y); - public readonly int4 abbb => new(w, z, z, z); - public readonly int4 abba => new(w, z, z, w); - public readonly int4 abar => new(w, z, w, x); - public readonly int4 abag => new(w, z, w, y); - public readonly int4 abab => new(w, z, w, z); - public readonly int4 abaa => new(w, z, w, w); - public readonly int4 aarr => new(w, w, x, x); - public readonly int4 aarg => new(w, w, x, y); - public readonly int4 aarb => new(w, w, x, z); - public readonly int4 aara => new(w, w, x, w); - public readonly int4 aagr => new(w, w, y, x); - public readonly int4 aagg => new(w, w, y, y); - public readonly int4 aagb => new(w, w, y, z); - public readonly int4 aaga => new(w, w, y, w); - public readonly int4 aabr => new(w, w, z, x); - public readonly int4 aabg => new(w, w, z, y); - public readonly int4 aabb => new(w, w, z, z); - public readonly int4 aaba => new(w, w, z, w); - public readonly int4 aaar => new(w, w, w, x); - public readonly int4 aaag => new(w, w, w, y); - public readonly int4 aaab => new(w, w, w, z); - public readonly int4 aaaa => new(w, w, w, w); -} - -public partial struct bool2 -{ - public bool2(bool2 xy) : this(xy.x, xy.y) { } - 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); - public bool2 xy { readonly get => new(x, y); set { x = value.x; y = value.y; } } - public bool2 yx { readonly get => new(y, x); set { y = value.x; x = value.y; } } - public readonly bool2 yy => new(y, y); - public readonly bool3 xxx => new(x, x, x); - public readonly bool3 xxy => new(x, x, y); - public readonly bool3 xyx => new(x, y, x); - public readonly bool3 xyy => new(x, y, y); - public readonly bool3 yxx => new(y, x, x); - public readonly bool3 yxy => new(y, x, y); - public readonly bool3 yyx => new(y, y, x); - public readonly bool3 yyy => new(y, y, y); - public readonly bool4 xxxx => new(x, x, x, x); - public readonly bool4 xxxy => new(x, x, x, y); - public readonly bool4 xxyx => new(x, x, y, x); - public readonly bool4 xxyy => new(x, x, y, y); - public readonly bool4 xyxx => new(x, y, x, x); - public readonly bool4 xyxy => new(x, y, x, y); - public readonly bool4 xyyx => new(x, y, y, x); - public readonly bool4 xyyy => new(x, y, y, y); - public readonly bool4 yxxx => new(y, x, x, x); - public readonly bool4 yxxy => new(y, x, x, y); - public readonly bool4 yxyx => new(y, x, y, x); - public readonly bool4 yxyy => new(y, x, y, y); - public readonly bool4 yyxx => new(y, y, x, x); - public readonly bool4 yyxy => new(y, y, x, y); - public readonly bool4 yyyx => new(y, y, y, x); - public readonly bool4 yyyy => new(y, y, y, y); - public readonly bool2 rr => new(x, x); - public bool2 rg { readonly get => new(x, y); set { x = value.x; y = value.y; } } - public bool2 gr { readonly get => new(y, x); set { y = value.x; x = value.y; } } - public readonly bool2 gg => new(y, y); - public readonly bool3 rrr => new(x, x, x); - public readonly bool3 rrg => new(x, x, y); - public readonly bool3 rgr => new(x, y, x); - public readonly bool3 rgg => new(x, y, y); - public readonly bool3 grr => new(y, x, x); - public readonly bool3 grg => new(y, x, y); - public readonly bool3 ggr => new(y, y, x); - public readonly bool3 ggg => new(y, y, y); - public readonly bool4 rrrr => new(x, x, x, x); - public readonly bool4 rrrg => new(x, x, x, y); - public readonly bool4 rrgr => new(x, x, y, x); - public readonly bool4 rrgg => new(x, x, y, y); - public readonly bool4 rgrr => new(x, y, x, x); - public readonly bool4 rgrg => new(x, y, x, y); - public readonly bool4 rggr => new(x, y, y, x); - public readonly bool4 rggg => new(x, y, y, y); - public readonly bool4 grrr => new(y, x, x, x); - public readonly bool4 grrg => new(y, x, x, y); - public readonly bool4 grgr => new(y, x, y, x); - public readonly bool4 grgg => new(y, x, y, y); - public readonly bool4 ggrr => new(y, y, x, x); - public readonly bool4 ggrg => new(y, y, x, y); - public readonly bool4 gggr => new(y, y, y, x); - public readonly bool4 gggg => new(y, y, y, y); -} - -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 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; } - public readonly bool2 xx => new(x, x); - public bool2 xy { readonly get => new(x, y); set { x = value.x; y = value.y; } } - public bool2 xz { readonly get => new(x, z); set { x = value.x; z = value.y; } } - public bool2 yx { readonly get => new(y, x); set { y = value.x; x = value.y; } } - public readonly bool2 yy => new(y, y); - public bool2 yz { readonly get => new(y, z); set { y = value.x; z = value.y; } } - public bool2 zx { readonly get => new(z, x); set { z = value.x; x = value.y; } } - public bool2 zy { readonly get => new(z, y); set { z = value.x; y = value.y; } } - public readonly bool2 zz => new(z, z); - public readonly bool3 xxx => new(x, x, x); - public readonly bool3 xxy => new(x, x, y); - public readonly bool3 xxz => new(x, x, z); - public readonly bool3 xyx => new(x, y, x); - public readonly bool3 xyy => new(x, y, y); - public bool3 xyz { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } - public readonly bool3 xzx => new(x, z, x); - public bool3 xzy { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } - public readonly bool3 xzz => new(x, z, z); - public readonly bool3 yxx => new(y, x, x); - public readonly bool3 yxy => new(y, x, y); - public bool3 yxz { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } - public readonly bool3 yyx => new(y, y, x); - public readonly bool3 yyy => new(y, y, y); - public readonly bool3 yyz => new(y, y, z); - public bool3 yzx { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } - public readonly bool3 yzy => new(y, z, y); - public readonly bool3 yzz => new(y, z, z); - public readonly bool3 zxx => new(z, x, x); - public bool3 zxy { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } - public readonly bool3 zxz => new(z, x, z); - public bool3 zyx { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } - public readonly bool3 zyy => new(z, y, y); - public readonly bool3 zyz => new(z, y, z); - public readonly bool3 zzx => new(z, z, x); - public readonly bool3 zzy => new(z, z, y); - public readonly bool3 zzz => new(z, z, z); - public readonly bool4 xxxx => new(x, x, x, x); - public readonly bool4 xxxy => new(x, x, x, y); - public readonly bool4 xxxz => new(x, x, x, z); - public readonly bool4 xxyx => new(x, x, y, x); - public readonly bool4 xxyy => new(x, x, y, y); - public readonly bool4 xxyz => new(x, x, y, z); - public readonly bool4 xxzx => new(x, x, z, x); - public readonly bool4 xxzy => new(x, x, z, y); - public readonly bool4 xxzz => new(x, x, z, z); - public readonly bool4 xyxx => new(x, y, x, x); - public readonly bool4 xyxy => new(x, y, x, y); - public readonly bool4 xyxz => new(x, y, x, z); - public readonly bool4 xyyx => new(x, y, y, x); - public readonly bool4 xyyy => new(x, y, y, y); - public readonly bool4 xyyz => new(x, y, y, z); - public readonly bool4 xyzx => new(x, y, z, x); - public readonly bool4 xyzy => new(x, y, z, y); - public readonly bool4 xyzz => new(x, y, z, z); - public readonly bool4 xzxx => new(x, z, x, x); - public readonly bool4 xzxy => new(x, z, x, y); - public readonly bool4 xzxz => new(x, z, x, z); - public readonly bool4 xzyx => new(x, z, y, x); - public readonly bool4 xzyy => new(x, z, y, y); - public readonly bool4 xzyz => new(x, z, y, z); - public readonly bool4 xzzx => new(x, z, z, x); - public readonly bool4 xzzy => new(x, z, z, y); - public readonly bool4 xzzz => new(x, z, z, z); - public readonly bool4 yxxx => new(y, x, x, x); - public readonly bool4 yxxy => new(y, x, x, y); - public readonly bool4 yxxz => new(y, x, x, z); - public readonly bool4 yxyx => new(y, x, y, x); - public readonly bool4 yxyy => new(y, x, y, y); - public readonly bool4 yxyz => new(y, x, y, z); - public readonly bool4 yxzx => new(y, x, z, x); - public readonly bool4 yxzy => new(y, x, z, y); - public readonly bool4 yxzz => new(y, x, z, z); - public readonly bool4 yyxx => new(y, y, x, x); - public readonly bool4 yyxy => new(y, y, x, y); - public readonly bool4 yyxz => new(y, y, x, z); - public readonly bool4 yyyx => new(y, y, y, x); - public readonly bool4 yyyy => new(y, y, y, y); - public readonly bool4 yyyz => new(y, y, y, z); - public readonly bool4 yyzx => new(y, y, z, x); - public readonly bool4 yyzy => new(y, y, z, y); - public readonly bool4 yyzz => new(y, y, z, z); - public readonly bool4 yzxx => new(y, z, x, x); - public readonly bool4 yzxy => new(y, z, x, y); - public readonly bool4 yzxz => new(y, z, x, z); - public readonly bool4 yzyx => new(y, z, y, x); - public readonly bool4 yzyy => new(y, z, y, y); - public readonly bool4 yzyz => new(y, z, y, z); - public readonly bool4 yzzx => new(y, z, z, x); - public readonly bool4 yzzy => new(y, z, z, y); - public readonly bool4 yzzz => new(y, z, z, z); - public readonly bool4 zxxx => new(z, x, x, x); - public readonly bool4 zxxy => new(z, x, x, y); - public readonly bool4 zxxz => new(z, x, x, z); - public readonly bool4 zxyx => new(z, x, y, x); - public readonly bool4 zxyy => new(z, x, y, y); - public readonly bool4 zxyz => new(z, x, y, z); - public readonly bool4 zxzx => new(z, x, z, x); - public readonly bool4 zxzy => new(z, x, z, y); - public readonly bool4 zxzz => new(z, x, z, z); - public readonly bool4 zyxx => new(z, y, x, x); - public readonly bool4 zyxy => new(z, y, x, y); - public readonly bool4 zyxz => new(z, y, x, z); - public readonly bool4 zyyx => new(z, y, y, x); - public readonly bool4 zyyy => new(z, y, y, y); - public readonly bool4 zyyz => new(z, y, y, z); - public readonly bool4 zyzx => new(z, y, z, x); - public readonly bool4 zyzy => new(z, y, z, y); - public readonly bool4 zyzz => new(z, y, z, z); - public readonly bool4 zzxx => new(z, z, x, x); - public readonly bool4 zzxy => new(z, z, x, y); - public readonly bool4 zzxz => new(z, z, x, z); - public readonly bool4 zzyx => new(z, z, y, x); - public readonly bool4 zzyy => new(z, z, y, y); - public readonly bool4 zzyz => new(z, z, y, z); - public readonly bool4 zzzx => new(z, z, z, x); - public readonly bool4 zzzy => new(z, z, z, y); - public readonly bool4 zzzz => new(z, z, z, z); - public readonly bool2 rr => new(x, x); - public bool2 rg { readonly get => new(x, y); set { x = value.x; y = value.y; } } - public bool2 rb { readonly get => new(x, z); set { x = value.x; z = value.y; } } - public bool2 gr { readonly get => new(y, x); set { y = value.x; x = value.y; } } - public readonly bool2 gg => new(y, y); - public bool2 gb { readonly get => new(y, z); set { y = value.x; z = value.y; } } - public bool2 br { readonly get => new(z, x); set { z = value.x; x = value.y; } } - public bool2 bg { readonly get => new(z, y); set { z = value.x; y = value.y; } } - public readonly bool2 bb => new(z, z); - public readonly bool3 rrr => new(x, x, x); - public readonly bool3 rrg => new(x, x, y); - public readonly bool3 rrb => new(x, x, z); - public readonly bool3 rgr => new(x, y, x); - public readonly bool3 rgg => new(x, y, y); - public bool3 rgb { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } - public readonly bool3 rbr => new(x, z, x); - public bool3 rbg { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } - public readonly bool3 rbb => new(x, z, z); - public readonly bool3 grr => new(y, x, x); - public readonly bool3 grg => new(y, x, y); - public bool3 grb { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } - public readonly bool3 ggr => new(y, y, x); - public readonly bool3 ggg => new(y, y, y); - public readonly bool3 ggb => new(y, y, z); - public bool3 gbr { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } - public readonly bool3 gbg => new(y, z, y); - public readonly bool3 gbb => new(y, z, z); - public readonly bool3 brr => new(z, x, x); - public bool3 brg { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } - public readonly bool3 brb => new(z, x, z); - public bool3 bgr { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } - public readonly bool3 bgg => new(z, y, y); - public readonly bool3 bgb => new(z, y, z); - public readonly bool3 bbr => new(z, z, x); - public readonly bool3 bbg => new(z, z, y); - public readonly bool3 bbb => new(z, z, z); - public readonly bool4 rrrr => new(x, x, x, x); - public readonly bool4 rrrg => new(x, x, x, y); - public readonly bool4 rrrb => new(x, x, x, z); - public readonly bool4 rrgr => new(x, x, y, x); - public readonly bool4 rrgg => new(x, x, y, y); - public readonly bool4 rrgb => new(x, x, y, z); - public readonly bool4 rrbr => new(x, x, z, x); - public readonly bool4 rrbg => new(x, x, z, y); - public readonly bool4 rrbb => new(x, x, z, z); - public readonly bool4 rgrr => new(x, y, x, x); - public readonly bool4 rgrg => new(x, y, x, y); - public readonly bool4 rgrb => new(x, y, x, z); - public readonly bool4 rggr => new(x, y, y, x); - public readonly bool4 rggg => new(x, y, y, y); - public readonly bool4 rggb => new(x, y, y, z); - public readonly bool4 rgbr => new(x, y, z, x); - public readonly bool4 rgbg => new(x, y, z, y); - public readonly bool4 rgbb => new(x, y, z, z); - public readonly bool4 rbrr => new(x, z, x, x); - public readonly bool4 rbrg => new(x, z, x, y); - public readonly bool4 rbrb => new(x, z, x, z); - public readonly bool4 rbgr => new(x, z, y, x); - public readonly bool4 rbgg => new(x, z, y, y); - public readonly bool4 rbgb => new(x, z, y, z); - public readonly bool4 rbbr => new(x, z, z, x); - public readonly bool4 rbbg => new(x, z, z, y); - public readonly bool4 rbbb => new(x, z, z, z); - public readonly bool4 grrr => new(y, x, x, x); - public readonly bool4 grrg => new(y, x, x, y); - public readonly bool4 grrb => new(y, x, x, z); - public readonly bool4 grgr => new(y, x, y, x); - public readonly bool4 grgg => new(y, x, y, y); - public readonly bool4 grgb => new(y, x, y, z); - public readonly bool4 grbr => new(y, x, z, x); - public readonly bool4 grbg => new(y, x, z, y); - public readonly bool4 grbb => new(y, x, z, z); - public readonly bool4 ggrr => new(y, y, x, x); - public readonly bool4 ggrg => new(y, y, x, y); - public readonly bool4 ggrb => new(y, y, x, z); - public readonly bool4 gggr => new(y, y, y, x); - public readonly bool4 gggg => new(y, y, y, y); - public readonly bool4 gggb => new(y, y, y, z); - public readonly bool4 ggbr => new(y, y, z, x); - public readonly bool4 ggbg => new(y, y, z, y); - public readonly bool4 ggbb => new(y, y, z, z); - public readonly bool4 gbrr => new(y, z, x, x); - public readonly bool4 gbrg => new(y, z, x, y); - public readonly bool4 gbrb => new(y, z, x, z); - public readonly bool4 gbgr => new(y, z, y, x); - public readonly bool4 gbgg => new(y, z, y, y); - public readonly bool4 gbgb => new(y, z, y, z); - public readonly bool4 gbbr => new(y, z, z, x); - public readonly bool4 gbbg => new(y, z, z, y); - public readonly bool4 gbbb => new(y, z, z, z); - public readonly bool4 brrr => new(z, x, x, x); - public readonly bool4 brrg => new(z, x, x, y); - public readonly bool4 brrb => new(z, x, x, z); - public readonly bool4 brgr => new(z, x, y, x); - public readonly bool4 brgg => new(z, x, y, y); - public readonly bool4 brgb => new(z, x, y, z); - public readonly bool4 brbr => new(z, x, z, x); - public readonly bool4 brbg => new(z, x, z, y); - public readonly bool4 brbb => new(z, x, z, z); - public readonly bool4 bgrr => new(z, y, x, x); - public readonly bool4 bgrg => new(z, y, x, y); - public readonly bool4 bgrb => new(z, y, x, z); - public readonly bool4 bggr => new(z, y, y, x); - public readonly bool4 bggg => new(z, y, y, y); - public readonly bool4 bggb => new(z, y, y, z); - public readonly bool4 bgbr => new(z, y, z, x); - public readonly bool4 bgbg => new(z, y, z, y); - public readonly bool4 bgbb => new(z, y, z, z); - public readonly bool4 bbrr => new(z, z, x, x); - public readonly bool4 bbrg => new(z, z, x, y); - public readonly bool4 bbrb => new(z, z, x, z); - public readonly bool4 bbgr => new(z, z, y, x); - public readonly bool4 bbgg => new(z, z, y, y); - public readonly bool4 bbgb => new(z, z, y, z); - public readonly bool4 bbbr => new(z, z, z, x); - public readonly bool4 bbbg => new(z, z, z, y); - public readonly bool4 bbbb => new(z, z, z, z); -} - -public partial struct bool4 -{ - public bool4(bool x, bool y, bool2 zw) : this(x, y, zw.x, zw.y) { } - public bool4(bool x, bool2 yz, bool w) : this(x, yz.x, yz.y, w) { } - public bool4(bool x, bool3 yzw) : this(x, yzw.x, yzw.y, yzw.z) { } - public bool4(bool2 xy, bool z, bool w) : this(xy.x, xy.y, z, w) { } - 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 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; } - public bool a { readonly get => w; set => w = value; } - public readonly bool2 xx => new(x, x); - public bool2 xy { readonly get => new(x, y); set { x = value.x; y = value.y; } } - public bool2 xz { readonly get => new(x, z); set { x = value.x; z = value.y; } } - public bool2 xw { readonly get => new(x, w); set { x = value.x; w = value.y; } } - public bool2 yx { readonly get => new(y, x); set { y = value.x; x = value.y; } } - public readonly bool2 yy => new(y, y); - public bool2 yz { readonly get => new(y, z); set { y = value.x; z = value.y; } } - public bool2 yw { readonly get => new(y, w); set { y = value.x; w = value.y; } } - public bool2 zx { readonly get => new(z, x); set { z = value.x; x = value.y; } } - public bool2 zy { readonly get => new(z, y); set { z = value.x; y = value.y; } } - public readonly bool2 zz => new(z, z); - public bool2 zw { readonly get => new(z, w); set { z = value.x; w = value.y; } } - public bool2 wx { readonly get => new(w, x); set { w = value.x; x = value.y; } } - public bool2 wy { readonly get => new(w, y); set { w = value.x; y = value.y; } } - public bool2 wz { readonly get => new(w, z); set { w = value.x; z = value.y; } } - public readonly bool2 ww => new(w, w); - public readonly bool3 xxx => new(x, x, x); - public readonly bool3 xxy => new(x, x, y); - public readonly bool3 xxz => new(x, x, z); - public readonly bool3 xxw => new(x, x, w); - public readonly bool3 xyx => new(x, y, x); - public readonly bool3 xyy => new(x, y, y); - public bool3 xyz { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } - public bool3 xyw { readonly get => new(x, y, w); set { x = value.x; y = value.y; w = value.z; } } - public readonly bool3 xzx => new(x, z, x); - public bool3 xzy { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } - public readonly bool3 xzz => new(x, z, z); - public bool3 xzw { readonly get => new(x, z, w); set { x = value.x; z = value.y; w = value.z; } } - public readonly bool3 xwx => new(x, w, x); - public bool3 xwy { readonly get => new(x, w, y); set { x = value.x; w = value.y; y = value.z; } } - public bool3 xwz { readonly get => new(x, w, z); set { x = value.x; w = value.y; z = value.z; } } - public readonly bool3 xww => new(x, w, w); - public readonly bool3 yxx => new(y, x, x); - public readonly bool3 yxy => new(y, x, y); - public bool3 yxz { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } - public bool3 yxw { readonly get => new(y, x, w); set { y = value.x; x = value.y; w = value.z; } } - public readonly bool3 yyx => new(y, y, x); - public readonly bool3 yyy => new(y, y, y); - public readonly bool3 yyz => new(y, y, z); - public readonly bool3 yyw => new(y, y, w); - public bool3 yzx { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } - public readonly bool3 yzy => new(y, z, y); - public readonly bool3 yzz => new(y, z, z); - public bool3 yzw { readonly get => new(y, z, w); set { y = value.x; z = value.y; w = value.z; } } - public bool3 ywx { readonly get => new(y, w, x); set { y = value.x; w = value.y; x = value.z; } } - public readonly bool3 ywy => new(y, w, y); - public bool3 ywz { readonly get => new(y, w, z); set { y = value.x; w = value.y; z = value.z; } } - public readonly bool3 yww => new(y, w, w); - public readonly bool3 zxx => new(z, x, x); - public bool3 zxy { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } - public readonly bool3 zxz => new(z, x, z); - public bool3 zxw { readonly get => new(z, x, w); set { z = value.x; x = value.y; w = value.z; } } - public bool3 zyx { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } - public readonly bool3 zyy => new(z, y, y); - public readonly bool3 zyz => new(z, y, z); - public bool3 zyw { readonly get => new(z, y, w); set { z = value.x; y = value.y; w = value.z; } } - public readonly bool3 zzx => new(z, z, x); - public readonly bool3 zzy => new(z, z, y); - public readonly bool3 zzz => new(z, z, z); - public readonly bool3 zzw => new(z, z, w); - public bool3 zwx { readonly get => new(z, w, x); set { z = value.x; w = value.y; x = value.z; } } - public bool3 zwy { readonly get => new(z, w, y); set { z = value.x; w = value.y; y = value.z; } } - public readonly bool3 zwz => new(z, w, z); - public readonly bool3 zww => new(z, w, w); - public readonly bool3 wxx => new(w, x, x); - public bool3 wxy { readonly get => new(w, x, y); set { w = value.x; x = value.y; y = value.z; } } - public bool3 wxz { readonly get => new(w, x, z); set { w = value.x; x = value.y; z = value.z; } } - public readonly bool3 wxw => new(w, x, w); - public bool3 wyx { readonly get => new(w, y, x); set { w = value.x; y = value.y; x = value.z; } } - public readonly bool3 wyy => new(w, y, y); - public bool3 wyz { readonly get => new(w, y, z); set { w = value.x; y = value.y; z = value.z; } } - public readonly bool3 wyw => new(w, y, w); - public bool3 wzx { readonly get => new(w, z, x); set { w = value.x; z = value.y; x = value.z; } } - public bool3 wzy { readonly get => new(w, z, y); set { w = value.x; z = value.y; y = value.z; } } - public readonly bool3 wzz => new(w, z, z); - public readonly bool3 wzw => new(w, z, w); - public readonly bool3 wwx => new(w, w, x); - public readonly bool3 wwy => new(w, w, y); - public readonly bool3 wwz => new(w, w, z); - public readonly bool3 www => new(w, w, w); - public readonly bool4 xxxx => new(x, x, x, x); - public readonly bool4 xxxy => new(x, x, x, y); - public readonly bool4 xxxz => new(x, x, x, z); - public readonly bool4 xxxw => new(x, x, x, w); - public readonly bool4 xxyx => new(x, x, y, x); - public readonly bool4 xxyy => new(x, x, y, y); - public readonly bool4 xxyz => new(x, x, y, z); - public readonly bool4 xxyw => new(x, x, y, w); - public readonly bool4 xxzx => new(x, x, z, x); - public readonly bool4 xxzy => new(x, x, z, y); - public readonly bool4 xxzz => new(x, x, z, z); - public readonly bool4 xxzw => new(x, x, z, w); - public readonly bool4 xxwx => new(x, x, w, x); - public readonly bool4 xxwy => new(x, x, w, y); - public readonly bool4 xxwz => new(x, x, w, z); - public readonly bool4 xxww => new(x, x, w, w); - public readonly bool4 xyxx => new(x, y, x, x); - public readonly bool4 xyxy => new(x, y, x, y); - public readonly bool4 xyxz => new(x, y, x, z); - public readonly bool4 xyxw => new(x, y, x, w); - public readonly bool4 xyyx => new(x, y, y, x); - public readonly bool4 xyyy => new(x, y, y, y); - public readonly bool4 xyyz => new(x, y, y, z); - public readonly bool4 xyyw => new(x, y, y, w); - public readonly bool4 xyzx => new(x, y, z, x); - public readonly bool4 xyzy => new(x, y, z, y); - public readonly bool4 xyzz => new(x, y, z, z); - public bool4 xyzw { readonly get => new(x, y, z, w); set { x = value.x; y = value.y; z = value.z; w = value.w; } } - public readonly bool4 xywx => new(x, y, w, x); - public readonly bool4 xywy => new(x, y, w, y); - public bool4 xywz { readonly get => new(x, y, w, z); set { x = value.x; y = value.y; w = value.z; z = value.w; } } - public readonly bool4 xyww => new(x, y, w, w); - public readonly bool4 xzxx => new(x, z, x, x); - public readonly bool4 xzxy => new(x, z, x, y); - public readonly bool4 xzxz => new(x, z, x, z); - public readonly bool4 xzxw => new(x, z, x, w); - public readonly bool4 xzyx => new(x, z, y, x); - public readonly bool4 xzyy => new(x, z, y, y); - public readonly bool4 xzyz => new(x, z, y, z); - public bool4 xzyw { readonly get => new(x, z, y, w); set { x = value.x; z = value.y; y = value.z; w = value.w; } } - public readonly bool4 xzzx => new(x, z, z, x); - public readonly bool4 xzzy => new(x, z, z, y); - public readonly bool4 xzzz => new(x, z, z, z); - public readonly bool4 xzzw => new(x, z, z, w); - public readonly bool4 xzwx => new(x, z, w, x); - public bool4 xzwy { readonly get => new(x, z, w, y); set { x = value.x; z = value.y; w = value.z; y = value.w; } } - public readonly bool4 xzwz => new(x, z, w, z); - public readonly bool4 xzww => new(x, z, w, w); - public readonly bool4 xwxx => new(x, w, x, x); - public readonly bool4 xwxy => new(x, w, x, y); - public readonly bool4 xwxz => new(x, w, x, z); - public readonly bool4 xwxw => new(x, w, x, w); - public readonly bool4 xwyx => new(x, w, y, x); - public readonly bool4 xwyy => new(x, w, y, y); - public bool4 xwyz { readonly get => new(x, w, y, z); set { x = value.x; w = value.y; y = value.z; z = value.w; } } - public readonly bool4 xwyw => new(x, w, y, w); - public readonly bool4 xwzx => new(x, w, z, x); - public bool4 xwzy { readonly get => new(x, w, z, y); set { x = value.x; w = value.y; z = value.z; y = value.w; } } - public readonly bool4 xwzz => new(x, w, z, z); - public readonly bool4 xwzw => new(x, w, z, w); - public readonly bool4 xwwx => new(x, w, w, x); - public readonly bool4 xwwy => new(x, w, w, y); - public readonly bool4 xwwz => new(x, w, w, z); - public readonly bool4 xwww => new(x, w, w, w); - public readonly bool4 yxxx => new(y, x, x, x); - public readonly bool4 yxxy => new(y, x, x, y); - public readonly bool4 yxxz => new(y, x, x, z); - public readonly bool4 yxxw => new(y, x, x, w); - public readonly bool4 yxyx => new(y, x, y, x); - public readonly bool4 yxyy => new(y, x, y, y); - public readonly bool4 yxyz => new(y, x, y, z); - public readonly bool4 yxyw => new(y, x, y, w); - public readonly bool4 yxzx => new(y, x, z, x); - public readonly bool4 yxzy => new(y, x, z, y); - public readonly bool4 yxzz => new(y, x, z, z); - public bool4 yxzw { readonly get => new(y, x, z, w); set { y = value.x; x = value.y; z = value.z; w = value.w; } } - public readonly bool4 yxwx => new(y, x, w, x); - public readonly bool4 yxwy => new(y, x, w, y); - public bool4 yxwz { readonly get => new(y, x, w, z); set { y = value.x; x = value.y; w = value.z; z = value.w; } } - public readonly bool4 yxww => new(y, x, w, w); - public readonly bool4 yyxx => new(y, y, x, x); - public readonly bool4 yyxy => new(y, y, x, y); - public readonly bool4 yyxz => new(y, y, x, z); - public readonly bool4 yyxw => new(y, y, x, w); - public readonly bool4 yyyx => new(y, y, y, x); - public readonly bool4 yyyy => new(y, y, y, y); - public readonly bool4 yyyz => new(y, y, y, z); - public readonly bool4 yyyw => new(y, y, y, w); - public readonly bool4 yyzx => new(y, y, z, x); - public readonly bool4 yyzy => new(y, y, z, y); - public readonly bool4 yyzz => new(y, y, z, z); - public readonly bool4 yyzw => new(y, y, z, w); - public readonly bool4 yywx => new(y, y, w, x); - public readonly bool4 yywy => new(y, y, w, y); - public readonly bool4 yywz => new(y, y, w, z); - public readonly bool4 yyww => new(y, y, w, w); - public readonly bool4 yzxx => new(y, z, x, x); - public readonly bool4 yzxy => new(y, z, x, y); - public readonly bool4 yzxz => new(y, z, x, z); - public bool4 yzxw { readonly get => new(y, z, x, w); set { y = value.x; z = value.y; x = value.z; w = value.w; } } - public readonly bool4 yzyx => new(y, z, y, x); - public readonly bool4 yzyy => new(y, z, y, y); - public readonly bool4 yzyz => new(y, z, y, z); - public readonly bool4 yzyw => new(y, z, y, w); - public readonly bool4 yzzx => new(y, z, z, x); - public readonly bool4 yzzy => new(y, z, z, y); - public readonly bool4 yzzz => new(y, z, z, z); - public readonly bool4 yzzw => new(y, z, z, w); - public bool4 yzwx { readonly get => new(y, z, w, x); set { y = value.x; z = value.y; w = value.z; x = value.w; } } - public readonly bool4 yzwy => new(y, z, w, y); - public readonly bool4 yzwz => new(y, z, w, z); - public readonly bool4 yzww => new(y, z, w, w); - public readonly bool4 ywxx => new(y, w, x, x); - public readonly bool4 ywxy => new(y, w, x, y); - public bool4 ywxz { readonly get => new(y, w, x, z); set { y = value.x; w = value.y; x = value.z; z = value.w; } } - public readonly bool4 ywxw => new(y, w, x, w); - public readonly bool4 ywyx => new(y, w, y, x); - public readonly bool4 ywyy => new(y, w, y, y); - public readonly bool4 ywyz => new(y, w, y, z); - public readonly bool4 ywyw => new(y, w, y, w); - public bool4 ywzx { readonly get => new(y, w, z, x); set { y = value.x; w = value.y; z = value.z; x = value.w; } } - public readonly bool4 ywzy => new(y, w, z, y); - public readonly bool4 ywzz => new(y, w, z, z); - public readonly bool4 ywzw => new(y, w, z, w); - public readonly bool4 ywwx => new(y, w, w, x); - public readonly bool4 ywwy => new(y, w, w, y); - public readonly bool4 ywwz => new(y, w, w, z); - public readonly bool4 ywww => new(y, w, w, w); - public readonly bool4 zxxx => new(z, x, x, x); - public readonly bool4 zxxy => new(z, x, x, y); - public readonly bool4 zxxz => new(z, x, x, z); - public readonly bool4 zxxw => new(z, x, x, w); - public readonly bool4 zxyx => new(z, x, y, x); - public readonly bool4 zxyy => new(z, x, y, y); - public readonly bool4 zxyz => new(z, x, y, z); - public bool4 zxyw { readonly get => new(z, x, y, w); set { z = value.x; x = value.y; y = value.z; w = value.w; } } - public readonly bool4 zxzx => new(z, x, z, x); - public readonly bool4 zxzy => new(z, x, z, y); - public readonly bool4 zxzz => new(z, x, z, z); - public readonly bool4 zxzw => new(z, x, z, w); - public readonly bool4 zxwx => new(z, x, w, x); - public bool4 zxwy { readonly get => new(z, x, w, y); set { z = value.x; x = value.y; w = value.z; y = value.w; } } - public readonly bool4 zxwz => new(z, x, w, z); - public readonly bool4 zxww => new(z, x, w, w); - public readonly bool4 zyxx => new(z, y, x, x); - public readonly bool4 zyxy => new(z, y, x, y); - public readonly bool4 zyxz => new(z, y, x, z); - public bool4 zyxw { readonly get => new(z, y, x, w); set { z = value.x; y = value.y; x = value.z; w = value.w; } } - public readonly bool4 zyyx => new(z, y, y, x); - public readonly bool4 zyyy => new(z, y, y, y); - public readonly bool4 zyyz => new(z, y, y, z); - public readonly bool4 zyyw => new(z, y, y, w); - public readonly bool4 zyzx => new(z, y, z, x); - public readonly bool4 zyzy => new(z, y, z, y); - public readonly bool4 zyzz => new(z, y, z, z); - public readonly bool4 zyzw => new(z, y, z, w); - public bool4 zywx { readonly get => new(z, y, w, x); set { z = value.x; y = value.y; w = value.z; x = value.w; } } - public readonly bool4 zywy => new(z, y, w, y); - public readonly bool4 zywz => new(z, y, w, z); - public readonly bool4 zyww => new(z, y, w, w); - public readonly bool4 zzxx => new(z, z, x, x); - public readonly bool4 zzxy => new(z, z, x, y); - public readonly bool4 zzxz => new(z, z, x, z); - public readonly bool4 zzxw => new(z, z, x, w); - public readonly bool4 zzyx => new(z, z, y, x); - public readonly bool4 zzyy => new(z, z, y, y); - public readonly bool4 zzyz => new(z, z, y, z); - public readonly bool4 zzyw => new(z, z, y, w); - public readonly bool4 zzzx => new(z, z, z, x); - public readonly bool4 zzzy => new(z, z, z, y); - public readonly bool4 zzzz => new(z, z, z, z); - public readonly bool4 zzzw => new(z, z, z, w); - public readonly bool4 zzwx => new(z, z, w, x); - public readonly bool4 zzwy => new(z, z, w, y); - public readonly bool4 zzwz => new(z, z, w, z); - public readonly bool4 zzww => new(z, z, w, w); - public readonly bool4 zwxx => new(z, w, x, x); - public bool4 zwxy { readonly get => new(z, w, x, y); set { z = value.x; w = value.y; x = value.z; y = value.w; } } - public readonly bool4 zwxz => new(z, w, x, z); - public readonly bool4 zwxw => new(z, w, x, w); - public bool4 zwyx { readonly get => new(z, w, y, x); set { z = value.x; w = value.y; y = value.z; x = value.w; } } - public readonly bool4 zwyy => new(z, w, y, y); - public readonly bool4 zwyz => new(z, w, y, z); - public readonly bool4 zwyw => new(z, w, y, w); - public readonly bool4 zwzx => new(z, w, z, x); - public readonly bool4 zwzy => new(z, w, z, y); - public readonly bool4 zwzz => new(z, w, z, z); - public readonly bool4 zwzw => new(z, w, z, w); - public readonly bool4 zwwx => new(z, w, w, x); - public readonly bool4 zwwy => new(z, w, w, y); - public readonly bool4 zwwz => new(z, w, w, z); - public readonly bool4 zwww => new(z, w, w, w); - public readonly bool4 wxxx => new(w, x, x, x); - public readonly bool4 wxxy => new(w, x, x, y); - public readonly bool4 wxxz => new(w, x, x, z); - public readonly bool4 wxxw => new(w, x, x, w); - public readonly bool4 wxyx => new(w, x, y, x); - public readonly bool4 wxyy => new(w, x, y, y); - public bool4 wxyz { readonly get => new(w, x, y, z); set { w = value.x; x = value.y; y = value.z; z = value.w; } } - public readonly bool4 wxyw => new(w, x, y, w); - public readonly bool4 wxzx => new(w, x, z, x); - public bool4 wxzy { readonly get => new(w, x, z, y); set { w = value.x; x = value.y; z = value.z; y = value.w; } } - public readonly bool4 wxzz => new(w, x, z, z); - public readonly bool4 wxzw => new(w, x, z, w); - public readonly bool4 wxwx => new(w, x, w, x); - public readonly bool4 wxwy => new(w, x, w, y); - public readonly bool4 wxwz => new(w, x, w, z); - public readonly bool4 wxww => new(w, x, w, w); - public readonly bool4 wyxx => new(w, y, x, x); - public readonly bool4 wyxy => new(w, y, x, y); - public bool4 wyxz { readonly get => new(w, y, x, z); set { w = value.x; y = value.y; x = value.z; z = value.w; } } - public readonly bool4 wyxw => new(w, y, x, w); - public readonly bool4 wyyx => new(w, y, y, x); - public readonly bool4 wyyy => new(w, y, y, y); - public readonly bool4 wyyz => new(w, y, y, z); - public readonly bool4 wyyw => new(w, y, y, w); - public bool4 wyzx { readonly get => new(w, y, z, x); set { w = value.x; y = value.y; z = value.z; x = value.w; } } - public readonly bool4 wyzy => new(w, y, z, y); - public readonly bool4 wyzz => new(w, y, z, z); - public readonly bool4 wyzw => new(w, y, z, w); - public readonly bool4 wywx => new(w, y, w, x); - public readonly bool4 wywy => new(w, y, w, y); - public readonly bool4 wywz => new(w, y, w, z); - public readonly bool4 wyww => new(w, y, w, w); - public readonly bool4 wzxx => new(w, z, x, x); - public bool4 wzxy { readonly get => new(w, z, x, y); set { w = value.x; z = value.y; x = value.z; y = value.w; } } - public readonly bool4 wzxz => new(w, z, x, z); - public readonly bool4 wzxw => new(w, z, x, w); - public bool4 wzyx { readonly get => new(w, z, y, x); set { w = value.x; z = value.y; y = value.z; x = value.w; } } - public readonly bool4 wzyy => new(w, z, y, y); - public readonly bool4 wzyz => new(w, z, y, z); - public readonly bool4 wzyw => new(w, z, y, w); - public readonly bool4 wzzx => new(w, z, z, x); - public readonly bool4 wzzy => new(w, z, z, y); - public readonly bool4 wzzz => new(w, z, z, z); - public readonly bool4 wzzw => new(w, z, z, w); - public readonly bool4 wzwx => new(w, z, w, x); - public readonly bool4 wzwy => new(w, z, w, y); - public readonly bool4 wzwz => new(w, z, w, z); - public readonly bool4 wzww => new(w, z, w, w); - public readonly bool4 wwxx => new(w, w, x, x); - public readonly bool4 wwxy => new(w, w, x, y); - public readonly bool4 wwxz => new(w, w, x, z); - public readonly bool4 wwxw => new(w, w, x, w); - public readonly bool4 wwyx => new(w, w, y, x); - public readonly bool4 wwyy => new(w, w, y, y); - public readonly bool4 wwyz => new(w, w, y, z); - public readonly bool4 wwyw => new(w, w, y, w); - public readonly bool4 wwzx => new(w, w, z, x); - public readonly bool4 wwzy => new(w, w, z, y); - public readonly bool4 wwzz => new(w, w, z, z); - public readonly bool4 wwzw => new(w, w, z, w); - public readonly bool4 wwwx => new(w, w, w, x); - public readonly bool4 wwwy => new(w, w, w, y); - public readonly bool4 wwwz => new(w, w, w, z); - public readonly bool4 wwww => new(w, w, w, w); - public readonly bool2 rr => new(x, x); - public bool2 rg { readonly get => new(x, y); set { x = value.x; y = value.y; } } - public bool2 rb { readonly get => new(x, z); set { x = value.x; z = value.y; } } - public bool2 ra { readonly get => new(x, w); set { x = value.x; w = value.y; } } - public bool2 gr { readonly get => new(y, x); set { y = value.x; x = value.y; } } - public readonly bool2 gg => new(y, y); - public bool2 gb { readonly get => new(y, z); set { y = value.x; z = value.y; } } - public bool2 ga { readonly get => new(y, w); set { y = value.x; w = value.y; } } - public bool2 br { readonly get => new(z, x); set { z = value.x; x = value.y; } } - public bool2 bg { readonly get => new(z, y); set { z = value.x; y = value.y; } } - public readonly bool2 bb => new(z, z); - public bool2 ba { readonly get => new(z, w); set { z = value.x; w = value.y; } } - public bool2 ar { readonly get => new(w, x); set { w = value.x; x = value.y; } } - public bool2 ag { readonly get => new(w, y); set { w = value.x; y = value.y; } } - public bool2 ab { readonly get => new(w, z); set { w = value.x; z = value.y; } } - public readonly bool2 aa => new(w, w); - public readonly bool3 rrr => new(x, x, x); - public readonly bool3 rrg => new(x, x, y); - public readonly bool3 rrb => new(x, x, z); - public readonly bool3 rra => new(x, x, w); - public readonly bool3 rgr => new(x, y, x); - public readonly bool3 rgg => new(x, y, y); - public bool3 rgb { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } - public bool3 rga { readonly get => new(x, y, w); set { x = value.x; y = value.y; w = value.z; } } - public readonly bool3 rbr => new(x, z, x); - public bool3 rbg { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } - public readonly bool3 rbb => new(x, z, z); - public bool3 rba { readonly get => new(x, z, w); set { x = value.x; z = value.y; w = value.z; } } - public readonly bool3 rar => new(x, w, x); - public bool3 rag { readonly get => new(x, w, y); set { x = value.x; w = value.y; y = value.z; } } - public bool3 rab { readonly get => new(x, w, z); set { x = value.x; w = value.y; z = value.z; } } - public readonly bool3 raa => new(x, w, w); - public readonly bool3 grr => new(y, x, x); - public readonly bool3 grg => new(y, x, y); - public bool3 grb { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } - public bool3 gra { readonly get => new(y, x, w); set { y = value.x; x = value.y; w = value.z; } } - public readonly bool3 ggr => new(y, y, x); - public readonly bool3 ggg => new(y, y, y); - public readonly bool3 ggb => new(y, y, z); - public readonly bool3 gga => new(y, y, w); - public bool3 gbr { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } - public readonly bool3 gbg => new(y, z, y); - public readonly bool3 gbb => new(y, z, z); - public bool3 gba { readonly get => new(y, z, w); set { y = value.x; z = value.y; w = value.z; } } - public bool3 gar { readonly get => new(y, w, x); set { y = value.x; w = value.y; x = value.z; } } - public readonly bool3 gag => new(y, w, y); - public bool3 gab { readonly get => new(y, w, z); set { y = value.x; w = value.y; z = value.z; } } - public readonly bool3 gaa => new(y, w, w); - public readonly bool3 brr => new(z, x, x); - public bool3 brg { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } - public readonly bool3 brb => new(z, x, z); - public bool3 bra { readonly get => new(z, x, w); set { z = value.x; x = value.y; w = value.z; } } - public bool3 bgr { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } - public readonly bool3 bgg => new(z, y, y); - public readonly bool3 bgb => new(z, y, z); - public bool3 bga { readonly get => new(z, y, w); set { z = value.x; y = value.y; w = value.z; } } - public readonly bool3 bbr => new(z, z, x); - public readonly bool3 bbg => new(z, z, y); - public readonly bool3 bbb => new(z, z, z); - public readonly bool3 bba => new(z, z, w); - public bool3 bar { readonly get => new(z, w, x); set { z = value.x; w = value.y; x = value.z; } } - public bool3 bag { readonly get => new(z, w, y); set { z = value.x; w = value.y; y = value.z; } } - public readonly bool3 bab => new(z, w, z); - public readonly bool3 baa => new(z, w, w); - public readonly bool3 arr => new(w, x, x); - public bool3 arg { readonly get => new(w, x, y); set { w = value.x; x = value.y; y = value.z; } } - public bool3 arb { readonly get => new(w, x, z); set { w = value.x; x = value.y; z = value.z; } } - public readonly bool3 ara => new(w, x, w); - public bool3 agr { readonly get => new(w, y, x); set { w = value.x; y = value.y; x = value.z; } } - public readonly bool3 agg => new(w, y, y); - public bool3 agb { readonly get => new(w, y, z); set { w = value.x; y = value.y; z = value.z; } } - public readonly bool3 aga => new(w, y, w); - public bool3 abr { readonly get => new(w, z, x); set { w = value.x; z = value.y; x = value.z; } } - public bool3 abg { readonly get => new(w, z, y); set { w = value.x; z = value.y; y = value.z; } } - public readonly bool3 abb => new(w, z, z); - public readonly bool3 aba => new(w, z, w); - public readonly bool3 aar => new(w, w, x); - public readonly bool3 aag => new(w, w, y); - public readonly bool3 aab => new(w, w, z); - public readonly bool3 aaa => new(w, w, w); - public readonly bool4 rrrr => new(x, x, x, x); - public readonly bool4 rrrg => new(x, x, x, y); - public readonly bool4 rrrb => new(x, x, x, z); - public readonly bool4 rrra => new(x, x, x, w); - public readonly bool4 rrgr => new(x, x, y, x); - public readonly bool4 rrgg => new(x, x, y, y); - public readonly bool4 rrgb => new(x, x, y, z); - public readonly bool4 rrga => new(x, x, y, w); - public readonly bool4 rrbr => new(x, x, z, x); - public readonly bool4 rrbg => new(x, x, z, y); - public readonly bool4 rrbb => new(x, x, z, z); - public readonly bool4 rrba => new(x, x, z, w); - public readonly bool4 rrar => new(x, x, w, x); - public readonly bool4 rrag => new(x, x, w, y); - public readonly bool4 rrab => new(x, x, w, z); - public readonly bool4 rraa => new(x, x, w, w); - public readonly bool4 rgrr => new(x, y, x, x); - public readonly bool4 rgrg => new(x, y, x, y); - public readonly bool4 rgrb => new(x, y, x, z); - public readonly bool4 rgra => new(x, y, x, w); - public readonly bool4 rggr => new(x, y, y, x); - public readonly bool4 rggg => new(x, y, y, y); - public readonly bool4 rggb => new(x, y, y, z); - public readonly bool4 rgga => new(x, y, y, w); - public readonly bool4 rgbr => new(x, y, z, x); - public readonly bool4 rgbg => new(x, y, z, y); - public readonly bool4 rgbb => new(x, y, z, z); - public bool4 rgba { readonly get => new(x, y, z, w); set { x = value.x; y = value.y; z = value.z; w = value.w; } } - public readonly bool4 rgar => new(x, y, w, x); - public readonly bool4 rgag => new(x, y, w, y); - public bool4 rgab { readonly get => new(x, y, w, z); set { x = value.x; y = value.y; w = value.z; z = value.w; } } - public readonly bool4 rgaa => new(x, y, w, w); - public readonly bool4 rbrr => new(x, z, x, x); - public readonly bool4 rbrg => new(x, z, x, y); - public readonly bool4 rbrb => new(x, z, x, z); - public readonly bool4 rbra => new(x, z, x, w); - public readonly bool4 rbgr => new(x, z, y, x); - public readonly bool4 rbgg => new(x, z, y, y); - public readonly bool4 rbgb => new(x, z, y, z); - public bool4 rbga { readonly get => new(x, z, y, w); set { x = value.x; z = value.y; y = value.z; w = value.w; } } - public readonly bool4 rbbr => new(x, z, z, x); - public readonly bool4 rbbg => new(x, z, z, y); - public readonly bool4 rbbb => new(x, z, z, z); - public readonly bool4 rbba => new(x, z, z, w); - public readonly bool4 rbar => new(x, z, w, x); - public bool4 rbag { readonly get => new(x, z, w, y); set { x = value.x; z = value.y; w = value.z; y = value.w; } } - public readonly bool4 rbab => new(x, z, w, z); - public readonly bool4 rbaa => new(x, z, w, w); - public readonly bool4 rarr => new(x, w, x, x); - public readonly bool4 rarg => new(x, w, x, y); - public readonly bool4 rarb => new(x, w, x, z); - public readonly bool4 rara => new(x, w, x, w); - public readonly bool4 ragr => new(x, w, y, x); - public readonly bool4 ragg => new(x, w, y, y); - public bool4 ragb { readonly get => new(x, w, y, z); set { x = value.x; w = value.y; y = value.z; z = value.w; } } - public readonly bool4 raga => new(x, w, y, w); - public readonly bool4 rabr => new(x, w, z, x); - public bool4 rabg { readonly get => new(x, w, z, y); set { x = value.x; w = value.y; z = value.z; y = value.w; } } - public readonly bool4 rabb => new(x, w, z, z); - public readonly bool4 raba => new(x, w, z, w); - public readonly bool4 raar => new(x, w, w, x); - public readonly bool4 raag => new(x, w, w, y); - public readonly bool4 raab => new(x, w, w, z); - public readonly bool4 raaa => new(x, w, w, w); - public readonly bool4 grrr => new(y, x, x, x); - public readonly bool4 grrg => new(y, x, x, y); - public readonly bool4 grrb => new(y, x, x, z); - public readonly bool4 grra => new(y, x, x, w); - public readonly bool4 grgr => new(y, x, y, x); - public readonly bool4 grgg => new(y, x, y, y); - public readonly bool4 grgb => new(y, x, y, z); - public readonly bool4 grga => new(y, x, y, w); - public readonly bool4 grbr => new(y, x, z, x); - public readonly bool4 grbg => new(y, x, z, y); - public readonly bool4 grbb => new(y, x, z, z); - public bool4 grba { readonly get => new(y, x, z, w); set { y = value.x; x = value.y; z = value.z; w = value.w; } } - public readonly bool4 grar => new(y, x, w, x); - public readonly bool4 grag => new(y, x, w, y); - public bool4 grab { readonly get => new(y, x, w, z); set { y = value.x; x = value.y; w = value.z; z = value.w; } } - public readonly bool4 graa => new(y, x, w, w); - public readonly bool4 ggrr => new(y, y, x, x); - public readonly bool4 ggrg => new(y, y, x, y); - public readonly bool4 ggrb => new(y, y, x, z); - public readonly bool4 ggra => new(y, y, x, w); - public readonly bool4 gggr => new(y, y, y, x); - public readonly bool4 gggg => new(y, y, y, y); - public readonly bool4 gggb => new(y, y, y, z); - public readonly bool4 ggga => new(y, y, y, w); - public readonly bool4 ggbr => new(y, y, z, x); - public readonly bool4 ggbg => new(y, y, z, y); - public readonly bool4 ggbb => new(y, y, z, z); - public readonly bool4 ggba => new(y, y, z, w); - public readonly bool4 ggar => new(y, y, w, x); - public readonly bool4 ggag => new(y, y, w, y); - public readonly bool4 ggab => new(y, y, w, z); - public readonly bool4 ggaa => new(y, y, w, w); - public readonly bool4 gbrr => new(y, z, x, x); - public readonly bool4 gbrg => new(y, z, x, y); - public readonly bool4 gbrb => new(y, z, x, z); - public bool4 gbra { readonly get => new(y, z, x, w); set { y = value.x; z = value.y; x = value.z; w = value.w; } } - public readonly bool4 gbgr => new(y, z, y, x); - public readonly bool4 gbgg => new(y, z, y, y); - public readonly bool4 gbgb => new(y, z, y, z); - public readonly bool4 gbga => new(y, z, y, w); - public readonly bool4 gbbr => new(y, z, z, x); - public readonly bool4 gbbg => new(y, z, z, y); - public readonly bool4 gbbb => new(y, z, z, z); - public readonly bool4 gbba => new(y, z, z, w); - public bool4 gbar { readonly get => new(y, z, w, x); set { y = value.x; z = value.y; w = value.z; x = value.w; } } - public readonly bool4 gbag => new(y, z, w, y); - public readonly bool4 gbab => new(y, z, w, z); - public readonly bool4 gbaa => new(y, z, w, w); - public readonly bool4 garr => new(y, w, x, x); - public readonly bool4 garg => new(y, w, x, y); - public bool4 garb { readonly get => new(y, w, x, z); set { y = value.x; w = value.y; x = value.z; z = value.w; } } - public readonly bool4 gara => new(y, w, x, w); - public readonly bool4 gagr => new(y, w, y, x); - public readonly bool4 gagg => new(y, w, y, y); - public readonly bool4 gagb => new(y, w, y, z); - public readonly bool4 gaga => new(y, w, y, w); - public bool4 gabr { readonly get => new(y, w, z, x); set { y = value.x; w = value.y; z = value.z; x = value.w; } } - public readonly bool4 gabg => new(y, w, z, y); - public readonly bool4 gabb => new(y, w, z, z); - public readonly bool4 gaba => new(y, w, z, w); - public readonly bool4 gaar => new(y, w, w, x); - public readonly bool4 gaag => new(y, w, w, y); - public readonly bool4 gaab => new(y, w, w, z); - public readonly bool4 gaaa => new(y, w, w, w); - public readonly bool4 brrr => new(z, x, x, x); - public readonly bool4 brrg => new(z, x, x, y); - public readonly bool4 brrb => new(z, x, x, z); - public readonly bool4 brra => new(z, x, x, w); - public readonly bool4 brgr => new(z, x, y, x); - public readonly bool4 brgg => new(z, x, y, y); - public readonly bool4 brgb => new(z, x, y, z); - public bool4 brga { readonly get => new(z, x, y, w); set { z = value.x; x = value.y; y = value.z; w = value.w; } } - public readonly bool4 brbr => new(z, x, z, x); - public readonly bool4 brbg => new(z, x, z, y); - public readonly bool4 brbb => new(z, x, z, z); - public readonly bool4 brba => new(z, x, z, w); - public readonly bool4 brar => new(z, x, w, x); - public bool4 brag { readonly get => new(z, x, w, y); set { z = value.x; x = value.y; w = value.z; y = value.w; } } - public readonly bool4 brab => new(z, x, w, z); - public readonly bool4 braa => new(z, x, w, w); - public readonly bool4 bgrr => new(z, y, x, x); - public readonly bool4 bgrg => new(z, y, x, y); - public readonly bool4 bgrb => new(z, y, x, z); - public bool4 bgra { readonly get => new(z, y, x, w); set { z = value.x; y = value.y; x = value.z; w = value.w; } } - public readonly bool4 bggr => new(z, y, y, x); - public readonly bool4 bggg => new(z, y, y, y); - public readonly bool4 bggb => new(z, y, y, z); - public readonly bool4 bgga => new(z, y, y, w); - public readonly bool4 bgbr => new(z, y, z, x); - public readonly bool4 bgbg => new(z, y, z, y); - public readonly bool4 bgbb => new(z, y, z, z); - public readonly bool4 bgba => new(z, y, z, w); - public bool4 bgar { readonly get => new(z, y, w, x); set { z = value.x; y = value.y; w = value.z; x = value.w; } } - public readonly bool4 bgag => new(z, y, w, y); - public readonly bool4 bgab => new(z, y, w, z); - public readonly bool4 bgaa => new(z, y, w, w); - public readonly bool4 bbrr => new(z, z, x, x); - public readonly bool4 bbrg => new(z, z, x, y); - public readonly bool4 bbrb => new(z, z, x, z); - public readonly bool4 bbra => new(z, z, x, w); - public readonly bool4 bbgr => new(z, z, y, x); - public readonly bool4 bbgg => new(z, z, y, y); - public readonly bool4 bbgb => new(z, z, y, z); - public readonly bool4 bbga => new(z, z, y, w); - public readonly bool4 bbbr => new(z, z, z, x); - public readonly bool4 bbbg => new(z, z, z, y); - public readonly bool4 bbbb => new(z, z, z, z); - public readonly bool4 bbba => new(z, z, z, w); - public readonly bool4 bbar => new(z, z, w, x); - public readonly bool4 bbag => new(z, z, w, y); - public readonly bool4 bbab => new(z, z, w, z); - public readonly bool4 bbaa => new(z, z, w, w); - public readonly bool4 barr => new(z, w, x, x); - public bool4 barg { readonly get => new(z, w, x, y); set { z = value.x; w = value.y; x = value.z; y = value.w; } } - public readonly bool4 barb => new(z, w, x, z); - public readonly bool4 bara => new(z, w, x, w); - public bool4 bagr { readonly get => new(z, w, y, x); set { z = value.x; w = value.y; y = value.z; x = value.w; } } - public readonly bool4 bagg => new(z, w, y, y); - public readonly bool4 bagb => new(z, w, y, z); - public readonly bool4 baga => new(z, w, y, w); - public readonly bool4 babr => new(z, w, z, x); - public readonly bool4 babg => new(z, w, z, y); - public readonly bool4 babb => new(z, w, z, z); - public readonly bool4 baba => new(z, w, z, w); - public readonly bool4 baar => new(z, w, w, x); - public readonly bool4 baag => new(z, w, w, y); - public readonly bool4 baab => new(z, w, w, z); - public readonly bool4 baaa => new(z, w, w, w); - public readonly bool4 arrr => new(w, x, x, x); - public readonly bool4 arrg => new(w, x, x, y); - public readonly bool4 arrb => new(w, x, x, z); - public readonly bool4 arra => new(w, x, x, w); - public readonly bool4 argr => new(w, x, y, x); - public readonly bool4 argg => new(w, x, y, y); - public bool4 argb { readonly get => new(w, x, y, z); set { w = value.x; x = value.y; y = value.z; z = value.w; } } - public readonly bool4 arga => new(w, x, y, w); - public readonly bool4 arbr => new(w, x, z, x); - public bool4 arbg { readonly get => new(w, x, z, y); set { w = value.x; x = value.y; z = value.z; y = value.w; } } - public readonly bool4 arbb => new(w, x, z, z); - public readonly bool4 arba => new(w, x, z, w); - public readonly bool4 arar => new(w, x, w, x); - public readonly bool4 arag => new(w, x, w, y); - public readonly bool4 arab => new(w, x, w, z); - public readonly bool4 araa => new(w, x, w, w); - public readonly bool4 agrr => new(w, y, x, x); - public readonly bool4 agrg => new(w, y, x, y); - public bool4 agrb { readonly get => new(w, y, x, z); set { w = value.x; y = value.y; x = value.z; z = value.w; } } - public readonly bool4 agra => new(w, y, x, w); - public readonly bool4 aggr => new(w, y, y, x); - public readonly bool4 aggg => new(w, y, y, y); - public readonly bool4 aggb => new(w, y, y, z); - public readonly bool4 agga => new(w, y, y, w); - public bool4 agbr { readonly get => new(w, y, z, x); set { w = value.x; y = value.y; z = value.z; x = value.w; } } - public readonly bool4 agbg => new(w, y, z, y); - public readonly bool4 agbb => new(w, y, z, z); - public readonly bool4 agba => new(w, y, z, w); - public readonly bool4 agar => new(w, y, w, x); - public readonly bool4 agag => new(w, y, w, y); - public readonly bool4 agab => new(w, y, w, z); - public readonly bool4 agaa => new(w, y, w, w); - public readonly bool4 abrr => new(w, z, x, x); - public bool4 abrg { readonly get => new(w, z, x, y); set { w = value.x; z = value.y; x = value.z; y = value.w; } } - public readonly bool4 abrb => new(w, z, x, z); - public readonly bool4 abra => new(w, z, x, w); - public bool4 abgr { readonly get => new(w, z, y, x); set { w = value.x; z = value.y; y = value.z; x = value.w; } } - public readonly bool4 abgg => new(w, z, y, y); - public readonly bool4 abgb => new(w, z, y, z); - public readonly bool4 abga => new(w, z, y, w); - public readonly bool4 abbr => new(w, z, z, x); - public readonly bool4 abbg => new(w, z, z, y); - public readonly bool4 abbb => new(w, z, z, z); - public readonly bool4 abba => new(w, z, z, w); - public readonly bool4 abar => new(w, z, w, x); - public readonly bool4 abag => new(w, z, w, y); - public readonly bool4 abab => new(w, z, w, z); - public readonly bool4 abaa => new(w, z, w, w); - public readonly bool4 aarr => new(w, w, x, x); - public readonly bool4 aarg => new(w, w, x, y); - public readonly bool4 aarb => new(w, w, x, z); - public readonly bool4 aara => new(w, w, x, w); - public readonly bool4 aagr => new(w, w, y, x); - public readonly bool4 aagg => new(w, w, y, y); - public readonly bool4 aagb => new(w, w, y, z); - public readonly bool4 aaga => new(w, w, y, w); - public readonly bool4 aabr => new(w, w, z, x); - public readonly bool4 aabg => new(w, w, z, y); - public readonly bool4 aabb => new(w, w, z, z); - public readonly bool4 aaba => new(w, w, z, w); - public readonly bool4 aaar => new(w, w, w, x); - public readonly bool4 aaag => new(w, w, w, y); - public readonly bool4 aaab => new(w, w, w, z); - public readonly bool4 aaaa => new(w, w, w, w); -} - -public partial struct double2 -{ - public double2(double2 xy) : this(xy.x, xy.y) { } - public double r { readonly get => x; set => x = value; } - public double g { readonly get => y; set => y = value; } - public readonly double2 xx => new(x, x); - public double2 xy { readonly get => new(x, y); set { x = value.x; y = value.y; } } - public double2 yx { readonly get => new(y, x); set { y = value.x; x = value.y; } } - public readonly double2 yy => new(y, y); - public readonly double3 xxx => new(x, x, x); - public readonly double3 xxy => new(x, x, y); - public readonly double3 xyx => new(x, y, x); - public readonly double3 xyy => new(x, y, y); - public readonly double3 yxx => new(y, x, x); - public readonly double3 yxy => new(y, x, y); - public readonly double3 yyx => new(y, y, x); - public readonly double3 yyy => new(y, y, y); - public readonly double2 rr => new(x, x); - public double2 rg { readonly get => new(x, y); set { x = value.x; y = value.y; } } - public double2 gr { readonly get => new(y, x); set { y = value.x; x = value.y; } } - public readonly double2 gg => new(y, y); - public readonly double3 rrr => new(x, x, x); - public readonly double3 rrg => new(x, x, y); - public readonly double3 rgr => new(x, y, x); - public readonly double3 rgg => new(x, y, y); - public readonly double3 grr => new(y, x, x); - public readonly double3 grg => new(y, x, y); - public readonly double3 ggr => new(y, y, x); - public readonly double3 ggg => new(y, y, y); -} - -public partial struct double3 -{ - public double3(double x, double2 yz) : this(x, yz.x, yz.y) { } - public double3(double2 xy, double z) : this(xy.x, xy.y, z) { } - public double3(double3 xyz) : this(xyz.x, xyz.y, xyz.z) { } - public double r { readonly get => x; set => x = value; } - public double g { readonly get => y; set => y = value; } - public double b { readonly get => z; set => z = value; } - public readonly double2 xx => new(x, x); - public double2 xy { readonly get => new(x, y); set { x = value.x; y = value.y; } } - public double2 xz { readonly get => new(x, z); set { x = value.x; z = value.y; } } - public double2 yx { readonly get => new(y, x); set { y = value.x; x = value.y; } } - public readonly double2 yy => new(y, y); - public double2 yz { readonly get => new(y, z); set { y = value.x; z = value.y; } } - public double2 zx { readonly get => new(z, x); set { z = value.x; x = value.y; } } - public double2 zy { readonly get => new(z, y); set { z = value.x; y = value.y; } } - public readonly double2 zz => new(z, z); - public readonly double3 xxx => new(x, x, x); - public readonly double3 xxy => new(x, x, y); - public readonly double3 xxz => new(x, x, z); - public readonly double3 xyx => new(x, y, x); - public readonly double3 xyy => new(x, y, y); - public double3 xyz { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } - public readonly double3 xzx => new(x, z, x); - public double3 xzy { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } - public readonly double3 xzz => new(x, z, z); - public readonly double3 yxx => new(y, x, x); - public readonly double3 yxy => new(y, x, y); - public double3 yxz { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } - public readonly double3 yyx => new(y, y, x); - public readonly double3 yyy => new(y, y, y); - public readonly double3 yyz => new(y, y, z); - public double3 yzx { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } - public readonly double3 yzy => new(y, z, y); - public readonly double3 yzz => new(y, z, z); - public readonly double3 zxx => new(z, x, x); - public double3 zxy { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } - public readonly double3 zxz => new(z, x, z); - public double3 zyx { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } - public readonly double3 zyy => new(z, y, y); - public readonly double3 zyz => new(z, y, z); - public readonly double3 zzx => new(z, z, x); - public readonly double3 zzy => new(z, z, y); - public readonly double3 zzz => new(z, z, z); - public readonly double2 rr => new(x, x); - public double2 rg { readonly get => new(x, y); set { x = value.x; y = value.y; } } - public double2 rb { readonly get => new(x, z); set { x = value.x; z = value.y; } } - public double2 gr { readonly get => new(y, x); set { y = value.x; x = value.y; } } - public readonly double2 gg => new(y, y); - public double2 gb { readonly get => new(y, z); set { y = value.x; z = value.y; } } - public double2 br { readonly get => new(z, x); set { z = value.x; x = value.y; } } - public double2 bg { readonly get => new(z, y); set { z = value.x; y = value.y; } } - public readonly double2 bb => new(z, z); - public readonly double3 rrr => new(x, x, x); - public readonly double3 rrg => new(x, x, y); - public readonly double3 rrb => new(x, x, z); - public readonly double3 rgr => new(x, y, x); - public readonly double3 rgg => new(x, y, y); - public double3 rgb { readonly get => new(x, y, z); set { x = value.x; y = value.y; z = value.z; } } - public readonly double3 rbr => new(x, z, x); - public double3 rbg { readonly get => new(x, z, y); set { x = value.x; z = value.y; y = value.z; } } - public readonly double3 rbb => new(x, z, z); - public readonly double3 grr => new(y, x, x); - public readonly double3 grg => new(y, x, y); - public double3 grb { readonly get => new(y, x, z); set { y = value.x; x = value.y; z = value.z; } } - public readonly double3 ggr => new(y, y, x); - public readonly double3 ggg => new(y, y, y); - public readonly double3 ggb => new(y, y, z); - public double3 gbr { readonly get => new(y, z, x); set { y = value.x; z = value.y; x = value.z; } } - public readonly double3 gbg => new(y, z, y); - public readonly double3 gbb => new(y, z, z); - public readonly double3 brr => new(z, x, x); - public double3 brg { readonly get => new(z, x, y); set { z = value.x; x = value.y; y = value.z; } } - public readonly double3 brb => new(z, x, z); - public double3 bgr { readonly get => new(z, y, x); set { z = value.x; y = value.y; x = value.z; } } - public readonly double3 bgg => new(z, y, y); - public readonly double3 bgb => new(z, y, z); - public readonly double3 bbr => new(z, z, x); - public readonly double3 bbg => new(z, z, y); - public readonly double3 bbb => new(z, z, z); -} - -public static partial class math -{ - public static float2 float2(float2 xy) => new(xy); - 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 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); - public static float4 float4(float2 xy, float z, float w) => new(xy, z, w); - 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 int2 int2(int2 xy) => new(xy); - 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 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); - public static int4 int4(int2 xy, int z, int w) => new(xy, z, w); - 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 bool2 bool2(bool2 xy) => new(xy); - 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 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); - public static bool4 bool4(bool2 xy, bool z, bool w) => new(xy, z, w); - 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); -} diff --git a/packages/cultmath/src/CultMath/Swizzles.g.cs.meta b/packages/cultmath/src/CultMath/Swizzles.g.cs.meta deleted file mode 100644 index 035e577f..00000000 --- a/packages/cultmath/src/CultMath/Swizzles.g.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: e89d6089b354459788eb6f0faebfd6c0 diff --git a/packages/cultmath/src/CultMath/UnscopedRefAttribute.cs b/packages/cultmath/src/CultMath/UnscopedRefAttribute.cs deleted file mode 100644 index 6e1e3148..00000000 --- a/packages/cultmath/src/CultMath/UnscopedRefAttribute.cs +++ /dev/null @@ -1,10 +0,0 @@ -#if !NET7_0_OR_GREATER -namespace System.Diagnostics.CodeAnalysis; - -// Compiler-recognized polyfill so netstandard2.1 builds can return refs into a struct's -// own fields (the matrix row indexer). net7.0+ supplies the real attribute. -[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)] -internal sealed class UnscopedRefAttribute : Attribute -{ -} -#endif diff --git a/packages/cultmath/src/CultMath/UnscopedRefAttribute.cs.meta b/packages/cultmath/src/CultMath/UnscopedRefAttribute.cs.meta deleted file mode 100644 index 61df7771..00000000 --- a/packages/cultmath/src/CultMath/UnscopedRefAttribute.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 3f204000f9854517a8dd754e2d9dc975 diff --git a/packages/cultmath/src/CultMath/Voronoi.cs b/packages/cultmath/src/CultMath/Voronoi.cs deleted file mode 100644 index 38b52d81..00000000 --- a/packages/cultmath/src/CultMath/Voronoi.cs +++ /dev/null @@ -1,229 +0,0 @@ -using System; -using System.Runtime.InteropServices; - -namespace CultMath; - -public static class Voronoi -{ - public static bool NativeAvailable { get; private set; } = true; - - public static Color32 SampleTone(float x, float y, float resolutionY, int frameIndex, CultMathTone tone, float span) - { - Span xs = stackalloc[] { x }; - Span ys = stackalloc[] { y }; - Span tones = stackalloc[] { tone }; - Span spans = stackalloc[] { span }; - Span colors = stackalloc Color32[1]; - SampleTones(xs, ys, tones, spans, resolutionY, frameIndex, colors); - return colors[0]; - } - - public static void SampleTones( - ReadOnlySpan xs, - ReadOnlySpan ys, - ReadOnlySpan tones, - ReadOnlySpan spans, - float resolutionY, - int frameIndex, - Span colors) - { - if (xs.Length != ys.Length || xs.Length != tones.Length || xs.Length != spans.Length || xs.Length != colors.Length) - { - throw new ArgumentException("CultMath Voronoi batch spans must have equal length."); - } - - if (xs.IsEmpty) - { - return; - } - - if (NativeAvailable && TrySampleNative(xs, ys, tones, spans, resolutionY, frameIndex, colors)) - { - return; - } - - for (var index = 0; index < xs.Length; index++) - { - colors[index] = SampleManaged(xs[index], ys[index], resolutionY, frameIndex, tones[index], spans[index]); - } - } - - private static unsafe bool TrySampleNative( - ReadOnlySpan xs, - ReadOnlySpan ys, - ReadOnlySpan tones, - ReadOnlySpan spans, - float resolutionY, - int frameIndex, - Span colors) - { - try - { - fixed (float* xPtr = xs) - fixed (float* yPtr = ys) - fixed (CultMathTone* tonePtr = tones) - fixed (float* spanPtr = spans) - fixed (Color32* colorPtr = colors) - { - var result = Native.cultmath_apollonian_voronoi_tones( - xPtr, - yPtr, - (byte*)tonePtr, - spanPtr, - (nuint)xs.Length, - resolutionY, - frameIndex, - colorPtr); - if (result == 0) - { - return true; - } - } - } - catch (DllNotFoundException) - { - NativeAvailable = false; - } - catch (EntryPointNotFoundException) - { - NativeAvailable = false; - } - - return false; - } - - private static Color32 SampleManaged(float x, float y, float resolutionY, int frameIndex, CultMathTone tone, float span) - { - var sample = StableSample(x, y, resolutionY, frameIndex, tone, span); - var luminance = math.saturate(sample.x * 0.299f + sample.y * 0.587f + sample.z * 0.114f); - if (tone == CultMathTone.Header) - { - var glow = 0.62f + luminance * 0.38f; - return new Color32((byte)(255 * glow), (byte)(96 * glow), (byte)(14 * glow)); - } - - if (tone == CultMathTone.Body) - { - var glow = 0.68f + luminance * 0.32f; - return new Color32((byte)(245 * glow), (byte)(248 * glow), (byte)(252 * glow)); - } - - var gain = tone switch - { - CultMathTone.Panel => 0.42f, - CultMathTone.Edge => 1.05f, - _ => 0.24f, - }; - var lift = tone switch - { - CultMathTone.Panel => 14.0f, - CultMathTone.Edge => 24.0f, - _ => 4.0f, - }; - return new Color32( - (byte)Math.Clamp(lift + sample.x * 255.0f * gain, 0, 255), - (byte)Math.Clamp(lift + sample.y * 255.0f * gain, 0, 255), - (byte)Math.Clamp(lift + sample.z * 255.0f * gain, 0, 255)); - } - - private static float3 StableSample(float x, float y, float resolutionY, int frameIndex, CultMathTone tone, float span) - { - var seed = x * 12.9898f + y * 78.233f + Hash1(((byte)tone + 1.0f) * 19.19f); - var jitter = new float2((Hash1(seed) - 0.5f) * span, (Hash1(seed + 37.17f) - 0.5f) * span); - return SampleField(x + jitter.x, y + jitter.y, resolutionY, frameIndex); - } - - private static float3 SampleField(float px, float py, float resolutionY, int frameIndex) - { - var time = frameIndex / 120.0f; - var p = new float2(6.0f * px / Math.Max(1.0f, resolutionY), 6.0f * py / Math.Max(1.0f, resolutionY)); - var n = math.floor(p); - var f = math.frac(p); - var distance = 8.0f; - var color = float3.zero; - const float smoothness = 0.005f; - for (var j = -2; j <= 2; j++) - { - for (var i = -2; i <= 2; i++) - { - var g = new float2(i, j); - var o = Hash2(n + g); - var weight = o.x * 0.5f + 0.5f; - o = 0.5f + 0.5f * new float2(math.sin(time + 6.2831f * o.x), math.sin(time + 6.2831f * o.y)); - var d2 = math.abs(g - f + o); - var d = Math.Max(d2.x, d2.y) * weight; - var seed = Hash1(math.dot(n + g, new float2(7.0f, 113.0f))); - var hue = math.frac((n.x + g.x) * 0.173f + (n.y + g.y) * 0.379f + seed * 0.431f); - var candidate = PastelSpectrum(hue); - var h = math.smoothstep(0.0f, 1.0f, 0.5f + 0.5f * (distance - d) / smoothness); - var correction = h * (1.0f - h) * smoothness / (1.0f + 3.0f * smoothness); - distance = math.lerp(distance, d, h) - correction; - color = math.lerp(color, candidate, h) - correction; - } - } - - return math.max(float3.zero, color * (1.0f - 0.1f * math.smoothstep(0.04f, 0.05f, distance))); - } - - private static float Hash1(float value) => math.frac(math.sin(value) * 43758.5453f); - - private static float3 PastelSpectrum(float hue) - { - return HsvToRgb(hue, 0.52f, 0.98f); - } - - private static float3 HsvToRgb(float hue, float saturation, float value) - { - var h = math.frac(hue) * 6.0f; - var c = value * saturation; - var x = c * (1.0f - Math.Abs(h % 2.0f - 1.0f)); - var m = value - c; - float3 rgb; - if (h < 1.0f) - { - rgb = new float3(c, x, 0.0f); - } - else if (h < 2.0f) - { - rgb = new float3(x, c, 0.0f); - } - else if (h < 3.0f) - { - rgb = new float3(0.0f, c, x); - } - else if (h < 4.0f) - { - rgb = new float3(0.0f, x, c); - } - else if (h < 5.0f) - { - rgb = new float3(x, 0.0f, c); - } - else - { - rgb = new float3(c, 0.0f, x); - } - - return rgb + m; - } - - private static float2 Hash2(float2 value) - { - var p = new float2(math.dot(value, new float2(127.1f, 311.7f)), math.dot(value, new float2(269.5f, 183.3f))); - return math.frac(new float2(math.sin(p.x) * 43758.5453f, math.sin(p.y) * 43758.5453f)); - } - - private static unsafe partial class Native - { - [DllImport("cultmath_core", CallingConvention = CallingConvention.Cdecl)] - public static extern int cultmath_apollonian_voronoi_tones( - float* xs, - float* ys, - byte* tones, - float* spans, - nuint count, - float resolutionY, - int frameIndex, - Color32* outColors); - } -} diff --git a/packages/cultmath/src/CultMath/Voronoi.cs.meta b/packages/cultmath/src/CultMath/Voronoi.cs.meta deleted file mode 100644 index 6c738085..00000000 --- a/packages/cultmath/src/CultMath/Voronoi.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: cb530f1428ac8ff43939b986986ae959 \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/bool2.cs b/packages/cultmath/src/CultMath/bool2.cs deleted file mode 100644 index 29710f0c..00000000 --- a/packages/cultmath/src/CultMath/bool2.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; - -namespace CultMath; - -public partial struct bool2 : IEquatable -{ - public static readonly bool2 @false = new(false, false); - public static readonly bool2 @true = new(true, true); - - public bool x; - public bool y; - - public bool2(bool x, bool y) - { - this.x = x; - this.y = y; - } - - public bool this[int index] - { - readonly get => index switch - { - 0 => x, - 1 => y, - _ => throw new ArgumentOutOfRangeException(nameof(index)), - }; - set - { - switch (index) - { - case 0: x = value; break; - case 1: y = value; break; - default: throw new ArgumentOutOfRangeException(nameof(index)); - } - } - } - - public static implicit operator bool2(bool value) => new(value, value); - - public static bool2 operator !(bool2 value) => new(!value.x, !value.y); - public static bool2 operator &(bool2 left, bool2 right) => new(left.x & right.x, left.y & right.y); - public static bool2 operator |(bool2 left, bool2 right) => new(left.x | right.x, left.y | right.y); - public static bool2 operator ^(bool2 left, bool2 right) => new(left.x ^ right.x, left.y ^ right.y); - public static bool2 operator ==(bool2 left, bool2 right) => new(left.x == right.x, left.y == right.y); - public static bool2 operator !=(bool2 left, bool2 right) => new(left.x != right.x, left.y != right.y); - - public readonly bool Equals(bool2 other) => x == other.x && y == other.y; - public override readonly bool Equals(object? obj) => obj is bool2 other && Equals(other); - public override readonly int GetHashCode() => HashCode.Combine(x, y); - public override readonly string ToString() => $"bool2({x}, {y})"; -} diff --git a/packages/cultmath/src/CultMath/bool2.cs.meta b/packages/cultmath/src/CultMath/bool2.cs.meta deleted file mode 100644 index 477ff144..00000000 --- a/packages/cultmath/src/CultMath/bool2.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 725efb2725e987449a3782452f5a95e0 \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/bool3.cs b/packages/cultmath/src/CultMath/bool3.cs deleted file mode 100644 index 9e4029c7..00000000 --- a/packages/cultmath/src/CultMath/bool3.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; - -namespace CultMath; - -public partial struct bool3 : IEquatable -{ - public static readonly bool3 @false = new(false, false, false); - public static readonly bool3 @true = new(true, true, true); - - public bool x; - public bool y; - public bool z; - - public bool3(bool x, bool y, bool z) - { - this.x = x; - this.y = y; - this.z = z; - } - - public bool this[int index] - { - readonly get => index switch - { - 0 => x, - 1 => y, - 2 => z, - _ => throw new ArgumentOutOfRangeException(nameof(index)), - }; - set - { - switch (index) - { - case 0: x = value; break; - case 1: y = value; break; - case 2: z = value; break; - default: throw new ArgumentOutOfRangeException(nameof(index)); - } - } - } - - public static implicit operator bool3(bool value) => new(value, value, value); - - public static bool3 operator !(bool3 value) => new(!value.x, !value.y, !value.z); - public static bool3 operator &(bool3 left, bool3 right) => new(left.x & right.x, left.y & right.y, left.z & right.z); - public static bool3 operator |(bool3 left, bool3 right) => new(left.x | right.x, left.y | right.y, left.z | right.z); - public static bool3 operator ^(bool3 left, bool3 right) => new(left.x ^ right.x, left.y ^ right.y, left.z ^ right.z); - public static bool3 operator ==(bool3 left, bool3 right) => new(left.x == right.x, left.y == right.y, left.z == right.z); - public static bool3 operator !=(bool3 left, bool3 right) => new(left.x != right.x, left.y != right.y, left.z != right.z); - - public readonly bool Equals(bool3 other) => x == other.x && y == other.y && z == other.z; - public override readonly bool Equals(object? obj) => obj is bool3 other && Equals(other); - public override readonly int GetHashCode() => HashCode.Combine(x, y, z); - public override readonly string ToString() => $"bool3({x}, {y}, {z})"; -} diff --git a/packages/cultmath/src/CultMath/bool3.cs.meta b/packages/cultmath/src/CultMath/bool3.cs.meta deleted file mode 100644 index ada81b6d..00000000 --- a/packages/cultmath/src/CultMath/bool3.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 323ec670fc374c8ba4d87b4bf4c91f34 diff --git a/packages/cultmath/src/CultMath/bool4.cs b/packages/cultmath/src/CultMath/bool4.cs deleted file mode 100644 index b12f15b6..00000000 --- a/packages/cultmath/src/CultMath/bool4.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System; - -namespace CultMath; - -public partial struct bool4 : IEquatable -{ - public static readonly bool4 @false = new(false, false, false, false); - public static readonly bool4 @true = new(true, true, true, true); - - public bool x; - public bool y; - public bool z; - public bool w; - - public bool4(bool x, bool y, bool z, bool w) - { - this.x = x; - this.y = y; - this.z = z; - this.w = w; - } - - public bool this[int index] - { - readonly get => index switch - { - 0 => x, - 1 => y, - 2 => z, - 3 => w, - _ => throw new ArgumentOutOfRangeException(nameof(index)), - }; - set - { - switch (index) - { - case 0: x = value; break; - case 1: y = value; break; - case 2: z = value; break; - case 3: w = value; break; - default: throw new ArgumentOutOfRangeException(nameof(index)); - } - } - } - - public static implicit operator bool4(bool value) => new(value, value, value, value); - - public static bool4 operator !(bool4 value) => new(!value.x, !value.y, !value.z, !value.w); - public static bool4 operator &(bool4 left, bool4 right) => new(left.x & right.x, left.y & right.y, left.z & right.z, left.w & right.w); - public static bool4 operator |(bool4 left, bool4 right) => new(left.x | right.x, left.y | right.y, left.z | right.z, left.w | right.w); - public static bool4 operator ^(bool4 left, bool4 right) => new(left.x ^ right.x, left.y ^ right.y, left.z ^ right.z, left.w ^ right.w); - public static bool4 operator ==(bool4 left, bool4 right) => new(left.x == right.x, left.y == right.y, left.z == right.z, left.w == right.w); - public static bool4 operator !=(bool4 left, bool4 right) => new(left.x != right.x, left.y != right.y, left.z != right.z, left.w != right.w); - - public readonly bool Equals(bool4 other) => x == other.x && y == other.y && z == other.z && w == other.w; - public override readonly bool Equals(object? obj) => obj is bool4 other && Equals(other); - public override readonly int GetHashCode() => HashCode.Combine(x, y, z, w); - public override readonly string ToString() => $"bool4({x}, {y}, {z}, {w})"; -} diff --git a/packages/cultmath/src/CultMath/bool4.cs.meta b/packages/cultmath/src/CultMath/bool4.cs.meta deleted file mode 100644 index 625bb46b..00000000 --- a/packages/cultmath/src/CultMath/bool4.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: d38b9f70d88c459584441b85ecbfd92a diff --git a/packages/cultmath/src/CultMath/double2.cs b/packages/cultmath/src/CultMath/double2.cs deleted file mode 100644 index 958b409d..00000000 --- a/packages/cultmath/src/CultMath/double2.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System; - -namespace CultMath; - -public partial struct double2 : IEquatable -{ - public static readonly double2 zero = new(0.0, 0.0); - public static readonly double2 one = new(1.0, 1.0); - - public double x; - public double y; - - public double2(double x, double y) - { - this.x = x; - this.y = y; - } - - public double this[int index] - { - readonly get => index switch - { - 0 => x, - 1 => y, - _ => throw new ArgumentOutOfRangeException(nameof(index)), - }; - set - { - switch (index) - { - case 0: x = value; break; - case 1: y = value; break; - default: throw new ArgumentOutOfRangeException(nameof(index)); - } - } - } - - public static implicit operator double2(double value) => new(value, value); - - public static double2 operator +(double2 value) => value; - public static double2 operator -(double2 value) => new(-value.x, -value.y); - public static double2 operator +(double2 left, double2 right) => new(left.x + right.x, left.y + right.y); - public static double2 operator -(double2 left, double2 right) => new(left.x - right.x, left.y - right.y); - public static double2 operator *(double2 left, double2 right) => new(left.x * right.x, left.y * right.y); - public static double2 operator /(double2 left, double2 right) => new(left.x / right.x, left.y / right.y); - - public static bool2 operator <(double2 left, double2 right) => new(left.x < right.x, left.y < right.y); - public static bool2 operator >(double2 left, double2 right) => new(left.x > right.x, left.y > right.y); - public static bool2 operator <=(double2 left, double2 right) => new(left.x <= right.x, left.y <= right.y); - public static bool2 operator >=(double2 left, double2 right) => new(left.x >= right.x, left.y >= right.y); - public static bool2 operator ==(double2 left, double2 right) => new(left.x == right.x, left.y == right.y); - public static bool2 operator !=(double2 left, double2 right) => new(left.x != right.x, left.y != right.y); - - public readonly bool Equals(double2 other) => x.Equals(other.x) && y.Equals(other.y); - public override readonly bool Equals(object? obj) => obj is double2 other && Equals(other); - public override readonly int GetHashCode() => HashCode.Combine(x, y); - public override readonly string ToString() => FormattableString.Invariant($"double2({x:R}, {y:R})"); -} diff --git a/packages/cultmath/src/CultMath/double2.cs.meta b/packages/cultmath/src/CultMath/double2.cs.meta deleted file mode 100644 index 61243b2a..00000000 --- a/packages/cultmath/src/CultMath/double2.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: bbe739a5f0216c3459762d2b5ee7401a \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/double3.cs b/packages/cultmath/src/CultMath/double3.cs deleted file mode 100644 index 5fbe9c65..00000000 --- a/packages/cultmath/src/CultMath/double3.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System; - -namespace CultMath; - -public partial struct double3 : IEquatable -{ - public static readonly double3 zero = new(0.0, 0.0, 0.0); - public static readonly double3 one = new(1.0, 1.0, 1.0); - - public double x; - public double y; - public double z; - - public double3(double x, double y, double z) - { - this.x = x; - this.y = y; - this.z = z; - } - - public double this[int index] - { - readonly get => index switch - { - 0 => x, - 1 => y, - 2 => z, - _ => throw new ArgumentOutOfRangeException(nameof(index)), - }; - set - { - switch (index) - { - case 0: x = value; break; - case 1: y = value; break; - case 2: z = value; break; - default: throw new ArgumentOutOfRangeException(nameof(index)); - } - } - } - - public static implicit operator double3(double value) => new(value, value, value); - - public static double3 operator +(double3 value) => value; - public static double3 operator -(double3 value) => new(-value.x, -value.y, -value.z); - public static double3 operator +(double3 left, double3 right) => new(left.x + right.x, left.y + right.y, left.z + right.z); - public static double3 operator -(double3 left, double3 right) => new(left.x - right.x, left.y - right.y, left.z - right.z); - public static double3 operator *(double3 left, double3 right) => new(left.x * right.x, left.y * right.y, left.z * right.z); - public static double3 operator /(double3 left, double3 right) => new(left.x / right.x, left.y / right.y, left.z / right.z); - - public static bool3 operator <(double3 left, double3 right) => new(left.x < right.x, left.y < right.y, left.z < right.z); - public static bool3 operator >(double3 left, double3 right) => new(left.x > right.x, left.y > right.y, left.z > right.z); - public static bool3 operator <=(double3 left, double3 right) => new(left.x <= right.x, left.y <= right.y, left.z <= right.z); - public static bool3 operator >=(double3 left, double3 right) => new(left.x >= right.x, left.y >= right.y, left.z >= right.z); - public static bool3 operator ==(double3 left, double3 right) => new(left.x == right.x, left.y == right.y, left.z == right.z); - public static bool3 operator !=(double3 left, double3 right) => new(left.x != right.x, left.y != right.y, left.z != right.z); - - public readonly bool Equals(double3 other) => x.Equals(other.x) && y.Equals(other.y) && z.Equals(other.z); - public override readonly bool Equals(object? obj) => obj is double3 other && Equals(other); - public override readonly int GetHashCode() => HashCode.Combine(x, y, z); - public override readonly string ToString() => FormattableString.Invariant($"double3({x:R}, {y:R}, {z:R})"); -} diff --git a/packages/cultmath/src/CultMath/double3.cs.meta b/packages/cultmath/src/CultMath/double3.cs.meta deleted file mode 100644 index 302d13d8..00000000 --- a/packages/cultmath/src/CultMath/double3.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 1cbaafd219703ee43ae279aa5cbf407f \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/float2.cs b/packages/cultmath/src/CultMath/float2.cs deleted file mode 100644 index e33655a8..00000000 --- a/packages/cultmath/src/CultMath/float2.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System; - -namespace CultMath; - -public partial struct float2 : IEquatable -{ - public static readonly float2 zero = new(0.0f, 0.0f); - public static readonly float2 one = new(1.0f, 1.0f); - - public float x; - public float y; - - public float2(float x, float y) - { - this.x = x; - this.y = y; - } - - public float this[int index] - { - readonly get => index switch - { - 0 => x, - 1 => y, - _ => throw new ArgumentOutOfRangeException(nameof(index)), - }; - set - { - switch (index) - { - case 0: x = value; break; - case 1: y = value; break; - default: throw new ArgumentOutOfRangeException(nameof(index)); - } - } - } - - public static implicit operator float2(float value) => new(value, value); - public static explicit operator System.Numerics.Vector2(float2 value) => new(value.x, value.y); - public static explicit operator float2(System.Numerics.Vector2 value) => new(value.X, value.Y); - - public static float2 operator +(float2 value) => value; - public static float2 operator -(float2 value) => new(-value.x, -value.y); - - public static float2 operator +(float2 left, float2 right) => new(left.x + right.x, left.y + right.y); - public static float2 operator +(float2 left, float right) => new(left.x + right, left.y + right); - public static float2 operator +(float left, float2 right) => new(left + right.x, left + right.y); - public static float2 operator -(float2 left, float2 right) => new(left.x - right.x, left.y - right.y); - public static float2 operator -(float2 left, float right) => new(left.x - right, left.y - right); - public static float2 operator -(float left, float2 right) => new(left - right.x, left - right.y); - public static float2 operator *(float2 left, float2 right) => new(left.x * right.x, left.y * right.y); - public static float2 operator *(float2 left, float right) => new(left.x * right, left.y * right); - public static float2 operator *(float left, float2 right) => new(left * right.x, left * right.y); - public static float2 operator /(float2 left, float2 right) => new(left.x / right.x, left.y / right.y); - public static float2 operator /(float2 left, float right) => new(left.x / right, left.y / right); - public static float2 operator /(float left, float2 right) => new(left / right.x, left / right.y); - public static float2 operator %(float2 left, float2 right) => new(left.x % right.x, left.y % right.y); - public static float2 operator %(float2 left, float right) => new(left.x % right, left.y % right); - public static float2 operator %(float left, float2 right) => new(left % right.x, left % right.y); - - public static bool2 operator <(float2 left, float2 right) => new(left.x < right.x, left.y < right.y); - public static bool2 operator <(float2 left, float right) => new(left.x < right, left.y < right); - public static bool2 operator <(float left, float2 right) => new(left < right.x, left < right.y); - public static bool2 operator >(float2 left, float2 right) => new(left.x > right.x, left.y > right.y); - public static bool2 operator >(float2 left, float right) => new(left.x > right, left.y > right); - public static bool2 operator >(float left, float2 right) => new(left > right.x, left > right.y); - public static bool2 operator <=(float2 left, float2 right) => new(left.x <= right.x, left.y <= right.y); - public static bool2 operator <=(float2 left, float right) => new(left.x <= right, left.y <= right); - public static bool2 operator <=(float left, float2 right) => new(left <= right.x, left <= right.y); - public static bool2 operator >=(float2 left, float2 right) => new(left.x >= right.x, left.y >= right.y); - public static bool2 operator >=(float2 left, float right) => new(left.x >= right, left.y >= right); - public static bool2 operator >=(float left, float2 right) => new(left >= right.x, left >= right.y); - public static bool2 operator ==(float2 left, float2 right) => new(left.x == right.x, left.y == right.y); - public static bool2 operator ==(float2 left, float right) => new(left.x == right, left.y == right); - public static bool2 operator ==(float left, float2 right) => new(left == right.x, left == right.y); - public static bool2 operator !=(float2 left, float2 right) => new(left.x != right.x, left.y != right.y); - public static bool2 operator !=(float2 left, float right) => new(left.x != right, left.y != right); - public static bool2 operator !=(float left, float2 right) => new(left != right.x, left != right.y); - - public readonly bool Equals(float2 other) => x.Equals(other.x) && y.Equals(other.y); - public override readonly bool Equals(object? obj) => obj is float2 other && Equals(other); - public override readonly int GetHashCode() => HashCode.Combine(x, y); - public override readonly string ToString() => FormattableString.Invariant($"float2({x:R}, {y:R})"); -} diff --git a/packages/cultmath/src/CultMath/float2.cs.meta b/packages/cultmath/src/CultMath/float2.cs.meta deleted file mode 100644 index c82f69ae..00000000 --- a/packages/cultmath/src/CultMath/float2.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: fa066a0aed5494f499d462a2ebc51e50 \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/float2x2.cs b/packages/cultmath/src/CultMath/float2x2.cs deleted file mode 100644 index bd8673ae..00000000 --- a/packages/cultmath/src/CultMath/float2x2.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; - -namespace CultMath; - -/// -/// HLSL float2x2: row-major. Constructors take rows, m[i] is a writable reference to -/// row i (so m[1][0] = s works), and _mRC names row R, column C exactly as HLSL -/// does. Use for products; there is no * -/// matrix product because HLSL's * is component-wise. -/// -public struct float2x2 : IEquatable -{ - public static readonly float2x2 identity = new(1.0f, 0.0f, 0.0f, 1.0f); - public static readonly float2x2 zero = new(0.0f, 0.0f, 0.0f, 0.0f); - - private float2 _row0; - private float2 _row1; - - public float2x2(float m00, float m01, float m10, float m11) - { - _row0 = new float2(m00, m01); - _row1 = new float2(m10, m11); - } - - public float2x2(float2 row0, float2 row1) - { - _row0 = row0; - _row1 = row1; - } - - public float _m00 { readonly get => _row0.x; set => _row0.x = value; } - public float _m01 { readonly get => _row0.y; set => _row0.y = value; } - public float _m10 { readonly get => _row1.x; set => _row1.x = value; } - public float _m11 { readonly get => _row1.y; set => _row1.y = value; } - - /// Writes through a readonly field, in parameter, property, or identity hit a defensive copy and are lost; write to a local and assign it back. - [UnscopedRef] - public ref float2 this[int row] - { - get - { - switch (row) - { - case 0: return ref _row0; - case 1: return ref _row1; - default: throw new ArgumentOutOfRangeException(nameof(row)); - } - } - } - - /// - /// Counter-clockwise rotation for column vectors: mul(Rotate(a), v) equals - /// math.rotate(v, a); mul(v, Rotate(a)) rotates the other way. - /// - public static float2x2 Rotate(float radians) - { - var s = MathF.Sin(radians); - var c = MathF.Cos(radians); - return new float2x2(c, -s, s, c); - } - - public readonly bool Equals(float2x2 other) => _row0.Equals(other._row0) && _row1.Equals(other._row1); - public override readonly bool Equals(object? obj) => obj is float2x2 other && Equals(other); - public override readonly int GetHashCode() => HashCode.Combine(_row0, _row1); - public override readonly string ToString() => FormattableString.Invariant($"float2x2({_row0.x:R}, {_row0.y:R}, {_row1.x:R}, {_row1.y:R})"); -} diff --git a/packages/cultmath/src/CultMath/float2x2.cs.meta b/packages/cultmath/src/CultMath/float2x2.cs.meta deleted file mode 100644 index 56840df7..00000000 --- a/packages/cultmath/src/CultMath/float2x2.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: c0dcec14c0b7432dbd8ba0bebb0a7c2f diff --git a/packages/cultmath/src/CultMath/float3.cs b/packages/cultmath/src/CultMath/float3.cs deleted file mode 100644 index ca83628b..00000000 --- a/packages/cultmath/src/CultMath/float3.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System; - -namespace CultMath; - -public partial struct float3 : IEquatable -{ - public static readonly float3 zero = new(0.0f, 0.0f, 0.0f); - public static readonly float3 one = new(1.0f, 1.0f, 1.0f); - - public float x; - public float y; - public float z; - - public float3(float x, float y, float z) - { - this.x = x; - this.y = y; - this.z = z; - } - - public float this[int index] - { - readonly get => index switch - { - 0 => x, - 1 => y, - 2 => z, - _ => throw new ArgumentOutOfRangeException(nameof(index)), - }; - set - { - switch (index) - { - case 0: x = value; break; - case 1: y = value; break; - case 2: z = value; break; - default: throw new ArgumentOutOfRangeException(nameof(index)); - } - } - } - - public static implicit operator float3(float value) => new(value, value, value); - public static explicit operator System.Numerics.Vector3(float3 value) => new(value.x, value.y, value.z); - public static explicit operator float3(System.Numerics.Vector3 value) => new(value.X, value.Y, value.Z); - - public static float3 operator +(float3 value) => value; - public static float3 operator -(float3 value) => new(-value.x, -value.y, -value.z); - - public static float3 operator +(float3 left, float3 right) => new(left.x + right.x, left.y + right.y, left.z + right.z); - public static float3 operator +(float3 left, float right) => new(left.x + right, left.y + right, left.z + right); - public static float3 operator +(float left, float3 right) => new(left + right.x, left + right.y, left + right.z); - public static float3 operator -(float3 left, float3 right) => new(left.x - right.x, left.y - right.y, left.z - right.z); - public static float3 operator -(float3 left, float right) => new(left.x - right, left.y - right, left.z - right); - public static float3 operator -(float left, float3 right) => new(left - right.x, left - right.y, left - right.z); - public static float3 operator *(float3 left, float3 right) => new(left.x * right.x, left.y * right.y, left.z * right.z); - public static float3 operator *(float3 left, float right) => new(left.x * right, left.y * right, left.z * right); - public static float3 operator *(float left, float3 right) => new(left * right.x, left * right.y, left * right.z); - public static float3 operator /(float3 left, float3 right) => new(left.x / right.x, left.y / right.y, left.z / right.z); - public static float3 operator /(float3 left, float right) => new(left.x / right, left.y / right, left.z / right); - public static float3 operator /(float left, float3 right) => new(left / right.x, left / right.y, left / right.z); - public static float3 operator %(float3 left, float3 right) => new(left.x % right.x, left.y % right.y, left.z % right.z); - public static float3 operator %(float3 left, float right) => new(left.x % right, left.y % right, left.z % right); - public static float3 operator %(float left, float3 right) => new(left % right.x, left % right.y, left % right.z); - - public static bool3 operator <(float3 left, float3 right) => new(left.x < right.x, left.y < right.y, left.z < right.z); - public static bool3 operator <(float3 left, float right) => new(left.x < right, left.y < right, left.z < right); - public static bool3 operator <(float left, float3 right) => new(left < right.x, left < right.y, left < right.z); - public static bool3 operator >(float3 left, float3 right) => new(left.x > right.x, left.y > right.y, left.z > right.z); - public static bool3 operator >(float3 left, float right) => new(left.x > right, left.y > right, left.z > right); - public static bool3 operator >(float left, float3 right) => new(left > right.x, left > right.y, left > right.z); - public static bool3 operator <=(float3 left, float3 right) => new(left.x <= right.x, left.y <= right.y, left.z <= right.z); - public static bool3 operator <=(float3 left, float right) => new(left.x <= right, left.y <= right, left.z <= right); - public static bool3 operator <=(float left, float3 right) => new(left <= right.x, left <= right.y, left <= right.z); - public static bool3 operator >=(float3 left, float3 right) => new(left.x >= right.x, left.y >= right.y, left.z >= right.z); - public static bool3 operator >=(float3 left, float right) => new(left.x >= right, left.y >= right, left.z >= right); - public static bool3 operator >=(float left, float3 right) => new(left >= right.x, left >= right.y, left >= right.z); - public static bool3 operator ==(float3 left, float3 right) => new(left.x == right.x, left.y == right.y, left.z == right.z); - public static bool3 operator ==(float3 left, float right) => new(left.x == right, left.y == right, left.z == right); - public static bool3 operator ==(float left, float3 right) => new(left == right.x, left == right.y, left == right.z); - public static bool3 operator !=(float3 left, float3 right) => new(left.x != right.x, left.y != right.y, left.z != right.z); - public static bool3 operator !=(float3 left, float right) => new(left.x != right, left.y != right, left.z != right); - public static bool3 operator !=(float left, float3 right) => new(left != right.x, left != right.y, left != right.z); - - public readonly bool Equals(float3 other) => x.Equals(other.x) && y.Equals(other.y) && z.Equals(other.z); - public override readonly bool Equals(object? obj) => obj is float3 other && Equals(other); - public override readonly int GetHashCode() => HashCode.Combine(x, y, z); - public override readonly string ToString() => FormattableString.Invariant($"float3({x:R}, {y:R}, {z:R})"); -} diff --git a/packages/cultmath/src/CultMath/float3.cs.meta b/packages/cultmath/src/CultMath/float3.cs.meta deleted file mode 100644 index 3c649c4a..00000000 --- a/packages/cultmath/src/CultMath/float3.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 58a355dd1cf6b9f48a7595eea0c0e1f2 \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/float3x3.cs b/packages/cultmath/src/CultMath/float3x3.cs deleted file mode 100644 index d7aad21c..00000000 --- a/packages/cultmath/src/CultMath/float3x3.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; - -namespace CultMath; - -/// -/// HLSL float3x3: row-major. Constructors take rows, m[i] is a writable reference to -/// row i (so m[1][2] = s works), and _mRC names row R, column C exactly as HLSL -/// does. Use for products; there is no * -/// matrix product because HLSL's * is component-wise. -/// -public struct float3x3 : IEquatable -{ - public static readonly float3x3 identity = new(1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f); - public static readonly float3x3 zero = new(0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f); - - private float3 _row0; - private float3 _row1; - private float3 _row2; - - public float3x3(float m00, float m01, float m02, float m10, float m11, float m12, float m20, float m21, float m22) - { - _row0 = new float3(m00, m01, m02); - _row1 = new float3(m10, m11, m12); - _row2 = new float3(m20, m21, m22); - } - - public float3x3(float3 row0, float3 row1, float3 row2) - { - _row0 = row0; - _row1 = row1; - _row2 = row2; - } - - public float _m00 { readonly get => _row0.x; set => _row0.x = value; } - public float _m01 { readonly get => _row0.y; set => _row0.y = value; } - public float _m02 { readonly get => _row0.z; set => _row0.z = value; } - public float _m10 { readonly get => _row1.x; set => _row1.x = value; } - public float _m11 { readonly get => _row1.y; set => _row1.y = value; } - public float _m12 { readonly get => _row1.z; set => _row1.z = value; } - public float _m20 { readonly get => _row2.x; set => _row2.x = value; } - public float _m21 { readonly get => _row2.y; set => _row2.y = value; } - public float _m22 { readonly get => _row2.z; set => _row2.z = value; } - - /// Writes through a readonly field, in parameter, property, or identity hit a defensive copy and are lost; write to a local and assign it back. - [UnscopedRef] - public ref float3 this[int row] - { - get - { - switch (row) - { - case 0: return ref _row0; - case 1: return ref _row1; - case 2: return ref _row2; - default: throw new ArgumentOutOfRangeException(nameof(row)); - } - } - } - - /// Rotation about +X for column vectors (mul(m, v)). - public static float3x3 RotateX(float radians) - { - var s = MathF.Sin(radians); - var c = MathF.Cos(radians); - return new float3x3(1.0f, 0.0f, 0.0f, 0.0f, c, -s, 0.0f, s, c); - } - - /// Rotation about +Y for column vectors (mul(m, v)). - public static float3x3 RotateY(float radians) - { - var s = MathF.Sin(radians); - var c = MathF.Cos(radians); - return new float3x3(c, 0.0f, s, 0.0f, 1.0f, 0.0f, -s, 0.0f, c); - } - - /// Rotation about +Z for column vectors (mul(m, v)). - public static float3x3 RotateZ(float radians) - { - var s = MathF.Sin(radians); - var c = MathF.Cos(radians); - return new float3x3(c, -s, 0.0f, s, c, 0.0f, 0.0f, 0.0f, 1.0f); - } - - /// - /// Extrinsic Euler rotation in radians. The order names the axis applied first: - /// rotates about Y, then X, then Z, so the - /// matrix is mul(RotateZ, mul(RotateX, RotateY)) for column vectors. - /// - public static float3x3 Euler(float3 xyz, math.RotationOrder order = math.RotationOrder.Default) - { - var x = RotateX(xyz.x); - var y = RotateY(xyz.y); - var z = RotateZ(xyz.z); - return order switch - { - math.RotationOrder.XYZ => math.mul(z, math.mul(y, x)), - math.RotationOrder.XZY => math.mul(y, math.mul(z, x)), - math.RotationOrder.YXZ => math.mul(z, math.mul(x, y)), - math.RotationOrder.YZX => math.mul(x, math.mul(z, y)), - math.RotationOrder.ZXY => math.mul(y, math.mul(x, z)), - math.RotationOrder.ZYX => math.mul(x, math.mul(y, z)), - _ => throw new ArgumentOutOfRangeException(nameof(order)), - }; - } - - public static float3x3 Euler(float x, float y, float z, math.RotationOrder order = math.RotationOrder.Default) => - Euler(new float3(x, y, z), order); - - public readonly bool Equals(float3x3 other) => _row0.Equals(other._row0) && _row1.Equals(other._row1) && _row2.Equals(other._row2); - public override readonly bool Equals(object? obj) => obj is float3x3 other && Equals(other); - public override readonly int GetHashCode() => HashCode.Combine(_row0, _row1, _row2); - - public override readonly string ToString() => - FormattableString.Invariant($"float3x3({_row0.x:R}, {_row0.y:R}, {_row0.z:R}, {_row1.x:R}, {_row1.y:R}, {_row1.z:R}, {_row2.x:R}, {_row2.y:R}, {_row2.z:R})"); -} diff --git a/packages/cultmath/src/CultMath/float3x3.cs.meta b/packages/cultmath/src/CultMath/float3x3.cs.meta deleted file mode 100644 index 99b25ed3..00000000 --- a/packages/cultmath/src/CultMath/float3x3.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 9ca36a9bfff144a9b51478a6d7b19c8e diff --git a/packages/cultmath/src/CultMath/float4.cs b/packages/cultmath/src/CultMath/float4.cs deleted file mode 100644 index 3f647971..00000000 --- a/packages/cultmath/src/CultMath/float4.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System; - -namespace CultMath; - -public partial struct float4 : IEquatable -{ - public static readonly float4 zero = new(0.0f, 0.0f, 0.0f, 0.0f); - public static readonly float4 one = new(1.0f, 1.0f, 1.0f, 1.0f); - - public float x; - public float y; - public float z; - public float w; - - public float4(float x, float y, float z, float w) - { - this.x = x; - this.y = y; - this.z = z; - this.w = w; - } - - public float this[int index] - { - readonly get => index switch - { - 0 => x, - 1 => y, - 2 => z, - 3 => w, - _ => throw new ArgumentOutOfRangeException(nameof(index)), - }; - set - { - switch (index) - { - case 0: x = value; break; - case 1: y = value; break; - case 2: z = value; break; - case 3: w = value; break; - default: throw new ArgumentOutOfRangeException(nameof(index)); - } - } - } - - public static implicit operator float4(float value) => new(value, value, value, value); - public static explicit operator System.Numerics.Vector4(float4 value) => new(value.x, value.y, value.z, value.w); - public static explicit operator float4(System.Numerics.Vector4 value) => new(value.X, value.Y, value.Z, value.W); - - public static float4 operator +(float4 value) => value; - public static float4 operator -(float4 value) => new(-value.x, -value.y, -value.z, -value.w); - - public static float4 operator +(float4 left, float4 right) => new(left.x + right.x, left.y + right.y, left.z + right.z, left.w + right.w); - public static float4 operator +(float4 left, float right) => new(left.x + right, left.y + right, left.z + right, left.w + right); - public static float4 operator +(float left, float4 right) => new(left + right.x, left + right.y, left + right.z, left + right.w); - public static float4 operator -(float4 left, float4 right) => new(left.x - right.x, left.y - right.y, left.z - right.z, left.w - right.w); - public static float4 operator -(float4 left, float right) => new(left.x - right, left.y - right, left.z - right, left.w - right); - public static float4 operator -(float left, float4 right) => new(left - right.x, left - right.y, left - right.z, left - right.w); - public static float4 operator *(float4 left, float4 right) => new(left.x * right.x, left.y * right.y, left.z * right.z, left.w * right.w); - public static float4 operator *(float4 left, float right) => new(left.x * right, left.y * right, left.z * right, left.w * right); - public static float4 operator *(float left, float4 right) => new(left * right.x, left * right.y, left * right.z, left * right.w); - public static float4 operator /(float4 left, float4 right) => new(left.x / right.x, left.y / right.y, left.z / right.z, left.w / right.w); - public static float4 operator /(float4 left, float right) => new(left.x / right, left.y / right, left.z / right, left.w / right); - public static float4 operator /(float left, float4 right) => new(left / right.x, left / right.y, left / right.z, left / right.w); - public static float4 operator %(float4 left, float4 right) => new(left.x % right.x, left.y % right.y, left.z % right.z, left.w % right.w); - public static float4 operator %(float4 left, float right) => new(left.x % right, left.y % right, left.z % right, left.w % right); - public static float4 operator %(float left, float4 right) => new(left % right.x, left % right.y, left % right.z, left % right.w); - - public static bool4 operator <(float4 left, float4 right) => new(left.x < right.x, left.y < right.y, left.z < right.z, left.w < right.w); - public static bool4 operator <(float4 left, float right) => new(left.x < right, left.y < right, left.z < right, left.w < right); - public static bool4 operator <(float left, float4 right) => new(left < right.x, left < right.y, left < right.z, left < right.w); - public static bool4 operator >(float4 left, float4 right) => new(left.x > right.x, left.y > right.y, left.z > right.z, left.w > right.w); - public static bool4 operator >(float4 left, float right) => new(left.x > right, left.y > right, left.z > right, left.w > right); - public static bool4 operator >(float left, float4 right) => new(left > right.x, left > right.y, left > right.z, left > right.w); - public static bool4 operator <=(float4 left, float4 right) => new(left.x <= right.x, left.y <= right.y, left.z <= right.z, left.w <= right.w); - public static bool4 operator <=(float4 left, float right) => new(left.x <= right, left.y <= right, left.z <= right, left.w <= right); - public static bool4 operator <=(float left, float4 right) => new(left <= right.x, left <= right.y, left <= right.z, left <= right.w); - public static bool4 operator >=(float4 left, float4 right) => new(left.x >= right.x, left.y >= right.y, left.z >= right.z, left.w >= right.w); - public static bool4 operator >=(float4 left, float right) => new(left.x >= right, left.y >= right, left.z >= right, left.w >= right); - public static bool4 operator >=(float left, float4 right) => new(left >= right.x, left >= right.y, left >= right.z, left >= right.w); - public static bool4 operator ==(float4 left, float4 right) => new(left.x == right.x, left.y == right.y, left.z == right.z, left.w == right.w); - public static bool4 operator ==(float4 left, float right) => new(left.x == right, left.y == right, left.z == right, left.w == right); - public static bool4 operator ==(float left, float4 right) => new(left == right.x, left == right.y, left == right.z, left == right.w); - public static bool4 operator !=(float4 left, float4 right) => new(left.x != right.x, left.y != right.y, left.z != right.z, left.w != right.w); - public static bool4 operator !=(float4 left, float right) => new(left.x != right, left.y != right, left.z != right, left.w != right); - public static bool4 operator !=(float left, float4 right) => new(left != right.x, left != right.y, left != right.z, left != right.w); - - public readonly bool Equals(float4 other) => x.Equals(other.x) && y.Equals(other.y) && z.Equals(other.z) && w.Equals(other.w); - public override readonly bool Equals(object? obj) => obj is float4 other && Equals(other); - public override readonly int GetHashCode() => HashCode.Combine(x, y, z, w); - public override readonly string ToString() => FormattableString.Invariant($"float4({x:R}, {y:R}, {z:R}, {w:R})"); -} diff --git a/packages/cultmath/src/CultMath/float4.cs.meta b/packages/cultmath/src/CultMath/float4.cs.meta deleted file mode 100644 index 206444e0..00000000 --- a/packages/cultmath/src/CultMath/float4.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: fe24fbdab33538849bc6be0afbb0a692 \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/int2.cs b/packages/cultmath/src/CultMath/int2.cs deleted file mode 100644 index f690a25d..00000000 --- a/packages/cultmath/src/CultMath/int2.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System; - -namespace CultMath; - -public partial struct int2 : IEquatable -{ - public static readonly int2 zero = new(0, 0); - public static readonly int2 one = new(1, 1); - - public int x; - public int y; - - public int2(int x, int y) - { - this.x = x; - this.y = y; - } - - public int this[int index] - { - readonly get => index switch - { - 0 => x, - 1 => y, - _ => throw new ArgumentOutOfRangeException(nameof(index)), - }; - set - { - switch (index) - { - case 0: x = value; break; - case 1: y = value; break; - default: throw new ArgumentOutOfRangeException(nameof(index)); - } - } - } - - public static implicit operator int2(int value) => new(value, value); - public static implicit operator float2(int2 value) => new(value.x, value.y); - public static explicit operator System.Numerics.Vector2(int2 value) => new(value.x, value.y); - public static explicit operator int2(System.Numerics.Vector2 value) => new((int)value.X, (int)value.Y); - - public static int2 operator +(int2 value) => value; - public static int2 operator -(int2 value) => new(-value.x, -value.y); - public static int2 operator +(int2 left, int2 right) => new(left.x + right.x, left.y + right.y); - public static int2 operator -(int2 left, int2 right) => new(left.x - right.x, left.y - right.y); - public static int2 operator *(int2 left, int2 right) => new(left.x * right.x, left.y * right.y); - public static int2 operator /(int2 left, int2 right) => new(left.x / right.x, left.y / right.y); - public static int2 operator %(int2 left, int2 right) => new(left.x % right.x, left.y % right.y); - - public static bool2 operator <(int2 left, int2 right) => new(left.x < right.x, left.y < right.y); - public static bool2 operator >(int2 left, int2 right) => new(left.x > right.x, left.y > right.y); - public static bool2 operator <=(int2 left, int2 right) => new(left.x <= right.x, left.y <= right.y); - public static bool2 operator >=(int2 left, int2 right) => new(left.x >= right.x, left.y >= right.y); - public static bool2 operator ==(int2 left, int2 right) => new(left.x == right.x, left.y == right.y); - public static bool2 operator !=(int2 left, int2 right) => new(left.x != right.x, left.y != right.y); - - public readonly bool Equals(int2 other) => x == other.x && y == other.y; - public override readonly bool Equals(object? obj) => obj is int2 other && Equals(other); - public override readonly int GetHashCode() => HashCode.Combine(x, y); - public override readonly string ToString() => FormattableString.Invariant($"int2({x}, {y})"); -} diff --git a/packages/cultmath/src/CultMath/int2.cs.meta b/packages/cultmath/src/CultMath/int2.cs.meta deleted file mode 100644 index 5920328a..00000000 --- a/packages/cultmath/src/CultMath/int2.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 577b174a998aaa4478ec26e7a6782f46 \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/int3.cs b/packages/cultmath/src/CultMath/int3.cs deleted file mode 100644 index 454a8970..00000000 --- a/packages/cultmath/src/CultMath/int3.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System; - -namespace CultMath; - -public partial struct int3 : IEquatable -{ - public static readonly int3 zero = new(0, 0, 0); - public static readonly int3 one = new(1, 1, 1); - - public int x; - public int y; - public int z; - - public int3(int x, int y, int z) - { - this.x = x; - this.y = y; - this.z = z; - } - - public int this[int index] - { - readonly get => index switch - { - 0 => x, - 1 => y, - 2 => z, - _ => throw new ArgumentOutOfRangeException(nameof(index)), - }; - set - { - switch (index) - { - case 0: x = value; break; - case 1: y = value; break; - case 2: z = value; break; - default: throw new ArgumentOutOfRangeException(nameof(index)); - } - } - } - - public static implicit operator int3(int value) => new(value, value, value); - public static implicit operator float3(int3 value) => new(value.x, value.y, value.z); - - public static int3 operator +(int3 value) => value; - public static int3 operator -(int3 value) => new(-value.x, -value.y, -value.z); - public static int3 operator +(int3 left, int3 right) => new(left.x + right.x, left.y + right.y, left.z + right.z); - public static int3 operator -(int3 left, int3 right) => new(left.x - right.x, left.y - right.y, left.z - right.z); - public static int3 operator *(int3 left, int3 right) => new(left.x * right.x, left.y * right.y, left.z * right.z); - public static int3 operator /(int3 left, int3 right) => new(left.x / right.x, left.y / right.y, left.z / right.z); - public static int3 operator %(int3 left, int3 right) => new(left.x % right.x, left.y % right.y, left.z % right.z); - - public static bool3 operator <(int3 left, int3 right) => new(left.x < right.x, left.y < right.y, left.z < right.z); - public static bool3 operator >(int3 left, int3 right) => new(left.x > right.x, left.y > right.y, left.z > right.z); - public static bool3 operator <=(int3 left, int3 right) => new(left.x <= right.x, left.y <= right.y, left.z <= right.z); - public static bool3 operator >=(int3 left, int3 right) => new(left.x >= right.x, left.y >= right.y, left.z >= right.z); - public static bool3 operator ==(int3 left, int3 right) => new(left.x == right.x, left.y == right.y, left.z == right.z); - public static bool3 operator !=(int3 left, int3 right) => new(left.x != right.x, left.y != right.y, left.z != right.z); - - public readonly bool Equals(int3 other) => x == other.x && y == other.y && z == other.z; - public override readonly bool Equals(object? obj) => obj is int3 other && Equals(other); - public override readonly int GetHashCode() => HashCode.Combine(x, y, z); - public override readonly string ToString() => FormattableString.Invariant($"int3({x}, {y}, {z})"); -} diff --git a/packages/cultmath/src/CultMath/int3.cs.meta b/packages/cultmath/src/CultMath/int3.cs.meta deleted file mode 100644 index d0400303..00000000 --- a/packages/cultmath/src/CultMath/int3.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 42ee98aace354db19b3ae87bdcbc1df9 diff --git a/packages/cultmath/src/CultMath/int4.cs b/packages/cultmath/src/CultMath/int4.cs deleted file mode 100644 index caaaeb9e..00000000 --- a/packages/cultmath/src/CultMath/int4.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System; - -namespace CultMath; - -public partial struct int4 : IEquatable -{ - public static readonly int4 zero = new(0, 0, 0, 0); - public static readonly int4 one = new(1, 1, 1, 1); - - public int x; - public int y; - public int z; - public int w; - - public int4(int x, int y, int z, int w) - { - this.x = x; - this.y = y; - this.z = z; - this.w = w; - } - - public int this[int index] - { - readonly get => index switch - { - 0 => x, - 1 => y, - 2 => z, - 3 => w, - _ => throw new ArgumentOutOfRangeException(nameof(index)), - }; - set - { - switch (index) - { - case 0: x = value; break; - case 1: y = value; break; - case 2: z = value; break; - case 3: w = value; break; - default: throw new ArgumentOutOfRangeException(nameof(index)); - } - } - } - - public static implicit operator int4(int value) => new(value, value, value, value); - public static implicit operator float4(int4 value) => new(value.x, value.y, value.z, value.w); - - public static int4 operator +(int4 value) => value; - public static int4 operator -(int4 value) => new(-value.x, -value.y, -value.z, -value.w); - public static int4 operator +(int4 left, int4 right) => new(left.x + right.x, left.y + right.y, left.z + right.z, left.w + right.w); - public static int4 operator -(int4 left, int4 right) => new(left.x - right.x, left.y - right.y, left.z - right.z, left.w - right.w); - public static int4 operator *(int4 left, int4 right) => new(left.x * right.x, left.y * right.y, left.z * right.z, left.w * right.w); - public static int4 operator /(int4 left, int4 right) => new(left.x / right.x, left.y / right.y, left.z / right.z, left.w / right.w); - public static int4 operator %(int4 left, int4 right) => new(left.x % right.x, left.y % right.y, left.z % right.z, left.w % right.w); - - public static bool4 operator <(int4 left, int4 right) => new(left.x < right.x, left.y < right.y, left.z < right.z, left.w < right.w); - public static bool4 operator >(int4 left, int4 right) => new(left.x > right.x, left.y > right.y, left.z > right.z, left.w > right.w); - public static bool4 operator <=(int4 left, int4 right) => new(left.x <= right.x, left.y <= right.y, left.z <= right.z, left.w <= right.w); - public static bool4 operator >=(int4 left, int4 right) => new(left.x >= right.x, left.y >= right.y, left.z >= right.z, left.w >= right.w); - public static bool4 operator ==(int4 left, int4 right) => new(left.x == right.x, left.y == right.y, left.z == right.z, left.w == right.w); - public static bool4 operator !=(int4 left, int4 right) => new(left.x != right.x, left.y != right.y, left.z != right.z, left.w != right.w); - - public readonly bool Equals(int4 other) => x == other.x && y == other.y && z == other.z && w == other.w; - public override readonly bool Equals(object? obj) => obj is int4 other && Equals(other); - public override readonly int GetHashCode() => HashCode.Combine(x, y, z, w); - public override readonly string ToString() => FormattableString.Invariant($"int4({x}, {y}, {z}, {w})"); -} diff --git a/packages/cultmath/src/CultMath/int4.cs.meta b/packages/cultmath/src/CultMath/int4.cs.meta deleted file mode 100644 index 48d0eaba..00000000 --- a/packages/cultmath/src/CultMath/int4.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 1a313709f76a430eb83901bb2d05c74e diff --git a/packages/cultmath/src/CultMath/math.cs b/packages/cultmath/src/CultMath/math.cs deleted file mode 100644 index ca74857f..00000000 --- a/packages/cultmath/src/CultMath/math.cs +++ /dev/null @@ -1,623 +0,0 @@ -using System; - -namespace CultMath; - -public static partial class math -{ - public const float PI = MathF.PI; - public const float TAU = MathF.PI * 2.0f; - public const float HALF_PI = MathF.PI * 0.5f; - - /// - /// Extrinsic Euler order for : - /// the first letter is the axis applied first. Nested here so using static CultMath.math; - /// brings it into scope with the rest of the shader vocabulary. - /// - public enum RotationOrder : byte - { - XYZ, - XZY, - YXZ, - YZX, - ZXY, - ZYX, - Default = ZXY, - } - - public static float2 float2(float x, float y) => new(x, y); - public static float2 float2(float value) => new(value, value); - public static bool2 bool2(bool x, bool y) => new(x, y); - public static bool2 bool2(bool value) => new(value, value); - public static int2 int2(int x, int y) => new(x, y); - public static int2 int2(int value) => new(value, value); - // Mixed constructor functions (float4(float2, float2), ...) are generated in Swizzles.g.cs. - public static float3 float3(float x, float y, float z) => new(x, y, z); - public static float3 float3(float value) => new(value, value, value); - public static float4 float4(float x, float y, float z, float w) => new(x, y, z, w); - public static float4 float4(float value) => new(value, value, value, value); - public static bool3 bool3(bool x, bool y, bool z) => new(x, y, z); - public static bool3 bool3(bool value) => new(value, value, value); - public static bool4 bool4(bool x, bool y, bool z, bool w) => new(x, y, z, w); - public static bool4 bool4(bool value) => new(value, value, value, value); - public static int3 int3(int x, int y, int z) => new(x, y, z); - public static int3 int3(int value) => new(value, value, value); - public static int4 int4(int x, int y, int z, int w) => new(x, y, z, w); - public static int4 int4(int value) => new(value, value, value, value); - - // Matrix constructor functions take rows, as HLSL does. Under `using static CultMath.math;` - // these method groups hide the type names in member access, so static members need a - // qualified type: `CultMath.float3x3.Euler(...)`, `CultMath.float2x2.Rotate(...)`. - public static float2x2 float2x2(float m00, float m01, float m10, float m11) => new(m00, m01, m10, m11); - public static float2x2 float2x2(float2 row0, float2 row1) => new(row0, row1); - public static float3x3 float3x3(float m00, float m01, float m02, float m10, float m11, float m12, float m20, float m21, float m22) => - new(m00, m01, m02, m10, m11, m12, m20, m21, m22); - public static float3x3 float3x3(float3 row0, float3 row1, float3 row2) => new(row0, row1, row2); - public static float radians(float degrees) => degrees * (PI / 180.0f); - public static float degrees(float radians) => radians * (180.0f / PI); - public static float sin(float value) => MathF.Sin(value); - public static float cos(float value) => MathF.Cos(value); - public static float tan(float value) => MathF.Tan(value); - public static float asin(float value) => MathF.Asin(value); - public static float acos(float value) => MathF.Acos(value); - public static float atan(float value) => MathF.Atan(value); - public static float atan2(float y, float x) => MathF.Atan2(y, x); - public static float sqrt(float value) => MathF.Sqrt(value); - public static float pow(float value, float power) => MathF.Pow(value, power); - public static bool isinf(float value) => float.IsInfinity(value); - public static float exp(float value) => MathF.Exp(value); - public static double exp(double value) => Math.Exp(value); - public static float2 exp(float2 value) => new(exp(value.x), exp(value.y)); - public static float3 exp(float3 value) => new(exp(value.x), exp(value.y), exp(value.z)); - public static float4 exp(float4 value) => new(exp(value.x), exp(value.y), exp(value.z), exp(value.w)); - public static double2 exp(double2 value) => new(exp(value.x), exp(value.y)); - public static double3 exp(double3 value) => new(exp(value.x), exp(value.y), exp(value.z)); - - public static float abs(float value) => MathF.Abs(value); - public static float2 abs(float2 value) => new(abs(value.x), abs(value.y)); - public static float3 abs(float3 value) => new(abs(value.x), abs(value.y), abs(value.z)); - public static float4 abs(float4 value) => new(abs(value.x), abs(value.y), abs(value.z), abs(value.w)); - public static int abs(int value) => Math.Abs(value); - public static int2 abs(int2 value) => new(abs(value.x), abs(value.y)); - public static int3 abs(int3 value) => new(abs(value.x), abs(value.y), abs(value.z)); - public static int4 abs(int4 value) => new(abs(value.x), abs(value.y), abs(value.z), abs(value.w)); - - // HLSL sign returns int. NaN compares false both ways, so it yields 0 instead of throwing. - public static int sign(float value) => value > 0.0f ? 1 : value < 0.0f ? -1 : 0; - public static int2 sign(float2 value) => new(sign(value.x), sign(value.y)); - public static int3 sign(float3 value) => new(sign(value.x), sign(value.y), sign(value.z)); - public static int4 sign(float4 value) => new(sign(value.x), sign(value.y), sign(value.z), sign(value.w)); - public static int sign(int value) => value > 0 ? 1 : value < 0 ? -1 : 0; - public static int2 sign(int2 value) => new(sign(value.x), sign(value.y)); - public static int3 sign(int3 value) => new(sign(value.x), sign(value.y), sign(value.z)); - public static int4 sign(int4 value) => new(sign(value.x), sign(value.y), sign(value.z), sign(value.w)); - - public static float floor(float value) => MathF.Floor(value); - public static float2 floor(float2 value) => new(floor(value.x), floor(value.y)); - public static float3 floor(float3 value) => new(floor(value.x), floor(value.y), floor(value.z)); - public static float4 floor(float4 value) => new(floor(value.x), floor(value.y), floor(value.z), floor(value.w)); - - public static float ceil(float value) => MathF.Ceiling(value); - public static float2 ceil(float2 value) => new(ceil(value.x), ceil(value.y)); - public static float3 ceil(float3 value) => new(ceil(value.x), ceil(value.y), ceil(value.z)); - public static float4 ceil(float4 value) => new(ceil(value.x), ceil(value.y), ceil(value.z), ceil(value.w)); - - public static float frac(float value) => value - floor(value); - 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 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. - public static float min(float left, float right) => left < right || float.IsNaN(right) ? left : right; - public static int min(int left, int right) => Math.Min(left, right); - public static float2 min(float2 left, float2 right) => new(min(left.x, right.x), min(left.y, right.y)); - public static float3 min(float3 left, float3 right) => new(min(left.x, right.x), min(left.y, right.y), min(left.z, right.z)); - public static float4 min(float4 left, float4 right) => new(min(left.x, right.x), min(left.y, right.y), min(left.z, right.z), min(left.w, right.w)); - public static int2 min(int2 left, int2 right) => new(min(left.x, right.x), min(left.y, right.y)); - public static int3 min(int3 left, int3 right) => new(min(left.x, right.x), min(left.y, right.y), min(left.z, right.z)); - public static int4 min(int4 left, int4 right) => new(min(left.x, right.x), min(left.y, right.y), min(left.z, right.z), min(left.w, right.w)); - - public static float max(float left, float right) => left >= right || float.IsNaN(right) ? left : right; - public static int max(int left, int right) => Math.Max(left, right); - public static float2 max(float2 left, float2 right) => new(max(left.x, right.x), max(left.y, right.y)); - public static float3 max(float3 left, float3 right) => new(max(left.x, right.x), max(left.y, right.y), max(left.z, right.z)); - public static float4 max(float4 left, float4 right) => new(max(left.x, right.x), max(left.y, right.y), max(left.z, right.z), max(left.w, right.w)); - public static int2 max(int2 left, int2 right) => new(max(left.x, right.x), max(left.y, right.y)); - public static int3 max(int3 left, int3 right) => new(max(left.x, right.x), max(left.y, right.y), max(left.z, right.z)); - public static int4 max(int4 left, int4 right) => new(max(left.x, right.x), max(left.y, right.y), max(left.z, right.z), max(left.w, right.w)); - - public static float clamp(float value, float minimum, float maximum) => min(max(value, minimum), maximum); - 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); - - // 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)); - public static float2 saturate(float2 value) => min(1.0f, max(0.0f, value)); - public static float3 saturate(float3 value) => min(1.0f, max(0.0f, value)); - public static float4 saturate(float4 value) => min(1.0f, max(0.0f, value)); - - public static float lerp(float start, float end, float amount) => start + (end - start) * amount; - public static double lerp(double start, double end, double amount) => start + (end - start) * amount; - public static float2 lerp(float2 start, float2 end, float2 amount) => start + (end - start) * amount; - public static float3 lerp(float3 start, float3 end, float3 amount) => start + (end - start) * amount; - public static float4 lerp(float4 start, float4 end, float4 amount) => start + (end - start) * amount; - public static double2 lerp(double2 start, double2 end, double2 amount) => start + (end - start) * amount; - public static double3 lerp(double3 start, double3 end, double3 amount) => start + (end - start) * amount; - - public static float unlerp(float start, float end, float value) => (value - start) / (end - start); - public static double unlerp(double start, double end, double value) => (value - start) / (end - start); - public static float2 unlerp(float2 start, float2 end, float2 value) => (value - start) / (end - start); - public static float3 unlerp(float3 start, float3 end, float3 value) => (value - start) / (end - start); - public static float4 unlerp(float4 start, float4 end, float4 value) => (value - start) / (end - start); - public static double2 unlerp(double2 start, double2 end, double2 value) => (value - start) / (end - start); - public static double3 unlerp(double3 start, double3 end, double3 value) => (value - start) / (end - start); - - // dxc lowers step(y, x) to x < y ? 0 : 1 (fcmp olt, then select), so a NaN on either side yields 1. - public static float step(float edge, float value) => value < edge ? 0.0f : 1.0f; - public static float2 step(float2 edge, float2 value) => new(step(edge.x, value.x), step(edge.y, value.y)); - public static float3 step(float3 edge, float3 value) => new(step(edge.x, value.x), step(edge.y, value.y), step(edge.z, value.z)); - public static float4 step(float4 edge, float4 value) => new(step(edge.x, value.x), step(edge.y, value.y), step(edge.z, value.z), step(edge.w, value.w)); - - public static float smoothstep(float minimum, float maximum, float value) - { - var t = saturate((value - minimum) / (maximum - minimum)); - return t * t * (3.0f - 2.0f * t); - } - - public static float2 smoothstep(float2 minimum, float2 maximum, float2 value) - { - var t = saturate((value - minimum) / (maximum - minimum)); - return t * t * (3.0f - 2.0f * t); - } - - public static float3 smoothstep(float3 minimum, float3 maximum, float3 value) - { - var t = saturate((value - minimum) / (maximum - minimum)); - return t * t * (3.0f - 2.0f * t); - } - - public static float4 smoothstep(float4 minimum, float4 maximum, float4 value) - { - var t = saturate((value - minimum) / (maximum - minimum)); - return t * t * (3.0f - 2.0f * t); - } - - public static float dot(float2 left, float2 right) => left.x * right.x + left.y * right.y; - public static float dot(float3 left, float3 right) => left.x * right.x + left.y * right.y + left.z * right.z; - public static float dot(float4 left, float4 right) => left.x * right.x + left.y * right.y + left.z * right.z + left.w * right.w; - - public static float3 cross(float3 left, float3 right) => - new( - left.y * right.z - left.z * right.y, - left.z * right.x - left.x * right.z, - left.x * right.y - left.y * right.x); - - public static float lengthsq(float2 value) => dot(value, value); - public static float lengthsq(float3 value) => dot(value, value); - public static float lengthsq(float4 value) => dot(value, value); - - public static float length(float2 value) => MathF.Sqrt(lengthsq(value)); - public static float length(float3 value) => MathF.Sqrt(lengthsq(value)); - public static float length(float4 value) => MathF.Sqrt(lengthsq(value)); - - public static float distance(float2 left, float2 right) => length(left - right); - public static float distance(float3 left, float3 right) => length(left - right); - public static float distance(float4 left, float4 right) => length(left - right); - - 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); - if (length <= 1.0e-20f) - { - return quaternion.identity; - } - - var invLength = 1.0f / length; - return new quaternion(value.x * invLength, value.y * invLength, value.z * invLength, value.w * invLength); - } - - public static float2 reflect(float2 incident, float2 normal) => incident - 2.0f * dot(normal, incident) * normal; - public static float3 reflect(float3 incident, float3 normal) => incident - 2.0f * dot(normal, incident) * normal; - - public static float2 rotate(float2 value, float radians) - { - var s = sin(radians); - var c = cos(radians); - return new float2(value.x * c - value.y * s, value.x * s + value.y * c); - } - - public static float2 rotate_degrees(float2 value, float degrees) => rotate(value, radians(degrees)); - - public static float csum(float2 value) => value.x + value.y; - public static float csum(float3 value) => value.x + value.y + value.z; - public static float csum(float4 value) => value.x + value.y + value.z + value.w; - - public static float decay(float source, float lambda, float dt) => source * exp(-lambda * dt); - public static float2 decay(float2 source, float lambda, float dt) => source * exp(-lambda * dt); - public static float3 decay(float3 source, float lambda, float dt) => source * exp(-lambda * dt); - public static float4 decay(float4 source, float lambda, float dt) => source * exp(-lambda * dt); - public static float2 decay(float2 source, float2 lambda, float dt) => source * exp(-lambda * dt); - public static float3 decay(float3 source, float3 lambda, float dt) => source * exp(-lambda * dt); - public static float4 decay(float4 source, float4 lambda, float dt) => source * exp(-lambda * dt); - - public static float damp(float start, float end, float lambda, float dt) => lerp(start, end, 1.0f - exp(-lambda * dt)); - public static float2 damp(float2 start, float2 end, float lambda, float dt) => lerp(start, end, 1.0f - exp(-lambda * dt)); - public static float3 damp(float3 start, float3 end, float lambda, float dt) => lerp(start, end, 1.0f - exp(-lambda * dt)); - public static float4 damp(float4 start, float4 end, float lambda, float dt) => lerp(start, end, 1.0f - exp(-lambda * dt)); - - public static float first_order_intercept_time(float shotSpeed, float3 targetRelativePosition, float3 targetRelativeVelocity) - { - var velocitySquared = lengthsq(targetRelativeVelocity); - if (velocitySquared < 0.001f) - { - return 0.0f; - } - - var a = velocitySquared - shotSpeed * shotSpeed; - if (abs(a) < 0.001f) - { - var time = -lengthsq(targetRelativePosition) / (2.0f * dot(targetRelativeVelocity, targetRelativePosition)); - return max(time, 0.0f); - } - - var b = 2.0f * dot(targetRelativeVelocity, targetRelativePosition); - var c = lengthsq(targetRelativePosition); - var determinant = b * b - 4.0f * a * c; - - if (determinant > 0.0f) - { - var root = sqrt(determinant); - var t1 = (-b + root) / (2.0f * a); - var t2 = (-b - root) / (2.0f * a); - if (t1 > 0.0f) - { - return t2 > 0.0f ? min(t1, t2) : t1; - } - - return max(t2, 0.0f); - } - - if (determinant < 0.0f) - { - return 0.0f; - } - - return max(-b / (2.0f * a), 0.0f); - } - - public static float3 first_order_intercept( - float3 shooterPosition, - float3 shooterVelocity, - float shotSpeed, - float3 targetPosition, - float3 targetVelocity) - { - var targetRelativePosition = targetPosition - shooterPosition; - var targetRelativeVelocity = targetVelocity - shooterVelocity; - var time = first_order_intercept_time(shotSpeed, targetRelativePosition, targetRelativeVelocity); - return targetPosition + time * targetRelativeVelocity; - } - - public static float distance_to_segment(float2 point, float2 start, float2 end, out float2 closest) - { - var segment = end - start; - var segmentLengthSq = lengthsq(segment); - if (segmentLengthSq <= 0.0f) - { - closest = start; - return distance(point, start); - } - - var t = saturate(dot(point - start, segment) / segmentLengthSq); - closest = start + segment * t; - return distance(point, closest); - } - - public static float catmullrom(float p0, float p1, float p2, float p3, float t) - { - var t2 = t * t; - var t3 = t2 * t; - return 0.5f * ((2.0f * p1) + (p2 - p0) * t + (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * t2 + (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * t3); - } - - public static float2 catmullrom(float2 p0, float2 p1, float2 p2, float2 p3, float t) => - 0.5f * ((2.0f * p1) + (p2 - p0) * t + (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * (t * t) + (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * (t * t * t)); - - public static float3 catmullrom(float3 p0, float3 p1, float3 p2, float3 p3, float t) => - 0.5f * ((2.0f * p1) + (p2 - p0) * t + (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * (t * t) + (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * (t * t * t)); - - public static float4 catmullrom(float4 p0, float4 p1, float4 p2, float4 p3, float t) => - 0.5f * ((2.0f * p1) + (p2 - p0) * t + (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * (t * t) + (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * (t * t * t)); - - public static float quadratic_bezier(float p0, float p1, float p2, float t) - { - t = saturate(t); - var inv = 1.0f - t; - return inv * inv * p0 + 2.0f * inv * t * p1 + t * t * p2; - } - - public static float3 quadratic_bezier(float3 p0, float3 p1, float3 p2, float t) - { - t = saturate(t); - var inv = 1.0f - t; - return inv * inv * p0 + 2.0f * inv * t * p1 + t * t * p2; - } - - public static float cubic_bezier(float p0, float p1, float p2, float p3, float t) - { - t = saturate(t); - var inv = 1.0f - t; - return inv * inv * inv * p0 + 3.0f * inv * inv * t * p1 + 3.0f * inv * t * t * p2 + t * t * t * p3; - } - - public static float3 cubic_bezier(float3 p0, float3 p1, float3 p2, float3 p3, float t) - { - t = saturate(t); - var inv = 1.0f - t; - return inv * inv * inv * p0 + 3.0f * inv * inv * t * p1 + 3.0f * inv * t * t * p2 + t * t * t * p3; - } - - public static float smoothstep01(float value) => smoothstep(0.0f, 1.0f, value); - - public static float smootherstep(float value) - { - value = saturate(value); - return value * value * value * (value * (value * 6.0f - 15.0f) + 10.0f); - } - - public static float hash(float value) => frac(sin(value) * 43758.5453f); - 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 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)); - public static float4 log(float4 value) => new(log(value.x), log(value.y), log(value.z), log(value.w)); - - public static float2 pow(float2 value, float2 power) => new(pow(value.x, power.x), pow(value.y, power.y)); - public static float3 pow(float3 value, float3 power) => new(pow(value.x, power.x), pow(value.y, power.y), pow(value.z, power.z)); - public static float4 pow(float4 value, float4 power) => new(pow(value.x, power.x), pow(value.y, power.y), pow(value.z, power.z), pow(value.w, power.w)); - - public static float2 sqrt(float2 value) => new(sqrt(value.x), sqrt(value.y)); - public static float3 sqrt(float3 value) => new(sqrt(value.x), sqrt(value.y), sqrt(value.z)); - public static float4 sqrt(float4 value) => new(sqrt(value.x), sqrt(value.y), sqrt(value.z), sqrt(value.w)); - - public static float2 sin(float2 value) => new(sin(value.x), sin(value.y)); - public static float3 sin(float3 value) => new(sin(value.x), sin(value.y), sin(value.z)); - public static float4 sin(float4 value) => new(sin(value.x), sin(value.y), sin(value.z), sin(value.w)); - - public static float2 cos(float2 value) => new(cos(value.x), cos(value.y)); - public static float3 cos(float3 value) => new(cos(value.x), cos(value.y), cos(value.z)); - public static float4 cos(float4 value) => new(cos(value.x), cos(value.y), cos(value.z), cos(value.w)); - - public static float2 acos(float2 value) => new(acos(value.x), acos(value.y)); - public static float3 acos(float3 value) => new(acos(value.x), acos(value.y), acos(value.z)); - public static float4 acos(float4 value) => new(acos(value.x), acos(value.y), acos(value.z), acos(value.w)); - - public static float2 atan2(float2 y, float2 x) => new(atan2(y.x, x.x), atan2(y.y, x.y)); - public static float3 atan2(float3 y, float3 x) => new(atan2(y.x, x.x), atan2(y.y, x.y), atan2(y.z, x.z)); - public static float4 atan2(float4 y, float4 x) => new(atan2(y.x, x.x), atan2(y.y, x.y), atan2(y.z, x.z), atan2(y.w, x.w)); - - // DXIL FMin/FMax take double overloads with the same rules as float (see min/max above). - public static double min(double left, double right) => left < right || double.IsNaN(right) ? left : right; - public static double max(double left, double right) => left >= right || double.IsNaN(right) ? left : right; - - public static bool any(bool2 value) => value.x || value.y; - public static bool any(bool3 value) => value.x || value.y || value.z; - public static bool any(bool4 value) => value.x || value.y || value.z || value.w; - public static bool all(bool2 value) => value.x && value.y; - public static bool all(bool3 value) => value.x && value.y && value.z; - public static bool all(bool4 value) => value.x && value.y && value.z && value.w; - - // HLSL any/all on numeric vectors test components against zero (NaN != 0 counts as true). - public static bool any(float2 value) => value.x != 0.0f || value.y != 0.0f; - public static bool any(float3 value) => value.x != 0.0f || value.y != 0.0f || value.z != 0.0f; - public static bool any(float4 value) => value.x != 0.0f || value.y != 0.0f || value.z != 0.0f || value.w != 0.0f; - public static bool all(float2 value) => value.x != 0.0f && value.y != 0.0f; - public static bool all(float3 value) => value.x != 0.0f && value.y != 0.0f && value.z != 0.0f; - public static bool all(float4 value) => value.x != 0.0f && value.y != 0.0f && value.z != 0.0f && value.w != 0.0f; - public static bool any(int2 value) => value.x != 0 || value.y != 0; - public static bool any(int3 value) => value.x != 0 || value.y != 0 || value.z != 0; - public static bool any(int4 value) => value.x != 0 || value.y != 0 || value.z != 0 || value.w != 0; - public static bool all(int2 value) => value.x != 0 && value.y != 0; - public static bool all(int3 value) => value.x != 0 && value.y != 0 && value.z != 0; - public static bool all(int4 value) => value.x != 0 && value.y != 0 && value.z != 0 && value.w != 0; - - // HLSL 2021 argument order: select(condition, whenTrue, whenFalse). - // Unity.Mathematics' select(falseValue, trueValue, condition) is the reverse. - public static float select(bool condition, float whenTrue, float whenFalse) => condition ? whenTrue : whenFalse; - public static float2 select(bool2 condition, float2 whenTrue, float2 whenFalse) => - new(condition.x ? whenTrue.x : whenFalse.x, condition.y ? whenTrue.y : whenFalse.y); - public static float3 select(bool3 condition, float3 whenTrue, float3 whenFalse) => - new(condition.x ? whenTrue.x : whenFalse.x, condition.y ? whenTrue.y : whenFalse.y, condition.z ? whenTrue.z : whenFalse.z); - public static float4 select(bool4 condition, float4 whenTrue, float4 whenFalse) => - new(condition.x ? whenTrue.x : whenFalse.x, condition.y ? whenTrue.y : whenFalse.y, condition.z ? whenTrue.z : whenFalse.z, condition.w ? whenTrue.w : whenFalse.w); - - // HLSL mul: mul(m, v) treats v as a column vector, mul(v, m) as a row vector. - public static float mul(float left, float right) => left * right; - - public static float2 mul(float2x2 m, float2 v) => - new(m._m00 * v.x + m._m01 * v.y, m._m10 * v.x + m._m11 * v.y); - - public static float2 mul(float2 v, float2x2 m) => - new(v.x * m._m00 + v.y * m._m10, v.x * m._m01 + v.y * m._m11); - - public static float2x2 mul(float2x2 a, float2x2 b) => - new( - a._m00 * b._m00 + a._m01 * b._m10, a._m00 * b._m01 + a._m01 * b._m11, - a._m10 * b._m00 + a._m11 * b._m10, a._m10 * b._m01 + a._m11 * b._m11); - - public static float3 mul(float3x3 m, float3 v) => - new( - m._m00 * v.x + m._m01 * v.y + m._m02 * v.z, - m._m10 * v.x + m._m11 * v.y + m._m12 * v.z, - m._m20 * v.x + m._m21 * v.y + m._m22 * v.z); - - public static float3 mul(float3 v, float3x3 m) => - new( - v.x * m._m00 + v.y * m._m10 + v.z * m._m20, - v.x * m._m01 + v.y * m._m11 + v.z * m._m21, - v.x * m._m02 + v.y * m._m12 + v.z * m._m22); - - public static float3x3 mul(float3x3 a, float3x3 b) => - new( - a._m00 * b._m00 + a._m01 * b._m10 + a._m02 * b._m20, - a._m00 * b._m01 + a._m01 * b._m11 + a._m02 * b._m21, - a._m00 * b._m02 + a._m01 * b._m12 + a._m02 * b._m22, - a._m10 * b._m00 + a._m11 * b._m10 + a._m12 * b._m20, - a._m10 * b._m01 + a._m11 * b._m11 + a._m12 * b._m21, - a._m10 * b._m02 + a._m11 * b._m12 + a._m12 * b._m22, - a._m20 * b._m00 + a._m21 * b._m10 + a._m22 * b._m20, - a._m20 * b._m01 + a._m21 * b._m11 + a._m22 * b._m21, - a._m20 * b._m02 + a._m21 * b._m12 + a._m22 * b._m22); - - // Ashima Arts / Ian McEwan 3D simplex noise (MIT), mirrored by cultmath_snoise(float3) - // in shaders/CultMath.hlsl with the same float32 evaluation order. - public static float snoise(float3 value) - { - 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 m = max(0.6f - new float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0f); - m = m * m; - 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 - // the C# and HLSL mirrors preserve the same float32 evaluation order. - public static float snoise(float2 value) - { - var c = new float4( - 0.211324865405187f, - 0.366025403784439f, - -0.577350269189626f, - 0.024390243902439f); - var i = floor(value + dot(value, new float2(c.y, c.y))); - var x0 = value - i + dot(i, new float2(c.x, c.x)); - var i1 = x0.x > x0.y ? new float2(1.0f, 0.0f) : new float2(0.0f, 1.0f); - var x12 = new float4( - x0.x + c.x - i1.x, - x0.y + c.x - i1.y, - x0.x + c.z, - x0.y + c.z); - - i = snoise_mod289(i); - var p = snoise_permute(snoise_permute(i.y + new float3(0.0f, i1.y, 1.0f)) + i.x + new float3(0.0f, i1.x, 1.0f)); - var m = max(0.5f - new float3( - dot(x0, x0), - x12.x * x12.x + x12.y * x12.y, - x12.z * x12.z + x12.w * x12.w), 0.0f); - m = m * m; - m = m * m; - - var x = 2.0f * frac(p * c.w) - 1.0f; - var h = abs(x) - 0.5f; - var ox = floor(x + 0.5f); - var a0 = x - ox; - m *= 1.79284291400159f - 0.85373472095314f * (a0 * a0 + h * h); - - var g = new float3( - a0.x * x0.x + h.x * x0.y, - a0.y * x12.x + h.y * x12.y, - a0.z * x12.z + h.z * x12.w); - return 130.0f * dot(m, g); - } - - 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) + 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) - { - var cell = floor(position); - var local = frac(position); - var u = local * local * (3.0f - 2.0f * local); - - var a = hash(cell); - var b = hash(cell + new float2(1.0f, 0.0f)); - var c = hash(cell + new float2(0.0f, 1.0f)); - var d = hash(cell + new float2(1.0f, 1.0f)); - - return lerp(lerp(a, b, u.x), lerp(c, d, u.x), u.y); - } - - public static float value_noise_bicubic(float2 position) - { - var cell = floor(position); - var local = frac(position); - - var y0 = catmullrom( - hash(cell + new float2(-1.0f, -1.0f)), - hash(cell + new float2(0.0f, -1.0f)), - hash(cell + new float2(1.0f, -1.0f)), - hash(cell + new float2(2.0f, -1.0f)), - local.x); - var y1 = catmullrom( - hash(cell + new float2(-1.0f, 0.0f)), - hash(cell + new float2(0.0f, 0.0f)), - hash(cell + new float2(1.0f, 0.0f)), - hash(cell + new float2(2.0f, 0.0f)), - local.x); - var y2 = catmullrom( - hash(cell + new float2(-1.0f, 1.0f)), - hash(cell + new float2(0.0f, 1.0f)), - hash(cell + new float2(1.0f, 1.0f)), - hash(cell + new float2(2.0f, 1.0f)), - local.x); - var y3 = catmullrom( - hash(cell + new float2(-1.0f, 2.0f)), - hash(cell + new float2(0.0f, 2.0f)), - hash(cell + new float2(1.0f, 2.0f)), - hash(cell + new float2(2.0f, 2.0f)), - local.x); - - return catmullrom(y0, y1, y2, y3, local.y); - } -} diff --git a/packages/cultmath/src/CultMath/math.cs.meta b/packages/cultmath/src/CultMath/math.cs.meta deleted file mode 100644 index 9222af7d..00000000 --- a/packages/cultmath/src/CultMath/math.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 0d9b4cacc40d169429ee743d19577cb7 \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/quaternion.cs b/packages/cultmath/src/CultMath/quaternion.cs deleted file mode 100644 index e8c4a48c..00000000 --- a/packages/cultmath/src/CultMath/quaternion.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System; - -namespace CultMath; - -/// -/// Not an HLSL type: shaders carry rotations as float4. Equality therefore stays -/// scalar (== returns bool) instead of following the component-wise vector rule. -/// -public struct quaternion : IEquatable -{ - public static readonly quaternion identity = new(0.0f, 0.0f, 0.0f, 1.0f); - - public float x; - public float y; - public float z; - public float w; - - public quaternion(float x, float y, float z, float w) - { - this.x = x; - this.y = y; - this.z = z; - this.w = w; - } - - public static quaternion LookRotation(float3 forward, float3 up) - { - forward = math.normalize(forward); - up = math.normalize(up); - - var right = math.normalize(math.cross(up, forward)); - up = math.cross(forward, right); - - var m00 = right.x; - var m01 = up.x; - var m02 = forward.x; - var m10 = right.y; - var m11 = up.y; - var m12 = forward.y; - var m20 = right.z; - var m21 = up.z; - var m22 = forward.z; - - var trace = m00 + m11 + m22; - if (trace > 0.0f) - { - var s = math.sqrt(trace + 1.0f) * 2.0f; - return math.normalize(new quaternion( - (m21 - m12) / s, - (m02 - m20) / s, - (m10 - m01) / s, - 0.25f * s)); - } - - if (m00 > m11 && m00 > m22) - { - var s = math.sqrt(1.0f + m00 - m11 - m22) * 2.0f; - return math.normalize(new quaternion( - 0.25f * s, - (m01 + m10) / s, - (m02 + m20) / s, - (m21 - m12) / s)); - } - - if (m11 > m22) - { - var s = math.sqrt(1.0f + m11 - m00 - m22) * 2.0f; - return math.normalize(new quaternion( - (m01 + m10) / s, - 0.25f * s, - (m12 + m21) / s, - (m02 - m20) / s)); - } - - { - var s = math.sqrt(1.0f + m22 - m00 - m11) * 2.0f; - return math.normalize(new quaternion( - (m02 + m20) / s, - (m12 + m21) / s, - 0.25f * s, - (m10 - m01) / s)); - } - } - - public static implicit operator quaternion(float4 value) => new(value.x, value.y, value.z, value.w); - public static implicit operator float4(quaternion value) => new(value.x, value.y, value.z, value.w); - - public static bool operator ==(quaternion left, quaternion right) => left.Equals(right); - public static bool operator !=(quaternion left, quaternion right) => !left.Equals(right); - - public readonly bool Equals(quaternion other) => x.Equals(other.x) && y.Equals(other.y) && z.Equals(other.z) && w.Equals(other.w); - public override readonly bool Equals(object? obj) => obj is quaternion other && Equals(other); - public override readonly int GetHashCode() => HashCode.Combine(x, y, z, w); - public override readonly string ToString() => FormattableString.Invariant($"quaternion({x:R}, {y:R}, {z:R}, {w:R})"); -} diff --git a/packages/cultmath/src/CultMath/quaternion.cs.meta b/packages/cultmath/src/CultMath/quaternion.cs.meta deleted file mode 100644 index ae6e8a11..00000000 --- a/packages/cultmath/src/CultMath/quaternion.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 12f3627d9250dc348be9c1a1056ec7d0 \ No newline at end of file diff --git a/packages/cultmath/src/CultMath/rect.cs b/packages/cultmath/src/CultMath/rect.cs deleted file mode 100644 index 51e05c19..00000000 --- a/packages/cultmath/src/CultMath/rect.cs +++ /dev/null @@ -1,59 +0,0 @@ -namespace CultMath; - -/// -/// Axis-aligned 2D bounds stored canonically as normalized minimum and maximum -/// corners. Point containment and intersection include the boundary. -/// -public readonly record struct rect -{ - public rect(float minX, float minY, float maxX, float maxY) - : this(new float2(minX, minY), new float2(maxX, maxY)) - { - } - - public rect(float2 min, float2 max) - { - this.min = new float2(MathF.Min(min.x, max.x), MathF.Min(min.y, max.y)); - this.max = new float2(MathF.Max(min.x, max.x), MathF.Max(min.y, max.y)); - } - - public float2 min { get; } - public float2 max { get; } - - public float width => max.x - min.x; - public float height => max.y - min.y; - public float2 size => max - min; - public float2 center => (min + max) * 0.5f; - public float area => width * height; - public bool isEmpty => width == 0.0f || height == 0.0f; - - public bool Contains(float2 point) => - point.x >= min.x && point.x <= max.x && - point.y >= min.y && point.y <= max.y; - - public bool Contains(rect other) => - other.min.x >= min.x && other.max.x <= max.x && - other.min.y >= min.y && other.max.y <= max.y; - - public bool Intersects(rect other) => - min.x <= other.max.x && max.x >= other.min.x && - min.y <= other.max.y && max.y >= other.min.y; - - public rect Intersection(rect other) - { - if (!Intersects(other)) - return new rect(0.0f, 0.0f, 0.0f, 0.0f); - - return new rect( - MathF.Max(min.x, other.min.x), - MathF.Max(min.y, other.min.y), - MathF.Min(max.x, other.max.x), - MathF.Min(max.y, other.max.y)); - } - - public rect Encapsulate(float2 point) => new( - MathF.Min(min.x, point.x), - MathF.Min(min.y, point.y), - MathF.Max(max.x, point.x), - MathF.Max(max.y, point.y)); -} diff --git a/packages/cultmath/src/CultMath/rect.cs.meta b/packages/cultmath/src/CultMath/rect.cs.meta deleted file mode 100644 index 0b04a5ce..00000000 --- a/packages/cultmath/src/CultMath/rect.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 5aab0d8e1ca94d42b85bba780a7ee449 diff --git a/packages/cultmath/tests.meta b/packages/cultmath/tests.meta deleted file mode 100644 index a35356c4..00000000 --- a/packages/cultmath/tests.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 4ca61dedbfb3c5d46b0acaf23202b5c2 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/tests/CultMath.Tests.meta b/packages/cultmath/tests/CultMath.Tests.meta deleted file mode 100644 index 3f1451c6..00000000 --- a/packages/cultmath/tests/CultMath.Tests.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: bcee65f13f85daa45b0cf3fdaa901779 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/tests/CultMath.Tests/CultMath.Tests.csproj b/packages/cultmath/tests/CultMath.Tests/CultMath.Tests.csproj deleted file mode 100644 index d77fd05f..00000000 --- a/packages/cultmath/tests/CultMath.Tests/CultMath.Tests.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - net10.0 - enable - enable - false - - - - - - - - - - - - - - diff --git a/packages/cultmath/tests/CultMath.Tests/CultMath.Tests.csproj.meta b/packages/cultmath/tests/CultMath.Tests/CultMath.Tests.csproj.meta deleted file mode 100644 index 709184bb..00000000 --- a/packages/cultmath/tests/CultMath.Tests/CultMath.Tests.csproj.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 636051db121e19146a0baf92b790415c -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/tests/CultMath.Tests/HlslMirrorTests.cs b/packages/cultmath/tests/CultMath.Tests/HlslMirrorTests.cs deleted file mode 100644 index 445ad255..00000000 --- a/packages/cultmath/tests/CultMath.Tests/HlslMirrorTests.cs +++ /dev/null @@ -1,77 +0,0 @@ -using Xunit; - -namespace CultMath.Tests; - -public sealed class HlslMirrorTests -{ - [Fact] - public void HlslMirrorDoesNotPublishGeometryKernels() - { - 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); - Assert.DoesNotContain("planetary_radial_refinement", source); - } - - [Fact] - public void HlslMirrorPublishesCultMathPrimitives() - { - var include = File.ReadAllText(Path.Combine(GetCultMathRoot(), "shaders", "CultMath.hlsl")); - var requiredSymbols = new[] - { - "cultmath_radians", - "cultmath_degrees", - "cultmath_frac", - "cultmath_clamp", - "cultmath_saturate", - "cultmath_lerp", - "cultmath_step", - "cultmath_smoothstep", - "cultmath_smootherstep", - "cultmath_lengthsq", - "cultmath_distance", - "cultmath_normalize", - "cultmath_reflect", - "cultmath_rotate", - "cultmath_csum", - "cultmath_decay", - "cultmath_damp", - "cultmath_catmullrom", - "cultmath_quadratic_bezier", - "cultmath_cubic_bezier", - "cultmath_hash", - "cultmath_value_noise", - "cultmath_snoise(float2", - "cultmath_snoise(float3", - "cultmath_value_noise_bicubic", - "cultmath_value_noise_texture", - "cultmath_value_noise_texture_bicubic", - }; - - foreach (var symbol in requiredSymbols) - { - Assert.Contains(symbol, include); - } - - Assert.Contains("CULTMATH_NORMALIZE_EPSILON", include); - Assert.Contains("max(length(value), CULTMATH_NORMALIZE_EPSILON)", include); - } - - private static string GetCultMathRoot() - { - var directory = new DirectoryInfo(AppContext.BaseDirectory); - while (directory is not null) - { - var monorepoPackage = Path.Combine(directory.FullName, "packages", "cultmath"); - if (File.Exists(Path.Combine(monorepoPackage, "shaders", "CultMath.hlsl"))) - { - return monorepoPackage; - } - - directory = directory.Parent; - } - - throw new InvalidOperationException("Could not locate the CultMath package root."); - } -} diff --git a/packages/cultmath/tests/CultMath.Tests/HlslMirrorTests.cs.meta b/packages/cultmath/tests/CultMath.Tests/HlslMirrorTests.cs.meta deleted file mode 100644 index 1fcf44b3..00000000 --- a/packages/cultmath/tests/CultMath.Tests/HlslMirrorTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 3d7eb9df849ea0148a71419bbfe2352c \ No newline at end of file diff --git a/packages/cultmath/tests/CultMath.Tests/HlslSemanticsTests.cs b/packages/cultmath/tests/CultMath.Tests/HlslSemanticsTests.cs deleted file mode 100644 index 7b845ea7..00000000 --- a/packages/cultmath/tests/CultMath.Tests/HlslSemanticsTests.cs +++ /dev/null @@ -1,441 +0,0 @@ -using System.Text.Json; -using Xunit; - -namespace CultMath.Tests; - -public sealed class HlslSemanticsTests -{ - [Fact] - public void ComponentsAndIndexersAreWritable() - { - var value = new float4(1.0f, 2.0f, 3.0f, 4.0f); - value.w = 9.0f; - value[0] = -1.0f; - - Assert.Equal(new float4(-1.0f, 2.0f, 3.0f, 9.0f), value); - - var cell = new int3(1, 2, 3); - cell[2] = 7; - Assert.Equal(new int3(1, 2, 7), cell); - - ref var component = ref value.y; - component = 20.0f; - Assert.Equal(20.0f, value.y); - } - - [Fact] - public void Float2SwizzlesReadAndWrite() - { - var value = new float2(1.0f, 2.0f); - Assert.Equal(new float2(2.0f, 1.0f), value.yx); - - value.yx = new float2(5.0f, 6.0f); - Assert.Equal(new float2(6.0f, 5.0f), value); - } - - [Fact] - public void Float3SwizzlesReadAndWrite() - { - var source = new float3(1.0f, 2.0f, 3.0f); - Assert.Equal(new float2(1.0f, 2.0f), source.xy); - Assert.Equal(new float2(1.0f, 3.0f), source.xz); - Assert.Equal(new float2(2.0f, 1.0f), source.yx); - Assert.Equal(new float2(2.0f, 3.0f), source.yz); - Assert.Equal(new float2(3.0f, 2.0f), source.zy); - Assert.Equal(new float3(1.0f, 2.0f, 3.0f), source.xyz); - Assert.Equal(new float3(1.0f, 3.0f, 2.0f), source.xzy); - Assert.Equal(new float3(2.0f, 3.0f, 1.0f), source.yzx); - Assert.Equal(new float3(3.0f, 2.0f, 1.0f), source.zyx); - - Assert.Equal(new float3(8.0f, 9.0f, 3.0f), Write(source, (ref float3 v) => v.xy = new float2(8.0f, 9.0f))); - Assert.Equal(new float3(8.0f, 2.0f, 9.0f), Write(source, (ref float3 v) => v.xz = new float2(8.0f, 9.0f))); - Assert.Equal(new float3(9.0f, 8.0f, 3.0f), Write(source, (ref float3 v) => v.yx = new float2(8.0f, 9.0f))); - Assert.Equal(new float3(1.0f, 8.0f, 9.0f), Write(source, (ref float3 v) => v.yz = new float2(8.0f, 9.0f))); - Assert.Equal(new float3(1.0f, 9.0f, 8.0f), Write(source, (ref float3 v) => v.zy = new float2(8.0f, 9.0f))); - Assert.Equal(new float3(7.0f, 8.0f, 9.0f), Write(source, (ref float3 v) => v.xyz = new float3(7.0f, 8.0f, 9.0f))); - Assert.Equal(new float3(7.0f, 9.0f, 8.0f), Write(source, (ref float3 v) => v.xzy = new float3(7.0f, 8.0f, 9.0f))); - Assert.Equal(new float3(9.0f, 7.0f, 8.0f), Write(source, (ref float3 v) => v.yzx = new float3(7.0f, 8.0f, 9.0f))); - Assert.Equal(new float3(9.0f, 8.0f, 7.0f), Write(source, (ref float3 v) => v.zyx = new float3(7.0f, 8.0f, 9.0f))); - } - - [Fact] - public void Float4SwizzlesReadAndWrite() - { - var source = new float4(1.0f, 2.0f, 3.0f, 4.0f); - Assert.Equal(new float2(1.0f, 2.0f), source.xy); - Assert.Equal(new float3(1.0f, 2.0f, 3.0f), source.xyz); - Assert.Equal(new float3(1.0f, 2.0f, 4.0f), source.xyw); - Assert.Equal(new float3(2.0f, 3.0f, 1.0f), source.yzx); - - var a = source; - a.xy = new float2(8.0f, 9.0f); - Assert.Equal(new float4(8.0f, 9.0f, 3.0f, 4.0f), a); - - var b = source; - b.xyz = new float3(7.0f, 8.0f, 9.0f); - Assert.Equal(new float4(7.0f, 8.0f, 9.0f, 4.0f), b); - - var c = source; - c.xyw = new float3(7.0f, 8.0f, 9.0f); - Assert.Equal(new float4(7.0f, 8.0f, 3.0f, 9.0f), c); - - var d = source; - d.yzx = new float3(7.0f, 8.0f, 9.0f); - Assert.Equal(new float4(9.0f, 7.0f, 8.0f, 4.0f), d); - } - - [Fact] - public void GeneratedSwizzlesCoverRepeatsColorNamesAndAllVectorFamilies() - { - var v = new float4(1.0f, 2.0f, 3.0f, 4.0f); - Assert.Equal(new float4(4.0f, 4.0f, 1.0f, 2.0f), v.wwxy); - Assert.Equal(new float3(3.0f, 2.0f, 1.0f), v.bgr); - Assert.Equal(4.0f, v.a); - Assert.Equal(new float4(2.0f, 2.0f, 2.0f, 2.0f), new float2(1.0f, 2.0f).yyyy); - - v.wx = new float2(9.0f, 8.0f); - Assert.Equal(new float4(8.0f, 2.0f, 3.0f, 9.0f), v); - v.r = 0.5f; - v.gb += new float2(1.0f, 1.0f); - Assert.Equal(new float4(0.5f, 3.0f, 4.0f, 9.0f), v); - - Assert.Equal(new int3(3, 1, 1), new int3(1, 2, 3).zxx); - Assert.Equal(new bool2(false, true), new bool4(true, false, false, true).zw); - Assert.Equal(new double2(2.0, 1.0), new double3(1.0, 2.0, 3.0).yx); - - var cell = new int4(1, 2, 3, 4); - cell.xyz = new int3(7, 8, 9); - Assert.Equal(new int4(7, 8, 9, 4), cell); - } - - [Fact] - public void MixedConstructorsMatchHlsl() - { - var xy = new float2(1.0f, 2.0f); - var zw = new float2(3.0f, 4.0f); - var expected = new float4(1.0f, 2.0f, 3.0f, 4.0f); - - Assert.Equal(expected, math.float4(xy, zw)); - Assert.Equal(expected, math.float4(1.0f, new float3(2.0f, 3.0f, 4.0f))); - Assert.Equal(expected, math.float4(new float3(1.0f, 2.0f, 3.0f), 4.0f)); - Assert.Equal(expected, math.float4(xy, 3.0f, 4.0f)); - Assert.Equal(expected, math.float4(1.0f, 2.0f, zw)); - Assert.Equal(expected, math.float4(1.0f, new float2(2.0f, 3.0f), 4.0f)); - Assert.Equal(expected, new float4(xy, zw)); - Assert.Equal(new float3(1.0f, 2.0f, 3.0f), math.float3(1.0f, zw - 1.0f)); - Assert.Equal(new int4(1, 2, 3, 4), math.int4(new int2(1, 2), new int2(3, 4))); - Assert.Equal(new bool3(true, false, true), math.bool3(true, new bool2(false, true))); - Assert.Equal(new double3(1.0, 2.0, 3.0), new double3(new double2(1.0, 2.0), 3.0)); - } - - [Fact] - public void MatrixRowElementsAreWritable() - { - var m = float3x3.identity; - m[1][2] = 5.0f; - m[2].x = 7.0f; - m._m00 = 2.0f; - - Assert.Equal(5.0f, m._m12); - Assert.Equal(7.0f, m._m20); - Assert.Equal(math.float3x3(2.0f, 0.0f, 0.0f, 0.0f, 1.0f, 5.0f, 7.0f, 0.0f, 1.0f), m); - Assert.Equal(float3x3.identity, math.float3x3(1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f)); - - var r = float2x2.identity; - r[0][1] = 3.0f; - Assert.Equal(math.float2x2(1.0f, 3.0f, 0.0f, 1.0f), r); - Assert.Equal(new float2(0.0f, 1.0f), float2x2.identity[1]); - } - - [Fact] - public void IntrinsicEdgeCasesFollowHlsl() - { - Assert.True(math.any(new float3(0.0f, 0.0f, -2.0f))); - Assert.False(math.any(float4.zero)); - Assert.True(math.all(new int2(1, -1))); - Assert.False(math.all(new int3(1, 0, 1))); - Assert.True(math.any(new float2(float.NaN, 0.0f))); - - Assert.Equal(new int3(1, 2, 3), math.min(new int3(1, 5, 3), new int3(4, 2, 6))); - Assert.Equal(new int2(4, 5), math.max(new int2(1, 5), new int2(4, 2))); - Assert.Equal(new int4(1, 2, 0, 3), math.abs(new int4(-1, 2, 0, -3))); - Assert.Equal(7, math.abs(-7)); - - Assert.Equal(1.0f, math.step(1.0f, float.NaN)); - Assert.Equal(1.0f, math.step(float.NaN, 1.0f)); - Assert.Equal(1.0f, math.step(1.0f, 1.0f)); - - // DXIL FMin/FMax/Saturate: a NaN operand returns the other; max(a, b) is a >= b ? a : b. - Assert.Equal(1.0f, math.min(float.NaN, 1.0f)); - Assert.Equal(1.0f, math.min(1.0f, float.NaN)); - Assert.Equal(1.0f, math.max(float.NaN, 1.0f)); - Assert.Equal(1.0f, math.max(1.0f, float.NaN)); - Assert.True(float.IsNaN(math.max(float.NaN, float.NaN))); - Assert.Equal(2.0f, math.clamp(float.NaN, 2.0f, 3.0f)); - Assert.Equal(new float3(0.0f, 1.0f, 0.0f), math.saturate(new float3(float.NaN, 2.0f, -1.0f))); - Assert.Equal(new float2(-1.0f, 4.0f), math.max(new float2(float.NaN, 4.0f), new float2(-1.0f, float.NaN))); - static int Bits(float value) => BitConverter.SingleToInt32Bits(value); - Assert.Equal(Bits(-0.0f), Bits(math.max(-0.0f, 0.0f))); - Assert.Equal(Bits(0.0f), Bits(math.min(-0.0f, 0.0f))); - Assert.Equal(Bits(0.0f), Bits(math.saturate(-0.0f))); - Assert.Equal(1.0, math.min(double.NaN, 1.0)); - Assert.Equal(1.0, math.min(1.0, double.NaN)); - Assert.Equal(1.0, math.max(double.NaN, 1.0)); - Assert.Equal(1.0, math.max(1.0, double.NaN)); - Assert.True(double.IsNaN(math.max(double.NaN, double.NaN))); - static long DoubleBits(double value) => BitConverter.DoubleToInt64Bits(value); - Assert.Equal(DoubleBits(-0.0), DoubleBits(math.max(-0.0, 0.0))); - Assert.Equal(DoubleBits(0.0), DoubleBits(math.min(-0.0, 0.0))); - - 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))); - Assert.Equal(new int2(1, -1), math.sign(new int2(9, -9))); - } - - [Fact] - public void ComparisonOperatorsReturnComponentWiseBoolVectors() - { - var a = new float3(1.0f, 2.0f, 3.0f); - var b = new float3(3.0f, 2.0f, 1.0f); - - Assert.Equal(new bool3(true, false, false), a < b); - Assert.Equal(new bool3(false, false, true), a > b); - Assert.Equal(new bool3(true, true, false), a <= b); - Assert.Equal(new bool3(false, true, true), a >= b); - Assert.Equal(new bool3(false, true, false), a == b); - Assert.Equal(new bool3(true, false, true), a != b); - - Assert.Equal(new bool3(true, false, false), a < 2.0f); - Assert.Equal(new bool3(false, false, true), 2.0f < a); - Assert.Equal(new bool2(true, false), new float2(0.0f, 1.0f) == 0.0f); - Assert.Equal(new bool4(false, true, true, true), new float4(0.0f, 1.0f, 2.0f, 3.0f) >= 1.0f); - Assert.Equal(new bool2(false, true), new int2(1, 2) > new int2(1, 1)); - Assert.Equal(new bool3(true, false, true), new int3(1, 2, 3) != new int3(0, 2, 4)); - Assert.Equal(new bool4(true, true, false, false), new int4(1, 2, 3, 4) <= new int4(1, 2, 2, 2)); - Assert.Equal(new bool2(true, false), new double2(0.5, 1.0) < new double2(1.0, 1.0)); - Assert.Equal(new bool3(false, false, true), new double3(1.0, 1.0, 2.0) > new double3(1.0, 1.0, 1.0)); - } - - [Fact] - public void ScalarOperatorsApplyPerComponentOnBothSides() - { - var value = new float3(1.0f, 2.0f, 4.0f); - - Assert.Equal(new float3(3.0f, 2.0f, 0.0f), 4.0f - value); - Assert.Equal(new float3(0.0f, 1.0f, 3.0f), value - 1.0f); - Assert.Equal(new float3(2.0f, 3.0f, 5.0f), 1.0f + value); - Assert.Equal(new float3(4.0f, 2.0f, 1.0f), 4.0f / value); - Assert.Equal(new float3(0.5f, 1.0f, 2.0f), value / 2.0f); - Assert.Equal(new float3(2.0f, 4.0f, 8.0f), 2.0f * value); - Assert.Equal(new float3(1.0f, 0.0f, 0.0f), value % 2.0f); - Assert.Equal(new float2(1.5f, 2.5f), new float2(1.0f, 2.0f) + 0.5f); - Assert.Equal(new float4(0.0f, 1.0f, 2.0f, 3.0f), new float4(1.0f, 2.0f, 3.0f, 4.0f) - 1.0f); - } - - [Fact] - public void EqualsAndHashCodeStayValueBasedForDictionaryKeys() - { - var map = new Dictionary { [new float3(1.0f, 2.0f, 3.0f)] = "hit" }; - Assert.Equal("hit", map[new float3(1.0f, 2.0f, 3.0f)]); - Assert.False(map.ContainsKey(new float3(1.0f, 2.0f, 3.5f))); - - var cells = new HashSet { new int2(3, 4) }; - Assert.Contains(new int2(3, 4), cells); - Assert.Contains(new int3(1, 2, 3), new HashSet { new int3(1, 2, 3) }); - - var nan = new float2(float.NaN, 0.0f); - var sameNan = nan; - Assert.True(nan.Equals(sameNan)); - Assert.False((nan == sameNan).x); - - Assert.True(new quaternion(0.0f, 0.0f, 0.0f, 1.0f) == quaternion.identity); - Assert.Equal(float2x2.identity, math.float2x2(1.0f, 0.0f, 0.0f, 1.0f)); - Assert.Equal("float3(1, 2.5, -3)", new float3(1.0f, 2.5f, -3.0f).ToString()); - } - - [Fact] - public void AnyAllAndSelectFollowHlsl() - { - Assert.True(math.any(new bool2(false, true))); - Assert.False(math.any(bool3.@false)); - Assert.True(math.all(bool4.@true)); - Assert.False(math.all(new bool3(true, false, true))); - - var mask = new bool3(true, false, true); - Assert.Equal(new float3(1.0f, 20.0f, 3.0f), math.select(mask, new float3(1.0f, 2.0f, 3.0f), new float3(10.0f, 20.0f, 30.0f))); - Assert.Equal(7.0f, math.select(true, 7.0f, 8.0f)); - Assert.Equal(new float2(0.0f, 5.0f), math.select(new bool2(false, true), math.float2(5.0f), math.float2(0.0f))); - Assert.Equal(new float4(1.0f, 0.0f, 1.0f, 0.0f), math.select(new bool4(true, false, true, false), 1.0f, 0.0f)); - - Assert.Equal(new bool2(false, true), !new bool2(true, false)); - Assert.Equal(new bool3(true, false, false), new bool3(true, true, false) & new bool3(true, false, true)); - Assert.Equal(new bool3(true, true, true), new bool3(true, true, false) | new bool3(true, false, true)); - } - - [Fact] - public void AddedIntrinsicsAreComponentWise() - { - Assert.Equal(MathF.Log(3.0f), math.log(3.0f)); - Assert.Equal(new float3(0.0f, MathF.Log(2.0f), MathF.Log(4.0f)), math.log(new float3(1.0f, 2.0f, 4.0f))); - Assert.Equal(new float2(8.0f, 9.0f), math.pow(new float2(2.0f, 3.0f), new float2(3.0f, 2.0f))); - Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), math.sqrt(new float4(1.0f, 4.0f, 9.0f, 16.0f))); - var sines = math.sin(new float3(0.0f, math.HALF_PI, math.PI)); - Assert.Equal(0.0f, sines.x, precision: 5); - Assert.Equal(1.0f, sines.y, precision: 5); - Assert.Equal(0.0f, sines.z, precision: 5); - var cosines = math.cos(new float2(0.0f, math.PI)); - Assert.Equal(1.0f, cosines.x, precision: 5); - Assert.Equal(-1.0f, cosines.y, precision: 5); - var arccosines = math.acos(new float3(1.0f, 0.0f, -1.0f)); - Assert.Equal(0.0f, arccosines.x, precision: 5); - Assert.Equal(math.HALF_PI, arccosines.y, precision: 5); - Assert.Equal(math.PI, arccosines.z, precision: 5); - Assert.Equal(math.atan2(1.0f, -1.0f), math.atan2(new float2(1.0f, 0.0f), new float2(-1.0f, 1.0f)).x); - Assert.Equal(0.0f, math.atan2(new float4(1.0f, 0.0f, 1.0f, 0.0f), new float4(1.0f, 1.0f, 1.0f, 1.0f)).y); - Assert.Equal(2.5, math.min(2.5, 3.5)); - Assert.Equal(3.5, math.max(2.5, 3.5)); - } - - [Fact] - public void IntVectorsPromoteToFloatVectors() - { - float3 promoted3 = new int3(1, 2, 3); - float4 promoted4 = new int4(1, 2, 3, 4); - - Assert.Equal(new float3(1.0f, 2.0f, 3.0f), promoted3); - Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), promoted4); - Assert.Equal(new int3(2, 4, 6), new int3(1, 2, 3) * 2); - Assert.Equal(new int4(1, 0, 1, 0), new int4(3, 4, 5, 6) % 2); - Assert.Equal(new int3(1, 2, 3), math.int3(1, 2, 3)); - Assert.Equal(new int4(5, 5, 5, 5), math.int4(5)); - } - - [Fact] - public void MatrixMulFollowsHlslRowMajorConvention() - { - var m = math.float2x2(1.0f, 2.0f, 3.0f, 4.0f); - var v = new float2(5.0f, 6.0f); - - Assert.Equal(new float2(1.0f, 2.0f), m[0]); - Assert.Equal(new float2(17.0f, 39.0f), math.mul(m, v)); - Assert.Equal(new float2(23.0f, 34.0f), math.mul(v, m)); - Assert.Equal(math.float2x2(7.0f, 10.0f, 15.0f, 22.0f), math.mul(m, m)); - - var m3 = math.float3x3(new float3(1.0f, 2.0f, 3.0f), new float3(4.0f, 5.0f, 6.0f), new float3(7.0f, 8.0f, 9.0f)); - var v3 = new float3(1.0f, 0.0f, -1.0f); - Assert.Equal(new float3(-2.0f, -2.0f, -2.0f), math.mul(m3, v3)); - Assert.Equal(new float3(-6.0f, -6.0f, -6.0f), math.mul(v3, m3)); - Assert.Equal(math.float3x3(30.0f, 36.0f, 42.0f, 66.0f, 81.0f, 96.0f, 102.0f, 126.0f, 150.0f), math.mul(m3, m3)); - Assert.Equal(m3, math.mul(m3, float3x3.identity)); - - m3[1] = new float3(0.0f, 1.0f, 0.0f); - Assert.Equal(1.0f, m3._m11); - Assert.Equal(12.0f, math.mul(3.0f, 4.0f)); - } - - [Fact] - public void Float2x2RotateMatchesRotateAndUnityMathematics() - { - var rotation = float2x2.Rotate(0.5f); - var column = math.mul(rotation, new float2(1.0f, 0.0f)); - var row = math.mul(new float2(1.0f, 0.0f), rotation); - var reference = math.rotate(new float2(1.0f, 0.0f), 0.5f); - - Assert.Equal(reference.x, column.x, precision: 6); - Assert.Equal(reference.y, column.y, precision: 6); - - // Unity.Mathematics 1.x reference values: mul(float2(1, 0), float2x2.Rotate(0.5f)). - Assert.Equal(0.87758255f, row.x, precision: 6); - Assert.Equal(-0.47942555f, row.y, precision: 6); - } - - [Fact] - public void Float3x3EulerMatchesUnityMathematicsReference() - { - // Unity.Mathematics 1.x reference values captured from float3x3.Euler. - AssertRows( - float3x3.Euler(new float3(0.4f, -0.7f, 1.3f), math.RotationOrder.YXZ), - 0.44632244f, -0.8874959f, 0.114662126f, - 0.6698625f, 0.24638279f, -0.700414f, - 0.59336376f, 0.38941833f, 0.70446634f); - AssertRows( - float3x3.Euler(new float3(0.4f, -0.7f, 1.3f)), - -0.037133574f, -0.8040775f, -0.59336376f, - 0.8874959f, 0.24638279f, -0.38941833f, - 0.45931715f, -0.5410684f, 0.70446634f); - - // Aetheria ActionGameManager view direction: mul(float3(0, 0, 1), Euler(float3(yawPitch.yx, 0), YXZ)). - var yawPitch = new float2(0.3f, -1.1f); - var view = math.mul(new float3(0.0f, 0.0f, 1.0f), float3x3.Euler(new float3(yawPitch.yx, 0.0f), math.RotationOrder.YXZ)); - Assert.Equal(-0.13404681f, view.x, precision: 5); - Assert.Equal(-0.8912074f, view.y, precision: 5); - Assert.Equal(0.4333369f, view.z, precision: 5); - - var composed = math.mul(float3x3.RotateZ(0.2f), math.mul(float3x3.RotateY(0.3f), float3x3.RotateX(0.1f))); - AssertRows(float3x3.Euler(0.1f, 0.3f, 0.2f, math.RotationOrder.XYZ), - composed._m00, composed._m01, composed._m02, - composed._m10, composed._m11, composed._m12, - composed._m20, composed._m21, composed._m22); - } - - [Theory] - [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); - } - - [Fact] - public void RandomFloat3OverloadsShareOneDrawOrder() - { - var a = new Random(99); - var b = new Random(99); - var c = new Random(99); - - var unit = a.NextFloat3(); - Assert.Equal(new float3(b.NextFloat(), b.NextFloat(), b.NextFloat()), unit); - Assert.Equal(unit * new float3(2.0f, 4.0f, 8.0f), c.NextFloat3(new float3(2.0f, 4.0f, 8.0f))); - - var d = new Random(99); - Assert.Equal(math.float3(-1.0f) + unit * 2.0f, d.NextFloat3(math.float3(-1.0f), math.float3(1.0f))); - } - - [Fact] - public void CoreAssemblyStaysEngineFree() - { - var references = typeof(math).Assembly.GetReferencedAssemblies(); - Assert.DoesNotContain(references, reference => reference.Name!.StartsWith("UnityEngine", StringComparison.Ordinal)); - - var root = new DirectoryInfo(AppContext.BaseDirectory); - while (root is not null && !File.Exists(Path.Combine(root.FullName, "packages", "cultmath", "src", "CultMath", "CultMath.asmdef"))) - { - root = root.Parent; - } - - Assert.NotNull(root); - var asmdefPath = Path.Combine(root!.FullName, "packages", "cultmath", "src", "CultMath", "CultMath.asmdef"); - using var asmdef = JsonDocument.Parse(File.ReadAllText(asmdefPath)); - Assert.True(asmdef.RootElement.GetProperty("noEngineReferences").GetBoolean()); - } - - private static float3 Write(float3 value, WriteAction action) - { - action(ref value); - return value; - } - - private delegate void WriteAction(ref float3 value); - - private static void AssertRows(float3x3 m, params float[] expected) - { - var actual = new[] { m._m00, m._m01, m._m02, m._m10, m._m11, m._m12, m._m20, m._m21, m._m22 }; - for (var index = 0; index < expected.Length; index++) - { - Assert.Equal(expected[index], actual[index], precision: 5); - } - } -} diff --git a/packages/cultmath/tests/CultMath.Tests/HlslSemanticsTests.cs.meta b/packages/cultmath/tests/CultMath.Tests/HlslSemanticsTests.cs.meta deleted file mode 100644 index 8158a66d..00000000 --- a/packages/cultmath/tests/CultMath.Tests/HlslSemanticsTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 7164d00a023e43578a31897347d8f106 diff --git a/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs b/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs deleted file mode 100644 index ca4584c7..00000000 --- a/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs +++ /dev/null @@ -1,203 +0,0 @@ -using System.Reflection; -using System.Text.RegularExpressions; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Xunit; -using static CultMath.math; - -namespace CultMath.Tests; - -/// -/// The real shader mirror, shaders/CultMath.hlsl, compiles as C# against CultMath under -/// using static CultMath.math; after exactly the source transformations listed in -/// docs/design.md (HLSL Target: Source Transformations). is -/// that list in code; if a body needs anything else, this test fails. -/// -public sealed class HlslSourceCompatibilityTests -{ - [Fact] - public void ShaderMirrorBodiesCompileAsCSharp() - { - var (assembly, errors) = CompileShaderMirror(); - Assert.True(assembly is not null, "HLSL mirror did not compile as C#:" + Environment.NewLine + string.Join(Environment.NewLine, errors)); - - var shader = assembly!.GetType("CultMathHlsl.HlslShader")!; - 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))); - } - - // HLSL functions carry no access modifier, so they compile as private instance methods. - private static MethodInfo? ShaderFunction(Type shader, string name, params Type[] parameters) => - shader.GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic, null, parameters, null); - - // Mirror internals with no public C# math counterpart. Any other cultmath_* function must match one. - private static readonly string[] MirrorOnly = { "cultmath_snoise_mod289", "cultmath_snoise_permute" }; - - private static readonly float[] Specials = { 0.0f, -0.0f, 1.0f, -1.0f, 0.5f, -2.75f, 3.0f, 1.0e-7f, 1.0e4f, -1.0e4f, 1.0e7f, float.NaN }; - - /// - /// Every mirror function with a C# math counterpart returns the same bits (any NaN equals any NaN; - /// -0 differs from 0) over random inputs, every special value in every argument (ties, zeros, - /// negatives, NaN, large values, integer lattice points), special pairs, and vectors whose first two - /// components tie (x0.x == x0.y in snoise). This proves the text of CultMath.hlsl computes the same - /// float32 results as C# math on the CPU. It does not prove GPU agreement: driver sin precision alone - /// makes cultmath_hash and value noise differ bit for bit on hardware. The mirror's intrinsics are C# - /// math itself (it compiles under using static CultMath.math), so this proves the composition in the - /// text, not the intrinsic rules; HlslSemanticsTests alone pins those. - /// - [Fact] - public void EveryMirrorFunctionMatchesCSharpMath() - { - 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 random = new System.Random(0x5EED); - float Next() => random.NextSingle() * 200.0f - 100.0f; - var cases = new List>(); - foreach (var s in Specials) cases.Add(_ => s); - for (var k = 0; k < Specials.Length; k++) { var o = k; cases.Add(i => Specials[(i + o + 1) % Specials.Length]); } - 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]); } - - var mismatches = new List(); - var compared = new HashSet(); - foreach (var mirror in shaderType.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic).Where(m => m.Name.StartsWith("cultmath_"))) - { - var types = mirror.GetParameters().Select(p => p.ParameterType).ToArray(); - var counterpart = typeof(math).GetMethod(mirror.Name["cultmath_".Length..], BindingFlags.Public | BindingFlags.Static, null, types, null); - if (counterpart is null) - { - Assert.Contains(mirror.Name, MirrorOnly); - continue; - } - - compared.Add(mirror.Name); - foreach (var input in cases) - { - var slot = 0; - 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(" ", 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(26, compared.Count); - } - - private static int Components(Type t) => t.GetFields().Count(f => f.FieldType == typeof(float) && !f.IsStatic); - - 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) - { - // 1. Include guards (#ifndef/#define/#endif) are dropped; C# has no textual include. - var source = Regex.Replace(hlsl, @"(?m)^\s*#(ifndef|define|endif)\b.*$", string.Empty); - - // 2. Functions taking Texture2D/SamplerState resources are dropped; GPU resources have - // no CultMath analog. - source = Regex.Replace(source, @"(?ms)^\w+ \w+\([^)]*\b(Texture2D|SamplerState)\b[^)]*\)\s*\{.*?^\}[ \t]*\r?$", string.Empty); - - // 3. File-scope `static const` becomes `const` (C# consts are implicitly static). - source = Regex.Replace(source, @"(?m)^static const ", "const "); - - // 4. Floating literals gain the `f` suffix; an unsuffixed C# literal is a double. - source = Regex.Replace(source, @"(? Errors) CompileShaderMirror() - { - 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) - .Append(typeof(math).Assembly.Location) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Select(path => MetadataReference.CreateFromFile(path)); - var compilation = CSharpCompilation.Create( - "CultMathHlslMirror", - new[] { tree }, - references, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - - using var stream = new MemoryStream(); - var result = compilation.Emit(stream); - var errors = result.Diagnostics - .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) - .Select(diagnostic => diagnostic.ToString()) - .ToList(); - return (result.Success ? Assembly.Load(stream.ToArray()) : null, errors); - } - - private static string FindCultMathRoot() - { - var directory = new DirectoryInfo(AppContext.BaseDirectory); - while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "packages", "cultmath", "shaders", "CultMath.hlsl"))) - { - directory = directory.Parent; - } - - return Path.Combine(directory?.FullName ?? throw new InvalidOperationException("CultMath package root not found."), "packages", "cultmath"); - } - - // ---- Hand-written HLSL-shaped host code: swizzles, mixed constructors, matrix rows. ---- - static float3 shade(float3 position, float3 normal, float2 wind, float time) - { - float3 p = position; - p.xz = p.xz + wind * time; - p.y = saturate(p.y); - - float3x3 basis = float3x3(float3(1, 0, 0), float3(0, 1, 0), float3(0, 0, 1)); - basis[1][1] = 1.0f; - float3 n = normalize(mul(basis, normal)); - float ndotl = saturate(dot(n, normalize(float3(0.3f, 1, 0.2f)))); - float4 color = float4(lerp(float3(0.1f, 0.1f, 0.2f), float3(1, 0.9f, 0.7f), ndotl), 1); - - if (any(p < 0.0f)) - color.rgb *= 0.5f; - if (all(abs(n) <= 1.0f)) - color.bgr = color.bgr * float3(1, 1, 1); - - color.rgb = select(p > 10.0f, float3(0), color.rgb); - float2 uv = frac(p.xz * 0.25f); - color.rg = lerp(color.rg, uv, 0.1f); - return float4(color.xy, color.zw).xyz; - } - - static float2 steer(float2 direction, float torque) - { - float2x2 turn = float2x2(cos(torque), -sin(torque), sin(torque), cos(torque)); - float2 turned = mul(direction, turn); - return normalize(turned) * pow(length(turned), 1.0f); - } - - [Fact] - public void HandWrittenShaderStyleMathEvaluates() - { - var color = shade(float3(1.0f, 0.5f, 2.0f), float3(0.0f, 1.0f, 0.0f), float2(1.0f, 0.0f), 0.0f); - Assert.True(color.x > 0.1f && color.x <= 1.0f); - Assert.True(float.IsFinite(color.y) && float.IsFinite(color.z)); - - var far = shade(float3(20.0f, 20.0f, 20.0f), float3(0.0f, 1.0f, 0.0f), float2(0.0f), 0.0f); - Assert.Equal(0.0f, far.z); - - var steered = steer(float2(1.0f, 0.0f), 0.5f); - var viaRotate = mul(float2(1.0f, 0.0f), CultMath.float2x2.Rotate(0.5f)); - Assert.Equal(viaRotate.x, steered.x, precision: 5); - Assert.Equal(viaRotate.y, steered.y, precision: 5); - } -} diff --git a/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs.meta b/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs.meta deleted file mode 100644 index 0d673e1e..00000000 --- a/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 942fbcb9c28a4e559e5baf435dc24cf1 diff --git a/packages/cultmath/tests/CultMath.Tests/MathTests.cs b/packages/cultmath/tests/CultMath.Tests/MathTests.cs deleted file mode 100644 index de1bf996..00000000 --- a/packages/cultmath/tests/CultMath.Tests/MathTests.cs +++ /dev/null @@ -1,335 +0,0 @@ -using CultMath; -using Xunit; - -namespace CultMath.Tests; - -public sealed class MathTests -{ - [Fact] - public void FracMatchesShaderStylePositiveAndNegativeValues() - { - Assert.Equal(0.25f, math.frac(1.25f)); - Assert.Equal(0.75f, math.frac(-1.25f)); - } - - [Fact] - public void VectorOperationsAreComponentWise() - { - var value = new float3(1.0f, 2.0f, 3.0f) * new float3(4.0f, 5.0f, 6.0f); - - Assert.Equal(new float3(4.0f, 10.0f, 18.0f), value); - } - - [Fact] - public void VectorConstructorsAcceptLowerDimensionVectors() - { - Assert.Equal(new int2(1, 2), math.int2(1, 2)); - Assert.Equal(new float2(1.0f, 2.0f), math.float2(1.0f, 2.0f)); - Assert.Equal(new float3(1.0f, 1.0f, 1.0f), math.float3(1.0f)); - Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), math.float4(new float3(1.0f, 2.0f, 3.0f), 4.0f)); - Assert.Equal(new float3(1.0f, 2.0f, 3.0f), new float3(new float2(1.0f, 2.0f), 3.0f)); - Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), new float4(new float2(1.0f, 2.0f), 3.0f, 4.0f)); - Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), new float4(new float3(1.0f, 2.0f, 3.0f), 4.0f)); - } - - [Fact] - public void VectorsExposeCommonSwizzles() - { - Assert.Equal(new float3(2.0f, 3.0f, 1.0f), new float3(1.0f, 2.0f, 3.0f).yzx); - Assert.Equal(new float3(3.0f, 2.0f, 1.0f), new float3(1.0f, 2.0f, 3.0f).zyx); - Assert.Equal(new float2(3.0f, 2.0f), new float3(1.0f, 2.0f, 3.0f).zy); - Assert.Equal(new float3(1.0f, 2.0f, 4.0f), new float4(1.0f, 2.0f, 3.0f, 4.0f).xyw); - Assert.Equal(new float3(2.0f, 3.0f, 1.0f), new float4(1.0f, 2.0f, 3.0f, 4.0f).yzx); - Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), new float4(1.0f, new float3(2.0f, 3.0f, 4.0f))); - Assert.Equal(new float4(1.0f, 2.0f, 3.0f, 4.0f), math.float4(1.0f, new float3(2.0f, 3.0f, 4.0f))); - } - - [Fact] - public void VectorsSupportUnityStyleFieldAndSwizzleMutation() - { - var position = new float3(1.0f, 2.0f, 3.0f); - position.y = 5.0f; - position.xz = new float2(8.0f, 13.0f); - - Assert.Equal(new float3(8.0f, 5.0f, 13.0f), position); - - var bounds = new float4(1.0f, 2.0f, 3.0f, 4.0f); - bounds.xy = new float2(-1.0f, -2.0f); - - Assert.Equal(new float4(-1.0f, -2.0f, 3.0f, 4.0f), bounds); - } - - [Fact] - public void DoubleVectorsSupportDampedMath() - { - var value = new double3(1.0, 2.0, 3.0); - value.xy = new double2(5.0, 8.0); - - Assert.Equal(new double3(5.0, 8.0, 3.0), value); - Assert.Equal(new double2(Math.Exp(-2.0), Math.Exp(-4.0)), math.exp(new double2(-2.0, -4.0))); - Assert.Equal(new double3(2.0, 4.0, 6.0), math.lerp(new double3(0.0, 0.0, 0.0), new double3(4.0, 8.0, 12.0), new double3(0.5, 0.5, 0.5))); - } - - [Fact] - public void Int2SupportsGridArithmeticAndFloatPromotion() - { - var cell = new int2(2, 3); - - Assert.Equal(new int2(3, 5), cell + new int2(1, 2)); - Assert.Equal(new int2(4, 6), cell * new int2(2, 2)); - Assert.Equal(3, cell[1]); - - float2 promoted = cell; - Assert.Equal(new float2(2.0f, 3.0f), promoted); - } - - [Fact] - public void Bool2SupportsHullGridStyleStorage() - { - bool2 conductivity = true; - - conductivity.y = false; - - Assert.True(conductivity.x); - Assert.False(conductivity[1]); - Assert.Equal(new bool2(false, true), math.bool2(false, true)); - } - - [Fact] - public void QuaternionLookRotationProducesUnitOrientation() - { - var rotation = quaternion.LookRotation(new float3(0.0f, 0.0f, 1.0f), new float3(0.0f, 1.0f, 0.0f)); - - Assert.Equal(quaternion.identity, rotation); - Assert.Equal(1.0f, math.length((float4)quaternion.LookRotation(new float3(1.0f, 0.0f, 0.0f), new float3(0.0f, 1.0f, 0.0f))), precision: 5); - } - - [Fact] - public void RandomUsesStableXorShift32Sequence() - { - var a = new Random(1234); - var b = new Random(1234); - - Assert.Equal(332584831u, a.NextUInt()); - Assert.Equal(1855942593u, a.NextUInt()); - Assert.Equal(4018585650u, a.NextUInt()); - Assert.Equal(3348358578u, a.NextUInt()); - Assert.Equal(332584831u, b.NextUInt()); - } - - [Fact] - public void RandomProvidesDeterministicPortableSampling() - { - var a = new Random(1234); - var b = new Random(1234); - - Assert.Equal(a.NextUInt(), b.NextUInt()); - Assert.Equal(a.NextFloat(), b.NextFloat()); - - var bounded = a.NextFloat(-2.0f, 4.0f); - Assert.True(bounded >= -2.0f && bounded < 4.0f); - - var direction = a.NextFloat2Direction(); - Assert.Equal(1.0f, math.length(direction), precision: 5); - - var point = a.NextFloat3(new float3(-1.0f, -2.0f, -3.0f), new float3(1.0f, 2.0f, 3.0f)); - Assert.True(point.x >= -1.0f && point.x < 1.0f); - Assert.True(point.y >= -2.0f && point.y < 2.0f); - Assert.True(point.z >= -3.0f && point.z < 3.0f); - } - - [Fact] - public void NormalizeProducesUnitLength() - { - var value = math.normalize(new float3(3.0f, 4.0f, 0.0f)); - - Assert.Equal(1.0f, math.length(value), precision: 5); - } - - [Fact] - public void SmoothstepUsesHermiteRamp() - { - Assert.Equal(0.0f, math.smoothstep(0.0f, 1.0f, -1.0f)); - Assert.Equal(0.5f, math.smoothstep(0.0f, 1.0f, 0.5f), precision: 5); - Assert.Equal(1.0f, math.smoothstep(0.0f, 1.0f, 2.0f)); - Assert.Equal(0.5f, math.smootherstep(0.5f), precision: 5); - } - - [Fact] - public void TrigIntrinsicsUseRadians() - { - Assert.Equal(1.0f, math.sin(math.HALF_PI), precision: 5); - Assert.Equal(-1.0f, math.cos(math.PI), precision: 5); - Assert.Equal(1.0f, math.tan(math.PI * 0.25f), precision: 5); - Assert.Equal(math.HALF_PI, math.atan2(1.0f, 0.0f), precision: 5); - Assert.Equal(2, math.min(2, 5)); - Assert.Equal(5, math.max(2, 5)); - Assert.True(math.isinf(float.PositiveInfinity)); - } - - [Fact] - public void SignIsComponentWiseForSimulationVectors() - { - Assert.Equal(-1, math.sign(-12.0f)); - Assert.Equal(0, math.sign(0.0f)); - Assert.Equal(1, math.sign(12.0f)); - Assert.Equal(new int3(-1, 0, 1), math.sign(new float3(-2.0f, 0.0f, 4.0f))); - } - - [Fact] - public void RotateTurnsFloat2CounterClockwiseInRadians() - { - var rotated = math.rotate(new float2(1.0f, 0.0f), math.HALF_PI); - - Assert.Equal(0.0f, rotated.x, precision: 5); - Assert.Equal(1.0f, rotated.y, precision: 5); - - var rotatedDegrees = math.rotate_degrees(new float2(0.0f, 1.0f), 90.0f); - Assert.Equal(-1.0f, rotatedDegrees.x, precision: 5); - Assert.Equal(0.0f, rotatedDegrees.y, precision: 5); - } - - [Fact] - public void AetheriaPrimitivesRemainSmallAndDeterministic() - { - Assert.Equal(6.0f, math.csum(new float3(1.0f, 2.0f, 3.0f))); - Assert.Equal(0.25f, math.unlerp(10.0f, 30.0f, 15.0f)); - Assert.Equal(new float2(0.25f, 0.75f), math.unlerp(new float2(10.0f, 10.0f), new float2(30.0f, 30.0f), new float2(15.0f, 25.0f))); - Assert.Equal(MathF.Exp(-2.0f), math.decay(1.0f, 2.0f, 1.0f), precision: 5); - Assert.Equal(1.0f - MathF.Exp(-2.0f), math.damp(0.0f, 1.0f, 2.0f, 1.0f), precision: 5); - - Assert.Equal(15.0f, math.catmullrom(0.0f, 10.0f, 20.0f, 30.0f, 0.5f), precision: 5); - Assert.Equal(0.25f, math.quadratic_bezier(0.0f, 0.0f, 1.0f, 0.5f), precision: 5); - Assert.Equal(0.5f, math.cubic_bezier(0.0f, 0.0f, 1.0f, 1.0f, 0.5f), precision: 5); - } - - [Fact] - public void SegmentDistanceReportsClosestPoint() - { - var distance = math.distance_to_segment(new float2(2.0f, 3.0f), new float2(0.0f, 0.0f), new float2(4.0f, 0.0f), out var closest); - - Assert.Equal(3.0f, distance, precision: 5); - Assert.Equal(new float2(2.0f, 0.0f), closest); - } - - [Fact] - public void RectNormalizesAndDerivesFromMinMax() - { - var bounds = new rect(8.0f, 6.0f, 2.0f, -2.0f); - - Assert.Equal(new float2(2.0f, -2.0f), bounds.min); - Assert.Equal(new float2(8.0f, 6.0f), bounds.max); - Assert.Equal(new float2(6.0f, 8.0f), bounds.size); - Assert.Equal(new float2(5.0f, 2.0f), bounds.center); - Assert.Equal(48.0f, bounds.area); - } - - [Fact] - public void RectContainmentIncludesBoundary() - { - var bounds = new rect(-2.0f, -1.0f, 4.0f, 3.0f); - - Assert.True(bounds.Contains(new float2(-2.0f, -1.0f))); - Assert.True(bounds.Contains(new float2(4.0f, 3.0f))); - Assert.True(bounds.Contains(new rect(-1.0f, 0.0f, 1.0f, 2.0f))); - Assert.False(bounds.Contains(new float2(4.001f, 3.0f))); - } - - [Fact] - public void RectIntersectionTreatsTouchingEdgesAsContact() - { - var left = new rect(0.0f, 0.0f, 4.0f, 4.0f); - var touching = new rect(4.0f, 1.0f, 8.0f, 3.0f); - var separate = new rect(4.001f, 1.0f, 8.0f, 3.0f); - - Assert.True(left.Intersects(touching)); - Assert.Equal(0.0f, left.Intersection(touching).area); - Assert.False(left.Intersects(separate)); - } - - [Fact] - public void FirstOrderInterceptSolvesSimpleClosingTarget() - { - var time = math.first_order_intercept_time( - 2.0f, - new float3(10.0f, 0.0f, 0.0f), - new float3(-1.0f, 0.0f, 0.0f)); - - Assert.Equal(10.0f / 3.0f, time, precision: 5); - } - - [Fact] - public void ValueNoiseIsDeterministicAndSmoothAtCellCorners() - { - var position = new float2(12.25f, -4.5f); - - Assert.Equal(math.value_noise(position), math.value_noise(position)); - Assert.Equal(math.hash(new float2(3.0f, 5.0f)), math.value_noise(new float2(3.0f, 5.0f)), precision: 5); - - var bicubic = math.value_noise_bicubic(position); - Assert.True(bicubic > -0.25f && bicubic < 1.25f); - } - - [Theory] - [InlineData(0.0f, 0.0f, 0.0f)] - [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); - } - - [Fact] - public void VoronoiBatchProducesToneMappedColors() - { - var xs = new[] { 0.0f, 24.0f, 96.0f }; - var ys = new[] { 0.0f, 36.0f, 144.0f }; - var tones = new[] { CultMathTone.Background, CultMathTone.Header, CultMathTone.Body }; - var spans = new[] { 1920.0f, 8.0f, 8.0f }; - var colors = new Color32[xs.Length]; - - Voronoi.SampleTones(xs, ys, tones, spans, 1080.0f, 12, colors); - - Assert.NotEqual(new Color32(), colors[0]); - Assert.True(colors[1].r > colors[1].g); - Assert.True(colors[2].r > 120 && colors[2].g > 120 && colors[2].b > 120); - } - - [Fact] - public void BatchRadialFalloffAccelerationMatchesScalarContract() - { - var xs = new[] { 0.0f, 5.0f, 10.0f }; - var ys = new[] { 0.0f, 0.0f, 0.0f }; - var ax = new float[xs.Length]; - var ay = new float[xs.Length]; - - BatchMath.AddRadialFalloffAcceleration2D(xs, ys, 10.0f, 0.0f, 10.0f, 20.0f, ax, ay); - - Assert.True(BatchMath.LaneCount >= 4); - Assert.Equal(5.0f, ax[0], precision: 5); - Assert.Equal(7.5f, ax[1], precision: 5); - Assert.Equal(0.0f, ax[2], precision: 5); - Assert.Equal(0.0f, ay[0], precision: 5); - } - - [Fact] - public void BatchEulerIntegrationHonorsDynamicMask() - { - var dynamicMask = new[] { 1.0f, 0.0f }; - var px = new[] { 0.0f, 0.0f }; - var py = new[] { 0.0f, 0.0f }; - var vx = new[] { 1.0f, 1.0f }; - var vy = new[] { 0.0f, 0.0f }; - var ax = new[] { 2.0f, 2.0f }; - var ay = new[] { 0.0f, 4.0f }; - - BatchMath.IntegrateSemiImplicitEuler2D(0.5f, dynamicMask, px, py, vx, vy, ax, ay); - - Assert.Equal(2.0f, vx[0], precision: 5); - Assert.Equal(1.0f, px[0], precision: 5); - Assert.Equal(1.0f, vx[1], precision: 5); - Assert.Equal(0.0f, px[1], precision: 5); - Assert.Equal(0.0f, py[1], precision: 5); - } -} diff --git a/packages/cultmath/tests/CultMath.Tests/MathTests.cs.meta b/packages/cultmath/tests/CultMath.Tests/MathTests.cs.meta deleted file mode 100644 index 277df5a3..00000000 --- a/packages/cultmath/tests/CultMath.Tests/MathTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: c7a709e5345da214aa650e8c796fcaff \ No newline at end of file diff --git a/packages/cultmath/tools.meta b/packages/cultmath/tools.meta deleted file mode 100644 index 3594fa6a..00000000 --- a/packages/cultmath/tools.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 697162910ffd9614ba9655d31af9afa4 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/tools/compile-hlsl-spirv.ps1 b/packages/cultmath/tools/compile-hlsl-spirv.ps1 deleted file mode 100644 index 280e08d5..00000000 --- a/packages/cultmath/tools/compile-hlsl-spirv.ps1 +++ /dev/null @@ -1,69 +0,0 @@ -param( - [Parameter(Mandatory = $true)] - [string]$ShaderPath, - - [Parameter(Mandatory = $true)] - [string]$OutputPath, - - [string]$EntryPoint = "main", - [string]$Profile = "cs_6_0", - [string[]]$IncludePath = @(), - [string]$TargetEnv = "", - [int]$VulkanCBufferShift = 0, - [int]$VulkanTextureShift = 10, - [int]$VulkanUavShift = 20, - [int]$VulkanSamplerShift = 30, - [string]$DxcPath = "" -) - -$ErrorActionPreference = "Stop" - -$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") -if (-not $DxcPath) { - $DxcPath = Join-Path $repoRoot ".tools\dxc\current\bin\x64\dxc.exe" -} - -if (-not (Test-Path $DxcPath)) { - throw "DXC not found at '$DxcPath'. Run tools\get-dxc.ps1 first or pass -DxcPath." -} - -$resolvedShader = Resolve-Path $ShaderPath -$resolvedIncludes = @((Join-Path $repoRoot "shaders")) -foreach ($path in $IncludePath) { - $resolvedIncludes += (Resolve-Path $path) -} - -$outputDirectory = Split-Path -Parent $OutputPath -if ($outputDirectory) { - New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null -} - -$arguments = @( - "-T", $Profile, - "-E", $EntryPoint, - "-spirv" -) - -if ($TargetEnv) { - $arguments += "-fspv-target-env=$TargetEnv" -} - -$arguments += @( - "-fvk-b-shift", $VulkanCBufferShift.ToString(), "0", - "-fvk-t-shift", $VulkanTextureShift.ToString(), "0", - "-fvk-u-shift", $VulkanUavShift.ToString(), "0", - "-fvk-s-shift", $VulkanSamplerShift.ToString(), "0" -) - -foreach ($include in $resolvedIncludes) { - $arguments += @("-I", $include) -} - -$arguments += @($resolvedShader, "-Fo", $OutputPath) - -& $DxcPath @arguments -if ($LASTEXITCODE -ne 0) { - throw "dxc.exe failed with exit code $LASTEXITCODE." -} - -Get-Item -LiteralPath $OutputPath diff --git a/packages/cultmath/tools/compile-hlsl-spirv.ps1.meta b/packages/cultmath/tools/compile-hlsl-spirv.ps1.meta deleted file mode 100644 index 31e7948a..00000000 --- a/packages/cultmath/tools/compile-hlsl-spirv.ps1.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: b47e7016a99a5d148bc44c399c13c4d3 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/tools/compile-spirv-metal.ps1 b/packages/cultmath/tools/compile-spirv-metal.ps1 deleted file mode 100644 index 490e6331..00000000 --- a/packages/cultmath/tools/compile-spirv-metal.ps1 +++ /dev/null @@ -1,34 +0,0 @@ -param( - [Parameter(Mandatory = $true)] - [string]$SpirvPath, - - [Parameter(Mandatory = $true)] - [string]$OutputPath, - - [int]$MslVersion = 23000, - [string]$SpirvCrossPath = "" -) - -$ErrorActionPreference = "Stop" - -$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") -if (-not $SpirvCrossPath) { - $SpirvCrossPath = Join-Path $repoRoot ".tools\spirv-cross\build\spirv-cross.exe" -} - -if (-not (Test-Path $SpirvCrossPath)) { - throw "SPIRV-Cross not found at '$SpirvCrossPath'. Run tools\get-spirv-cross.ps1 first or pass -SpirvCrossPath." -} - -$resolvedSpirv = Resolve-Path $SpirvPath -$outputDirectory = Split-Path -Parent $OutputPath -if ($outputDirectory) { - New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null -} - -& $SpirvCrossPath $resolvedSpirv --msl --msl-version $MslVersion --output $OutputPath -if ($LASTEXITCODE -ne 0) { - throw "spirv-cross failed with exit code $LASTEXITCODE." -} - -Get-Item -LiteralPath $OutputPath diff --git a/packages/cultmath/tools/compile-spirv-metal.ps1.meta b/packages/cultmath/tools/compile-spirv-metal.ps1.meta deleted file mode 100644 index 2e7e74c2..00000000 --- a/packages/cultmath/tools/compile-spirv-metal.ps1.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 6150c8f461e471b4c8641fd32d1984ef -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/tools/generate-swizzles.ps1 b/packages/cultmath/tools/generate-swizzles.ps1 deleted file mode 100644 index 9030f2be..00000000 --- a/packages/cultmath/tools/generate-swizzles.ps1 +++ /dev/null @@ -1,132 +0,0 @@ -param( - [string]$OutputPath = "" -) - -# Writes src\CultMath\Swizzles.g.cs: HLSL swizzles and mixed constructors for -# CultMath's vector types. The output is checked in; rerun after changing the -# family table below and commit both files together. - -$ErrorActionPreference = "Stop" - -if (-not $OutputPath) { - $OutputPath = Join-Path $PSScriptRoot "..\src\CultMath\Swizzles.g.cs" -} - -# Functions: emit math.(...) constructor functions. double has none because -# a math.double3 method group would hide the double3 type name under -# `using static CultMath.math;` and HLSL shader code does not construct doubles. -$families = @( - @{ Scalar = "float"; Sizes = @(2, 3, 4); Functions = $true }, - @{ Scalar = "int"; Sizes = @(2, 3, 4); Functions = $true }, - @{ Scalar = "bool"; Sizes = @(2, 3, 4); Functions = $true }, - @{ Scalar = "double"; Sizes = @(2, 3); Functions = $false } -) -$componentFields = @("x", "y", "z", "w") -$namings = @(@("x", "y", "z", "w"), @("r", "g", "b", "a")) - -function Get-Compositions([int]$total) { - # Ordered part sizes summing to $total, excluding the all-scalar form that the - # hand-written base constructor already owns. - $results = [System.Collections.Generic.List[object]]::new() - function Expand([int]$remaining, [int[]]$prefix) { - if ($remaining -eq 0) { - if (@($prefix | Where-Object { $_ -ne 1 }).Count -gt 0) { - $results.Add($prefix) - } - return - } - for ($part = 1; $part -le $remaining; $part++) { - Expand ($remaining - $part) ($prefix + $part) - } - } - Expand $total @() - return , $results -} - -$builder = [System.Text.StringBuilder]::new() -function Line([string]$text = "") { [void]$builder.Append($text).Append("`n") } - -Line "// " -Line "// Generated by tools/generate-swizzles.ps1. Do not edit by hand." -Line "// " -Line "" -Line "namespace CultMath;" - -$functionLines = [System.Collections.Generic.List[string]]::new() - -foreach ($family in $families) { - $scalar = $family.Scalar - $maxSize = ($family.Sizes | Measure-Object -Maximum).Maximum - foreach ($size in $family.Sizes) { - $type = "$scalar$size" - Line "" - Line "public partial struct $type" - Line "{" - - foreach ($composition in (Get-Compositions $size)) { - $parameters = @() - $arguments = @() - $offset = 0 - foreach ($part in $composition) { - $name = -join $componentFields[$offset..($offset + $part - 1)] - if ($part -eq 1) { - $parameters += "$scalar $name" - $arguments += $name - } else { - $parameters += "$scalar$part $name" - for ($i = 0; $i -lt $part; $i++) { $arguments += "$name.$($componentFields[$i])" } - } - $offset += $part - } - $parameterList = $parameters -join ", " - Line " public $type($parameterList) : this($($arguments -join ', ')) { }" - if ($family.Functions) { - $names = @($parameters | ForEach-Object { ($_ -split " ")[1] }) - $functionLines.Add(" public static $type $type($parameterList) => new($($names -join ', '));") - } - } - - # Single-component color aliases; x/y/z/w are the fields themselves. - $letters = $namings[1] - for ($i = 0; $i -lt $size; $i++) { - Line " public $scalar $($letters[$i]) { readonly get => $($componentFields[$i]); set => $($componentFields[$i]) = value; }" - } - - foreach ($letters in $namings) { - for ($length = 2; $length -le $maxSize; $length++) { - $count = [math]::Pow($size, $length) - for ($index = 0; $index -lt $count; $index++) { - [int[]]$digits = @() - [int]$remainder = $index - for ($d = 0; $d -lt $length; $d++) { - $digits = @([int]($remainder % $size)) + $digits - $remainder = [int][math]::Floor($remainder / $size) - } - $name = -join ($digits | ForEach-Object { $letters[$_] }) - $getter = ($digits | ForEach-Object { $componentFields[$_] }) -join ", " - $resultType = "$scalar$length" - $unique = @($digits | Select-Object -Unique).Count -eq $length - if ($unique) { - $assignments = for ($d = 0; $d -lt $length; $d++) { "$($componentFields[$digits[$d]]) = value.$($componentFields[$d]);" } - Line " public $resultType $name { readonly get => new($getter); set { $($assignments -join ' ') } }" - } else { - Line " public readonly $resultType $name => new($getter);" - } - } - } - } - - Line "}" - } -} - -Line "" -Line "public static partial class math" -Line "{" -foreach ($functionLine in $functionLines) { Line $functionLine } -Line "}" - -$fullPath = [System.IO.Path]::GetFullPath($OutputPath) -$content = $builder.ToString() -replace "`n", "`r`n" -[System.IO.File]::WriteAllText($fullPath, $content, [System.Text.UTF8Encoding]::new($false)) -Write-Host "Wrote $fullPath ($((Get-Item -LiteralPath $fullPath).Length) bytes)" diff --git a/packages/cultmath/tools/generate-swizzles.ps1.meta b/packages/cultmath/tools/generate-swizzles.ps1.meta deleted file mode 100644 index 74842405..00000000 --- a/packages/cultmath/tools/generate-swizzles.ps1.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: d7a796fd2d87489c9f055fd6e73513b5 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/tools/get-dxc.ps1 b/packages/cultmath/tools/get-dxc.ps1 deleted file mode 100644 index d9b12593..00000000 --- a/packages/cultmath/tools/get-dxc.ps1 +++ /dev/null @@ -1,43 +0,0 @@ -param( - [string]$InstallRoot = ".tools\dxc" -) - -$ErrorActionPreference = "Stop" - -$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") -$installPath = Join-Path $repoRoot $InstallRoot -New-Item -ItemType Directory -Force -Path $installPath | Out-Null - -# Pinned: parity means this compiler's lowering (dxcompiler 1.9.0.5402). The hash is the SHA256 -# of dxc_2026_07_29.zip downloaded from Microsoft's GitHub release v1.9.2607, and matches the -# digest GitHub publishes for that asset. -$zipName = "dxc_2026_07_29.zip" -$zipUrl = "https://github.com/microsoft/DirectXShaderCompiler/releases/download/v1.9.2607/$zipName" -$zipSha256 = "A1DFB116BA3EEAE6A1582291B53A8E7BF65AD760676BD3194685C8F7367CD241" - -$zipPath = Join-Path $installPath $zipName -Remove-Item -LiteralPath $zipPath -Force -ErrorAction SilentlyContinue - -& curl.exe -L --retry 5 --retry-delay 2 --fail -o $zipPath $zipUrl -if ($LASTEXITCODE -ne 0) { - throw "curl.exe failed with exit code $LASTEXITCODE." -} - -$actualSha256 = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash -if ($actualSha256 -ne $zipSha256) { - Remove-Item -LiteralPath $zipPath -Force - throw "$zipName SHA256 is $actualSha256, expected $zipSha256." -} - -$currentPath = Join-Path $installPath "current" -Remove-Item -LiteralPath $currentPath -Recurse -Force -ErrorAction SilentlyContinue -New-Item -ItemType Directory -Force -Path $currentPath | Out-Null -Expand-Archive -LiteralPath $zipPath -DestinationPath $currentPath -Force - -$dxcPath = Join-Path $currentPath "bin\x64\dxc.exe" -if (-not (Test-Path $dxcPath)) { - throw "DXC x64 executable was not found after extraction." -} - -& $dxcPath --version -Write-Host "DXC installed at $dxcPath" diff --git a/packages/cultmath/tools/get-dxc.ps1.meta b/packages/cultmath/tools/get-dxc.ps1.meta deleted file mode 100644 index c9ab0d82..00000000 --- a/packages/cultmath/tools/get-dxc.ps1.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 86d0b9a1006042745b7fff523831049c -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/tools/get-spirv-cross.ps1 b/packages/cultmath/tools/get-spirv-cross.ps1 deleted file mode 100644 index f5105b9a..00000000 --- a/packages/cultmath/tools/get-spirv-cross.ps1 +++ /dev/null @@ -1,34 +0,0 @@ -param( - [string]$InstallRoot = ".tools\spirv-cross" -) - -$ErrorActionPreference = "Stop" - -$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") -$installPath = Join-Path $repoRoot $InstallRoot -$sourcePath = Join-Path $installPath "src" -$buildPath = Join-Path $installPath "build" - -New-Item -ItemType Directory -Force -Path $installPath | Out-Null - -if (-not (Test-Path $sourcePath)) { - git clone --depth 1 https://github.com/KhronosGroup/SPIRV-Cross.git $sourcePath -} - -cmake ` - -S $sourcePath ` - -B $buildPath ` - -G Ninja ` - -DSPIRV_CROSS_CLI=ON ` - -DSPIRV_CROSS_ENABLE_TESTS=OFF ` - -DSPIRV_CROSS_ENABLE_C_API=OFF - -cmake --build $buildPath --target spirv-cross --config Release - -$spirvCrossPath = Join-Path $buildPath "spirv-cross.exe" -if (-not (Test-Path $spirvCrossPath)) { - throw "SPIRV-Cross executable was not found after build." -} - -& $spirvCrossPath --version -Write-Host "SPIRV-Cross installed at $spirvCrossPath" diff --git a/packages/cultmath/tools/get-spirv-cross.ps1.meta b/packages/cultmath/tools/get-spirv-cross.ps1.meta deleted file mode 100644 index 12ccdd8e..00000000 --- a/packages/cultmath/tools/get-spirv-cross.ps1.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: dcf41da07502f4b439f8fc5ad25d16c9 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/unity.meta b/packages/cultmath/unity.meta deleted file mode 100644 index de47a618..00000000 --- a/packages/cultmath/unity.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 366efeaef9f68224b80229332c7ecfc6 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/unity/org.gamecult.cultmath/LICENSE.md b/packages/cultmath/unity/org.gamecult.cultmath/LICENSE.md deleted file mode 100644 index 5e50f2d6..00000000 --- a/packages/cultmath/unity/org.gamecult.cultmath/LICENSE.md +++ /dev/null @@ -1,10 +0,0 @@ -# CultMath License - -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/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/LICENSE.md.meta b/packages/cultmath/unity/org.gamecult.cultmath/LICENSE.md.meta deleted file mode 100644 index c234cd28..00000000 --- a/packages/cultmath/unity/org.gamecult.cultmath/LICENSE.md.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 7172bce28532878409e57f5fbbb12719 \ No newline at end of file diff --git a/packages/cultmath/unity/org.gamecult.cultmath/README.md b/packages/cultmath/unity/org.gamecult.cultmath/README.md deleted file mode 100644 index 20e1840c..00000000 --- a/packages/cultmath/unity/org.gamecult.cultmath/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# CultMath for Unity - -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, 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 -`Vector2/3/4`, `Vector2Int/3Int`, `Quaternion`, and `Color`; `ToUnity()` on -`float2/3/4`, `int2/3`, and `quaternion`; `ToColor()` on `float4`. C# only -admits conversion operators on the source or target type, so these are -extension methods rather than implicit casts. Import `CultMath.UnityBridge` to -use them. - -Unity does not compile CultMath's repository source tree. That source uses the -current C# language and remains owned by the normal .NET project. The package -imports only the tracked auto-referenced precompiled assembly, so consumers do -not need `csc.rsp` language overrides or an asmdef propagation shim. - -Build and inspect the package from the repository root: - -```powershell -.\scripts\build-unity-package.ps1 -``` - -The builder fresh-builds CultMath for `netstandard2.1`, verifies the tracked -DLL/PDB and stable Unity metadata, and stages an inspectable package under -`artifacts/unity/org.gamecult.cultmath`. - -Consume the repository package with: - -```json -"org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath" -``` - -Production consumers should pin that URL to a commit or release tag. diff --git a/packages/cultmath/unity/org.gamecult.cultmath/README.md.meta b/packages/cultmath/unity/org.gamecult.cultmath/README.md.meta deleted file mode 100644 index 0ea270c2..00000000 --- a/packages/cultmath/unity/org.gamecult.cultmath/README.md.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: b7429934026d747489a3876fd15576e5 \ No newline at end of file diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime.meta b/packages/cultmath/unity/org.gamecult.cultmath/Runtime.meta deleted file mode 100644 index b0e76599..00000000 --- a/packages/cultmath/unity/org.gamecult.cultmath/Runtime.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: a31bf2bb35e54dd1ad1c765337682769 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins.meta b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins.meta deleted file mode 100644 index fe8acafa..00000000 --- a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: f84156013e814c14b73f668b6610b9e0 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll deleted file mode 100644 index bf712012..00000000 Binary files a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll and /dev/null differ diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll.meta b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll.meta deleted file mode 100644 index 917e3849..00000000 --- a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll.meta +++ /dev/null @@ -1,21 +0,0 @@ -fileFormatVersion: 2 -guid: 063b8a850f0f474aa7e89c4167f97a19 -PluginImporter: - externalObjects: {} - serializedVersion: 2 - iconMap: {} - executionOrder: {} - defineConstraints: [] - isPreloaded: 0 - isOverridable: 0 - isExplicitlyReferenced: 0 - validateReferences: 1 - platformData: - - first: - Any: - second: - enabled: 1 - settings: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb deleted file mode 100644 index 1eaadd23..00000000 Binary files a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb and /dev/null differ diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb.meta b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb.meta deleted file mode 100644 index e18fedbe..00000000 --- a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: a8cc555f643a4d7eb4dd340025b208f6 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge.meta b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge.meta deleted file mode 100644 index d34f2ef0..00000000 --- a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 01f9f472b0a645b1ab392567aa38911a -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge/CultMath.UnityBridge.asmdef b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge/CultMath.UnityBridge.asmdef deleted file mode 100644 index c54da7da..00000000 --- a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge/CultMath.UnityBridge.asmdef +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "CultMath.UnityBridge", - "rootNamespace": "CultMath.UnityBridge", - "references": [], - "includePlatforms": [], - "excludePlatforms": [], - "allowUnsafeCode": false, - "overrideReferences": true, - "precompiledReferences": [ - "CultMath.dll" - ], - "autoReferenced": true, - "defineConstraints": [], - "versionDefines": [], - "noEngineReferences": false -} diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge/CultMath.UnityBridge.asmdef.meta b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge/CultMath.UnityBridge.asmdef.meta deleted file mode 100644 index 6875b788..00000000 --- a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge/CultMath.UnityBridge.asmdef.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 77e50a05974842bc99d285ef6d5d04f7 -AssemblyDefinitionImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge/UnityConversions.cs b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge/UnityConversions.cs deleted file mode 100644 index c8289a77..00000000 --- a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge/UnityConversions.cs +++ /dev/null @@ -1,28 +0,0 @@ -using UnityEngine; - -namespace CultMath.UnityBridge -{ - /// - /// Explicit conversions between UnityEngine and CultMath value types. - /// C# only admits user-defined conversion operators declared on the source or target - /// type, and CultMath's core types stay engine-free, so the bridge is extension methods. - /// - public static class UnityConversions - { - public static float2 ToCultMath(this Vector2 value) => new float2(value.x, value.y); - public static float3 ToCultMath(this Vector3 value) => new float3(value.x, value.y, value.z); - public static float4 ToCultMath(this Vector4 value) => new float4(value.x, value.y, value.z, value.w); - public static float4 ToCultMath(this Color value) => new float4(value.r, value.g, value.b, value.a); - public static quaternion ToCultMath(this Quaternion value) => new quaternion(value.x, value.y, value.z, value.w); - public static int2 ToCultMath(this Vector2Int value) => new int2(value.x, value.y); - public static int3 ToCultMath(this Vector3Int value) => new int3(value.x, value.y, value.z); - - public static Vector2 ToUnity(this float2 value) => new Vector2(value.x, value.y); - public static Vector3 ToUnity(this float3 value) => new Vector3(value.x, value.y, value.z); - public static Vector4 ToUnity(this float4 value) => new Vector4(value.x, value.y, value.z, value.w); - public static Quaternion ToUnity(this quaternion value) => new Quaternion(value.x, value.y, value.z, value.w); - public static Vector2Int ToUnity(this int2 value) => new Vector2Int(value.x, value.y); - public static Vector3Int ToUnity(this int3 value) => new Vector3Int(value.x, value.y, value.z); - public static Color ToColor(this float4 value) => new Color(value.x, value.y, value.z, value.w); - } -} diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge/UnityConversions.cs.meta b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge/UnityConversions.cs.meta deleted file mode 100644 index ccbd3f10..00000000 --- a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/UnityBridge/UnityConversions.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 8fc742177b3f4715b204afe0e69a400c -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Shaders.meta b/packages/cultmath/unity/org.gamecult.cultmath/Shaders.meta deleted file mode 100644 index 1e7e7110..00000000 --- a/packages/cultmath/unity/org.gamecult.cultmath/Shaders.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: ca5d773061ed49b3a173526048d40cb3 -folderAsset: yes -DefaultImporter: - 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 deleted file mode 100644 index 365705f5..00000000 --- a/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl +++ /dev/null @@ -1,356 +0,0 @@ -#ifndef CULTMATH_HLSL -#define CULTMATH_HLSL - -// CultMath HLSL mirror. -// -// HLSL already owns the float2/float3/float4 type names and most primitive -// intrinsics. This include names the CultMath semantic surface explicitly so -// shader code, C# wrappers, and Rust/native parity fixtures can share the same -// small vocabulary without local helper drift. - -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); } - -float cultmath_frac(float value) { return value - floor(value); } -float2 cultmath_frac(float2 value) { return value - floor(value); } -float3 cultmath_frac(float3 value) { return value - floor(value); } -float4 cultmath_frac(float4 value) { return value - floor(value); } - -float cultmath_clamp(float value, float minimum, float maximum) { return min(max(value, minimum), maximum); } -float2 cultmath_clamp(float2 value, float2 minimum, float2 maximum) { return min(max(value, minimum), maximum); } -float3 cultmath_clamp(float3 value, float3 minimum, float3 maximum) { return min(max(value, minimum), maximum); } -float4 cultmath_clamp(float4 value, float4 minimum, float4 maximum) { return min(max(value, minimum), maximum); } - -// DXIL Saturate is FMin(1, FMax(0, x)); dxc lowers min/max to the same FMin/FMax, NaN rules included. -float cultmath_saturate(float value) { return min(1.0, max(0.0, value)); } -float2 cultmath_saturate(float2 value) { return min(float2(1.0, 1.0), max(float2(0.0, 0.0), value)); } -float3 cultmath_saturate(float3 value) { return min(float3(1.0, 1.0, 1.0), max(float3(0.0, 0.0, 0.0), value)); } -float4 cultmath_saturate(float4 value) { return min(float4(1.0, 1.0, 1.0, 1.0), max(float4(0.0, 0.0, 0.0, 0.0), value)); } - -float cultmath_lerp(float start, float end, float amount) { return start + (end - start) * amount; } -float2 cultmath_lerp(float2 start, float2 end, float2 amount) { return start + (end - start) * amount; } -float3 cultmath_lerp(float3 start, float3 end, float3 amount) { return start + (end - start) * amount; } -float4 cultmath_lerp(float4 start, float4 end, float4 amount) { return start + (end - start) * amount; } - -float cultmath_step(float edge, float value) { return value < edge ? 0.0 : 1.0; } -float2 cultmath_step(float2 edge, float2 value) { return float2(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y)); } -float3 cultmath_step(float3 edge, float3 value) { return float3(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z)); } -float4 cultmath_step(float4 edge, float4 value) { return float4(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z), cultmath_step(edge.w, value.w)); } - -float cultmath_smoothstep(float minimum, float maximum, float value) -{ - float t = cultmath_saturate((value - minimum) / (maximum - minimum)); - return t * t * (3.0 - 2.0 * t); -} - -float2 cultmath_smoothstep(float2 minimum, float2 maximum, float2 value) -{ - float2 t = cultmath_saturate((value - minimum) / (maximum - minimum)); - return t * t * (float2(3.0, 3.0) - float2(2.0, 2.0) * t); -} - -float3 cultmath_smoothstep(float3 minimum, float3 maximum, float3 value) -{ - float3 t = cultmath_saturate((value - minimum) / (maximum - minimum)); - return t * t * (float3(3.0, 3.0, 3.0) - float3(2.0, 2.0, 2.0) * t); -} - -float4 cultmath_smoothstep(float4 minimum, float4 maximum, float4 value) -{ - float4 t = cultmath_saturate((value - minimum) / (maximum - minimum)); - return t * t * (float4(3.0, 3.0, 3.0, 3.0) - float4(2.0, 2.0, 2.0, 2.0) * t); -} - -float cultmath_smoothstep01(float value) { return cultmath_smoothstep(0.0, 1.0, value); } - -float cultmath_smootherstep(float value) -{ - value = cultmath_saturate(value); - return value * value * value * (value * (value * 6.0 - 15.0) + 10.0); -} - -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) + 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. -float cultmath_snoise(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 m = max(0.6 - float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); - m = m * m; - return 42.0 * dot(m * m, float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3))); -} - -float cultmath_snoise(float2 value) -{ - float4 c = float4( - 0.211324865405187, - 0.366025403784439, - -0.577350269189626, - 0.024390243902439); - float2 i = floor(value + dot(value, c.yy)); - float2 x0 = value - i + dot(i, c.xx); - float2 i1 = x0.x > x0.y ? float2(1.0, 0.0) : float2(0.0, 1.0); - float4 x12 = float4(x0.x + c.x - i1.x, x0.y + c.x - i1.y, x0.x + c.z, x0.y + c.z); - - i = cultmath_snoise_mod289(i); - float3 p = cultmath_snoise_permute( - cultmath_snoise_permute(i.y + float3(0.0, i1.y, 1.0)) + i.x + float3(0.0, i1.x, 1.0)); - float3 m = max(0.5 - float3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0); - m *= m; - m *= m; - - float3 x = 2.0 * frac(p * c.w) - 1.0; - float3 h = abs(x) - 0.5; - float3 ox = floor(x + 0.5); - float3 a0 = x - ox; - m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h); - - float3 g = float3( - a0.x * x0.x + h.x * x0.y, - a0.y * x12.x + h.y * x12.y, - a0.z * x12.z + h.z * x12.w); - return 130.0 * dot(m, g); -} - -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); } - -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; -} - -float3 cultmath_reflect(float3 incident, float3 normal_value) -{ - return incident - 2.0 * dot(normal_value, incident) * normal_value; -} - -float2 cultmath_rotate(float2 value, float radians_value) -{ - float s = sin(radians_value); - float c = cos(radians_value); - return float2(value.x * c - value.y * s, value.x * s + value.y * c); -} - -float2 cultmath_rotate_degrees(float2 value, float degrees_value) -{ - return cultmath_rotate(value, cultmath_radians(degrees_value)); -} - -float cultmath_csum(float2 value) { return value.x + value.y; } -float cultmath_csum(float3 value) { return value.x + value.y + value.z; } -float cultmath_csum(float4 value) { return value.x + value.y + value.z + value.w; } - -float cultmath_decay(float source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } -float2 cultmath_decay(float2 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } -float3 cultmath_decay(float3 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } -float4 cultmath_decay(float4 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } - -float cultmath_damp(float start, float end, float lambda_value, float dt) -{ - return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); -} - -float2 cultmath_damp(float2 start, float2 end, float lambda_value, float dt) -{ - return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); -} - -float3 cultmath_damp(float3 start, float3 end, float lambda_value, float dt) -{ - return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); -} - -float4 cultmath_damp(float4 start, float4 end, float lambda_value, float dt) -{ - return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); -} - -float cultmath_catmullrom(float p0, float p1, float p2, float p3, float t) -{ - float t2 = t * t; - float t3 = t2 * t; - return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); -} - -float2 cultmath_catmullrom(float2 p0, float2 p1, float2 p2, float2 p3, float t) -{ - float t2 = t * t; - float t3 = t2 * t; - return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); -} - -float3 cultmath_catmullrom(float3 p0, float3 p1, float3 p2, float3 p3, float t) -{ - float t2 = t * t; - float t3 = t2 * t; - return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); -} - -float4 cultmath_catmullrom(float4 p0, float4 p1, float4 p2, float4 p3, float t) -{ - float t2 = t * t; - float t3 = t2 * t; - return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); -} - -float cultmath_quadratic_bezier(float p0, float p1, float p2, float t) -{ - t = cultmath_saturate(t); - float inv = 1.0 - t; - return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2; -} - -float3 cultmath_quadratic_bezier(float3 p0, float3 p1, float3 p2, float t) -{ - t = cultmath_saturate(t); - float inv = 1.0 - t; - return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2; -} - -float cultmath_cubic_bezier(float p0, float p1, float p2, float p3, float t) -{ - t = cultmath_saturate(t); - float inv = 1.0 - t; - return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3; -} - -float3 cultmath_cubic_bezier(float3 p0, float3 p1, float3 p2, float3 p3, float t) -{ - t = cultmath_saturate(t); - float inv = 1.0 - t; - return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3; -} - -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))); } - -float cultmath_value_noise(float2 position) -{ - float2 cell = floor(position); - float2 local = cultmath_frac(position); - float2 u = local * local * (float2(3.0, 3.0) - float2(2.0, 2.0) * local); - - float a = cultmath_hash(cell); - float b = cultmath_hash(cell + float2(1.0, 0.0)); - float c = cultmath_hash(cell + float2(0.0, 1.0)); - float d = cultmath_hash(cell + float2(1.0, 1.0)); - - return cultmath_lerp(cultmath_lerp(a, b, u.x), cultmath_lerp(c, d, u.x), u.y); -} - -float cultmath_value_noise_bicubic(float2 position) -{ - float2 cell = floor(position); - float2 local = cultmath_frac(position); - - float y0 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, -1.0)), cultmath_hash(cell + float2(0.0, -1.0)), cultmath_hash(cell + float2(1.0, -1.0)), cultmath_hash(cell + float2(2.0, -1.0)), local.x); - float y1 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, 0.0)), cultmath_hash(cell + float2(0.0, 0.0)), cultmath_hash(cell + float2(1.0, 0.0)), cultmath_hash(cell + float2(2.0, 0.0)), local.x); - float y2 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, 1.0)), cultmath_hash(cell + float2(0.0, 1.0)), cultmath_hash(cell + float2(1.0, 1.0)), cultmath_hash(cell + float2(2.0, 1.0)), local.x); - float y3 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, 2.0)), cultmath_hash(cell + float2(0.0, 2.0)), cultmath_hash(cell + float2(1.0, 2.0)), cultmath_hash(cell + float2(2.0, 2.0)), local.x); - - return cultmath_catmullrom(y0, y1, y2, y3, local.y); -} - -float cultmath_value_noise_texture(Texture2D noise_texture, SamplerState noise_sampler, float2 uv, float scale) -{ - return noise_texture.SampleLevel(noise_sampler, uv * scale, 0.0).r; -} - -float cultmath_value_noise_texture_bicubic(Texture2D noise_texture, SamplerState noise_sampler, float2 uv, float scale, float2 texel_size) -{ - float2 position = uv * scale; - float2 cell = floor(position); - float2 local = cultmath_frac(position); - float2 base_uv = cell / scale; - - float row0 = cultmath_catmullrom( - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, -1.0), 0.0).r, - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, -1.0), 0.0).r, - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, -1.0), 0.0).r, - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, -1.0), 0.0).r, - local.x); - float row1 = cultmath_catmullrom( - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, 0.0), 0.0).r, - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, 0.0), 0.0).r, - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, 0.0), 0.0).r, - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, 0.0), 0.0).r, - local.x); - float row2 = cultmath_catmullrom( - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, 1.0), 0.0).r, - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, 1.0), 0.0).r, - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, 1.0), 0.0).r, - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, 1.0), 0.0).r, - local.x); - float row3 = cultmath_catmullrom( - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, 2.0), 0.0).r, - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, 2.0), 0.0).r, - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, 2.0), 0.0).r, - noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, 2.0), 0.0).r, - local.x); - - return cultmath_catmullrom(row0, row1, row2, row3, local.y); -} - -#endif diff --git a/packages/cultmath/unity/org.gamecult.cultmath/THIRD-PARTY-NOTICES.md b/packages/cultmath/unity/org.gamecult.cultmath/THIRD-PARTY-NOTICES.md deleted file mode 100644 index 06ebb6c3..00000000 --- a/packages/cultmath/unity/org.gamecult.cultmath/THIRD-PARTY-NOTICES.md +++ /dev/null @@ -1,11 +0,0 @@ -# Third-Party Notices - -## 2D and 3D Simplex Noise - -`math.snoise` and `cultmath_snoise` port the textureless 2D and 3D -simplex-noise functions by Ian McEwan and Ashima Arts, as distributed by Stefan -Gustavson's `webgl-noise` project and Unity Mathematics. - -- Copyright 2011 Ashima Arts. -- Upstream: -- License: MIT. diff --git a/packages/cultmath/unity/org.gamecult.cultmath/THIRD-PARTY-NOTICES.md.meta b/packages/cultmath/unity/org.gamecult.cultmath/THIRD-PARTY-NOTICES.md.meta deleted file mode 100644 index a53f08d8..00000000 --- a/packages/cultmath/unity/org.gamecult.cultmath/THIRD-PARTY-NOTICES.md.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 0c88669e4d1db604588be2a726b7d231 \ No newline at end of file diff --git a/packages/cultmath/unity/org.gamecult.cultmath/package.json b/packages/cultmath/unity/org.gamecult.cultmath/package.json deleted file mode 100644 index c09bf6bb..00000000 --- a/packages/cultmath/unity/org.gamecult.cultmath/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "org.gamecult.cultmath", - "displayName": "CultMath", - "version": "0.2.0", - "unity": "2021.3", - "description": "Precompiled CultMath numeric primitives and matching HLSL semantics for GameCult Unity runtimes.", - "license": "MPL-2.0", - "author": { - "name": "GameCult" - }, - "keywords": [ - "GameCult", - "Math", - "HLSL", - "Simulation" - ] -} diff --git a/packages/cultmath/unity/org.gamecult.cultmath/package.json.meta b/packages/cultmath/unity/org.gamecult.cultmath/package.json.meta deleted file mode 100644 index 337ba716..00000000 --- a/packages/cultmath/unity/org.gamecult.cultmath/package.json.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 33d8d4af49bf0f946ad00a375f26b1d1 \ No newline at end of file diff --git a/packages/cultmath/web.meta b/packages/cultmath/web.meta deleted file mode 100644 index d65ff3e6..00000000 --- a/packages/cultmath/web.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: daf45471f8b5f4f44a2cb86e77e010db -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/cultmesh-browser/README.md b/packages/cultmesh-browser/README.md deleted file mode 100644 index 59a44016..00000000 --- a/packages/cultmesh-browser/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# CultMesh Browser - -`cultmesh-browser` is the browser-safe CultMesh client boundary. It owns one -WebSocket session for a stable Verse/authority-runtime target, retains explicit raw -document leases across route replacement, and correlates typed CultNet -operations with provider receipts. - -It does not own application state and it does not expose direct document -writes. A renderer watches provider-owned records and invokes provider-owned -operations. - -```ts -const odin = new CultMeshBrowserOdinRendezvous({ - endpoints: ["wss://odin.example/verse"], - runtimeId: "counter-browser.discovery", -}); - -const mesh = await CultMeshBrowserClient.connect({ - verseId: "sample.counter", - authorityRuntimeId: "sample.counter-daemon", - runtimeId: "counter-browser", - rendezvous: odin, -}); - -const counter = await mesh.leaseRawDocument({ - schemaId: "sample.counter_state.v1", - recordKey: "counter:main", -}); - -counter.watch(record => renderCounter(decodeCultNetPayload(record))); -``` - -`invoke(...)` returns the correlated provider response. Routing, schema, and -envelope failures reject immediately with `CultMeshBrowserOperationError`; -callers can branch on its stable `status` and `code` without parsing prose or -waiting for the request timeout. Domain rejections remain typed provider -responses because the domain still owns their receipt schema. - -`CultMeshBrowserOdinRendezvous` speaks the canonical CultNet Verse-catalog -messages to one or more configured Odin WebSocket endpoints. Application code -supplies the Verse and authority-runtime identity; it does not own endpoint -selection, reconnect, or resubscription. This is deliberately distinct from an -Eve surface's `providerId`, which identifies the UI provider inside the Verse. -The physical socket is a replaceable route, not either stable identity. - -Odin returns exact authority-route bindings. The browser selects only routes -bound to the requested authority and `cultmesh.documents.v1`, then performs a -session-open handshake. It does not report `connected` until the peer proves -the exact Verse, authority runtime, protocol, and route generation. Operation -responses must also name that authority. Legacy catalogs are accepted only -when one authority runtime makes the endpoint binding unambiguous. - -The browser handshake carries authentication through the host application's -ordinary secure cookie or equivalent upgrade credential. The C# host adapter -rejects endpoints without an authorization predicate unless anonymous local -development is explicitly enabled. - -Run `scripts/verify-eve-browser-network.mjs --eve-root ` for the -real Chromium + C# headless chronology. The witness publishes a lower-priority -decoy endpoint owned by another runtime and proves that neither client connects -to it before and after route rotation. diff --git a/packages/cultmesh-browser/package.json b/packages/cultmesh-browser/package.json deleted file mode 100644 index c49062b0..00000000 --- a/packages/cultmesh-browser/package.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "name": "cultmesh-browser", - "version": "0.3.0", - "description": "Browser-safe CultMesh document leases and typed operations over CultNet WebSocket transport.", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "default": "./dist/index.js" - } - }, - "files": [ - "dist", - "README.md" - ], - "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 --test dist-test/test/*.test.js" - }, - "keywords": [ - "cultmesh", - "cultnet", - "browser", - "websocket", - "gamecult" - ], - "author": "metacratic", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/GameCult/CultLib.git", - "directory": "packages/cultmesh-browser" - }, - "engines": { - "node": ">=18" - }, - "dependencies": { - "@msgpack/msgpack": "^3.1.3", - "cultnet-ts": "^0.2.0" - }, - "devDependencies": { - "@types/node": "^24.0.0", - "typescript": "^5.9.0" - } -} diff --git a/packages/cultmesh-browser/src/index.ts b/packages/cultmesh-browser/src/index.ts deleted file mode 100644 index 4dddfa7d..00000000 --- a/packages/cultmesh-browser/src/index.ts +++ /dev/null @@ -1,1076 +0,0 @@ -import { decode, encode } from "@msgpack/msgpack"; -import { - encodeCultNetMessageForWire, - parseCultNetMessage, - type CultNetDatabaseChangeRawMessage, - type CultNetDatabaseSubscribeMessage, - type CultNetDatabaseUnsubscribeMessage, - type CultNetMessage, - type CultNetOperationRequestMessage, - type CultNetOperationResponseMessage, - type CultNetRawDocumentRecord, - type CultNetSnapshotResponseRawMessage, - type CultMeshVerseCatalogRequestMessage, - type CultMeshVerseCatalogResponseMessage, - type CultMeshSessionAcceptedMessage, - type CultMeshSessionOpenMessage, -} from "cultnet-ts/contracts"; - -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( - identity: CultMeshBrowserIdentity, - trust?: CultMeshBrowserAuthorityTrustPolicy, - ): Promise; -} - -export interface CultMeshBrowserOdinRendezvousOptions { - endpoints: readonly string[]; - runtimeId: string; - transportVersion?: string; - timeoutMs?: number; - maxFrameBytes?: number; - createId?: () => string; - socketFactory?: (endpoint: string) => CultMeshBrowserSocket; -} - -export interface CultMeshBrowserSocket { - binaryType: BinaryType; - readonly readyState: number; - onopen: ((event: Event) => void) | null; - onmessage: ((event: MessageEvent) => void) | null; - onerror: ((event: Event) => void) | null; - onclose: ((event: CloseEvent) => void) | null; - send(data: ArrayBuffer | ArrayBufferView): void; - close(code?: number, reason?: string): void; -} - -/** Resolves a stable Verse/authority-runtime target through Odin's canonical CultNet Verse catalog. */ -export class CultMeshBrowserOdinRendezvous implements CultMeshBrowserRendezvous { - #options: Required> & { - transportVersion?: string; - socketFactory: (endpoint: string) => CultMeshBrowserSocket; - }; - - constructor(options: CultMeshBrowserOdinRendezvousOptions) { - requireText(options.runtimeId, "runtimeId"); - const endpoints = [...new Set(options.endpoints.map(value => value.trim()).filter(Boolean))]; - if (endpoints.length === 0) throw new Error("At least one Odin WebSocket endpoint is required."); - for (const endpoint of endpoints) requireWebSocketEndpoint(endpoint, "Odin rendezvous endpoint"); - this.#options = { - ...options, - endpoints, - timeoutMs: options.timeoutMs ?? 10_000, - maxFrameBytes: options.maxFrameBytes ?? 4 * 1024 * 1024, - createId: options.createId ?? (() => crypto.randomUUID()), - socketFactory: options.socketFactory ?? (endpoint => new WebSocket(endpoint)), - }; - if (this.#options.timeoutMs <= 0) throw new Error("Odin rendezvous timeoutMs must be positive."); - if (this.#options.maxFrameBytes <= 0) throw new Error("Odin rendezvous maxFrameBytes must be positive."); - } - - async resolve( - identity: CultMeshBrowserIdentity, - trust: CultMeshBrowserAuthorityTrustPolicy = { mode: "authenticated-remote", odinRoots: [] }, - ): Promise { - requireText(identity.verseId, "verseId"); - requireText(identity.authorityRuntimeId, "authorityRuntimeId"); - const failures: Error[] = []; - for (const endpoint of this.#options.endpoints) { - try { - return await this.resolveFrom(endpoint, identity, trust); - } catch (error) { - failures.push(error instanceof Error ? error : new Error(String(error))); - } - } - throw new AggregateError( - failures, - `Odin could not resolve Verse '${identity.verseId}' authority runtime '${identity.authorityRuntimeId}'.`, - ); - } - - private resolveFrom( - endpoint: string, - identity: CultMeshBrowserIdentity, - trust: CultMeshBrowserAuthorityTrustPolicy, - ): Promise { - const socket = this.#options.socketFactory(endpoint); - socket.binaryType = "arraybuffer"; - const messageId = this.#options.createId(); - const request: CultMeshVerseCatalogRequestMessage = { - schemaVersion: "cultmesh.verse_catalog_request.v0", - messageId, - verseIds: [identity.verseId], - ...(this.#options.transportVersion ? { transportVersion: this.#options.transportVersion } : {}), - }; - return new Promise((resolve, reject) => { - let settled = false; - const finish = (error?: Error, route?: CultMeshBrowserRoute) => { - if (settled) return; - settled = true; - clearTimeout(timer); - socket.onopen = null; - socket.onmessage = null; - socket.onerror = null; - socket.onclose = null; - socket.close(1000, error ? "Odin resolution failed" : "Odin resolution complete"); - if (error) reject(error); - else resolve(route!); - }; - const timer = setTimeout( - () => finish(new Error(`Odin resolution timed out at '${endpoint}'.`)), - this.#options.timeoutMs, - ); - socket.onopen = () => { - try { - sendSocketMessage(socket, { - schemaVersion: "cultnet.hello.v0", - runtimeId: this.#options.runtimeId, - runtimeKind: "browser", - supportedMessageVersions: [ - "cultmesh.verse_catalog_request.v0", - "cultmesh.verse_catalog_response.v0", - ], - }, this.#options.maxFrameBytes); - sendSocketMessage(socket, request, this.#options.maxFrameBytes); - } catch (error) { - finish(error instanceof Error ? error : new Error(String(error))); - } - }; - socket.onmessage = event => { - void decodeSocketMessage(event, this.#options.maxFrameBytes).then(message => { - if (message.schemaVersion === "cultnet.error.v0") { - finish(new Error(message.error)); - return; - } - if (message.schemaVersion !== "cultmesh.verse_catalog_response.v0" || message.messageId !== messageId) return; - void selectOdinRoute(message, identity, trust) - .then(route => finish(undefined, route)) - .catch(error => finish(error instanceof Error ? error : new Error(String(error)))); - }).catch(error => finish(error instanceof Error ? error : new Error(String(error)))); - }; - socket.onerror = () => finish(new Error(`Odin WebSocket could not open '${endpoint}'.`)); - socket.onclose = () => finish(new Error(`Odin WebSocket closed before resolving '${identity.verseId}'.`)); - }); - } -} - -export interface CultMeshBrowserClientOptions extends CultMeshBrowserIdentity { - runtimeId: string; - rendezvous: CultMeshBrowserRendezvous; - socketFactory?: (endpoint: string) => CultMeshBrowserSocket; - reconnectDelayMs?: number; - connectTimeoutMs?: number; - requestTimeoutMs?: number; - maxFrameBytes?: number; - createId?: () => string; - /** Consumer-owned authority roots. Remote sessions fail closed when omitted. */ - trust?: CultMeshBrowserAuthorityTrustPolicy; -} - -export interface CultMeshRawDocumentLeaseOptions { - schemaId: string; - recordKey: string; - subscriptionId?: string; -} - -export interface CultMeshOperationOptions { - serviceId: string; - operation: string; - payloadSchema: string; - payload: unknown; - idempotencyKey?: string; - /** @deprecated The connected authority is the operation target. A different value is rejected. */ - targetRuntimeId?: string; -} - -/** Portable schema returned when routing or envelope validation fails before a domain reply exists. */ -export const cultNetOperationFailureSchema = "gamecult.cultnet.operation_failure.v1"; - -export interface CultNetOperationFailure { - code: string; - message: string; -} - -/** Correlated framework-level failure returned by a CultMesh operation provider. */ -export class CultMeshBrowserOperationError extends Error { - readonly status: string; - readonly code: string; - readonly diagnostics: readonly string[]; - - constructor(response: CultNetOperationResponseMessage, failure: CultNetOperationFailure) { - super(failure.message || "CultMesh operation failed."); - this.name = "CultMeshBrowserOperationError"; - this.status = response.status || "error"; - this.code = failure.code || "operation-error"; - this.diagnostics = response.diagnostics ?? []; - } -} - -export type CultMeshConnectionState = "connecting" | "connected" | "reconnecting" | "disposed"; - -type DocumentWatcher = (record: CultNetRawDocumentRecord | undefined) => void; - -interface PendingSnapshot { - lease: CultMeshRawDocumentLease; - resolve: () => void; - reject: (error: Error) => void; - timer: ReturnType; -} - -interface PendingOperation { - request: CultNetOperationRequestMessage; - resolve: (response: CultNetOperationResponseMessage) => void; - reject: (error: Error) => void; - timer: ReturnType; -} - -export class CultMeshRawDocumentLease implements Disposable { - readonly schemaId: string; - readonly recordKey: string; - readonly subscriptionId: string; - #client: CultMeshBrowserClient | undefined; - #current: CultNetRawDocumentRecord | undefined; - #watchers = new Set(); - - constructor( - client: CultMeshBrowserClient, - options: Required, - ) { - this.#client = client; - this.schemaId = options.schemaId; - this.recordKey = options.recordKey; - this.subscriptionId = options.subscriptionId; - } - - get current(): CultNetRawDocumentRecord | undefined { - return this.#current; - } - - watch(watcher: DocumentWatcher): () => void { - if (!this.#client) throw new Error("CultMesh document lease is disposed."); - this.#watchers.add(watcher); - watcher(this.#current); - return () => this.#watchers.delete(watcher); - } - - dispose(): void { - this.#client?.releaseLease(this); - this.#client = undefined; - this.#watchers.clear(); - } - - [Symbol.dispose](): void { - this.dispose(); - } - - apply(record: CultNetRawDocumentRecord | undefined): void { - this.#current = record; - for (const watcher of this.#watchers) watcher(record); - } -} - -export class CultMeshBrowserClient implements AsyncDisposable { - readonly identity: CultMeshBrowserIdentity; - readonly runtimeId: string; - #options: Required> & { - socketFactory: (endpoint: string) => CultMeshBrowserSocket; - }; - #socket: CultMeshBrowserSocket | undefined; - #socketGeneration = 0; - #state: CultMeshConnectionState = "connecting"; - #disposed = false; - #reconnectTimer: ReturnType | undefined; - #connectPromise: Promise | undefined; - #leases = new Map(); - #openingLeases = new Map(); - #pendingSnapshots = new Map(); - #pendingOperations = new Map(); - #stateWatchers = new Set<(state: CultMeshConnectionState) => void>(); - - private constructor(options: CultMeshBrowserClientOptions) { - requireText(options.verseId, "verseId"); - requireText(options.authorityRuntimeId, "authorityRuntimeId"); - requireText(options.runtimeId, "runtimeId"); - this.identity = { verseId: options.verseId, authorityRuntimeId: options.authorityRuntimeId }; - this.runtimeId = options.runtimeId; - this.#options = { - ...options, - reconnectDelayMs: options.reconnectDelayMs ?? 250, - connectTimeoutMs: options.connectTimeoutMs ?? 10_000, - requestTimeoutMs: options.requestTimeoutMs ?? 10_000, - maxFrameBytes: options.maxFrameBytes ?? 4 * 1024 * 1024, - createId: options.createId ?? (() => crypto.randomUUID()), - socketFactory: options.socketFactory ?? (endpoint => new WebSocket(endpoint)), - trust: options.trust ?? { mode: "authenticated-remote", odinRoots: [] }, - }; - } - - static async connect(options: CultMeshBrowserClientOptions): Promise { - const client = new CultMeshBrowserClient(options); - await client.connectSocket(); - return client; - } - - get state(): CultMeshConnectionState { - return this.#state; - } - - watchState(watcher: (state: CultMeshConnectionState) => void): () => void { - this.#stateWatchers.add(watcher); - watcher(this.#state); - return () => this.#stateWatchers.delete(watcher); - } - - async leaseRawDocument(options: CultMeshRawDocumentLeaseOptions): Promise { - this.throwIfDisposed(); - requireText(options.schemaId, "schemaId"); - requireText(options.recordKey, "recordKey"); - const subscriptionId = options.subscriptionId ?? `lease:${this.#options.createId()}`; - if (this.#leases.has(subscriptionId) || this.#openingLeases.has(subscriptionId)) { - throw new Error(`CultMesh subscription '${subscriptionId}' is already leased.`); - } - const lease = new CultMeshRawDocumentLease(this, { ...options, subscriptionId }); - this.#openingLeases.set(subscriptionId, lease); - try { - await this.subscribe(lease); - this.#openingLeases.delete(subscriptionId); - this.#leases.set(subscriptionId, lease); - return lease; - } catch (error) { - this.#openingLeases.delete(subscriptionId); - throw error; - } - } - - async invoke(options: CultMeshOperationOptions): Promise { - this.throwIfDisposed(); - requireText(options.serviceId, "serviceId"); - requireText(options.operation, "operation"); - requireText(options.payloadSchema, "payloadSchema"); - if (options.targetRuntimeId && options.targetRuntimeId !== this.identity.authorityRuntimeId) { - throw new Error( - `CultMesh operation target '${options.targetRuntimeId}' does not match connected authority '${this.identity.authorityRuntimeId}'.`, - ); - } - await this.ensureConnected(); - const messageId = options.idempotencyKey ?? this.#options.createId(); - requireText(messageId, "operation idempotencyKey"); - if (this.#pendingOperations.has(messageId)) { - throw new Error(`CultMesh operation '${messageId}' is already in flight.`); - } - const request: CultNetOperationRequestMessage = { - schemaVersion: "cultnet.operation_request.v0", - messageId, - serviceId: options.serviceId, - operation: options.operation, - payloadSchema: options.payloadSchema, - payloadEncoding: "messagepack-base64", - payload: bytesToBase64(encode(options.payload)), - sourceRuntimeId: this.runtimeId, - targetRuntimeId: this.identity.authorityRuntimeId, - }; - const response = new Promise((resolve, reject) => { - const timer = setTimeout(() => { - if (!this.#pendingOperations.delete(messageId)) return; - reject(new Error(`CultMesh operation '${messageId}' timed out.`)); - }, this.#options.requestTimeoutMs); - this.#pendingOperations.set(messageId, { request, resolve, reject, timer }); - }); - try { - this.send(request); - } catch (error) { - this.scheduleReconnect(); - } - return response; - } - - async refreshRoute(): Promise { - this.throwIfDisposed(); - this.#socketGeneration++; - const socket = this.#socket; - this.#socket = undefined; - socket?.close(1012, "CultMesh route refresh"); - await this.connectSocket(); - } - - releaseLease(lease: CultMeshRawDocumentLease): void { - if (this.#leases.get(lease.subscriptionId) !== lease) return; - this.#leases.delete(lease.subscriptionId); - for (const [messageId, pending] of this.#pendingSnapshots) { - if (pending.lease !== lease) continue; - this.#pendingSnapshots.delete(messageId); - clearTimeout(pending.timer); - pending.reject(new Error("CultMesh document lease was disposed before its snapshot arrived.")); - } - if (this.#state === "connected") { - const message: CultNetDatabaseUnsubscribeMessage = { - schemaVersion: "cultnet.database_unsubscribe.v0", - messageId: this.#options.createId(), - subscriptionId: lease.subscriptionId, - }; - this.send(message); - } - } - - async dispose(): Promise { - if (this.#disposed) return; - this.#disposed = true; - this.setState("disposed"); - if (this.#reconnectTimer) clearTimeout(this.#reconnectTimer); - this.#reconnectTimer = undefined; - const error = new Error("CultMesh browser client was disposed."); - for (const pending of this.#pendingSnapshots.values()) { - clearTimeout(pending.timer); - pending.reject(error); - } - for (const pending of this.#pendingOperations.values()) { - clearTimeout(pending.timer); - pending.reject(error); - } - this.#pendingSnapshots.clear(); - this.#pendingOperations.clear(); - this.#leases.clear(); - this.#openingLeases.clear(); - const socket = this.#socket; - this.#socket = undefined; - socket?.close(1000, "CultMesh client disposed"); - } - - async [Symbol.asyncDispose](): Promise { - await this.dispose(); - } - - private async subscribe(lease: CultMeshRawDocumentLease): Promise { - await this.ensureConnected(); - const messageId = this.#options.createId(); - const ready = new Promise((resolve, reject) => { - const timer = setTimeout(() => { - if (!this.#pendingSnapshots.delete(messageId)) return; - reject(new Error(`CultMesh subscription '${lease.subscriptionId}' timed out.`)); - }, this.#options.requestTimeoutMs); - this.#pendingSnapshots.set(messageId, { lease, resolve, reject, timer }); - }); - const request: CultNetDatabaseSubscribeMessage = { - schemaVersion: "cultnet.database_subscribe.v0", - messageId, - subscriptionId: lease.subscriptionId, - schemaIds: [lease.schemaId], - recordKeys: [lease.recordKey], - includeSnapshot: true, - consumerRuntimeId: this.runtimeId, - }; - try { - this.send(request); - } catch (error) { - const pending = this.#pendingSnapshots.get(messageId); - if (pending) clearTimeout(pending.timer); - this.#pendingSnapshots.delete(messageId); - throw error; - } - return ready; - } - - private ensureConnected(): Promise { - if (this.#state === "connected" && this.#socket?.readyState === 1) return Promise.resolve(); - return this.connectSocket(); - } - - private connectSocket(): Promise { - this.throwIfDisposed(); - if (this.#state === "connected" && this.#socket?.readyState === 1) return Promise.resolve(); - if (this.#reconnectTimer) { - clearTimeout(this.#reconnectTimer); - this.#reconnectTimer = undefined; - } - if (this.#connectPromise) return this.#connectPromise; - const generation = ++this.#socketGeneration; - this.setState(this.#socket ? "reconnecting" : "connecting"); - this.#connectPromise = this.openSocket(generation).finally(() => { - this.#connectPromise = undefined; - }); - return this.#connectPromise; - } - - private async openSocket(generation: number): Promise { - const route = await this.#options.rendezvous.resolve(this.identity, this.#options.trust); - if (route.verseId !== this.identity.verseId || - route.authorityRuntimeId !== this.identity.authorityRuntimeId) { - throw new Error("CultMesh rendezvous returned a route for the wrong stable identity."); - } - await verifyAuthorityRoute(route, this.#options.trust); - const endpoint = new URL(route.endpoint); - if (endpoint.protocol !== "ws:" && endpoint.protocol !== "wss:") { - throw new Error(`CultMesh browser route must use ws:// or wss://, got '${route.endpoint}'.`); - } - const socket = this.#options.socketFactory(route.endpoint); - socket.binaryType = "arraybuffer"; - try { - await new Promise((resolve, reject) => { - let opened = false; - let settled = false; - const handshakeId = this.#options.createId(); - const clientNonce = randomNonce(); - const sessionRequest: CultMeshSessionOpenMessage = { - schemaVersion: "cultmesh.session_open.v2", - messageId: handshakeId, - sourceRuntimeId: this.runtimeId, - verseId: this.identity.verseId, - authorityRuntimeId: this.identity.authorityRuntimeId, - protocolId: route.protocolId ?? "cultmesh.documents.v1", - routeGeneration: route.generation, - clientNonce, - }; - const finish = (error?: Error) => { - if (settled) return; - settled = true; - clearTimeout(timer); - if (error) reject(error); - else resolve(); - }; - const timer = setTimeout( - () => finish(new Error( - opened - ? `CultMesh authority handshake timed out for '${route.endpoint}'.` - : `CultMesh WebSocket open timed out for '${route.endpoint}'.`, - )), - this.#options.connectTimeoutMs, - ); - socket.onopen = () => { - opened = true; - try { - sendSocketMessage(socket, { - schemaVersion: "cultnet.hello.v0", - runtimeId: this.runtimeId, - runtimeKind: "browser", - supportedMessageVersions: [ - "cultmesh.session_open.v2", - "cultmesh.session_accepted.v2", - "cultnet.database_subscribe.v0", - "cultnet.database_unsubscribe.v0", - "cultnet.database_change_raw.v0", - "cultnet.operation_request.v0", - "cultnet.operation_response.v0", - ], - }, this.#options.maxFrameBytes); - sendSocketMessage(socket, sessionRequest, this.#options.maxFrameBytes); - } catch (error) { - finish(error instanceof Error ? error : new Error(String(error))); - } - }; - socket.onmessage = event => { - void decodeSocketMessage(event, this.#options.maxFrameBytes).then(message => { - if (message.schemaVersion === "cultnet.error.v0") { - finish(new Error(message.error)); - return; - } - if (message.schemaVersion !== "cultmesh.session_accepted.v2" || message.messageId !== handshakeId) return; - void validateSessionAcceptance( - message, - sessionRequest, - route, - this.#options.trust, - ).then(finish).catch(error => finish(error instanceof Error ? error : new Error(String(error)))); - }).catch(error => finish(error instanceof Error ? error : new Error(String(error)))); - }; - socket.onerror = () => finish(new Error(`CultMesh WebSocket could not open '${route.endpoint}'.`)); - socket.onclose = () => finish(new Error(`CultMesh WebSocket closed before proving authority at '${route.endpoint}'.`)); - }); - } catch (error) { - socket.close(1000, "CultMesh route open failed"); - throw error; - } - if (this.#disposed || generation !== this.#socketGeneration) { - socket.close(1000, "Stale CultMesh route"); - return; - } - this.#socket = socket; - socket.onmessage = event => { void this.receive(event, generation); }; - socket.onerror = () => undefined; - socket.onclose = () => this.handleClosed(generation); - this.setState("connected"); - try { - await Promise.all([...this.#leases.values()].map(lease => this.subscribe(lease))); - for (const pending of this.#pendingOperations.values()) this.send(pending.request); - } catch (error) { - if (this.#socket === socket) this.#socket = undefined; - socket.onclose = null; - socket.close(1000, "CultMesh session initialization failed"); - throw error; - } - } - - private handleClosed(generation: number): void { - if (this.#disposed || generation !== this.#socketGeneration) return; - this.#socket = undefined; - const error = new Error("CultMesh route closed before the provider response arrived."); - for (const pending of this.#pendingSnapshots.values()) { - clearTimeout(pending.timer); - pending.reject(error); - } - this.#pendingSnapshots.clear(); - this.setState("reconnecting"); - this.#reconnectTimer = setTimeout(() => { - this.#reconnectTimer = undefined; - void this.connectSocket().catch(() => this.handleClosed(this.#socketGeneration)); - }, this.#options.reconnectDelayMs); - } - - private async receive(event: MessageEvent, generation: number): Promise { - if (this.#disposed || generation !== this.#socketGeneration) return; - let message: CultNetMessage; - try { - message = await decodeSocketMessage(event, this.#options.maxFrameBytes); - } catch (error) { - const oversized = error instanceof Error && error.message.includes("exceeds"); - this.#socket?.close( - oversized ? 1009 : 1003, - oversized ? "CultNet frame exceeds configured maximum" : "CultNet requires binary WebSocket frames", - ); - return; - } - switch (message.schemaVersion) { - case "cultnet.snapshot_response_raw.v0": - this.applySnapshot(message); - return; - case "cultnet.database_change_raw.v0": - this.applyChange(message); - return; - case "cultnet.operation_response.v0": { - const pending = this.#pendingOperations.get(message.messageId); - if (!pending) return; - if (message.sourceRuntimeId !== this.identity.authorityRuntimeId) { - this.#pendingOperations.delete(message.messageId); - clearTimeout(pending.timer); - pending.reject(new Error( - `CultMesh operation response came from '${message.sourceRuntimeId ?? "unknown"}', expected '${this.identity.authorityRuntimeId}'.`, - )); - this.#socket?.close(1008, "CultMesh authority identity changed"); - return; - } - this.#pendingOperations.delete(message.messageId); - clearTimeout(pending.timer); - if (message.payloadSchema === cultNetOperationFailureSchema) { - try { - pending.reject(new CultMeshBrowserOperationError( - message, - decodeCultNetOperationPayload(message), - )); - } catch (error) { - pending.reject(error instanceof Error - ? error - : new Error("CultMesh operation failure payload was malformed.")); - } - return; - } - pending.resolve(message); - return; - } - case "cultnet.error.v0": - this.rejectCorrelated(message.error); - return; - default: - return; - } - } - - private applySnapshot(message: CultNetSnapshotResponseRawMessage): void { - const pending = this.#pendingSnapshots.get(message.messageId); - if (!pending) return; - this.#pendingSnapshots.delete(message.messageId); - clearTimeout(pending.timer); - const matches = message.documents.filter(record => - record.recordKey === pending.lease.recordKey && recordMatchesSchema(record, pending.lease.schemaId)); - if (matches.length > 1) { - pending.reject(new Error(`CultMesh snapshot returned duplicate record '${pending.lease.recordKey}'.`)); - return; - } - pending.lease.apply(matches[0]); - pending.resolve(); - } - - private applyChange(message: CultNetDatabaseChangeRawMessage): void { - const lease = this.#leases.get(message.subscriptionId) ?? this.#openingLeases.get(message.subscriptionId); - if (!lease) return; - if (message.changeKind === "removed") { - if (message.recordKey === lease.recordKey) lease.apply(undefined); - return; - } - const record = message.document; - if (record?.recordKey === lease.recordKey && recordMatchesSchema(record, lease.schemaId)) lease.apply(record); - } - - private rejectCorrelated(reason: string): void { - if (this.#pendingOperations.size === 1) { - const [messageId, pending] = this.#pendingOperations.entries().next().value!; - this.#pendingOperations.delete(messageId); - clearTimeout(pending.timer); - pending.reject(new Error(reason)); - } - } - - private send(message: CultNetMessage): void { - const socket = this.#socket; - if (!socket || socket.readyState !== 1) throw new Error("CultMesh browser route is not connected."); - const payload = encode(encodeCultNetMessageForWire(message)); - if (payload.byteLength > this.#options.maxFrameBytes) { - throw new Error(`CultMesh message exceeds the ${this.#options.maxFrameBytes}-byte limit.`); - } - socket.send(payload); - } - - private setState(state: CultMeshConnectionState): void { - if (this.#state === state) return; - this.#state = state; - for (const watcher of this.#stateWatchers) watcher(state); - } - - private scheduleReconnect(): void { - if (this.#disposed || this.#reconnectTimer) return; - this.setState("reconnecting"); - this.#reconnectTimer = setTimeout(() => { - this.#reconnectTimer = undefined; - void this.connectSocket().catch(() => this.scheduleReconnect()); - }, this.#options.reconnectDelayMs); - } - - private throwIfDisposed(): void { - if (this.#disposed) throw new Error("CultMesh browser client is disposed."); - } -} - -export function decodeCultNetPayload(record: CultNetRawDocumentRecord): T { - if (record.payloadEncoding !== "messagepack") { - throw new Error(`Unsupported CultNet document payload '${record.payloadEncoding}'.`); - } - return decode(record.payload) as T; -} - -function recordMatchesSchema(record: CultNetRawDocumentRecord, requestedSchema: string): boolean { - return record.schemaId === requestedSchema || - record.schemaName === requestedSchema || - record.schemaVersion === requestedSchema; -} - -export function decodeCultNetOperationPayload(response: CultNetOperationResponseMessage): T { - if (response.payloadEncoding !== "messagepack-base64") { - throw new Error(`Unsupported CultNet operation payload '${response.payloadEncoding ?? "unspecified"}'.`); - } - 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 { - endpoint = new URL(value); - } catch { - throw new Error(`${field} '${value}' is not a valid URL.`); - } - if (endpoint.protocol !== "ws:" && endpoint.protocol !== "wss:") { - throw new Error(`${field} must use ws:// or wss://, got '${value}'.`); - } - return endpoint; -} - -function sendSocketMessage(socket: CultMeshBrowserSocket, message: CultNetMessage, maxFrameBytes: number): void { - const payload = encode(encodeCultNetMessageForWire(message)); - if (payload.byteLength > maxFrameBytes) { - throw new Error(`CultNet message exceeds the ${maxFrameBytes}-byte limit.`); - } - socket.send(payload); -} - -async function decodeSocketMessage(event: MessageEvent, maxFrameBytes: number): Promise { - let payload: Uint8Array; - if (event.data instanceof ArrayBuffer) { - payload = new Uint8Array(event.data); - } else if (ArrayBuffer.isView(event.data)) { - payload = new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength); - } else if (event.data instanceof Blob) { - payload = new Uint8Array(await event.data.arrayBuffer()); - } else { - throw new Error("CultNet requires binary WebSocket frames."); - } - if (payload.byteLength > maxFrameBytes) { - throw new Error(`CultNet frame exceeds the ${maxFrameBytes}-byte limit.`); - } - return parseCultNetMessage(decode(payload)); -} - -async function selectOdinRoute( - response: CultMeshVerseCatalogResponseMessage, - identity: CultMeshBrowserIdentity, - trust: CultMeshBrowserAuthorityTrustPolicy, -): Promise { - const matchingVerses = response.verses.filter(verse => verse.verseId === identity.verseId); - if (matchingVerses.length !== 1) { - throw new Error( - `Odin returned ${matchingVerses.length} routes for Verse '${identity.verseId}' authority runtime '${identity.authorityRuntimeId}'.`, - ); - } - const verse = matchingVerses[0]; - if (verse.authorityRoutes == null && verse.authorityRuntimeIds.length !== 1) { - throw new Error( - `Odin returned ambiguous legacy routes for Verse '${identity.verseId}'. Explicit authority route bindings are required.`, - ); - } - if (verse.authorityRoutes == null && verse.authorityRuntimeIds[0] !== identity.authorityRuntimeId) { - throw new Error( - `Odin advertised no route for authority runtime '${identity.authorityRuntimeId}'.`, - ); - } - const boundRoutes = verse.authorityRoutes ?? verse.discoveryEndpoints.map(endpoint => ({ - authorityRuntimeId: verse.authorityRuntimeIds[0], - endpoint, - protocolIds: ["cultmesh.documents.v1"], - priority: 0, - generation: [verse.compatibility.transportVersion, verse.compatibility.rulesHash, endpoint].join(":"), - certificate: undefined, - })); - const routes = boundRoutes.filter(route => - route.authorityRuntimeId === identity.authorityRuntimeId && - route.protocolIds.includes("cultmesh.documents.v1") && - (() => { - try { - requireWebSocketEndpoint(route.endpoint, "Odin provider route"); - return true; - } catch { - return false; - } - })()).sort((left, right) => left.priority - right.priority || left.endpoint.localeCompare(right.endpoint)); - if (routes.length === 0) { - throw new Error(`Odin advertised no browser-compatible route for authority runtime '${identity.authorityRuntimeId}'.`); - } - const failures: Error[] = []; - for (const route of routes) { - const candidate: CultMeshBrowserRoute = { - ...identity, - endpoint: route.endpoint, - protocolId: "cultmesh.documents.v1", - protocolIds: route.protocolIds, - priority: route.priority, - generation: route.generation, - ...(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); - return candidate; - } catch (error) { - failures.push(error instanceof Error ? error : new Error(String(error))); - } - } - throw new AggregateError( - failures, - `Odin advertised no trusted browser route for authority runtime '${identity.authorityRuntimeId}'.`, - ); -} - -async function validateSessionAcceptance( - message: CultMeshSessionAcceptedMessage, - request: CultMeshSessionOpenMessage, - route: CultMeshBrowserRoute, - trust: CultMeshBrowserAuthorityTrustPolicy, -): Promise { - if (!message.accepted) return new Error(message.error ?? "CultMesh authority rejected the session."); - const protocolId = route.protocolId ?? "cultmesh.documents.v1"; - if (message.verseId !== request.verseId || - message.authorityRuntimeId !== request.authorityRuntimeId || - message.protocolId !== protocolId || - message.routeGeneration !== route.generation || - message.clientNonce !== request.clientNonce) { - return new Error( - `CultMesh route proved '${message.verseId}/${message.authorityRuntimeId}/${message.protocolId}/${message.routeGeneration}', ` + - `expected '${request.verseId}/${request.authorityRuntimeId}/${protocolId}/${route.generation}'.`, - ); - } - 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 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 deleted file mode 100644 index 6c169d93..00000000 --- a/packages/cultmesh-browser/test/cultmesh-browser.test.ts +++ /dev/null @@ -1,929 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { decode, encode } from "@msgpack/msgpack"; -import { - encodeCultNetMessageForWire, - parseCultNetMessage, - type CultNetDatabaseSubscribeMessage, - type CultNetMessage, - type CultNetOperationRequestMessage, - type CultNetRawDocumentRecord, - type CultMeshVerseCatalogResponseMessage, - type CultMeshSessionOpenMessage, -} from "cultnet-ts/contracts"; - -import { - CultMeshBrowserClient, - CultMeshBrowserOperationError, - CultMeshBrowserOdinRendezvous, - decodeCultNetOperationPayload, - decodeCultNetPayload, - type CultMeshBrowserRoute, - type CultMeshBrowserP256PublicKey, - type CultMeshBrowserSocket, -} from "../src/index.js"; - -const localTrust = { mode: "local-development" as const }; - -test("Odin rendezvous resolves stable authority-runtime identity and observes route rotation", async () => { - const odin = new FakeOdin("ws://127.0.0.1:4050/mesh"); - let id = 0; - const rendezvous = new CultMeshBrowserOdinRendezvous({ - endpoints: ["ws://127.0.0.1:4040/odin"], - runtimeId: "browser-odin-test", - createId: () => `odin-${++id}`, - socketFactory: () => odin.open(), - }); - - assert.equal((await rendezvous.resolve({ - verseId: "sample.counter", - authorityRuntimeId: "sample.counter-daemon", - }, localTrust)).endpoint, "ws://127.0.0.1:4050/mesh"); - - odin.providerEndpoint = "ws://127.0.0.1:4060/mesh"; - assert.equal((await rendezvous.resolve({ - verseId: "sample.counter", - authorityRuntimeId: "sample.counter-daemon", - }, localTrust)).endpoint, "ws://127.0.0.1:4060/mesh"); - - await assert.rejects( - rendezvous.resolve({ verseId: "sample.counter", authorityRuntimeId: "other-runtime" }, localTrust), - /could not resolve/, - ); -}); - -test("Odin rendezvous never selects another authority's faster endpoint", async () => { - const odin = new FakeOdin("ws://127.0.0.1:4050/mesh"); - odin.decoyEndpoint = "ws://127.0.0.1:4049/decoy"; - const rendezvous = new CultMeshBrowserOdinRendezvous({ - endpoints: ["ws://127.0.0.1:4040/odin"], - runtimeId: "browser-route-binding-test", - socketFactory: () => odin.open(), - }); - - const route = await rendezvous.resolve({ - verseId: "sample.counter", - authorityRuntimeId: "sample.counter-daemon", - }, localTrust); - assert.equal(route.endpoint, "ws://127.0.0.1:4050/mesh"); -}); - -test("Odin rendezvous accepts one-authority legacy routes and rejects ambiguous legacy catalogs", async () => { - const odin = new FakeOdin("ws://127.0.0.1:4070/mesh"); - odin.omitAuthorityRoutes = true; - const rendezvous = new CultMeshBrowserOdinRendezvous({ - endpoints: ["ws://127.0.0.1:4040/odin"], - runtimeId: "browser-legacy-route-test", - socketFactory: () => odin.open(), - }); - - const route = await rendezvous.resolve({ - verseId: "sample.counter", - authorityRuntimeId: "sample.counter-daemon", - }, localTrust); - assert.equal(route.endpoint, "ws://127.0.0.1:4070/mesh"); - - odin.decoyEndpoint = "ws://127.0.0.1:4069/decoy"; - await assert.rejects( - rendezvous.resolve({ verseId: "sample.counter", authorityRuntimeId: "sample.counter-daemon" }, localTrust), - (error: unknown) => - error instanceof AggregateError && - error.errors.some(inner => inner instanceof Error && /ambiguous legacy routes/.test(inner.message)), - ); -}); - -test("Odin rendezvous rejects a poisoned route without vetoing a certified fallback", async () => { - const signed = await createSignedRoute("wss://provider.example/mesh"); - const odin = new FakeOdin(signed.route.endpoint); - odin.authorityRoutesOverride = [ - { - authorityRuntimeId: signed.route.authorityRuntimeId, - endpoint: "wss://poison.example/mesh", - protocolIds: ["cultmesh.documents.v1"], - priority: 0, - generation: "poisoned", - }, - routeForCatalog(signed.route), - ]; - const rendezvous = new CultMeshBrowserOdinRendezvous({ - endpoints: ["ws://127.0.0.1:4040/odin"], - runtimeId: "browser-certified-fallback-test", - socketFactory: () => odin.open(), - }); - - const selected = await rendezvous.resolve( - { verseId: signed.route.verseId, authorityRuntimeId: signed.route.authorityRuntimeId }, - { mode: "authenticated-remote", odinRoots: [signed.odinPublic] }, - ); - - assert.equal(selected.endpoint, signed.route.endpoint); -}); - -test("browser client leases provider state, invokes an operation, and follows route replacement", async () => { - const first = new FakeProvider("ws://127.0.0.1:4101/mesh", 4); - const second = new FakeProvider("ws://127.0.0.1:4102/mesh", 9); - let route = first.route; - let id = 0; - const client = await CultMeshBrowserClient.connect({ - verseId: route.verseId, - authorityRuntimeId: route.authorityRuntimeId, - runtimeId: "browser-test", - trust: localTrust, - rendezvous: { resolve: async () => route }, - createId: () => `id-${++id}`, - socketFactory: endpoint => endpoint === first.route.endpoint ? first.open() : second.open(), - }); - - const lease = await client.leaseRawDocument({ - schemaId: "sample.counter_state.v1", - recordKey: "counter:main", - }); - assert.deepEqual(decodeCultNetPayload<{ count: number }>(lease.current!).count, 4); - - const observed: number[] = []; - const unwatch = lease.watch(record => { - if (record) observed.push(decodeCultNetPayload<{ count: number }>(record).count); - }); - const response = await client.invoke({ - serviceId: "sample.counter", - operation: "increment", - payloadSchema: "sample.increment.v1", - payload: { amount: 2 }, - }); - assert.equal(response.status, "accepted"); - assert.deepEqual(decodeCultNetOperationPayload<{ count: number }>(response), { count: 6 }); - assert.deepEqual(observed, [4, 6]); - - route = second.route; - await client.refreshRoute(); - assert.equal(client.state, "connected"); - assert.deepEqual(decodeCultNetPayload<{ count: number }>(lease.current!).count, 9); - assert.deepEqual(observed, [4, 6, 9]); - assert.equal(first.activeSubscriptionCount, 0); - assert.equal(second.activeSubscriptionCount, 1); - - unwatch(); - lease.dispose(); - assert.equal(second.activeSubscriptionCount, 0); - await client.dispose(); -}); - -test("browser client rejects wrong rendezvous identity and has no document write API", async () => { - const provider = new FakeProvider("ws://127.0.0.1:4201/mesh", 1); - await assert.rejects( - CultMeshBrowserClient.connect({ - verseId: "sample.counter", - authorityRuntimeId: "sample.counter-daemon", - runtimeId: "browser-test", - trust: localTrust, - rendezvous: { - resolve: async () => ({ - ...provider.route, - authorityRuntimeId: "intruder-runtime", - }), - }, - socketFactory: () => provider.open(), - }), - /wrong stable identity/, - ); - assert.equal("putDocument" in CultMeshBrowserClient.prototype, false); - assert.equal("setDocument" in CultMeshBrowserClient.prototype, false); -}); - -test("browser client rejects a route whose peer proves the wrong authority", async () => { - const provider = new FakeProvider("ws://127.0.0.1:4251/mesh", 1, false, "intruder-runtime"); - await assert.rejects( - CultMeshBrowserClient.connect({ - verseId: "sample.counter", - authorityRuntimeId: "sample.counter-daemon", - runtimeId: "browser-peer-proof-test", - trust: localTrust, - rendezvous: { resolve: async () => ({ ...provider.route, authorityRuntimeId: "sample.counter-daemon" }) }, - socketFactory: () => provider.open(), - }), - /proved .*intruder-runtime.*expected .*sample.counter-daemon/, - ); -}); - -test("browser client accepts an Odin-certified route only after provider nonce proof", async () => { - const fixture = await createSignedRoute("wss://provider.example/mesh"); - const client = await CultMeshBrowserClient.connect({ - ...fixture.route, - runtimeId: "browser-authenticated-test", - rendezvous: { resolve: async () => fixture.route }, - trust: { mode: "authenticated-remote", odinRoots: [fixture.odinPublic] }, - socketFactory: () => signedHandshakeSocket(fixture.route, fixture.providerPrivate), - }); - - assert.equal(client.state, "connected"); - await client.dispose(); -}); - -test("browser client rejects a credential-free authority impersonator", async () => { - const fixture = await createSignedRoute("wss://provider.example/mesh"); - const impersonator = new FakeProvider("wss://provider.example/mesh", 1); - - await assert.rejects( - CultMeshBrowserClient.connect({ - ...fixture.route, - runtimeId: "browser-impersonation-test", - rendezvous: { resolve: async () => fixture.route }, - trust: { mode: "authenticated-remote", odinRoots: [fixture.odinPublic] }, - socketFactory: () => impersonator.open(), - }), - /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; - const connect = (route: CultMeshBrowserRoute, now?: () => number) => CultMeshBrowserClient.connect({ - ...route, - runtimeId: "browser-route-certificate-test", - rendezvous: { resolve: async () => route }, - trust: { mode: "authenticated-remote", odinRoots: [fixture.odinPublic], now }, - socketFactory: () => { - opened = true; - return signedHandshakeSocket(route, fixture.providerPrivate); - }, - }); - - await assert.rejects(connect({ ...fixture.route, endpoint: "wss://evil.example/mesh" }), /signature is invalid/); - assert.equal(opened, false); - await assert.rejects(connect(fixture.route, () => fixture.expiresAt), /not currently valid/); - assert.equal(opened, false); -}); - -test("browser client bounds unanswered provider operations", async () => { - const route = { - verseId: "sample.counter", - authorityRuntimeId: "sample.counter-daemon", - endpoint: "ws://127.0.0.1:4301/mesh", - generation: "route-4301", - }; - const client = await CultMeshBrowserClient.connect({ - ...route, - runtimeId: "browser-timeout-test", - trust: localTrust, - rendezvous: { resolve: async () => route }, - requestTimeoutMs: 5, - socketFactory: () => handshakeOnlySocket(route), - }); - await assert.rejects( - client.invoke({ - serviceId: "sample.counter", - operation: "increment", - payloadSchema: "sample.increment.v1", - payload: { amount: 1 }, - idempotencyKey: "timeout-1", - }), - /timed out/, - ); - await client.dispose(); -}); - -test("browser client rejects a correlated framework failure without waiting for timeout", async () => { - const provider = new FakeProvider("ws://127.0.0.1:4351/mesh", 1, true); - const client = await CultMeshBrowserClient.connect({ - ...provider.route, - runtimeId: "browser-failure-test", - trust: localTrust, - rendezvous: { resolve: async () => provider.route }, - requestTimeoutMs: 60_000, - socketFactory: () => provider.open(), - }); - - await assert.rejects( - client.invoke({ - serviceId: "sample.counter", - operation: "increment", - payloadSchema: "wrong.request.v1", - payload: { amount: 1 }, - idempotencyKey: "failure-1", - }), - (error: unknown) => { - assert.ok(error instanceof CultMeshBrowserOperationError); - assert.equal(error.status, "invalid"); - assert.equal(error.code, "request-schema-mismatch"); - assert.match(error.message, /expected payload schema/i); - return true; - }, - ); - await client.dispose(); -}); - -test("browser client replays one idempotent operation after route loss without duplicating its effect", async () => { - const firstRoute = localRoute("ws://127.0.0.1:4311/mesh", "generation-1"); - const secondRoute = localRoute("ws://127.0.0.1:4312/mesh", "generation-2"); - let route = firstRoute; - let effectCount = 0; - let dropFirstResponse = true; - const results = new Map(); - const open = () => { - let socket: FakeSocket; - socket = new FakeSocket(wire => { - const message = parseCultNetMessage(decode(wire)); - if (message.schemaVersion === "cultmesh.session_open.v2") { - socket.deliver({ - schemaVersion: "cultmesh.session_accepted.v2", - messageId: message.messageId, - accepted: true, - verseId: message.verseId, - authorityRuntimeId: message.authorityRuntimeId, - protocolId: message.protocolId, - routeGeneration: message.routeGeneration, - clientNonce: message.clientNonce, - providerKeyId: "", - providerSignature: "", - }); - return; - } - if (message.schemaVersion !== "cultnet.operation_request.v0") return; - let response = results.get(message.messageId); - if (!response) { - effectCount++; - response = { - schemaVersion: "cultnet.operation_response.v0", - messageId: message.messageId, - serviceId: message.serviceId, - operation: message.operation, - status: "accepted", - payloadSchema: "sample.increment_receipt.v1", - payloadEncoding: "messagepack-base64", - payload: Buffer.from(encode({ count: effectCount })).toString("base64"), - sourceRuntimeId: "sample.counter-daemon", - }; - results.set(message.messageId, response!); - } - if (dropFirstResponse) { - dropFirstResponse = false; - route = secondRoute; - socket.close(1012, "route lost after commit"); - return; - } - socket.deliver(response!); - }, () => undefined); - return socket; - }; - const client = await CultMeshBrowserClient.connect({ - ...firstRoute, - runtimeId: "browser-operation-replay-test", - rendezvous: { resolve: async () => route }, - trust: localTrust, - reconnectDelayMs: 1, - requestTimeoutMs: 2_000, - socketFactory: () => open(), - }); - - const response = await client.invoke({ - serviceId: "sample.counter", - operation: "increment", - payloadSchema: "sample.increment.v1", - payload: { amount: 1 }, - idempotencyKey: "replay-once", - }); - - assert.deepEqual(decodeCultNetOperationPayload<{ count: number }>(response), { count: 1 }); - assert.equal(effectCount, 1); - await client.dispose(); -}); - -test("an early caller reconnect cancels the scheduled reconnect and keeps one live socket", async () => { - const provider = new FakeProvider("ws://127.0.0.1:4361/mesh", 2); - const client = await CultMeshBrowserClient.connect({ - ...provider.route, - runtimeId: "browser-single-reconnect-test", - rendezvous: { resolve: async () => provider.route }, - trust: localTrust, - reconnectDelayMs: 40, - socketFactory: () => provider.open(), - }); - const lease = await client.leaseRawDocument({ - schemaId: "sample.counter_state.v1", - recordKey: "counter:main", - }); - - provider.dropConnections(); - await client.invoke({ - serviceId: "sample.counter", - operation: "increment", - payloadSchema: "sample.increment.v1", - payload: { amount: 1 }, - }); - await new Promise(resolve => setTimeout(resolve, 80)); - - assert.equal(provider.openCount, 2); - assert.equal(provider.liveSocketCount, 1); - assert.equal(provider.activeSubscriptionCount, 1); - lease.dispose(); - await client.dispose(); -}); - -test("browser client rejects an operation response from another runtime", async () => { - const provider = new FakeProvider( - "ws://127.0.0.1:4371/mesh", - 1, - false, - "sample.counter-daemon", - "intruder-runtime", - ); - const client = await CultMeshBrowserClient.connect({ - ...provider.route, - runtimeId: "browser-response-authority-test", - trust: localTrust, - rendezvous: { resolve: async () => provider.route }, - socketFactory: () => provider.open(), - }); - - await assert.rejects( - client.invoke({ - serviceId: "sample.counter", - operation: "increment", - payloadSchema: "sample.increment.v1", - payload: { amount: 1 }, - }), - /response came from 'intruder-runtime'/, - ); - await client.dispose(); -}); - -test("browser client rejects oversized outbound schema messages", async () => { - const route = { - verseId: "sample.counter", - authorityRuntimeId: "sample.counter-daemon", - endpoint: "ws://127.0.0.1:4401/mesh", - generation: "route-4401", - }; - await assert.rejects( - CultMeshBrowserClient.connect({ - ...route, - runtimeId: "browser-size-test", - trust: localTrust, - rendezvous: { resolve: async () => route }, - maxFrameBytes: 8, - socketFactory: () => new FakeSocket(() => undefined, () => undefined), - }), - /exceeds the 8-byte limit/, - ); -}); - -test("browser client reserves subscription identity while the initial lease opens", async () => { - const provider = new FakeProvider("ws://127.0.0.1:4501/mesh", 3); - const client = await CultMeshBrowserClient.connect({ - ...provider.route, - runtimeId: "browser-lease-race-test", - trust: localTrust, - rendezvous: { resolve: async () => provider.route }, - socketFactory: () => provider.open(), - }); - const first = client.leaseRawDocument({ - schemaId: "sample.counter_state.v1", - recordKey: "counter:main", - subscriptionId: "one-owner", - }); - await assert.rejects( - client.leaseRawDocument({ - schemaId: "sample.counter_state.v1", - recordKey: "counter:main", - subscriptionId: "one-owner", - }), - /already leased/, - ); - (await first).dispose(); - await client.dispose(); -}); - -class FakeProvider { - readonly route: CultMeshBrowserRoute; - #count: number; - #subscriptions = new Map>(); - #rejectOperations: boolean; - #runtimeId: string; - #responseRuntimeId: string; - #openCount = 0; - - constructor( - endpoint: string, - count: number, - rejectOperations = false, - runtimeId = "sample.counter-daemon", - responseRuntimeId = runtimeId, - ) { - this.route = { - verseId: "sample.counter", - authorityRuntimeId: "sample.counter-daemon", - endpoint, - generation: endpoint, - }; - this.#count = count; - this.#rejectOperations = rejectOperations; - this.#runtimeId = runtimeId; - this.#responseRuntimeId = responseRuntimeId; - } - - get activeSubscriptionCount(): number { - let count = 0; - for (const subscriptions of this.#subscriptions.values()) count += subscriptions.size; - return count; - } - - get openCount(): number { - return this.#openCount; - } - - get liveSocketCount(): number { - return this.#subscriptions.size; - } - - dropConnections(): void { - for (const socket of [...this.#subscriptions.keys()]) socket.close(1012, "route dropped"); - } - - open(): FakeSocket { - this.#openCount++; - const socket = new FakeSocket( - wire => this.receive(socket, wire), - () => this.#subscriptions.delete(socket), - ); - this.#subscriptions.set(socket, new Map()); - return socket; - } - - private receive(socket: FakeSocket, wire: Uint8Array): void { - const message = parseCultNetMessage(decode(wire)); - switch (message.schemaVersion) { - case "cultmesh.session_open.v2": - this.acceptSession(socket, message); - return; - case "cultnet.database_subscribe.v0": - this.subscribe(socket, message); - return; - case "cultnet.database_unsubscribe.v0": - this.#subscriptions.get(socket)?.delete(message.subscriptionId); - return; - case "cultnet.operation_request.v0": - this.invoke(socket, message); - return; - default: - return; - } - } - - private acceptSession(socket: FakeSocket, message: CultMeshSessionOpenMessage): void { - socket.deliver({ - schemaVersion: "cultmesh.session_accepted.v2", - messageId: message.messageId, - accepted: true, - verseId: message.verseId, - authorityRuntimeId: this.#runtimeId, - protocolId: message.protocolId, - routeGeneration: message.routeGeneration, - clientNonce: message.clientNonce, - providerKeyId: "", - providerSignature: "", - }); - } - - private subscribe(socket: FakeSocket, message: CultNetDatabaseSubscribeMessage): void { - this.#subscriptions.get(socket)!.set(message.subscriptionId, message); - socket.deliver({ - schemaVersion: "cultnet.snapshot_response_raw.v0", - messageId: message.messageId, - documents: [this.record()], - }); - } - - private invoke(socket: FakeSocket, message: CultNetOperationRequestMessage): void { - assert.equal(message.targetRuntimeId, this.#runtimeId); - if (this.#rejectOperations) { - socket.deliver({ - schemaVersion: "cultnet.operation_response.v0", - messageId: message.messageId, - serviceId: message.serviceId, - operation: message.operation, - status: "invalid", - payloadSchema: "gamecult.cultnet.operation_failure.v1", - payloadEncoding: "messagepack-base64", - payload: bytesToBase64(encode({ - code: "request-schema-mismatch", - message: "Expected payload schema 'sample.increment.v1'.", - })), - diagnostics: ["Request schema did not match."], - sourceRuntimeId: this.#responseRuntimeId, - }); - return; - } - const payload = decode(base64ToBytes(message.payload)) as { amount: number }; - this.#count += payload.amount; - for (const [peer, subscriptions] of this.#subscriptions) { - for (const subscriptionId of subscriptions.keys()) { - peer.deliver({ - schemaVersion: "cultnet.database_change_raw.v0", - messageId: `change-${message.messageId}`, - subscriptionId, - changeKind: "updated", - document: this.record(), - }); - } - } - socket.deliver({ - schemaVersion: "cultnet.operation_response.v0", - messageId: message.messageId, - serviceId: message.serviceId, - operation: message.operation, - status: "accepted", - payloadSchema: "sample.increment_receipt.v1", - payloadEncoding: "messagepack-base64", - payload: bytesToBase64(encode({ count: this.#count })), - sourceRuntimeId: this.#responseRuntimeId, - }); - } - - private record(): CultNetRawDocumentRecord { - return { - schemaId: "sha256:sample-counter-state", - schemaName: "sample.counter_state", - schemaVersion: "sample.counter_state.v1", - schemaContentHash: "sha256:sample-counter-state-content", - recordKey: "counter:main", - storedAt: "2026-08-17T00:00:00Z", - payloadEncoding: "messagepack", - payload: encode({ count: this.#count }), - sourceRuntimeId: this.#runtimeId, - }; - } -} - -class FakeOdin { - providerEndpoint: string; - decoyEndpoint: string | undefined; - omitAuthorityRoutes = false; - authorityRoutesOverride: NonNullable | undefined; - - constructor(providerEndpoint: string) { - this.providerEndpoint = providerEndpoint; - } - - open(): FakeSocket { - const socket = new FakeSocket(wire => { - const message = parseCultNetMessage(decode(wire)); - if (message.schemaVersion !== "cultmesh.verse_catalog_request.v0") return; - socket.deliver({ - schemaVersion: "cultmesh.verse_catalog_response.v0", - messageId: message.messageId, - verses: [{ - verseId: "sample.counter", - displayName: "Counter", - authorityModel: "ServerAuthoritative", - compatibility: { - transportVersion: "cultmesh.v1", - rulesHash: "counter-v1", - compatibleVerseIds: [], - requiredPluginIds: [], - optionalPluginIds: [], - }, - discoveryEndpoints: [this.providerEndpoint], - authorityRuntimeIds: this.decoyEndpoint - ? ["sample.counter-daemon", "sample.decoy-daemon"] - : ["sample.counter-daemon"], - ...(!this.omitAuthorityRoutes ? { authorityRoutes: this.authorityRoutesOverride ?? [ - ...(this.decoyEndpoint ? [{ - authorityRuntimeId: "sample.decoy-daemon", - endpoint: this.decoyEndpoint, - protocolIds: ["cultmesh.documents.v1"], - priority: 0, - generation: "decoy-generation", - }] : []), - { - authorityRuntimeId: "sample.counter-daemon", - endpoint: this.providerEndpoint, - protocolIds: ["cultmesh.documents.v1"], - priority: 10, - generation: this.providerEndpoint, - }, - ] } : {}), - }], - }); - }, () => undefined); - return socket; - } -} - -function routeForCatalog(route: CultMeshBrowserRoute): NonNullable< - CultMeshVerseCatalogResponseMessage["verses"][number]["authorityRoutes"] ->[number] { - return { - authorityRuntimeId: route.authorityRuntimeId, - endpoint: route.endpoint, - protocolIds: [...(route.protocolIds ?? [route.protocolId ?? "cultmesh.documents.v1"])], - priority: route.priority ?? 0, - generation: route.generation, - ...(route.certificate ? { - certificate: { - providerKeyId: route.certificate.providerKey.keyId, - providerPublicKeyX: route.certificate.providerKey.x, - providerPublicKeyY: route.certificate.providerKey.y, - odinKeyId: route.certificate.odinKeyId, - issuedAtUnixMilliseconds: route.certificate.issuedAtUnixMilliseconds, - expiresAtUnixMilliseconds: route.certificate.expiresAtUnixMilliseconds, - signature: route.certificate.signature, - }, - } : {}), - }; -} - -function handshakeOnlySocket(route: CultMeshBrowserRoute): FakeSocket { - let socket: FakeSocket; - socket = new FakeSocket(wire => { - const message = parseCultNetMessage(decode(wire)); - if (message.schemaVersion !== "cultmesh.session_open.v2") return; - socket.deliver({ - schemaVersion: "cultmesh.session_accepted.v2", - messageId: message.messageId, - accepted: true, - verseId: route.verseId, - authorityRuntimeId: route.authorityRuntimeId, - protocolId: message.protocolId, - routeGeneration: message.routeGeneration, - clientNonce: message.clientNonce, - providerKeyId: "", - providerSignature: "", - }); - }, () => undefined); - return socket; -} - -function localRoute(endpoint: string, generation: string): CultMeshBrowserRoute { - return { - verseId: "sample.counter", - authorityRuntimeId: "sample.counter-daemon", - endpoint, - protocolId: "cultmesh.documents.v1", - protocolIds: ["cultmesh.documents.v1"], - priority: 0, - generation, - }; -} - -class FakeSocket implements CultMeshBrowserSocket { - binaryType: BinaryType = "arraybuffer"; - readyState = 0; - onopen: ((event: Event) => void) | null = null; - onmessage: ((event: MessageEvent) => void) | null = null; - onerror: ((event: Event) => void) | null = null; - onclose: ((event: CloseEvent) => void) | null = null; - #receive: (wire: Uint8Array) => void; - #closed: () => void; - - constructor(receive: (wire: Uint8Array) => void, closed: () => void) { - this.#receive = receive; - this.#closed = closed; - queueMicrotask(() => { - this.readyState = 1; - this.onopen?.({} as Event); - }); - } - - send(data: ArrayBuffer | ArrayBufferView): void { - if (this.readyState !== 1) throw new Error("Fake socket is not open."); - const wire = data instanceof ArrayBuffer - ? new Uint8Array(data) - : new Uint8Array(data.buffer, data.byteOffset, data.byteLength); - this.#receive(wire); - } - - close(code = 1000, reason = ""): void { - if (this.readyState === 3) return; - this.readyState = 3; - this.#closed(); - this.onclose?.({ code, reason } as CloseEvent); - } - - deliver(message: CultNetMessage): void { - const wire = encode(encodeCultNetMessageForWire(message)); - queueMicrotask(() => this.onmessage?.({ data: wire.slice().buffer } as MessageEvent)); - } -} - -async function createSignedRoute(endpoint: string): Promise<{ - route: CultMeshBrowserRoute; - odinPublic: CultMeshBrowserP256PublicKey; - providerPrivate: CryptoKey; - expiresAt: number; -}> { - const odin = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]); - const provider = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]); - const odinPublic = await exportPublic("odin-root-1", odin.publicKey); - const providerPublic = await exportPublic("provider-1", provider.publicKey); - const issuedAt = Date.now() - 1_000; - const expiresAt = Date.now() + 60_000; - const unsigned: CultMeshBrowserRoute = { - verseId: "sample.counter", - authorityRuntimeId: "sample.counter-daemon", - endpoint, - protocolId: "cultmesh.documents.v1", - protocolIds: ["cultmesh.documents.v1"], - priority: 0, - generation: "signed-generation-1", - certificate: { - providerKey: providerPublic, - odinKeyId: odinPublic.keyId, - issuedAtUnixMilliseconds: issuedAt, - expiresAtUnixMilliseconds: expiresAt, - signature: "", - }, - }; - const signature = await crypto.subtle.sign( - { name: "ECDSA", hash: "SHA-256" }, - odin.privateKey, - testCanonicalRoute(unsigned).slice().buffer as ArrayBuffer, - ); - return { - route: { ...unsigned, certificate: { ...unsigned.certificate!, signature: toBase64(new Uint8Array(signature)) } }, - odinPublic, - providerPrivate: provider.privateKey, - expiresAt, - }; -} - -function signedHandshakeSocket(route: CultMeshBrowserRoute, providerPrivate: CryptoKey): FakeSocket { - let socket: FakeSocket; - socket = new FakeSocket(wire => { - const message = parseCultNetMessage(decode(wire)); - if (message.schemaVersion !== "cultmesh.session_open.v2") return; - void crypto.subtle.sign( - { name: "ECDSA", hash: "SHA-256" }, - providerPrivate, - testCanonicalSession(message, route.endpoint).slice().buffer as ArrayBuffer, - ).then(signature => socket.deliver({ - schemaVersion: "cultmesh.session_accepted.v2", - messageId: message.messageId, - accepted: true, - verseId: message.verseId, - authorityRuntimeId: message.authorityRuntimeId, - protocolId: message.protocolId, - routeGeneration: message.routeGeneration, - clientNonce: message.clientNonce, - providerKeyId: route.certificate!.providerKey.keyId, - providerSignature: toBase64(new Uint8Array(signature)), - })); - }, () => undefined); - return socket; -} - -async function exportPublic(keyId: string, key: CryptoKey): Promise { - const jwk = await crypto.subtle.exportKey("jwk", key); - return { keyId, x: urlBase64ToBase64(jwk.x!), y: urlBase64ToBase64(jwk.y!) }; -} - -function testCanonicalRoute(route: CultMeshBrowserRoute): Uint8Array { - const certificate = route.certificate!; - return testCanonical( - "gamecult.cultmesh.route-certificate.v1", route.verseId, route.authorityRuntimeId, route.endpoint, - [...route.protocolIds!].sort().join("\u001f"), String(route.priority), route.generation, - certificate.providerKey.keyId, certificate.providerKey.x, certificate.providerKey.y, - certificate.odinKeyId, String(certificate.issuedAtUnixMilliseconds), String(certificate.expiresAtUnixMilliseconds), - ); -} - -function testCanonicalSession(message: CultMeshSessionOpenMessage, endpoint: string): Uint8Array { - return testCanonical( - "gamecult.cultmesh.session-proof.v1", message.clientNonce, message.messageId, message.sourceRuntimeId, - message.verseId, message.authorityRuntimeId, message.protocolId, endpoint, message.routeGeneration, - ); -} - -function testCanonical(...values: string[]): Uint8Array { - const chunks = values.map(value => new TextEncoder().encode(value)); - const bytes = new Uint8Array(chunks.reduce((sum, chunk) => sum + 4 + chunk.length, 0)); - const view = new DataView(bytes.buffer); - let offset = 0; - for (const chunk of chunks) { - view.setUint32(offset, chunk.length, false); - offset += 4; - bytes.set(chunk, offset); - offset += chunk.length; - } - return bytes; -} - -function toBase64(bytes: Uint8Array): string { - return Buffer.from(bytes).toString("base64"); -} - -function urlBase64ToBase64(value: string): string { - return Buffer.from(value, "base64url").toString("base64"); -} - -function bytesToBase64(bytes: Uint8Array): string { - let binary = ""; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary); -} - -function base64ToBytes(value: string): Uint8Array { - const binary = atob(value); - return Uint8Array.from(binary, character => character.charCodeAt(0)); -} diff --git a/packages/cultmesh-browser/tsconfig.json b/packages/cultmesh-browser/tsconfig.json deleted file mode 100644 index b9b7caba..00000000 --- a/packages/cultmesh-browser/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "Bundler", - "lib": ["ES2022", "DOM"], - "strict": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "dist", - "rootDir": "src", - "skipLibCheck": true - }, - "include": ["src/**/*.ts"] -} diff --git a/packages/cultmesh-browser/tsconfig.test.json b/packages/cultmesh-browser/tsconfig.test.json deleted file mode 100644 index 4d148683..00000000 --- a/packages/cultmesh-browser/tsconfig.test.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "types": ["node"], - "declaration": false, - "declarationMap": false, - "sourceMap": false, - "outDir": "dist-test", - "rootDir": "." - }, - "include": ["src/**/*.ts", "test/**/*.ts"] -} 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 104bd1b9..968e7669 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 @@ -2587,7 +2587,6 @@ data class CultNetRudpPacket( data class CultNetRudpDeliveredFrame(val channelId: String, val payload: ByteArray, val sequence: Long) data class CultNetRudpReceiveResult( val delivered: List = emptyList(), - val readyToSend: List = emptyList(), val reply: CultNetRudpPacket? = null, val pong: Boolean = false, val pongPayload: ByteArray = ByteArray(0), @@ -2620,16 +2619,10 @@ private data class FragmentBuffer( ) class CultNetRudpSession(options: CultNetRudpSessionOptions) { - companion object { - const val ReliableSendWindowPackets = 32 - private const val ReceivedSequenceWindow = 4_096 - } - val connectionId: Long = uint32(options.connectionId, "connectionId") val resendDelayMs: Long = options.resendDelayMs private val maxPendingReliablePackets: Int? = options.maxPendingReliablePackets private var nextSequence = uint32(options.initialSequence, "initialSequence") - private val nextSequencedByChannel = linkedMapOf() private var nextFragmentId = 1 var connected: Boolean = false private set @@ -2637,19 +2630,13 @@ class CultNetRudpSession(options: CultNetRudpSessionOptions) { private set private var highestReceivedSequence: Long? = null private val receivedSequences = linkedSetOf() - private val latestSequencedByChannel = linkedMapOf() private val pendingReliable = linkedMapOf() - private val queuedReliable = ArrayDeque() private val orderedNextSequenceByChannel = linkedMapOf() private val orderedBuffers = linkedMapOf>() private val fragmentBuffers = linkedMapOf, FragmentBuffer>() val pendingReliableSequences: List get() = pendingReliable.keys.sorted() - val queuedReliablePacketCount: Int - get() = queuedReliable.size - val outstandingReliablePacketCount: Int - get() = pendingReliable.size + queuedReliable.size init { if (maxPendingReliablePackets != null && maxPendingReliablePackets <= 0) { @@ -2676,9 +2663,6 @@ class CultNetRudpSession(options: CultNetRudpSessionOptions) { } fun send(channelId: String, payload: ByteArray, options: CultNetRudpSendOptions = CultNetRudpSendOptions()): CultNetRudpPacket { - if (options.reliable && pendingReliable.size >= ReliableSendWindowPackets) { - throw IOException("RUDP reliable send window is full; receive acknowledgements before sending") - } return sendMany(channelId, payload, options).first() } @@ -2689,14 +2673,13 @@ class CultNetRudpSession(options: CultNetRudpSessionOptions) { maxFragmentBytes: Int? = null, ): List { if (!connected) throw IOException("Cannot send RUDP data before the session is connected") - if (options.ordered && !options.reliable) throw IOException("RUDP ordered delivery requires reliability") if (maxFragmentBytes != null && maxFragmentBytes <= 0) throw IOException("RUDP maxFragmentBytes must be greater than zero") if (maxFragmentBytes != null && payload.size > maxFragmentBytes) { val fragmentCount = (payload.size + maxFragmentBytes - 1) / maxFragmentBytes if (fragmentCount > 0xffff) throw IOException("RUDP payload requires more than 65535 fragments") ensureReliableCapacity(if (options.reliable) fragmentCount else 0) val fragmentId = allocateFragmentId() - val packets = (0 until fragmentCount).map { index -> + return (0 until fragmentCount).map { index -> val start = index * maxFragmentBytes val packet = createPacket( channelId = channelId, @@ -2709,13 +2692,14 @@ class CultNetRudpSession(options: CultNetRudpSessionOptions) { fragmentIndex = index, fragmentCount = fragmentCount, ) + if (packet.reliable) trackReliable(packet, options.nowMs) packet } - return if (options.reliable) admitReliablePackets(packets, options.nowMs) else packets } ensureReliableCapacity(if (options.reliable) 1 else 0) val packet = createPacket(channelId = channelId, packetType = CultNetRudpPacketType.Data, payload = payload, reliable = options.reliable, ordered = options.ordered, sequenced = options.sequenced) - return if (packet.reliable) admitReliablePackets(listOf(packet), options.nowMs) else listOf(packet) + if (packet.reliable) trackReliable(packet, options.nowMs) + return listOf(packet) } fun receive(packet: CultNetRudpPacket, nowMs: Long = 0): CultNetRudpReceiveResult { @@ -2723,85 +2707,42 @@ class CultNetRudpSession(options: CultNetRudpSessionOptions) { val ignoredNow = nowMs requireConnection(packet) applyAcknowledgements(packet) - val readyToSend = promoteQueuedReliable(nowMs) lastReceivedAtMs = nowMs val expectedSequenceIfUninitialized = (highestReceivedSequence ?: (packet.sequence - 1)) + 1 when (packet.packetType) { CultNetRudpPacketType.Accept -> { rememberReceived(packet.sequence) connected = true - return CultNetRudpReceiveResult(readyToSend = readyToSend) + return CultNetRudpReceiveResult() } CultNetRudpPacketType.Ping -> { - return CultNetRudpReceiveResult(readyToSend = readyToSend, reply = createPacket(CultNetRudpPacketType.Pong, "control", packet.payload)) + rememberReceived(packet.sequence) + return CultNetRudpReceiveResult(reply = createPacket(CultNetRudpPacketType.Pong, "control", packet.payload)) } CultNetRudpPacketType.Ack, CultNetRudpPacketType.Pong -> { + rememberReceived(packet.sequence) return CultNetRudpReceiveResult( - readyToSend = readyToSend, pong = packet.packetType == CultNetRudpPacketType.Pong, pongPayload = if (packet.packetType == CultNetRudpPacketType.Pong) packet.payload.copyOf() else ByteArray(0), ) } CultNetRudpPacketType.Disconnect -> { + rememberReceived(packet.sequence) connected = false - return CultNetRudpReceiveResult(readyToSend = readyToSend, disconnected = true, disconnectReason = packet.payload.copyOf()) + return CultNetRudpReceiveResult(disconnected = true, disconnectReason = packet.payload.copyOf()) } CultNetRudpPacketType.Data -> Unit - else -> return CultNetRudpReceiveResult(readyToSend = readyToSend) + else -> return CultNetRudpReceiveResult() } - val duplicate = packet.reliable && (receivedSequences.contains(packet.sequence) || ( - highestReceivedSequence != null && - packet.sequence < highestReceivedSequence!! && - highestReceivedSequence!! - packet.sequence >= ReceivedSequenceWindow.toLong() - )) - if (packet.reliable) rememberReceived(packet.sequence) - if (duplicate) return CultNetRudpReceiveResult(readyToSend = readyToSend) - val reassembled = reassemble(packet) ?: return CultNetRudpReceiveResult(readyToSend = readyToSend) - if (!reassembled.ordered && packet.sequenced) { - val newestSequence = reassembled.nextSequence - 1 - val latestSequence = latestSequencedByChannel[reassembled.frame.channelId] - if (latestSequence != null && newestSequence <= latestSequence) { - return CultNetRudpReceiveResult(readyToSend = readyToSend) - } - latestSequencedByChannel[reassembled.frame.channelId] = newestSequence - } - return CultNetRudpReceiveResult( - delivered = if (reassembled.ordered) deliverOrdered(reassembled.frame, reassembled.nextSequence, expectedSequenceIfUninitialized) else listOf(reassembled.frame), - readyToSend = readyToSend, - ) + val duplicate = receivedSequences.contains(packet.sequence) + rememberReceived(packet.sequence) + if (duplicate) return CultNetRudpReceiveResult() + val reassembled = reassemble(packet) ?: return CultNetRudpReceiveResult() + return CultNetRudpReceiveResult(delivered = if (reassembled.ordered) deliverOrdered(reassembled.frame, reassembled.nextSequence, expectedSequenceIfUninitialized) else listOf(reassembled.frame)) } - fun createAck(): CultNetRudpPacket { - val (ack, ackMask) = ackState() - return CultNetRudpPacket( - packetType = CultNetRudpPacketType.Ack, - connectionId = connectionId, - sequence = 0, - ack = ack, - ackMask = ackMask, - channelId = "control", - ) - } - - fun createAckFor(sequence: Long): CultNetRudpPacket = CultNetRudpPacket( - packetType = CultNetRudpPacketType.Ack, - connectionId = connectionId, - sequence = 0, - ack = uint32(sequence, "ack sequence"), - ackMask = 0, - channelId = "control", - ) - - fun createAckForReceived(sequence: Long): CultNetRudpPacket { - val receivedSequence = uint32(sequence, "received sequence") - val (ack, _) = ackState() - return if (ack >= receivedSequence && ack - receivedSequence <= 32L) { - createAck() - } else { - createAckFor(receivedSequence) - } - } + fun createAck(): CultNetRudpPacket = createPacket(CultNetRudpPacketType.Ack, "control", ByteArray(0)) fun createPing(payload: ByteArray = ByteArray(0)): CultNetRudpPacket = createPacket(CultNetRudpPacketType.Ping, "control", payload) @@ -2836,15 +2777,8 @@ class CultNetRudpSession(options: CultNetRudpSessionOptions) { fragmentIndex: Int = 0, fragmentCount: Int = 0, ): CultNetRudpPacket { - val sequence = when { - reliable -> nextSequence.also { - nextSequence = uint32(nextSequence + 1, "reliable sequence") - } - sequenced -> (nextSequencedByChannel[channelId] ?: 1).also { - nextSequencedByChannel[channelId] = uint32(it + 1, "sequenced channel sequence") - } - else -> 0 - } + val sequence = nextSequence + nextSequence = uint32(nextSequence + 1, "sequence") val (ack, ackMask) = ackState() return CultNetRudpPacket(packetType, connectionId, sequence, ack, ackMask, channelId, reliable, ordered, sequenced, fragmentId, fragmentIndex, fragmentCount, payload.copyOf()) } @@ -2853,28 +2787,9 @@ class CultNetRudpSession(options: CultNetRudpSessionOptions) { pendingReliable[packet.sequence] = PendingReliablePacket(packet.copy(payload = packet.payload.copyOf()), nowMs) } - private fun admitReliablePackets(packets: List, nowMs: Long): List { - val available = maxOf(0, ReliableSendWindowPackets - pendingReliable.size) - val ready = packets.take(available) - ready.forEach { trackReliable(it, nowMs) } - packets.drop(available).forEach { queuedReliable.addLast(it.copy(payload = it.payload.copyOf())) } - return ready - } - - private fun promoteQueuedReliable(nowMs: Long): List { - val available = maxOf(0, ReliableSendWindowPackets - pendingReliable.size) - val ready = mutableListOf() - while (ready.size < available && !queuedReliable.isEmpty()) { - val packet = queuedReliable.removeFirst() - trackReliable(packet, nowMs) - ready.add(packet) - } - return ready - } - private fun ensureReliableCapacity(packetCount: Int) { if (packetCount == 0 || maxPendingReliablePackets == null) return - if (outstandingReliablePacketCount + packetCount > maxPendingReliablePackets) { + if (pendingReliable.size + packetCount > maxPendingReliablePackets) { throw IOException("RUDP reliable send queue is full") } } @@ -2891,10 +2806,6 @@ class CultNetRudpSession(options: CultNetRudpSessionOptions) { private fun rememberReceived(sequence: Long) { receivedSequences.add(sequence) if (highestReceivedSequence == null || sequence > highestReceivedSequence!!) highestReceivedSequence = sequence - if (receivedSequences.size > ReceivedSequenceWindow) { - val keepFrom = maxOf(0L, (highestReceivedSequence ?: sequence) - ReceivedSequenceWindow.toLong() + 1) - receivedSequences.removeIf { it < keepFrom } - } } private fun ackState(): Pair { @@ -3099,7 +3010,6 @@ class CultNetRudpSocketTransportConnection( maxPendingReliablePackets = maxPendingReliablePackets, ) val connected: Boolean get() = session.connected - val outstandingReliablePacketCount: Int get() = session.outstandingReliablePacketCount val localPort: Int get() = socket.localPort override val stats: CultNetTransportStats get() = CultNetTransportStats(bytesReceived, bytesSent, framesReceived, framesSent) @@ -3183,27 +3093,6 @@ class CultNetRudpSocketTransportConnection( override fun receiveSchemaMessage(timeoutMs: Long, pollIntervalMs: Long): CultNetMessage? = receiveSchema(timeoutMs, pollIntervalMs)?.let { parseCultNetMessage(it) } - fun flushReliable(timeoutMs: Long = 30_000, pollIntervalMs: Long = 5) { - val deadline = System.nanoTime() + timeoutMs.coerceAtLeast(0) * 1_000_000L - val preserved = ArrayDeque() - while (!delivered.isEmpty()) preserved.addLast(delivered.removeFirst()) - try { - while (session.outstandingReliablePacketCount > 0) { - if (System.nanoTime() >= deadline) { - throw SocketTimeoutException( - "RUDP reliable flush timed out with ${session.outstandingReliablePacketCount} packets outstanding" - ) - } - receiveOnce()?.let { preserved.addLast(it) } - pollResends() - Thread.sleep(pollIntervalMs) - } - } finally { - while (!delivered.isEmpty()) preserved.addLast(delivered.removeFirst()) - while (!preserved.isEmpty()) delivered.addLast(preserved.removeFirst()) - } - } - fun receiveOnce(): CultNetTransportFrame? { if (!delivered.isEmpty()) return delivered.removeFirst() val buffer = ByteArray(65535) @@ -3227,7 +3116,6 @@ class CultNetRudpSocketTransportConnection( } val result = session.receive(packet, nowMs()) result.reply?.let { sendPacket(it) } - result.readyToSend.forEach { sendPacket(it) } if (result.pong) { pongPayloads.add(result.pongPayload.copyOf()) } @@ -3240,9 +3128,7 @@ class CultNetRudpSocketTransportConnection( framesReceived += 1 } val frame = if (delivered.isEmpty()) null else delivered.removeFirst() - if (packet.reliable || packet.packetType == CultNetRudpPacketType.Accept || frame != null) { - sendPacket(session.createAckForReceived(packet.sequence)) - } + if (packet.packetType == CultNetRudpPacketType.Accept || frame != null) sendPacket(session.createAck()) return frame } @@ -3366,7 +3252,6 @@ fun main(args: Array) { rudpSessionPingsAndDetectsReceiveTimeout() rudpSessionBoundsPendingReliablePacketsBeforeEnqueue() rudpSessionFragmentsAndReassemblesReliableOrderedPayloads() - rudpSessionAdvancesLargeFragmentSetsThroughBoundedReliableWindow() rudpSocketTransportErgonomicFactoriesCarrySchemaFrames() rudpSocketTransportHandshakesAndCarriesReliableOrderedSchemaFrames() rudpSocketTransportCarriesFragmentedReliableOrderedSchemaFrames() @@ -4008,10 +3893,7 @@ private fun startInteropRudpServer( } val result = session.receive(packet, nowMs()) result.reply?.let { sendInteropRudpPacket(socket, remote, it) } - result.readyToSend.forEach { sendInteropRudpPacket(socket, remote, it) } - if (packet.reliable || packet.packetType == CultNetRudpPacketType.Data) { - sendInteropRudpPacket(socket, remote, session.createAckForReceived(packet.sequence)) - } + if (packet.packetType == CultNetRudpPacketType.Data) sendInteropRudpPacket(socket, remote, session.createAck()) for (frame in result.delivered) { if (frame.channelId != "schema") continue val sender = InteropRudpSessionSender(socket, remote, session) @@ -5341,43 +5223,6 @@ private fun rudpSessionFragmentsAndReassemblesReliableOrderedPayloads() { check(delivered.first().sequence == packets.first().sequence) } -private fun rudpSessionAdvancesLargeFragmentSetsThroughBoundedReliableWindow() { - val connectionId = 457L - val sender = CultNetRudpSession(CultNetRudpSessionOptions(connectionId = connectionId, initialSequence = 1)) - val receiver = CultNetRudpSession(CultNetRudpSessionOptions(connectionId = connectionId, initialSequence = 100)) - sender.receive(CultNetRudpPacket(CultNetRudpPacketType.Accept, connectionId, 0, 0, 0, "control")) - receiver.receive(CultNetRudpPacket(CultNetRudpPacketType.Accept, connectionId, 0, 0, 0, "control")) - - val fragmentCount = CultNetRudpSession.ReliableSendWindowPackets + 17 - val payload = ByteArray(fragmentCount * 8) { index -> (index % 251).toByte() } - val wire = ArrayDeque(sender.sendMany( - "schema", - payload, - CultNetRudpSendOptions(reliable = true, ordered = true, nowMs = 1), - maxFragmentBytes = 8, - )) - check(wire.size == CultNetRudpSession.ReliableSendWindowPackets) - val oldestSequence = wire.first().sequence - check(sender.pendingReliableSequences.size == wire.size) - check(sender.queuedReliablePacketCount == 17) - - val delivered = mutableListOf() - while (!wire.isEmpty()) { - val admitted = wire.size - repeat(admitted) { - delivered.addAll(receiver.receive(wire.removeFirst(), 2).delivered) - } - val acknowledged = sender.receive(receiver.createAck(), 3) - acknowledged.readyToSend.forEach { wire.addLast(it) } - } - check(sender.outstandingReliablePacketCount == 0) - check(delivered.size == 1) - check(delivered.first().payload.contentEquals(payload)) - val oldAck = receiver.createAckForReceived(oldestSequence) - check(oldAck.ack == oldestSequence) - check(oldAck.ackMask == 0L) -} - private fun rudpSocketTransportErgonomicFactoriesCarrySchemaFrames() { val connectionId = 0x10203042L CultMesh.createRudpServer( diff --git a/packages/cultmesh-py/README.md b/packages/cultmesh-py/README.md deleted file mode 100644 index 63799e52..00000000 --- a/packages/cultmesh-py/README.md +++ /dev/null @@ -1,252 +0,0 @@ -# cultmesh-py - -`cultmesh-py` is the Python CultMesh runtime: a cache-backed local node, -reactive and observed documents, the branded `CultMesh` facade, Verse and peer -discovery clients, snapshot and observation fan-out, peer health monitoring, -game sessions with prediction reconciliation, committed simulation facts, the -local TCP/RUDP server, and a package daemon. - -It depends on `cultcache-py` and `cultnet-py`, mirroring `cultmesh-rs` and -`cultmesh-ts`. The Verse catalog, peer exchange, authority lease, stream -negotiation, and simulation fact contracts are defined in -`cultnet_py.cultmesh_contracts` and re-exported here. - -This is the package a Python process embeds to serve a Verse — the Brokkr -Blender add-on serves its editor mirror through `CultMesh.serve_node(...)` -from inside Blender's own interpreter. - -## Example - -```python -from datetime import UTC, datetime, timedelta - -from cultcache_py import define_database_entry_type -from cultnet_py import ( - CultNetClientAuthorityScope, - CultNetFileShardReplicaCursorStore, - CultNetRawClient, - CultNetSchemaShardLogFetcher, - CultNetSchemaShardSnapshotFetcher, - CultNetShardDescriptor, - CultNetShardReplicator, - CultNetShardReplicatorOptions, -) -from cultmesh_py import ( - CultMesh, - CultMeshAuthorityLease, - CultMeshGameSessionOptions, - CultMeshHmacAuthorityLeaseVerifier, - CultMeshNodeOptions, - CultMeshSnapshotFanout, - peer_exchange_request, -) - -note_doc = define_database_entry_type( - "mesh.note", - [("body", 0), ("name", 1), ("kind", 2)], - name="name", - indexes={"kind": "kind"}, -) -node = CultMesh.start_node( - "mesh.cc", - runtime_id="python-runtime", - options=CultMeshNodeOptions(enable_durable_shard_logs=True), -) -node.database.register_document(note_doc) -unsubscribe = node.database.watch_record(note_doc, "note:1", lambda change: print(change.change_kind)) -unsubscribe_named = node.database.watch_by_name(note_doc, "intro", lambda change: print(change.record_key)) -unsubscribe_kind = node.database.watch_by_index(note_doc, "kind", "guide", lambda change: print(change.record_key)) -node.database.put(note_doc, "note:1", {"body": "hello", "name": "intro", "kind": "guide"}) -live_note = node.database.get_required(note_doc, "note:1") -put_message = node.database.put_raw_message( - note_doc, - "note:2", - {"body": "wire me", "name": "wire", "kind": "interop"}, - shard_id="interop", - shard_epoch=1, -) -snapshot_response = node.database.build_snapshot_response(schema_ids=[note_doc.catalog_entry().schema_id]) -snapshot_wire = node.database.create_snapshot_response(schema_ids=[note_doc.catalog_entry().schema_id]) -delete_message = node.database.delete_raw_message(note_doc, "note:2", shard_id="interop", shard_epoch=1) -shard_log_response = node.database.build_shard_log_response(shard_id="interop", shard_epoch=1) -shard_log_wire = node.database.create_shard_log_response(shard_id="interop", shard_epoch=1) - -station_stock_doc = define_database_entry_type( - "aetheria.station_stock", - [("missiles", 0), ("coolant", 1)], - schema_id="gamecult.aetheria.station_stock.v1", -) -station_stock_ui_doc = define_database_entry_type( - "aetheria.station_stock.ui", - [("missiles", 0), ("coolant", 1)], - schema_id="gamecult.aetheria.station_stock.v1", -) -node.database.register_document(station_stock_doc) -node.database.put(station_stock_ui_doc, "station:starbridge:stock", {"missiles": 24, "coolant": 80}) -stock = node.database.get_required(station_stock_ui_doc, "station:starbridge:stock") - -peers = CultMesh.create_peer_catalog() -response = peers.create_response(peer_exchange_request("pex-1", verse_id="local")) -lease_verifier = CultMeshHmacAuthorityLeaseVerifier({"odin": b"shared-lease-key"}) -lease_catalog = CultMesh.create_authority_lease_catalog( - signature_verifier=lease_verifier.verify, - require_verified_signatures=True, -) -lease_valid_from = datetime.now(UTC) -lease = lease_verifier.issue(CultMeshAuthorityLease( - lease_id="lease:python-runtime", - verse_id="local", - peer_id="python-runtime", - roles=("shard-primary",), - valid_from=lease_valid_from, - expires_at=lease_valid_from + timedelta(minutes=5), - shard_ids=("interop",), - issuer_runtime_id="odin", -)) -lease_catalog.upsert(lease) -session = CultMesh.create_game_session( - node, - CultMeshGameSessionOptions( - client_authority_scopes=( - CultNetClientAuthorityScope("python-runtime", schema_ids=(note_doc.catalog_entry().schema_id,), key_prefix="input:python"), - ), - ), -) -server = CultMesh.serve_node( - node, - peer_catalog=peers, - observation_hub=session.observation_hub, - port=3075, - max_snapshot_documents=1000, - max_snapshot_bytes=4 * 1024 * 1024, -) -``` - -The same local server can be launched as a package daemon when another runtime -or operator process needs a CultNet endpoint without embedding Python glue: - -```powershell -python -m cultmesh_py.daemon --runtime-id python-runtime --display-name "Python Runtime" --host 127.0.0.1 --port 3075 --cache-file mesh.cc --enable-durable-shard-logs --seed-interop-note --seed-shard-id interop --verse-id local --role shard-primary --ready-file mesh.ready.json -``` - -The ready file and stdout line are process readiness hints. The served state and -capability truth still live on the framed CultNet/CultMesh endpoint. Use -`--register-interop-note` to expose the package interop-note schema without -seeding a note, or `--seed-interop-note` to register it and publish one local -record for wire probes. Seeded records are committed through the raw mutation -path, so peers can read them through both snapshot and shard-log catch-up. Add -`--verse-id` and one or more `--role` values to publish the launched endpoint in -its own Verse and peer catalogs for discovery clients. When the daemon is -launched with `--cache-file`, `--enable-durable-shard-logs`, and -`--shard-log-file`, a later daemon process can rehydrate and serve the persisted -snapshot and shard log without reseeding. - -`CultMeshPeerHealthMonitor` probes peer-card endpoints with `cultnet.hello.v0` -and preserves the runtime id, display name, document types, message versions, -and mutation contracts reported by the peer. - -```python -client = CultMesh.create_verse_discovery_client("127.0.0.1", 3075) -verses = client.fetch_verses(transport_version="cultmesh.v0") -mesh_peers = client.fetch_peers(verse_id="python-interop", roles=["read-replica"]) -client.sync_peer_catalog(peers, verse_id="python-interop", roles=["read-replica"]) -client.fanout_peer_catalog(peers, verse_id="python-interop", roles=["read-replica"]) -replica_notes = client.sync_documents( - node.database, - peers, - verse_id="python-interop", - roles=["read-replica"], - documents=[(note_doc, "note:remote")], -) -snapshot_fanout = CultMeshSnapshotFanout( - client, - node.database, - peers, - verse_id="python-interop", - roles=["read-replica"], - documents=[(note_doc, "note:remote")], - on_document=lambda note: print(note), -) -snapshot_fanout.sync_once() - -raw_client = CultNetRawClient("127.0.0.1", 3075) -synced_note = node.database.sync_document(raw_client, note_doc, "note:remote") -synced_stock = CultMesh.sync_document( - node, - raw_client, - station_stock_ui_doc, - "station:starbridge:stock", -) -with CultMesh.subscribe_document( - node, - raw_client, - station_stock_ui_doc, - "station:starbridge:stock", -) as stock_subscription: - stock = stock_subscription.sync_initial() - next_change = stock_subscription.read_next_change() -node.database.sync_shard_log(raw_client, shard_id="interop", shard_epoch=1) -replicator = CultNetShardReplicator( - node.database, - CultNetShardReplicatorOptions( - fetcher=CultNetSchemaShardLogFetcher(), - snapshot_fetcher=CultNetSchemaShardSnapshotFetcher(), - cursor_store=CultNetFileShardReplicaCursorStore("mesh.replica-cursors.msgpack"), - poll_interval_seconds=1.0, - on_error=lambda error: print(error), - ), -) -replica_shard = CultNetShardDescriptor( - shard_id="interop", - owner_runtime_id="primary-runtime", - epoch=1, - schema_ids=(note_doc.catalog_entry().schema_id,), - primary_endpoints=("cultnet://127.0.0.1:3075",), -) -replicator.pull_once(replica_shard) -replicator.start([replica_shard]) -replicator.stop() -server.stop() - -streams = CultMesh.create_stream_catalog() - -facts = CultMesh.create_simulation_fact_committer(node) -committed = facts.commit({ - "shardId": "arena", - "shardEpoch": 4, - "frame": 100, - "subjectId": "bob", - "claimKind": "hit", - "claimHash": "accepted-claim-hash", - "witnessCount": 2, - "supportWeight": 2.0, - "totalWeight": 2.0, - "confidence": 1.0, - "hasQuorum": True, -}) -prediction = session.predict(note_doc, "input:python:move", {"body": "predicted input"}) -unsubscribe() -``` - -## Verify - -`cultmesh_py.verify` checks the public exports and `py.typed` markers of all -three Python packages, smokes a local node over the wire, checks the served -capability truth, and runs the benchmark for sanity: - -```powershell -python -m cultmesh_py.verify --json -``` - -## Tests - -```powershell -$env:PYTHONPATH="$PWD\packages\cultcache-py\src;$PWD\packages\cultnet-py\src;$PWD\packages\cultmesh-py\src" -python -m unittest discover -s packages\cultmesh-py\tests -``` - -`packages\cultnet-ts\test\interop\cultnet-interop.test.ts` verifies the public -`CultMeshDiscoveryClient` can fetch typed Verse and peer descriptors from the -live Python peer. See -[docs/python-runtime-parity.md](../../docs/python-runtime-parity.md) for the -evidence map across the three Python packages. diff --git a/packages/cultmesh-py/pyproject.toml b/packages/cultmesh-py/pyproject.toml deleted file mode 100644 index a1df5ee3..00000000 --- a/packages/cultmesh-py/pyproject.toml +++ /dev/null @@ -1,28 +0,0 @@ -[build-system] -requires = ["setuptools>=68"] -build-backend = "setuptools.build_meta" - -[project] -name = "cultmesh-py" -version = "0.1.0" -description = "Python CultMesh node, discovery, sessions, and local server for GameCult typed state." -readme = "README.md" -requires-python = ">=3.10" -license = "MIT" -authors = [{ name = "GameCult" }] -classifiers = [ - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", -] -dependencies = ["cultcache-py>=0.2.0", "cultnet-py>=0.1.0"] -[project.scripts] -cultmesh-py-daemon = "cultmesh_py.daemon:main" -cultmesh-py-verify = "cultmesh_py.verify:main" - -[tool.setuptools.packages.find] -where = ["src"] - -[tool.setuptools.package-data] -cultmesh_py = ["py.typed"] diff --git a/packages/cultmesh-py/src/cultmesh_py/simulation.py b/packages/cultmesh-py/src/cultmesh_py/simulation.py deleted file mode 100644 index a89ba4a2..00000000 --- a/packages/cultmesh-py/src/cultmesh_py/simulation.py +++ /dev/null @@ -1,47 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any - -from cultcache_py.documents import DocumentDefinition -from cultnet_py.cultmesh_contracts import ( - SIMULATION_FACT_DOCUMENT_TYPE, - SIMULATION_FACT_SCHEMA_VERSION, - CultMeshSimulationFact, - simulation_fact_document, -) - -from .node import CultMeshNode - -__all__ = [ - "SIMULATION_FACT_DOCUMENT_TYPE", - "SIMULATION_FACT_SCHEMA_VERSION", - "CultMeshSimulationFact", - "CultMeshSimulationFactCommit", - "CultMeshSimulationFactCommitter", - "simulation_fact_document", -] - - -@dataclass(frozen=True) -class CultMeshSimulationFactCommit: - key: str - fact: CultMeshSimulationFact - - -@dataclass -class CultMeshSimulationFactCommitter: - node: CultMeshNode - document: DocumentDefinition[CultMeshSimulationFact] = simulation_fact_document - - def __post_init__(self) -> None: - if all(document.type != self.document.type for document in self.node.documents): - self.node.register_document(self.document) - - def commit(self, candidate: dict[str, Any], *, committed_at: str | None = None) -> CultMeshSimulationFactCommit: - if not bool(candidate.get("hasQuorum") if "hasQuorum" in candidate else candidate.get("has_quorum")): - raise ValueError("Simulation candidate cannot be committed before quorum") - key = CultMeshSimulationFact.create_record_key(candidate) - fact = CultMeshSimulationFact.from_candidate(candidate, committed_at=committed_at) - self.node.put(self.document, key, fact) - return CultMeshSimulationFactCommit(key=key, fact=fact) diff --git a/packages/cultmesh-py/tests/test_cultmesh.py b/packages/cultmesh-py/tests/test_cultmesh.py deleted file mode 100644 index b6a21bb8..00000000 --- a/packages/cultmesh-py/tests/test_cultmesh.py +++ /dev/null @@ -1,4491 +0,0 @@ -from __future__ import annotations - -import json -import os -import socket -import subprocess -import sys -import tempfile -import threading -import time -import unittest -from unittest.mock import patch -from dataclasses import dataclass -from datetime import UTC, datetime, timedelta -from pathlib import Path -from typing import Any, Callable -from cultcache_py import ( - CultCache, - SingleFileMessagePackBackingStore, - define_database_entry_type, - define_document_type, -) -from cultcache_py.interop import INTEROP_SCHEMA_VERSION, interop_note_document -from cultmesh_py.verify import verify -from cultnet_py import ( - compute_simulation_claim_hash, - CultNetClientAuthorityScope, - CultNetRawClient, - CultNetRawDocumentRecord, - CultNetRawSnapshotResponse, - CultNetSchemaCatalog, - CultNetShardCatalog, - CultNetShardDescriptor, - CultNetShardLogResponse, - CultNetFileShardReplicaCursorStore, - CultNetFileShardMutationLogStore, - CultNetInMemoryShardReplicaCursorStore, - CultNetSchemaWriteForwarder, - CultNetSchemaShardLogFetcher, - CultNetSchemaShardSnapshotFetcher, - CultNetPeerError, - CultNetShardReplicator, - CultNetShardReplicatorOptions, - CultNetShardMutationLogStore, - CultNetShardWriteForwarder, - CultNetSimulationConsensusOptions, - CultNetSimulationConsensusCandidate, - CultNetSimulationObservation, - CultNetSimulationObservationHub, - CultNetRudpSocketMode, - CultNetRudpSocketTransportConnection, - CultNetRudpSocketTransportOptions, - apply_shard_log_response, - document_delete, - document_put_raw, - hello, - simulation_observation, - snapshot_request, -) -from cultmesh_py import create_node -from cultmesh_py import ( - CultMesh, - CultMeshDatabase, - CultMeshDatabaseChange, - CultMeshGameSessionOptions, - CultMeshNodeOptions, - CultMeshReactiveDocumentOptions, - CultMeshPeerCard, - CultMeshPeerCatalog, - CultMeshSimulationFact, - CultMeshAuthorityLease, - CultMeshAuthorityLeaseCatalog, - CultMeshHmacAuthorityLeaseVerifier, - CultMeshDiscoveryClient, - CultMeshPeerExchangeClient, - CultMeshPeerHealthMonitor, - CultMeshSimulationObservationFanout, - CultMeshSnapshotFanout, - CultMeshStreamCatalog, - CultMeshStreamConsumerProfile, - CultMeshStreamDescriptor, - CultMeshStreamFrameHandle, - CultMeshVerseCatalog, - CultMeshVerseCompatibility, - CultMeshVerseDescriptor, - CultMeshVerseDiscoveryClient, - READY_SCHEMA_VERSION, - peer_exchange_request, - simulation_fact_document, - verse_catalog_request, -) - - -def bind_udp_socket() -> socket.socket: - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.bind(("127.0.0.1", 0)) - sock.settimeout(0.02) - return sock - - -def pump_rudp_handshake( - client: CultNetRudpSocketTransportConnection, - server: CultNetRudpSocketTransportConnection, -) -> None: - for _ in range(20): - server.receive_once() - client.receive_once() - server.receive_once() - if client.connected and server.connected: - return - time.sleep(0.005) - raise AssertionError("RUDP socket handshake did not complete") - - -def receive_rudp_frame(transport: CultNetRudpSocketTransportConnection): - for _ in range(20): - frame = transport.receive_once() - if frame is not None: - return frame - time.sleep(0.005) - raise AssertionError("RUDP socket frame was not delivered") - - -@dataclass -class Item: - name: str - category: str - value: int - - -class CultMeshTests(unittest.TestCase): - def _wait_until(self, predicate: Callable[[], bool], *, timeout_seconds: float = 2.0) -> None: - deadline = time.monotonic() + timeout_seconds - while time.monotonic() < deadline: - if predicate(): - return - time.sleep(0.01) - self.fail("Timed out waiting for condition") - - def test_verify_reports_python_runtime_surface_health(self) -> None: - result = verify(records=4) - - self.assertEqual(result["status"], "ok") - self.assertEqual( - {check["name"] for check in result["checks"]}, - { - "public_exports", - "typed_markers", - "local_cultmesh_wire_smoke", - "cultmesh_capability_truth", - "benchmark_sanity", - }, - ) - by_name = {check["name"]: check for check in result["checks"]} - self.assertEqual(by_name["public_exports"]["missing"], {}) - self.assertEqual(by_name["cultmesh_capability_truth"]["failures"], []) - - def test_cultmesh_client_uses_rudp_schema_transport_endpoint(self) -> None: - import msgpack # type: ignore - - server_socket = bind_udp_socket() - server_host, server_port = server_socket.getsockname()[:2] - connection_id = 0x43554C54 - server = CultNetRudpSocketTransportConnection( - CultNetRudpSocketTransportOptions( - runtime_id="python-rudp-schema-server", - socket=server_socket, - mode=CultNetRudpSocketMode.SERVER, - connection_id=connection_id, - initial_sequence=100, - resend_delay_ms=25, - ) - ) - received_messages: list[dict[str, object]] = [] - - def serve_once() -> None: - deadline = time.monotonic() + 2.0 - try: - while time.monotonic() < deadline: - frame = server.receive_once() - server.poll_resends() - if frame is None: - time.sleep(0.005) - continue - received = msgpack.unpackb(frame.payload, raw=False) - received_messages.append(received) - response = { - "schemaVersion": "cultnet.schema_catalog_response.v0", - "messageId": received["messageId"], - "schemas": [], - } - server.send("schema", msgpack.packb(response, use_bin_type=True)) - return - raise AssertionError("RUDP schema server did not receive a request") - finally: - server.close() - - server_thread = threading.Thread(target=serve_once, daemon=True) - server_thread.start() - client = CultMesh.create_client( - endpoint=f"rudp://{server_host}:{server_port}", - timeout_seconds=2.0, - connection_id=connection_id, - runtime_id="python-rudp-schema-client", - ) - - response = client.fetch_schema_catalog(message_id="rudp-transport-factory") - server_thread.join(timeout=2.0) - - self.assertFalse(server_thread.is_alive()) - self.assertEqual(response["schemaVersion"], "cultnet.schema_catalog_response.v0") - self.assertEqual(response["messageId"], "rudp-transport-factory") - self.assertEqual(received_messages[0]["schemaVersion"], "cultnet.schema_catalog_request.v0") - self.assertEqual(received_messages[0]["messageId"], "rudp-transport-factory") - - def test_cultmesh_facade_creates_rudp_client_from_peer_endpoint(self) -> None: - connection_id = 0x10203042 - server = CultMesh.create_rudp_server( - "python-cultmesh-rudp-server", - connection_id, - initial_sequence=100, - resend_delay_ms=25, - max_fragment_bytes=1024, - max_pending_reliable_packets=16, - ) - server_host, server_port = server.socket.getsockname()[:2] - endpoint = CultMesh.parse_rudp_endpoint(f"rudp://{server_host}:{server_port}") - self.assertEqual(endpoint.host, server_host) - self.assertEqual(endpoint.port, server_port) - peer = CultMeshPeerCard( - peer_id="python-cultmesh-rudp-server", - verse_id="local", - endpoints=(endpoint.uri,), - roles=("schema",), - authority_lease_id="lease:python-cultmesh-rudp-server", - ) - peers = CultMesh.create_peer_catalog() - leases = CultMesh.create_authority_lease_catalog() - peers.upsert(peer) - with self.assertRaisesRegex(ValueError, "No authorized RUDP peer"): - CultMesh.create_rudp_client_for_authorized_peer( - "python-cultmesh-rudp-client", - connection_id, - peers, - leases, - "local", - "schema", - resend_delay_ms=25, - max_fragment_bytes=1024, - max_pending_reliable_packets=16, - ) - leases.upsert( - CultMeshAuthorityLease( - lease_id="lease:python-cultmesh-rudp-server", - verse_id="local", - peer_id="python-cultmesh-rudp-server", - roles=("schema",), - valid_from=datetime.now(UTC) - timedelta(seconds=1), - expires_at=datetime.now(UTC) + timedelta(seconds=30), - issuer_runtime_id="python-authority", - ) - ) - client = CultMesh.create_rudp_client_for_authorized_peer( - "python-cultmesh-rudp-client", - connection_id, - peers, - leases, - "local", - "schema", - resend_delay_ms=25, - max_fragment_bytes=1024, - max_pending_reliable_packets=16, - ) - - try: - client.connect(b"join") - pump_rudp_handshake(client, server) - self.assertTrue(client.connected) - self.assertTrue(server.connected) - - client.send("schema", b"client-state") - server_frame = receive_rudp_frame(server) - self.assertEqual(server_frame.channel_id, "schema") - self.assertEqual(server_frame.payload, b"client-state") - self.assertEqual(server.profile["transports"][0]["protocol"], "rudp") - self.assertEqual(client.profile["transports"][0]["protocol"], "rudp") - finally: - client.close() - server.close() - - def test_cultmesh_node_applies_foreign_schema_snapshot_as_local_update(self) -> None: - document = define_database_entry_type( - "mesh.runtime-policy", - [ - ("schema_version", 0), - ("name", 1), - ("value", 2), - ], - schema_id="mesh.runtime-policy.current", - schema_name="mesh.runtime_policy", - schema_version="mesh.runtime_policy.v1", - ) - node = create_node(runtime_id="python-node") - node.register_document(document) - node.put(document, "policy:1", { - "schema_version": "mesh.runtime_policy.v1", - "name": "policy", - "value": "old", - }) - observed: list[CultMeshDatabaseChange] = [] - unsubscribe = node.database.watch_record(document, "policy:1", observed.append) - try: - applied = node.database.apply_snapshot_response({ - "schemaVersion": "cultnet.snapshot_response_raw.v0", - "messageId": "foreign-schema-snapshot", - "documents": [ - { - "schemaId": "sha256:foreign-mesh-runtime-policy", - "recordKey": "policy:1", - "storedAt": "2026-06-25T00:00:00Z", - "payloadEncoding": "messagepack", - "payload": document.encode_payload({ - "schema_version": "mesh.runtime_policy.v1", - "name": "policy", - "value": "new", - }), - }, - ], - }) - finally: - unsubscribe() - - local_schema_id = document.catalog_entry().schema_id - self.assertEqual(applied[0].schema_id, local_schema_id) - self.assertEqual(observed[0].schema_id, local_schema_id) - self.assertEqual(observed[0].change_kind, "updated") - self.assertEqual(observed[0].previous_value["value"], "old") - self.assertEqual(observed[0].value["value"], "new") - self.assertEqual(node.cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id) - - def test_cultmesh_node_syncs_snapshot_and_shard_log_through_cultnet_client(self) -> None: - import msgpack # type: ignore - from cultnet_py import read_frame, write_frame - - document = define_database_entry_type( - "mesh.sync_item", - [ - ("name", 0), - ("category", 1), - ("value", 2, 0), - ], - cls=Item, - ) - node = create_node(runtime_id="python-node") - node.register_document(document) - schema_id = document.catalog_entry().schema_id - ready = threading.Event() - server_error: list[BaseException] = [] - port_holder: list[int] = [] - - def serve_requests() -> None: - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: - server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - server.bind(("127.0.0.1", 0)) - port_holder.append(server.getsockname()[1]) - server.listen(2) - ready.set() - for _ in range(2): - connection, _ = server.accept() - with connection: - stream = connection.makefile("rwb") - request = msgpack.unpackb(read_frame(stream), raw=False) - if request["schemaVersion"] == "cultnet.snapshot_request.v0": - response = { - "schemaVersion": "cultnet.snapshot_response_raw.v0", - "messageId": request["messageId"], - "documents": [ - { - "schemaId": schema_id, - "recordKey": "item:node", - "storedAt": "2026-06-13T00:00:00Z", - "payloadEncoding": "messagepack", - "payload": document.encode_payload(Item("node", "mesh", 1)), - } - ], - } - elif request["schemaVersion"] == "cultnet.shard_log_request.v0": - response = { - "schemaVersion": "cultnet.shard_log_response.v0", - "messageId": request["messageId"], - "shardId": request["shardId"], - "shardEpoch": request["shardEpoch"], - "resyncRequired": False, - "entries": [ - { - "sequence": 1, - "changeKind": "updated", - "put": { - "schemaVersion": "cultnet.document_put_raw.v0", - "messageId": "put-node", - "document": { - "schemaId": schema_id, - "recordKey": "item:node", - "storedAt": "2026-06-13T00:00:01Z", - "payloadEncoding": "messagepack", - "payload": document.encode_payload(Item("node", "mesh", 9)), - }, - "shardId": request["shardId"], - "shardEpoch": request["shardEpoch"], - }, - } - ], - } - else: - raise AssertionError(f"unexpected request {request['schemaVersion']}") - write_frame(stream, msgpack.packb(response, use_bin_type=True)) - stream.flush() - except BaseException as error: - server_error.append(error) - ready.set() - - thread = threading.Thread(target=serve_requests, daemon=True) - thread.start() - self.assertTrue(ready.wait(2.0)) - self.assertFalse(server_error) - - client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0) - snapshot_changes = node.sync_snapshot(client, schema_ids=[schema_id]) - log_changes = node.sync_shard_log(client, shard_id="mesh", shard_epoch=1) - - thread.join(2.0) - self.assertFalse(server_error) - self.assertEqual(snapshot_changes[0].record_key, "item:node") - self.assertEqual(log_changes[0].change_kind, "updated") - self.assertEqual(node.get_required(document, "item:node").value, 9) - - def test_cultmesh_node_sync_document_returns_requested_alias_type(self) -> None: - @dataclass - class CanonicalNote: - body: str - - @dataclass - class UiNote: - body: str - - document = define_database_entry_type( - "mesh.sync_alias_note", - [("body", 0)], - cls=CanonicalNote, - schema_id="mesh.sync_alias_note.v1", - schema_name="mesh.sync_alias_note", - schema_version="mesh.sync_alias_note.v1", - ) - alias = define_database_entry_type( - "mesh.sync_alias_note.ui", - [("body", 0)], - cls=UiNote, - schema_id="mesh.sync_alias_note.v1", - schema_name="mesh.sync_alias_note", - schema_version="mesh.sync_alias_note.v1", - ) - node = create_node(runtime_id="python-node") - node.register_document(document) - requests: list[dict[str, Any]] = [] - - class SnapshotClient: - def fetch_snapshot_response( - self, - *, - schema_ids: list[str] | None = None, - record_keys: list[str] | None = None, - shard_id: str | None = None, - shard_epoch: int | None = None, - ) -> dict[str, Any]: - requests.append({ - "schema_ids": schema_ids, - "record_keys": record_keys, - "shard_id": shard_id, - "shard_epoch": shard_epoch, - }) - return { - "schemaVersion": "cultnet.snapshot_response_raw.v0", - "messageId": "sync-document", - "documents": [ - { - "schemaId": document.catalog_entry().schema_id, - "recordKey": "note:remote", - "storedAt": "2026-06-27T00:00:00Z", - "payloadEncoding": "messagepack", - "payload": document.encode_payload(CanonicalNote("synced-alias")), - } - ], - } - - synced = node.sync_document(SnapshotClient(), alias, "note:remote") - facade_synced = CultMesh.sync_document(node, SnapshotClient(), alias, "note:remote") - - self.assertIsInstance(synced, UiNote) - self.assertEqual(synced.body, "synced-alias") - self.assertIsInstance(facade_synced, UiNote) - self.assertEqual(facade_synced.body, "synced-alias") - self.assertEqual(node.get_required(document, "note:remote").body, "synced-alias") - self.assertIsInstance(node.get_required(alias, "note:remote"), UiNote) - self.assertEqual(requests, [ - { - "schema_ids": ["mesh.sync_alias_note.v1"], - "record_keys": ["note:remote"], - "shard_id": None, - "shard_epoch": None, - }, - { - "schema_ids": ["mesh.sync_alias_note.v1"], - "record_keys": ["note:remote"], - "shard_id": None, - "shard_epoch": None, - }, - ]) - - def test_cultmesh_node_sync_document_from_publication_returns_requested_alias_type(self) -> None: - @dataclass - class CanonicalNote: - body: str - - @dataclass - class UiNote: - body: str - - document = define_database_entry_type( - "mesh.publication_alias_note", - [("body", 0)], - cls=CanonicalNote, - schema_id="mesh.publication_alias_note.v1", - schema_name="mesh.publication_alias_note", - schema_version="mesh.publication_alias_note.v1", - ) - alias = define_database_entry_type( - "mesh.publication_alias_note.ui", - [("body", 0)], - cls=UiNote, - schema_id="mesh.publication_alias_note.v1", - schema_name="mesh.publication_alias_note", - schema_version="mesh.publication_alias_note.v1", - ) - node = create_node(runtime_id="python-publication-node") - node.register_document(document) - requests: list[dict[str, Any]] = [] - - class SnapshotClient: - def fetch_snapshot_response( - self, - *, - schema_ids: list[str] | None = None, - record_keys: list[str] | None = None, - shard_id: str | None = None, - shard_epoch: int | None = None, - ) -> dict[str, Any]: - requests.append({ - "schema_ids": schema_ids, - "record_keys": record_keys, - "shard_id": shard_id, - "shard_epoch": shard_epoch, - }) - return { - "schemaVersion": "cultnet.snapshot_response_raw.v0", - "messageId": "publication-sync-document", - "documents": [ - { - "schemaId": document.catalog_entry().schema_id, - "recordKey": "note:published-peer", - "storedAt": "2026-06-27T00:00:00Z", - "payloadEncoding": "messagepack", - "payload": document.encode_payload(CanonicalNote("peer-publication-alias")), - } - ], - } - - source = CultMesh.publication_source_from_peer_snapshot( - SnapshotClient(), - shard_id="notes", - shard_epoch=3, - ) - - synced = node.sync_document_from_publication(source, alias, "note:published-peer") - facade_synced = CultMesh.sync_document_from_publication(node, source, alias, "note:published-peer") - - self.assertIsInstance(synced, UiNote) - self.assertEqual(synced.body, "peer-publication-alias") - self.assertIsInstance(facade_synced, UiNote) - self.assertEqual(facade_synced.body, "peer-publication-alias") - self.assertEqual(node.get_required(document, "note:published-peer").body, "peer-publication-alias") - self.assertEqual(requests, [ - { - "schema_ids": ["mesh.publication_alias_note.v1"], - "record_keys": ["note:published-peer"], - "shard_id": "notes", - "shard_epoch": 3, - }, - { - "schema_ids": ["mesh.publication_alias_note.v1"], - "record_keys": ["note:published-peer"], - "shard_id": "notes", - "shard_epoch": 3, - }, - ]) - - def test_cultmesh_node_sync_document_from_single_file_publication_uses_node_aliases(self) -> None: - @dataclass - class CanonicalNote: - body: str - - @dataclass - class UiNote: - body: str - - document = define_database_entry_type( - "mesh.file_publication_alias_note", - [("body", 0)], - cls=CanonicalNote, - schema_id="mesh.file_publication_alias_note.v1", - schema_name="mesh.file_publication_alias_note", - schema_version="mesh.file_publication_alias_note.v1", - ) - alias = define_database_entry_type( - "mesh.file_publication_alias_note.ui", - [("body", 0)], - cls=UiNote, - schema_id="mesh.file_publication_alias_note.v1", - schema_name="mesh.file_publication_alias_note", - schema_version="mesh.file_publication_alias_note.v1", - ) - with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "publication.ccmp" - writer = CultCache() - writer.register_document_type(document) - writer.add_generic_store(SingleFileMessagePackBackingStore(path)) - writer.put(document, "note:published-file", CanonicalNote("file-publication-alias")) - - node = create_node(runtime_id="python-file-publication-node") - node.register_document(document) - source = CultMesh.publication_source_from_single_file(path) - - synced = CultMesh.sync_document_from_publication(node, source, alias, "note:published-file") - - self.assertIsInstance(synced, UiNote) - self.assertEqual(synced.body, "file-publication-alias") - self.assertEqual(node.get_required(document, "note:published-file").body, "file-publication-alias") - self.assertIsInstance(node.get_required(alias, "note:published-file"), UiNote) - - def test_cultmesh_node_sync_documents_from_publication_uses_binding_sources_and_aliases(self) -> None: - @dataclass - class CanonicalNote: - body: str - - @dataclass - class UiNote: - body: str - - document = define_database_entry_type( - "mesh.catalog_publication_alias_note", - [("body", 0)], - cls=CanonicalNote, - schema_id="mesh.catalog_publication_alias_note.v1", - schema_name="mesh.catalog_publication_alias_note", - schema_version="mesh.catalog_publication_alias_note.v1", - ) - alias = define_database_entry_type( - "mesh.catalog_publication_alias_note.ui", - [("body", 0)], - cls=UiNote, - schema_id="mesh.catalog_publication_alias_note.v1", - schema_name="mesh.catalog_publication_alias_note", - schema_version="mesh.catalog_publication_alias_note.v1", - ) - with tempfile.TemporaryDirectory() as tmp: - first_path = Path(tmp) / "first.ccmp" - second_path = Path(tmp) / "second.ccmp" - first = CultCache() - first.register_document_type(document) - first.add_generic_store(SingleFileMessagePackBackingStore(first_path)) - first.put(document, "note:first", CanonicalNote("first catalog publication")) - second = CultCache() - second.register_document_type(document) - second.add_generic_store(SingleFileMessagePackBackingStore(second_path)) - second.put(document, "note:second", CanonicalNote("second catalog publication")) - - node = create_node(runtime_id="python-catalog-publication-node") - node.register_document(document) - source = CultMesh.publication_source_from_single_file(first_path) - bindings = [ - CultMesh.publication_document(alias, "note:first"), - CultMesh.publication_document( - alias, - "note:second", - source=CultMesh.publication_source_from_single_file(second_path), - ), - ] - - synced = node.sync_documents_from_publication(source, bindings) - facade_synced = CultMesh.sync_documents_from_publication(node, source, bindings) - - self.assertEqual([value.body for value in synced], [ - "first catalog publication", - "second catalog publication", - ]) - self.assertTrue(all(isinstance(value, UiNote) for value in synced)) - self.assertEqual([value.body for value in facade_synced], [ - "first catalog publication", - "second catalog publication", - ]) - self.assertEqual(node.get_required(document, "note:first").body, "first catalog publication") - self.assertEqual(node.get_required(document, "note:second").body, "second catalog publication") - self.assertIsInstance(node.get_required(alias, "note:second"), UiNote) - - def test_cultmesh_node_emits_raw_put_and_delete_messages_for_local_writes(self) -> None: - document = define_database_entry_type( - "mesh.emit_item", - [ - ("name", 0), - ("category", 1), - ("value", 2, 0), - ], - cls=Item, - ) - node = create_node(runtime_id="python-node") - node.register_document(document) - - put = node.put_raw_message( - document, - "item:emit", - Item("wand", "gear", 4), - message_id="put-emit", - shard_id="mesh", - shard_epoch=1, - ).to_wire() - self.assertEqual(node.get_required(document, "item:emit").name, "wand") - self.assertEqual(put["schemaVersion"], "cultnet.document_put_raw.v0") - self.assertEqual(put["messageId"], "put-emit") - self.assertEqual(put["document"]["schemaId"], document.catalog_entry().schema_id) - self.assertEqual(put["document"]["recordKey"], "item:emit") - self.assertEqual(put["document"]["sourceRuntimeId"], "python-node") - self.assertEqual(put["shardId"], "mesh") - self.assertEqual(put["shardEpoch"], 1) - - delete = node.delete_raw_message( - document, - "item:emit", - message_id="delete-emit", - shard_id="mesh", - shard_epoch=1, - ).to_wire() - self.assertIsNone(node.get(document, "item:emit")) - self.assertEqual(delete["schemaVersion"], "cultnet.document_delete.v0") - self.assertEqual(delete["messageId"], "delete-emit") - self.assertEqual(delete["schemaId"], document.catalog_entry().schema_id) - self.assertEqual(delete["recordKey"], "item:emit") - self.assertEqual(delete["shardId"], "mesh") - self.assertEqual(delete["shardEpoch"], 1) - - def test_cultmesh_node_resolves_same_schema_aliases_for_local_state(self) -> None: - @dataclass - class CanonicalNote: - body: str - - @dataclass - class UiNote: - body: str - - document = define_database_entry_type( - "mesh.alias_note", - [("body", 0)], - cls=CanonicalNote, - schema_id="mesh.alias_note.v1", - schema_name="mesh.alias_note", - schema_version="mesh.alias_note.v1", - ) - alias = define_database_entry_type( - "mesh.alias_note.ui", - [("body", 0)], - cls=UiNote, - schema_id="mesh.alias_note.v1", - schema_name="mesh.alias_note", - schema_version="mesh.alias_note.v1", - ) - node = create_node(runtime_id="python-node") - node.register_document(document) - seen: list[CultMeshDatabaseChange] = [] - all_seen: list[CultMeshDatabaseChange] = [] - node.database.watch(all_seen.append, document=alias) - node.database.watch_record(alias, "note:alias", seen.append) - - node.put(alias, "note:alias", UiNote("from-alias-put")) - self.assertEqual(node.get_required(document, "note:alias").body, "from-alias-put") - alias_value = node.get_required(alias, "note:alias") - self.assertIsInstance(alias_value, UiNote) - self.assertEqual(alias_value.body, "from-alias-put") - alias_collection = node.get_all(alias) - self.assertEqual(len(alias_collection), 1) - self.assertIsInstance(alias_collection[0], UiNote) - self.assertEqual(alias_collection[0].body, "from-alias-put") - self.assertEqual(seen[0].document.type, alias.type) - self.assertEqual(seen[0].schema_id, "mesh.alias_note.v1") - self.assertIsInstance(seen[0].value, UiNote) - self.assertEqual(seen[0].value.body, "from-alias-put") - self.assertEqual(all_seen[0].document.type, alias.type) - self.assertIsInstance(all_seen[0].value, UiNote) - self.assertEqual(all_seen[0].value.body, "from-alias-put") - - node.put(document, "note:alias", CanonicalNote("from-canonical-put")) - self.assertEqual(seen[1].document.type, alias.type) - self.assertIsInstance(seen[1].value, UiNote) - self.assertIsInstance(seen[1].previous_value, UiNote) - self.assertEqual(seen[1].value.body, "from-canonical-put") - self.assertEqual(seen[1].previous_value.body, "from-alias-put") - - put = node.put_raw_message( - alias, - "note:alias", - UiNote("from-alias-raw-put"), - message_id="alias-put", - ).to_wire() - self.assertEqual(put["document"]["schemaId"], "mesh.alias_note.v1") - self.assertEqual(put["document"]["recordKey"], "note:alias") - self.assertEqual(node.get_required(alias, "note:alias").body, "from-alias-raw-put") - - delete = node.delete_raw_message(alias, "note:alias", message_id="alias-delete").to_wire() - self.assertEqual(delete["schemaId"], "mesh.alias_note.v1") - self.assertIsNone(node.get(alias, "note:alias")) - self.assertEqual(node.get_all(alias), []) - - def test_cultmesh_reactive_document_coalesces_explicit_alias_updates(self) -> None: - @dataclass - class CanonicalNote: - body: str - revision: int - - @dataclass - class UiNote: - body: str - revision: int - - document = define_database_entry_type( - "mesh.reactive_alias_note", - [("body", 0), ("revision", 1)], - cls=CanonicalNote, - schema_id="mesh.reactive_alias_note.v1", - schema_name="mesh.reactive_alias_note", - schema_version="mesh.reactive_alias_note.v1", - ) - alias = define_database_entry_type( - "mesh.reactive_alias_note.ui", - [("body", 0), ("revision", 1)], - cls=UiNote, - schema_id="mesh.reactive_alias_note.v1", - schema_name="mesh.reactive_alias_note", - schema_version="mesh.reactive_alias_note.v1", - ) - node = create_node(runtime_id="python-reactive") - node.register_document(document) - node.put(document, "note:reactive", CanonicalNote("initial", 1)) - seen: list[CultMeshDatabaseChange] = [] - node.database.watch_record(alias, "note:reactive", seen.append) - - reactive = node.authoritative_writer(alias, "note:reactive").reactive( - CultMeshReactiveDocumentOptions(flush_delay_seconds=0.02) - ) - try: - self.assertIsInstance(reactive.snapshot, UiNote) - reactive.update(lambda note: setattr(note, "body", "first-local-edit")) - reactive.update(lambda note: setattr(note, "body", "second-local-edit")) - reactive.update(lambda note: setattr(note, "revision", 2)) - - self._wait_until(lambda: node.get_required(alias, "note:reactive").body == "second-local-edit") - - self.assertFalse(reactive.is_dirty) - self.assertEqual(node.get_required(document, "note:reactive").revision, 2) - self.assertEqual(seen[-1].value.body, "second-local-edit") - finally: - reactive.dispose() - - def test_cultmesh_reactive_document_tracks_canonical_reconciliation_delta(self) -> None: - @dataclass - class Note: - body: str - revision: int - - document = define_database_entry_type( - "mesh.reactive_reconcile_note", - [("body", 0), ("revision", 1)], - cls=Note, - schema_id="mesh.reactive_reconcile_note.v1", - schema_name="mesh.reactive_reconcile_note", - schema_version="mesh.reactive_reconcile_note.v1", - ) - node = create_node(runtime_id="python-reactive-reconcile") - node.register_document(document) - node.put(document, "note:reconcile", Note("initial", 1)) - - reactive = CultMesh.authoritative_writer( - node, - document, - "note:reconcile", - ).reactive(CultMeshReactiveDocumentOptions(flush_delay_seconds=60)) - try: - reactive.update(lambda note: setattr(note, "body", "local-prediction")) - reactive.update(lambda note: setattr(note, "revision", 2)) - - node.put(document, "note:reconcile", Note("authoritative", 7)) - - self.assertEqual(reactive.snapshot.body, "local-prediction") - self.assertIsNotNone(reactive.reconciliation) - self.assertEqual(reactive.reconciliation.canonical.body, "authoritative") - self.assertEqual(reactive.reconciliation.predicted.body, "local-prediction") - self.assertEqual(reactive.reconciliation.delta["body"], "local-prediction") - self.assertEqual(reactive.reconciliation.delta["revision"], -5) - - reactive.flush() - self.assertEqual(node.get_required(document, "note:reconcile").body, "local-prediction") - self.assertIsNone(reactive.reconciliation) - finally: - reactive.dispose() - - def test_cultmesh_reactive_document_is_idle_until_explicit_nested_update(self) -> None: - document = define_document_type( - "mesh.nested_loadout", - encode=lambda value: value, - decode=lambda value: value, - ) - node = create_node(runtime_id="python-reactive-nested") - node.register_document(document) - node.put( - document, - "ship:nested", - {"loadout": {"weapon": {"tuning": {"damage": 10}}}}, - ) - - with patch("cultmesh_py.node.threading.Timer") as timer: - reactive = node.authoritative_writer(document, "ship:nested").reactive() - timer.assert_not_called() - - try: - borrowed = reactive.snapshot - borrowed["loadout"]["weapon"]["tuning"]["damage"] = 99 - self.assertEqual( - node.get_required(document, "ship:nested")["loadout"]["weapon"]["tuning"]["damage"], - 10, - ) - - reactive.update( - lambda draft: draft["loadout"]["weapon"]["tuning"].__setitem__("damage", 12) - ) - reactive.flush() - - self.assertEqual(reactive.snapshot["loadout"]["weapon"]["tuning"]["damage"], 12) - self.assertEqual( - node.get_required(document, "ship:nested")["loadout"]["weapon"]["tuning"]["damage"], - 12, - ) - finally: - reactive.dispose() - - def test_cultmesh_reactive_scheduling_scales_with_changed_documents_only(self) -> None: - document = define_document_type( - "mesh.reactive_scale_note", - encode=lambda value: value, - decode=lambda value: value, - ) - - for document_count in (1, 100, 1000): - node = create_node(runtime_id=f"python-reactive-scale-{document_count}") - node.register_document(document) - for index in range(document_count): - node.put(document, f"note:{index}", {"body": "initial", "revision": 1}) - - scheduled_timers: list[Any] = [] - - class FakeScheduledCall: - def __init__(self, delay_seconds: float, callback: Callable[[], None]) -> None: - self.delay_seconds = delay_seconds - self.callback = callback - self.cancelled = False - - def cancel(self) -> None: - self.cancelled = True - - class FakeScheduler: - def schedule( - self, - delay_seconds: float, - callback: Callable[[], None], - ) -> FakeScheduledCall: - call = FakeScheduledCall(delay_seconds, callback) - scheduled_timers.append(call) - return call - - reactive_documents: list[Any] = [] - with patch("cultmesh_py.node._REACTIVE_SCHEDULER", FakeScheduler()): - try: - for index in range(document_count): - reactive_documents.append( - node.authoritative_writer(document, f"note:{index}").reactive() - ) - - self.assertEqual( - len(scheduled_timers), - 0, - f"{document_count} idle documents must schedule no timers", - ) - - changed_document_count = max(1, document_count // 100) - for index in range(changed_document_count): - reactive_documents[index].update( - lambda draft, changed=index: draft.update( - body=f"changed-{changed}", - revision=2, - ) - ) - - self.assertEqual(len(scheduled_timers), changed_document_count) - self.assertEqual( - sum(reactive.is_dirty for reactive in reactive_documents), - changed_document_count, - ) - finally: - for reactive in reactive_documents: - reactive.dispose() - - self.assertTrue(all(timer.cancelled for timer in scheduled_timers)) - - def test_cultmesh_reactive_documents_share_one_lazy_scheduler_thread(self) -> None: - document = define_document_type( - "mesh.reactive_shared_scheduler_note", - encode=lambda value: value, - decode=lambda value: value, - ) - node = create_node(runtime_id="python-reactive-shared-scheduler") - node.register_document(document) - reactive_documents: list[Any] = [] - try: - for index in range(100): - node.put(document, f"note:{index}", {"body": "initial", "revision": 1}) - reactive_documents.append( - node.authoritative_writer(document, f"note:{index}").reactive( - CultMeshReactiveDocumentOptions(flush_delay_seconds=60.0) - ) - ) - - before = sum( - thread.name == "cultmesh-reactive-scheduler" - for thread in threading.enumerate() - ) - for index, reactive in enumerate(reactive_documents): - reactive.update( - lambda draft, changed=index: draft.update( - body=f"changed-{changed}", - revision=2, - ) - ) - after = sum( - thread.name == "cultmesh-reactive-scheduler" - for thread in threading.enumerate() - ) - - self.assertLessEqual(before, 1) - self.assertEqual(after, 1) - finally: - for reactive in reactive_documents: - reactive.dispose() - - def test_cultmesh_observed_document_cannot_publish_borrowed_mutation(self) -> None: - document = define_document_type( - "mesh.observed_note", - encode=lambda value: value, - decode=lambda value: value, - ) - node = create_node(runtime_id="python-observed") - node.register_document(document) - node.put(document, "note:observed", {"body": "initial"}) - - observed = node.observe_document(document, "note:observed") - try: - observed.current["body"] = "borrowed-local-edit" - self.assertEqual(node.get_required(document, "note:observed")["body"], "initial") - - node.put(document, "note:observed", {"body": "canonical"}) - self.assertEqual(observed.current["body"], "canonical") - finally: - observed.dispose() - - def test_cultmesh_node_uses_cultcache_store(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - document = define_database_entry_type("mesh.note", [("body", 0)]) - store_path = Path(tmp) / "mesh.cc" - node = create_node(store_path, runtime_id="mesh-test") - self.assertIsInstance(node.database, CultMeshDatabase) - node.database.register_document(document) - node.database.put(document, "note:1", {"body": "hello"}) - self.assertEqual(node.get_required(document, "note:1")["body"], "hello") - - reopened = create_node(store_path, runtime_id="mesh-test") - reopened.database.register_document(document) - reopened.database.pull() - self.assertEqual(reopened.database.get(document, "note:1")["body"], "hello") - - def test_cultmesh_node_options_attach_default_durable_shard_log_store(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - document = define_database_entry_type( - "mesh.durable_option_note", - [("body", 0)], - schema_id="mesh.durable_option_note.v1", - ) - cache_path = Path(tmp) / "world.cc" - node = CultMesh.create_node( - cache_path, - runtime_id="durable-option-primary", - options=CultMeshNodeOptions(enable_durable_shard_logs=True), - ) - node.database.register_document(document) - node.database.put_raw_message(document, "note:1", {"body": "stored"}, shard_id="notes", shard_epoch=5) - - log_path = Path(tmp) / "world.cultmesh" / "shard-logs" - self.assertTrue(log_path.exists()) - - reopened = CultMesh.start_node( - cache_path, - runtime_id="durable-option-primary", - enable_durable_shard_logs=True, - ) - reopened.database.register_document(document) - response = reopened.database.build_shard_log_response(shard_id="notes", shard_epoch=5) - - self.assertEqual(reopened.database.shard_ids(), ["notes"]) - self.assertEqual([entry.sequence for entry in response.entries], [1]) - self.assertEqual(response.entries[0].raw_document.record_key, "note:1") - - def test_cultmesh_database_watchers_observe_local_changes_and_unsubscribe(self) -> None: - document = define_database_entry_type("mesh.watch", [("body", 0)]) - node = CultMesh.create_node(runtime_id="mesh-watch") - node.database.register_document(document) - changes: list[CultMeshDatabaseChange] = [] - all_changes: list[CultMeshDatabaseChange] = [] - - unsubscribe_record = node.database.watch_record(document, "note:1", changes.append) - unsubscribe_all = node.database.watch(all_changes.append, document=document) - - node.database.put(document, "note:1", {"body": "hello"}) - node.database.put(document, "note:2", {"body": "skip"}) - node.database.put(document, "note:1", {"body": "updated"}) - node.database.delete(document, "note:1") - unsubscribe_record() - node.database.put(document, "note:1", {"body": "after-unsubscribe"}) - unsubscribe_all() - - self.assertEqual([change.change_kind for change in changes], ["added", "updated", "removed"]) - self.assertEqual([change.record_key for change in changes], ["note:1", "note:1", "note:1"]) - self.assertEqual(changes[1].previous_value, {"body": "hello"}) - self.assertIsNone(changes[2].value) - self.assertEqual([change.record_key for change in all_changes], ["note:1", "note:2", "note:1", "note:1", "note:1"]) - - def test_cultmesh_database_register_document_is_idempotent(self) -> None: - document = define_database_entry_type("mesh.idempotent", [("body", 0)]) - conflicting = define_database_entry_type("mesh.idempotent", [("title", 0)]) - node = CultMesh.create_node(runtime_id="mesh-idempotent") - - node.database.register_document(document) - node.database.register_document(document) - node.database.put(document, "note:1", {"body": "still registered once"}) - - self.assertEqual(node.database.get_required(document, "note:1")["body"], "still registered once") - self.assertEqual([registered.type for registered in node.documents], ["mesh.idempotent"]) - with self.assertRaises(ValueError): - node.database.register_document(conflicting) - - def test_cultmesh_database_global_document_facade_uses_singleton_key_and_watchers(self) -> None: - document = define_database_entry_type( - "mesh.global_settings", - [("theme", 0)], - global_document=True, - ) - node = CultMesh.create_node(runtime_id="mesh-global") - node.database.register_document(document) - changes: list[CultMeshDatabaseChange] = [] - - node.database.watch_global(document, changes.append) - node.database.put_global(document, {"theme": "ash"}) - node.put_global(document, {"theme": "ember"}) - node.delete_global(document) - - self.assertIsNone(node.database.get_global(document)) - self.assertEqual([change.record_key for change in changes], [node.cache.GLOBAL_KEY] * 3) - self.assertEqual([change.change_kind for change in changes], ["added", "updated", "removed"]) - self.assertEqual(changes[1].previous_value, {"theme": "ash"}) - self.assertEqual(changes[2].previous_value, {"theme": "ember"}) - with self.assertRaises(Exception): - node.database.put(document, "settings:wrong", {"theme": "bad"}) - - def test_cultmesh_database_creates_raw_snapshot_response_from_envelopes(self) -> None: - document = define_database_entry_type( - "mesh.snapshot_note", - [("body", 0)], - schema_id="mesh.snapshot_note.v1", - ) - source = CultMesh.create_node(runtime_id="mesh-snapshot-source") - source.database.register_document(document) - source.database.put(document, "note:1", {"body": "include"}) - source.database.put(document, "note:2", {"body": "skip"}) - - typed_response = source.database.build_snapshot_response( - message_id="snapshot-1", - schema_ids=["mesh.snapshot_note.v1"], - record_keys=["note:1"], - shard_id="notes", - shard_epoch=4, - shard_log_sequence=7, - ) - response = source.database.create_snapshot_response( - message_id="snapshot-1", - schema_ids=["mesh.snapshot_note.v1"], - record_keys=["note:1"], - shard_id="notes", - shard_epoch=4, - shard_log_sequence=7, - ) - - self.assertIsInstance(typed_response, CultNetRawSnapshotResponse) - self.assertEqual(typed_response.message_id, "snapshot-1") - self.assertEqual(typed_response.documents[0].record_key, "note:1") - self.assertEqual(typed_response.to_wire(), response) - self.assertEqual(source.build_snapshot_response(schema_ids=["mesh.snapshot_note.v1"]).documents[0].record_key, "note:1") - self.assertEqual(response["schemaVersion"], "cultnet.snapshot_response_raw.v0") - self.assertEqual(response["messageId"], "snapshot-1") - self.assertEqual(response["shardId"], "notes") - self.assertEqual(response["shardEpoch"], 4) - self.assertEqual(response["shardLogSequence"], 7) - self.assertEqual([record["recordKey"] for record in response["documents"]], ["note:1"]) - self.assertEqual(response["documents"][0]["schemaId"], "mesh.snapshot_note.v1") - self.assertEqual(response["documents"][0]["payloadEncoding"], "messagepack") - - target = CultMesh.create_node(runtime_id="mesh-snapshot-target") - target.database.register_document(document) - applied = target.database.apply_snapshot_response(response) - self.assertEqual([(record.schema_id, record.record_key) for record in applied], [("mesh.snapshot_note.v1", "note:1")]) - self.assertEqual(target.database.get_required(document, "note:1"), {"body": "include"}) - self.assertIsNone(target.database.get(document, "note:2")) - - typed_target = CultMesh.create_node(runtime_id="mesh-snapshot-typed-target") - typed_target.database.register_document(document) - typed_applied = typed_target.database.apply_snapshot_response(typed_response) - self.assertEqual([(record.schema_id, record.record_key) for record in typed_applied], [("mesh.snapshot_note.v1", "note:1")]) - self.assertEqual(typed_target.database.get_required(document, "note:1"), {"body": "include"}) - - def test_cultmesh_database_filters_snapshot_by_logged_shard_membership(self) -> None: - document = define_database_entry_type( - "mesh.sharded_snapshot_note", - [("body", 0)], - schema_id="mesh.sharded_snapshot_note.v1", - ) - source = CultMesh.create_node(runtime_id="mesh-sharded-snapshot-source") - source.database.register_document(document) - source.database.put_raw_message(document, "note:notes", {"body": "include"}, shard_id="notes", shard_epoch=2) - source.database.put_raw_message(document, "note:other", {"body": "skip"}, shard_id="other", shard_epoch=1) - - response = source.database.build_snapshot_response( - schema_ids=["mesh.sharded_snapshot_note.v1"], - shard_id="notes", - shard_epoch=2, - ) - - self.assertEqual(response.shard_id, "notes") - self.assertEqual(response.shard_epoch, 2) - self.assertEqual([record.record_key for record in response.documents], ["note:notes"]) - - def test_cultmesh_database_creates_shard_log_response_from_raw_mutations(self) -> None: - document = define_database_entry_type( - "mesh.log_note", - [("body", 0)], - schema_id="mesh.log_note.v1", - ) - source = CultMesh.create_node(runtime_id="mesh-log-source") - source.database.register_document(document) - source.database.put_raw_message(document, "note:1", {"body": "first"}, shard_id="notes", shard_epoch=3) - source.database.put_raw_message(document, "note:1", {"body": "second"}, shard_id="notes", shard_epoch=3) - source.database.put_raw_message(document, "note:2", {"body": "other"}, shard_id="other", shard_epoch=1) - source.database.delete_raw_message(document, "note:1", shard_id="notes", shard_epoch=3) - - self.assertEqual(source.database.shard_ids(), ["notes", "other"]) - self.assertEqual(source.database.shard_schema_ids("notes"), ["mesh.log_note.v1"]) - - typed_response = source.database.build_shard_log_response( - message_id="log-1", - shard_id="notes", - shard_epoch=3, - after_sequence=1, - limit=2, - ) - response = source.database.create_shard_log_response( - message_id="log-1", - shard_id="notes", - shard_epoch=3, - after_sequence=1, - limit=2, - ) - - self.assertIsInstance(typed_response, CultNetShardLogResponse) - self.assertEqual(typed_response.message_id, "log-1") - self.assertEqual([entry.sequence for entry in typed_response.entries], [2, 3]) - self.assertEqual(typed_response.to_wire(), response) - self.assertEqual([entry.sequence for entry in source.build_shard_log_response(shard_id="notes").entries], [1, 2, 3]) - self.assertEqual(response["schemaVersion"], "cultnet.shard_log_response.v0") - self.assertEqual(response["messageId"], "log-1") - self.assertEqual(response["shardId"], "notes") - self.assertEqual(response["shardEpoch"], 3) - self.assertFalse(response["resyncRequired"]) - self.assertEqual([entry["sequence"] for entry in response["entries"]], [2, 3]) - self.assertEqual([entry["changeKind"] for entry in response["entries"]], ["updated", "removed"]) - self.assertEqual(response["entries"][0]["put"]["document"]["recordKey"], "note:1") - self.assertEqual(response["entries"][1]["delete"]["recordKey"], "note:1") - empty = source.database.create_shard_log_response(shard_id="missing") - self.assertEqual(empty["shardEpoch"], 0) - self.assertEqual(empty["entries"], []) - self.assertTrue(empty["resyncRequired"]) - self.assertEqual(empty["reason"], "unknown_shard") - stale = source.database.create_shard_log_response(shard_id="notes", shard_epoch=2) - self.assertEqual(stale["shardEpoch"], 3) - self.assertEqual(stale["entries"], []) - self.assertTrue(stale["resyncRequired"]) - self.assertEqual(stale["reason"], "stale_epoch") - with self.assertRaises(ValueError): - source.database.create_shard_log_response(shard_id="notes", after_sequence=-1) - with self.assertRaises(ValueError): - source.database.create_shard_log_response(shard_id="notes", limit=-1) - - target = CultMesh.create_node(runtime_id="mesh-log-target") - target.database.register_document(document) - target.database.put(document, "note:1", {"body": "first"}) - applied = target.database.apply_shard_log_response(response) - self.assertEqual([record.change_kind for record in applied], ["updated", "removed"]) - self.assertIsNone(target.database.get(document, "note:1")) - self.assertIsNone(target.database.get(document, "note:2")) - - typed_target = CultMesh.create_node(runtime_id="mesh-log-typed-target") - typed_target.database.register_document(document) - typed_target.database.put(document, "note:1", {"body": "first"}) - typed_applied = typed_target.database.apply_shard_log_response(typed_response) - self.assertEqual([record.change_kind for record in typed_applied], ["updated", "removed"]) - self.assertIsNone(typed_target.database.get(document, "note:1")) - - def test_cultnet_file_shard_mutation_log_store_persists_authoritative_entries(self) -> None: - document = define_database_entry_type( - "mesh.durable_log_note", - [("body", 0)], - schema_id="mesh.durable_log_note.v1", - ) - with tempfile.TemporaryDirectory() as tmp: - store: CultNetShardMutationLogStore = CultNetFileShardMutationLogStore(Path(tmp) / "logs") - source = CultMesh.create_node(runtime_id="durable-primary") - source.database.register_document(document) - source.database.use_shard_mutation_log_store(store) - source.database.put_raw_message(document, "note:1", {"body": "first"}, shard_id="durable", shard_epoch=4) - source.database.put_raw_message(document, "note:2", {"body": "second"}, shard_id="durable", shard_epoch=4) - - reopened = CultMesh.create_node(runtime_id="durable-primary") - reopened.database.register_document(document) - reopened.database.use_shard_mutation_log_store(CultNetFileShardMutationLogStore(Path(tmp) / "logs")) - response = reopened.database.build_shard_log_response(shard_id="durable", shard_epoch=4) - self.assertEqual(reopened.database.shard_ids(), ["durable"]) - self.assertEqual(reopened.database.shard_schema_ids("durable"), ["mesh.durable_log_note.v1"]) - self.assertEqual([entry.sequence for entry in response.entries], [1, 2]) - self.assertEqual(response.entries[0].raw_document.record_key, "note:1") - - store.compact_through("durable", 1) - compacted = reopened.database.build_shard_log_response(shard_id="durable", shard_epoch=4, after_sequence=0) - retained = reopened.database.build_shard_log_response(shard_id="durable", shard_epoch=4, after_sequence=1) - self.assertTrue(compacted.resync_required) - self.assertEqual(compacted.reason, "compacted_history") - self.assertEqual(compacted.compacted_through, 1) - self.assertEqual([entry.sequence for entry in retained.entries], [2]) - - def test_cultnet_shard_replicator_pulls_logs_and_resyncs_from_snapshot(self) -> None: - document = define_database_entry_type( - "mesh.replica_note", - [("body", 0)], - schema_id="mesh.replica_note.v1", - ) - source = CultMesh.create_node(runtime_id="primary") - source.database.register_document(document) - source.database.put_raw_message(document, "note:1", {"body": "one"}, shard_id="notes", shard_epoch=3) - source.database.put_raw_message(document, "note:2", {"body": "two"}, shard_id="notes", shard_epoch=3) - with tempfile.TemporaryDirectory() as tmp: - cursor_path = Path(tmp) / "replica-cursors.msgpack" - server = CultMesh.serve_node(source) - try: - shard = CultNetShardDescriptor( - shard_id="notes", - owner_runtime_id="primary", - epoch=3, - is_primary=False, - schema_ids=("mesh.replica_note.v1",), - primary_endpoints=(f"cultnet://127.0.0.1:{server.port}",), - ) - target = CultMesh.create_node(runtime_id="replica") - target.database.register_document(document) - cursor_store = CultNetFileShardReplicaCursorStore(cursor_path) - replicator = CultNetShardReplicator( - target.database, - CultNetShardReplicatorOptions( - fetcher=CultNetSchemaShardLogFetcher(timeout_seconds=2.0), - snapshot_fetcher=CultNetSchemaShardSnapshotFetcher(timeout_seconds=2.0), - cursor_store=cursor_store, - batch_size=16, - ), - ) - - first_sequence = replicator.pull_once(shard) - restarted_replicator = CultNetShardReplicator( - target.database, - CultNetShardReplicatorOptions( - fetcher=CultNetSchemaShardLogFetcher(timeout_seconds=2.0), - snapshot_fetcher=CultNetSchemaShardSnapshotFetcher(timeout_seconds=2.0), - cursor_store=CultNetFileShardReplicaCursorStore(cursor_path), - batch_size=16, - ), - ) - second_sequence = restarted_replicator.pull_once(shard) - - self.assertEqual(first_sequence, 2) - self.assertEqual(second_sequence, 2) - self.assertEqual(target.get_required(document, "note:1")["body"], "one") - self.assertEqual(target.get_required(document, "note:2")["body"], "two") - cursor = CultNetFileShardReplicaCursorStore(cursor_path).read("notes") - self.assertIsNotNone(cursor) - assert cursor is not None - self.assertEqual(cursor.last_applied_sequence, 2) - self.assertEqual(cursor.shard_epoch, 3) - finally: - server.stop() - - resync_target = CultMesh.create_node(runtime_id="resync-replica") - resync_target.database.register_document(document) - snapshot = source.database.build_snapshot_response(shard_id="notes", shard_epoch=3, shard_log_sequence=2) - - class CompactingFetcher: - def fetch(self, shard: CultNetShardDescriptor, *, after_sequence: int, limit: int | None = None) -> CultNetShardLogResponse: - return CultNetShardLogResponse( - message_id="compact", - shard_id=shard.shard_id, - shard_epoch=shard.epoch, - entries=(), - resync_required=True, - reason="compacted_history", - compacted_through=after_sequence, - ) - - class SnapshotFetcher: - def fetch(self, shard: CultNetShardDescriptor) -> CultNetRawSnapshotResponse: - return snapshot - - resync_replicator = CultNetShardReplicator( - resync_target.database, - CultNetShardReplicatorOptions( - fetcher=CompactingFetcher(), - snapshot_fetcher=SnapshotFetcher(), - cursor_store=CultNetInMemoryShardReplicaCursorStore(), - ), - ) - resync_sequence = resync_replicator.pull_once(CultNetShardDescriptor( - shard_id="notes", - owner_runtime_id="primary", - epoch=3, - is_primary=False, - schema_ids=("mesh.replica_note.v1",), - primary_endpoints=("cultnet://primary:3075",), - )) - - self.assertEqual(resync_sequence, 2) - self.assertEqual(resync_target.get_required(document, "note:1")["body"], "one") - - def test_cultnet_shard_replicator_background_loop_polls_non_primary_shards(self) -> None: - document = define_database_entry_type( - "mesh.background_replica_note", - [("body", 0)], - schema_id="mesh.background_replica_note.v1", - ) - source = CultMesh.create_node(runtime_id="background-primary") - source.database.register_document(document) - target = CultMesh.create_node(runtime_id="background-replica") - target.database.register_document(document) - cursor_store = CultNetInMemoryShardReplicaCursorStore() - errors: list[Exception] = [] - - class FlakySourceFetcher: - def __init__(self) -> None: - self.calls = 0 - - def fetch(self, shard: CultNetShardDescriptor, *, after_sequence: int, limit: int | None = None) -> CultNetShardLogResponse: - self.calls += 1 - if self.calls == 1: - raise RuntimeError("transient fetch failure") - return source.database.build_shard_log_response( - shard_id=shard.shard_id, - shard_epoch=shard.epoch, - after_sequence=after_sequence, - limit=limit, - ) - - fetcher = FlakySourceFetcher() - shard = CultNetShardDescriptor( - shard_id="background", - owner_runtime_id="background-primary", - epoch=7, - is_primary=False, - schema_ids=("mesh.background_replica_note.v1",), - primary_endpoints=("cultnet://127.0.0.1:3075",), - ) - replicator = CultNetShardReplicator( - target.database, - CultNetShardReplicatorOptions( - fetcher=fetcher, - cursor_store=cursor_store, - batch_size=1, - poll_interval_seconds=0.01, - on_error=errors.append, - ), - ) - try: - source.database.put_raw_message(document, "note:1", {"body": "one"}, shard_id="background", shard_epoch=7) - replicator.start([shard]) - self._wait_until(lambda: target.database.get(document, "note:1") is not None) - source.database.put_raw_message(document, "note:2", {"body": "two"}, shard_id="background", shard_epoch=7) - self._wait_until(lambda: target.database.get(document, "note:2") is not None) - finally: - replicator.stop() - - cursor = cursor_store.read("background") - self.assertEqual(target.get_required(document, "note:1")["body"], "one") - self.assertEqual(target.get_required(document, "note:2")["body"], "two") - self.assertIsNotNone(cursor) - assert cursor is not None - self.assertEqual(cursor.last_applied_sequence, 2) - self.assertEqual(cursor.shard_epoch, 7) - self.assertEqual(len(errors), 1) - self.assertEqual(str(errors[0]), "transient fetch failure") - - def test_cultnet_schema_write_forwarder_sends_raw_writes_to_primary(self) -> None: - document = define_database_entry_type( - "mesh.forward_note", - [("body", 0)], - schema_id="mesh.forward_note.v1", - ) - primary = CultMesh.create_node(runtime_id="forward-primary") - primary.database.register_document(document) - server = CultMesh.serve_node(primary) - try: - shard = CultNetShardDescriptor( - shard_id="forward", - owner_runtime_id="forward-primary", - epoch=9, - is_primary=False, - schema_ids=("mesh.forward_note.v1",), - primary_endpoints=(f"cultnet://127.0.0.1:{server.port}",), - ) - forwarder: CultNetShardWriteForwarder = CultNetSchemaWriteForwarder(timeout_seconds=2.0) - put = document_put_raw( - message_id="forward-put", - key="note:forward", - schema_id=document.catalog_entry().schema_id, - stored_at="2026-06-14T00:00:00Z", - payload=document.encode_payload({"body": "forwarded"}), - ).to_wire() - delete = document_delete( - message_id="forward-delete", - schema_id=document.catalog_entry().schema_id, - record_key="note:forward", - ).to_wire() - - forwarder.forward_put(shard, put) - self._eventually(lambda: primary.database.get(document, "note:forward") is not None) - self.assertEqual(primary.database.get_required(document, "note:forward")["body"], "forwarded") - forwarder.forward_delete(shard, delete) - self._eventually(lambda: primary.database.get(document, "note:forward") is None) - - log = primary.database.build_shard_log_response(shard_id="forward", shard_epoch=9) - self.assertIsNone(primary.database.get(document, "note:forward")) - self.assertEqual([entry.change_kind for entry in log.entries], ["added", "removed"]) - self.assertEqual(log.entries[0].put["shardId"], "forward") - self.assertEqual(log.entries[1].delete["shardEpoch"], 9) - finally: - server.stop() - - def _eventually(self, predicate: Callable[[], bool], *, timeout_seconds: float = 2.0) -> None: - deadline = time.monotonic() + timeout_seconds - while time.monotonic() < deadline: - if predicate(): - return - time.sleep(0.01) - self.assertTrue(predicate()) - - def test_cultmesh_database_watchers_observe_name_and_index_changes(self) -> None: - document = define_database_entry_type( - "mesh.named_watch", - [("name", 0), ("kind", 1)], - name="name", - indexes={"kind": "kind"}, - ) - node = CultMesh.create_node(runtime_id="mesh-named-watch") - node.database.register_document(document) - name_changes: list[CultMeshDatabaseChange] = [] - index_changes: list[CultMeshDatabaseChange] = [] - - node.database.watch_by_name(document, "Potion", name_changes.append) - node.database.watch_by_index(document, "kind", "consumable", index_changes.append) - - node.database.put(document, "item:1", {"name": "Potion", "kind": "consumable"}) - node.database.put(document, "item:2", {"name": "Sword", "kind": "weapon"}) - node.database.put(document, "item:1", {"name": "Elixir", "kind": "rare"}) - node.database.delete(document, "item:1") - - self.assertEqual([change.change_kind for change in name_changes], ["added", "updated"]) - self.assertEqual([change.record_key for change in name_changes], ["item:1", "item:1"]) - self.assertEqual(name_changes[1].previous_value, {"name": "Potion", "kind": "consumable"}) - self.assertEqual([change.change_kind for change in index_changes], ["added", "updated"]) - self.assertEqual([change.record_key for change in index_changes], ["item:1", "item:1"]) - self.assertEqual(index_changes[1].value, {"name": "Elixir", "kind": "rare"}) - - def test_cultmesh_database_collection_reads_resolve_same_schema_aliases(self) -> None: - @dataclass - class CanonicalItem: - name: str - kind: str - - @dataclass - class UiItem: - name: str - kind: str - - document = define_database_entry_type( - "mesh.alias_collection_item", - [("name", 0), ("kind", 1)], - cls=CanonicalItem, - name="name", - indexes={"kind": "kind"}, - schema_id="mesh.alias_collection_item.v1", - schema_name="mesh.alias_collection_item", - schema_version="mesh.alias_collection_item.v1", - ) - alias = define_database_entry_type( - "mesh.alias_collection_item.ui", - [("name", 0), ("kind", 1)], - cls=UiItem, - name="name", - indexes={"kind": "kind"}, - schema_id="mesh.alias_collection_item.v1", - schema_name="mesh.alias_collection_item", - schema_version="mesh.alias_collection_item.v1", - ) - node = CultMesh.create_node(runtime_id="mesh-alias-collection") - node.database.register_document(document) - - node.database.put(alias, "item:potion", UiItem("Potion", "consumable")) - node.database.put(document, "item:sword", CanonicalItem("Sword", "weapon")) - - all_items = node.database.get_all(alias) - potion = node.database.get_by_name(alias, "Potion") - sword = node.get_by_index(alias, "kind", "weapon") - - self.assertEqual([item.name for item in all_items], ["Potion", "Sword"]) - self.assertTrue(all(isinstance(item, UiItem) for item in all_items)) - self.assertIsInstance(potion, UiItem) - self.assertEqual(potion.kind, "consumable") - self.assertIsInstance(sword, UiItem) - self.assertEqual(sword.name, "Sword") - self.assertIsNone(node.database.get_by_name(alias, "Missing")) - - def test_cultmesh_database_alias_collection_watches_emit_alias_values(self) -> None: - @dataclass - class CanonicalItem: - name: str - kind: str - - @dataclass - class UiItem: - name: str - kind: str - - document = define_database_entry_type( - "mesh.alias_collection_watch_item", - [("name", 0), ("kind", 1)], - cls=CanonicalItem, - name="name", - indexes={"kind": "kind"}, - schema_id="mesh.alias_collection_watch_item.v1", - schema_name="mesh.alias_collection_watch_item", - schema_version="mesh.alias_collection_watch_item.v1", - ) - alias = define_database_entry_type( - "mesh.alias_collection_watch_item.ui", - [("name", 0), ("kind", 1)], - cls=UiItem, - name="name", - indexes={"kind": "kind"}, - schema_id="mesh.alias_collection_watch_item.v1", - schema_name="mesh.alias_collection_watch_item", - schema_version="mesh.alias_collection_watch_item.v1", - ) - node = CultMesh.create_node(runtime_id="mesh-alias-collection-watch") - node.database.register_document(document) - name_changes: list[CultMeshDatabaseChange] = [] - index_changes: list[CultMeshDatabaseChange] = [] - - node.database.watch_by_name(alias, "Potion", name_changes.append) - node.database.watch_by_index(alias, "kind", "consumable", index_changes.append) - - node.database.put(document, "item:potion", CanonicalItem("Potion", "consumable")) - node.database.put(document, "item:potion", CanonicalItem("Elixir", "rare")) - - self.assertEqual([change.change_kind for change in name_changes], ["added", "updated"]) - self.assertTrue(all(change.document.type == alias.type for change in name_changes)) - self.assertIsInstance(name_changes[0].value, UiItem) - self.assertIsInstance(name_changes[1].value, UiItem) - self.assertIsInstance(name_changes[1].previous_value, UiItem) - self.assertEqual(name_changes[1].previous_value.name, "Potion") - self.assertEqual(name_changes[1].value.name, "Elixir") - self.assertEqual([change.change_kind for change in index_changes], ["added", "updated"]) - self.assertTrue(all(change.document.type == alias.type for change in index_changes)) - self.assertIsInstance(index_changes[0].value, UiItem) - self.assertIsInstance(index_changes[1].previous_value, UiItem) - - def test_cultmesh_database_watch_by_name_and_index_validate_lookup_shape(self) -> None: - document = define_database_entry_type("mesh.unnamed_watch", [("body", 0)]) - node = CultMesh.create_node(runtime_id="mesh-watch-validation") - node.database.register_document(document) - - with self.assertRaises(ValueError): - node.database.watch_by_name(document, "missing", lambda _: None) - with self.assertRaises(ValueError): - node.database.watch_by_index(document, "missing", "value", lambda _: None) - with self.assertRaises(ValueError): - node.database.watch_by_index(document, "", "value", lambda _: None) - with self.assertRaises(ValueError): - node.database.watch_by_index(document, "missing", "", lambda _: None) - - def test_cultmesh_facade_matches_peer_runtime_entrypoints(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - store_path = Path(tmp) / "mesh.cc" - node = CultMesh.start_node(store_path, runtime_id="mesh-facade") - - self.assertEqual(node.runtime_id, "mesh-facade") - self.assertIsInstance(CultMesh.create_node(), type(node)) - self.assertIsInstance(CultMesh.create_verse_catalog(), CultMeshVerseCatalog) - self.assertIsInstance(CultMesh.create_peer_catalog(), CultMeshPeerCatalog) - self.assertIsInstance(CultMesh.create_authority_lease_catalog(), CultMeshAuthorityLeaseCatalog) - self.assertIsInstance(CultMesh.create_stream_catalog(), CultMeshStreamCatalog) - self.assertIsInstance(CultMesh.create_schema_catalog(), CultNetSchemaCatalog) - self.assertIsInstance(CultMesh.create_builtin_schema_catalog(), CultNetSchemaCatalog) - self.assertIsInstance(CultMesh.create_shard_catalog(), CultNetShardCatalog) - builtins = CultMesh.create_builtin_schema_catalog( - include_schema_json=False, - kinds=["shared_contract"], - ) - self.assertEqual( - builtins.list()[0].schema_id, - "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/cultnet-ts/contracts/cultmesh.peer-exchange-request.schema.json" - ) - filtered_builtin = CultMesh.create_builtin_schema_catalog( - include_schema_json=True, - schema_ids=[peer_exchange_schema_id], - ) - self.assertEqual( - filtered_builtin.list()[0].schema_version, - "cultmesh.peer_exchange_request.v0", - ) - self.assertIn( - "cultmesh.peer_exchange_request.v0", - filtered_builtin.list()[0].schema_json or "", - ) - shard_catalog = CultMesh.create_shard_catalog() - shard_catalog.upsert( - CultNetShardDescriptor( - shard_id="python-notes-a", - owner_runtime_id="mesh-facade", - epoch=7, - is_primary=True, - schema_ids=("python.note.v1",), - key_prefix="note:", - ) - ) - self.assertEqual( - [ - shard.shard_id - for shard in shard_catalog.list(schema_ids=["python.note.v1"], record_keys=["note:1"]) - ], - ["python-notes-a"], - ) - verse_client = CultMesh.create_verse_discovery_client("127.0.0.1", 4010, timeout_seconds=1.5) - peer_client = CultMesh.create_peer_exchange_client("127.0.0.1", 4011, timeout_seconds=1.5) - raw_client = CultMesh.create_client("127.0.0.1", 4012, timeout_seconds=1.5) - connected_client = CultMesh.connect_client("127.0.0.1", 4013, timeout_seconds=1.5) - self.assertIsInstance(verse_client, CultMeshVerseDiscoveryClient) - self.assertIsInstance(peer_client, CultMeshPeerExchangeClient) - self.assertIsInstance(verse_client, CultMeshDiscoveryClient) - self.assertIsInstance(peer_client, CultMeshDiscoveryClient) - self.assertIsInstance(raw_client, CultNetRawClient) - self.assertIsInstance(connected_client, CultNetRawClient) - self.assertEqual((verse_client.host, verse_client.port, verse_client.timeout_seconds), ("127.0.0.1", 4010, 1.5)) - self.assertEqual((peer_client.host, peer_client.port, peer_client.timeout_seconds), ("127.0.0.1", 4011, 1.5)) - self.assertEqual((raw_client.host, raw_client.port, raw_client.timeout_seconds), ("127.0.0.1", 4012, 1.5)) - self.assertEqual((connected_client.host, connected_client.port, connected_client.timeout_seconds), ("127.0.0.1", 4013, 1.5)) - - def test_cultmesh_simulation_fact_uses_csharp_slot_contract(self) -> None: - import msgpack # type: ignore - - candidate = { - "shardId": "arena", - "shardEpoch": 4, - "frame": 100, - "subjectId": "bob", - "claimKind": "hit", - "claimHash": compute_simulation_claim_hash("hit", "alice", "bob", "frame:100"), - "claimSummary": "alice shot bob first", - "witnessCount": 2, - "supportWeight": 2.0, - "totalWeight": 2.0, - "confidence": 1.0, - "hasQuorum": True, - } - - fact = CultMeshSimulationFact.from_candidate(candidate, committed_at="2026-06-13T00:00:00Z") - payload = simulation_fact_document.encode_payload(fact) - slots = msgpack.unpackb(payload, raw=False) - - self.assertEqual(CultMeshSimulationFact.create_record_key(candidate), f"simulation:{fact.fact_id}") - self.assertEqual(slots[0], fact.fact_id) - self.assertEqual(slots[1], "arena") - self.assertEqual(slots[2], 4) - self.assertEqual(slots[3], 100) - self.assertEqual(slots[4], "bob") - self.assertEqual(slots[5], "hit") - self.assertEqual(slots[6], candidate["claimHash"]) - self.assertEqual(slots[7], "alice shot bob first") - self.assertEqual(slots[8], 2) - self.assertEqual(slots[9], 2.0) - self.assertEqual(slots[10], 2.0) - self.assertEqual(slots[11], 1.0) - self.assertEqual(slots[12], "2026-06-13T00:00:00Z") - - def test_cultmesh_simulation_fact_committer_rejects_without_quorum_and_stores_fact(self) -> None: - candidate = { - "shardId": "arena", - "shardEpoch": 4, - "frame": 100, - "subjectId": "bob", - "claimKind": "hit", - "claimHash": compute_simulation_claim_hash("hit", "alice", "bob", "frame:100"), - "claimSummary": "alice shot bob first", - "witnessCount": 2, - "supportWeight": 2.0, - "totalWeight": 2.0, - "confidence": 1.0, - "hasQuorum": True, - } - - with tempfile.TemporaryDirectory() as tmp: - node = CultMesh.create_node(Path(tmp) / "facts.cc", runtime_id="mesh-facts") - committer = CultMesh.create_simulation_fact_committer(node) - - rejected = dict(candidate) - rejected["hasQuorum"] = False - with self.assertRaisesRegex(ValueError, "before quorum"): - committer.commit(rejected) - - committed = committer.commit(candidate, committed_at="2026-06-13T00:00:00Z") - stored = node.get_required(simulation_fact_document, committed.key) - - self.assertEqual(stored.claim_hash, candidate["claimHash"]) - self.assertEqual(stored.committed_at, "2026-06-13T00:00:00Z") - self.assertEqual(committed.fact.fact_id, stored.fact_id) - - def test_cultmesh_game_session_submits_observations_and_commits_quorum_once(self) -> None: - claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100") - first = simulation_observation( - message_id="obs-1", - witness_runtime_id="watcher-1", - shard_id="arena", - shard_epoch=4, - frame=100, - subject_id="bob", - claim_kind="hit", - claim_hash=claim_hash, - claim_summary="alice shot bob first", - ).to_wire() - second = simulation_observation( - message_id="obs-2", - witness_runtime_id="watcher-2", - shard_id="arena", - shard_epoch=4, - frame=100, - subject_id="bob", - claim_kind="hit", - claim_hash=claim_hash, - claim_summary="alice shot bob first", - ).to_wire() - - with tempfile.TemporaryDirectory() as tmp: - node = CultMesh.create_node(Path(tmp) / "session.cc", runtime_id="mesh-session") - session = CultMesh.create_game_session( - node, - CultMeshGameSessionOptions( - consensus_options=CultNetSimulationConsensusOptions(minimum_witnesses=2, quorum_ratio=1.0) - ), - ) - seen_candidates: list[CultNetSimulationConsensusCandidate] = [] - seen_facts: list[CultMeshDatabaseChange] = [] - unsubscribe_candidates = session.watch_candidates(seen_candidates.append) - unsubscribe_facts = session.watch_simulation_facts(seen_facts.append) - - self.assertEqual(session.submit_and_commit(CultNetSimulationObservation.from_wire(first)), []) - typed_candidates = session.submit_observation_candidates(CultNetSimulationObservation.from_wire(second)) - commits = session.commit_quorum_candidate_objects(typed_candidates) - replay = session.commit_quorum_candidates(session.submit_observation(second)) - unsubscribe_candidates() - unsubscribe_facts() - - self.assertEqual(len(typed_candidates), 1) - self.assertTrue(typed_candidates[0].has_quorum) - self.assertEqual(len(commits), 1) - self.assertEqual(replay, []) - self.assertEqual([candidate.claim_hash for candidate in seen_candidates], [claim_hash, claim_hash, claim_hash]) - self.assertEqual(len(seen_facts), 1) - self.assertEqual(seen_facts[0].change_kind, "added") - self.assertEqual(seen_facts[0].value.claim_hash, claim_hash) - stored = node.get_required(simulation_fact_document, commits[0].key) - self.assertEqual(stored.claim_hash, claim_hash) - self.assertEqual(stored.witness_count, 2) - - def test_cultmesh_game_session_serves_catalogs_and_observations_over_wire(self) -> None: - verse_catalog = CultMeshVerseCatalog() - peer_catalog = CultMeshPeerCatalog() - verse_catalog.upsert(CultMeshVerseDescriptor( - verse_id="session-verse", - display_name="Session Verse", - authority_model="local", - compatibility=CultMeshVerseCompatibility("cultmesh.v0", "session-rules"), - )) - peer_catalog.upsert(CultMeshPeerCard( - peer_id="session-peer", - verse_id="session-verse", - endpoints=("cultnet://127.0.0.1:0",), - roles=("simulation-observer", "read-replica"), - shard_ids=("arena",), - )) - session = CultMesh.create_game_session( - CultMesh.create_node(runtime_id="session-peer"), - CultMeshGameSessionOptions( - verse_catalog=verse_catalog, - peer_catalog=peer_catalog, - consensus_options=CultNetSimulationConsensusOptions(minimum_witnesses=2, quorum_ratio=1.0), - ), - ) - server = session.serve(display_name="Session Peer") - try: - raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) - hello_response = raw_client.request( - hello(runtime_id="session-prober"), - expected_schema_version="cultnet.hello.v0", - ) - discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", server.port, timeout_seconds=2.0) - verses = discovery_client.fetch_verses(transport_version="cultmesh.v0") - peers = discovery_client.fetch_peers(verse_id="session-verse", roles=["simulation-observer"]) - claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100") - first = raw_client.request( - simulation_observation( - message_id="session-wire-1", - witness_runtime_id="watcher-1", - shard_id="arena", - shard_epoch=4, - frame=100, - subject_id="bob", - claim_kind="hit", - claim_hash=claim_hash, - claim_summary="alice shot bob first", - ), - expected_schema_version="cultnet.simulation_consensus_candidate.v0", - ) - second = raw_client.request( - simulation_observation( - message_id="session-wire-2", - witness_runtime_id="watcher-2", - shard_id="arena", - shard_epoch=4, - frame=100, - subject_id="bob", - claim_kind="hit", - claim_hash=claim_hash, - claim_summary="alice shot bob first", - ), - expected_schema_version="cultnet.simulation_consensus_candidate.v0", - ) - finally: - server.stop() - - self.assertIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"]) - self.assertEqual(verses[0].verse_id, "session-verse") - self.assertEqual(peers[0].peer_id, "session-peer") - self.assertIn("simulation-observer", peers[0].roles) - self.assertFalse(first["hasQuorum"]) - self.assertEqual(first["witnessCount"], 1) - self.assertTrue(second["hasQuorum"]) - self.assertEqual(second["witnessCount"], 2) - - def test_cultmesh_game_session_can_disable_served_simulation_observations(self) -> None: - verse_catalog = CultMeshVerseCatalog() - verse_catalog.upsert(CultMeshVerseDescriptor( - verse_id="session-quiet", - display_name="Quiet Session", - authority_model="local", - compatibility=CultMeshVerseCompatibility("cultmesh.v0", "quiet-rules"), - )) - session = CultMesh.create_game_session( - CultMesh.create_node(runtime_id="quiet-session-peer"), - CultMeshGameSessionOptions( - verse_catalog=verse_catalog, - serve_simulation_observations=False, - ), - ) - server = session.serve(display_name="Quiet Session Peer") - try: - raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) - hello_response = raw_client.request( - hello(runtime_id="quiet-session-prober"), - expected_schema_version="cultnet.hello.v0", - ) - discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", server.port, timeout_seconds=2.0) - verses = discovery_client.fetch_verses(transport_version="cultmesh.v0") - with self.assertRaisesRegex(CultNetPeerError, "Simulation observations are not enabled") as raised: - raw_client.request( - simulation_observation( - message_id="quiet-session-sim", - witness_runtime_id="watcher-1", - shard_id="arena", - shard_epoch=4, - frame=100, - subject_id="bob", - claim_kind="hit", - claim_hash=compute_simulation_claim_hash("hit", "alice", "bob", "frame:100"), - ), - expected_schema_version="cultnet.simulation_consensus_candidate.v0", - ) - finally: - server.stop() - - self.assertNotIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"]) - self.assertEqual(verses[0].verse_id, "session-quiet") - self.assertEqual(raised.exception.response["code"], "simulation_observations_disabled") - - def test_cultmesh_game_session_prediction_requires_scope_and_reconciles_shard_log(self) -> None: - note_doc = define_database_entry_type( - "mesh.input", - [("body", 0)], - schema_id="mesh.input.v1", - ) - with tempfile.TemporaryDirectory() as tmp: - node = CultMesh.create_node(Path(tmp) / "prediction.cc", runtime_id="client-a") - node.register_document(note_doc) - session = CultMesh.create_game_session( - node, - CultMeshGameSessionOptions( - client_authority_scopes=( - CultNetClientAuthorityScope( - "client-a", - schema_ids=("mesh.input.v1",), - key_prefix="input:client-a", - ), - ) - ), - ) - - with self.assertRaisesRegex(ValueError, "does not have client prediction authority"): - session.predict(note_doc, "input:other", {"body": "nope"}) - - prediction = session.predict(note_doc, "input:client-a:move", {"body": "predicted"}) - put = document_put_raw( - message_id="authoritative", - key=prediction.key, - schema_id=prediction.schema_id, - stored_at="2026-06-13T00:00:00Z", - payload=note_doc.encode_payload({"body": "authoritative"}), - shard_id="inputs", - shard_epoch=1, - ) - authoritative_response = { - "schemaVersion": "cultnet.shard_log_response.v0", - "messageId": "inputs-log", - "shardId": "inputs", - "shardEpoch": 1, - "entries": [ - { - "sequence": 1, - "committedAt": "2026-06-13T00:00:00Z", - "changeKind": "updated", - "put": put.to_wire(), - } - ], - "resyncRequired": False, - } - changes = session.apply_shard_log_response(CultNetShardLogResponse.from_wire(authoritative_response)) - - self.assertEqual(changes[0].change_kind, "reconciled") - self.assertEqual(node.get_required(note_doc, prediction.key)["body"], "authoritative") - self.assertEqual(session.pending_predictions(), ()) - self.assertEqual(session.resimulation_inputs(), ()) - - def test_cultmesh_game_session_partial_reconciliation_keeps_unmatched_predictions(self) -> None: - note_doc = define_database_entry_type( - "mesh.partial_input", - [("body", 0)], - schema_id="mesh.partial_input.v1", - ) - node = CultMesh.create_node(runtime_id="client-partial") - node.register_document(note_doc) - session = CultMesh.create_game_session( - node, - CultMeshGameSessionOptions( - client_authority_scopes=( - CultNetClientAuthorityScope( - "client-partial", - schema_ids=("mesh.partial_input.v1",), - key_prefix="input:client-partial", - ), - ) - ), - ) - pending = session.predict(note_doc, "input:client-partial:pending", {"body": "pending-local"}) - accepted = session.predict(note_doc, "input:client-partial:accepted", {"body": "accepted-local"}) - put = document_put_raw( - message_id="authoritative-partial", - key=accepted.key, - schema_id=accepted.schema_id, - stored_at="2026-06-13T00:00:00Z", - payload=note_doc.encode_payload({"body": "accepted-authority"}), - shard_id="inputs", - shard_epoch=1, - ) - - changes = session.apply_shard_log_response({ - "schemaVersion": "cultnet.shard_log_response.v0", - "messageId": "inputs-partial-log", - "shardId": "inputs", - "shardEpoch": 1, - "entries": [ - { - "sequence": 1, - "committedAt": "2026-06-13T00:00:00Z", - "changeKind": "updated", - "put": put.to_wire(), - } - ], - "resyncRequired": False, - }) - - self.assertEqual([(change.change_kind, change.record_key) for change in changes], [ - ("reconciled", accepted.key), - ]) - self.assertEqual(node.get_required(note_doc, accepted.key)["body"], "accepted-authority") - self.assertEqual(node.get_required(note_doc, pending.key)["body"], "pending-local") - self.assertEqual([prediction.key for prediction in session.pending_predictions()], [pending.key]) - self.assertEqual([prediction.key for prediction in session.resimulation_inputs()], [pending.key]) - - def test_cultmesh_game_session_rolls_back_pending_predictions(self) -> None: - note_doc = define_database_entry_type( - "mesh.rollback_input", - [("body", 0)], - schema_id="mesh.rollback_input.v1", - ) - node = CultMesh.create_node(runtime_id="client-rollback") - node.register_document(note_doc) - node.put(note_doc, "input:client-rollback:existing", {"body": "previous"}) - session = CultMesh.create_game_session( - node, - CultMeshGameSessionOptions( - client_authority_scopes=( - CultNetClientAuthorityScope( - "client-rollback", - schema_ids=("mesh.rollback_input.v1",), - key_prefix="input:client-rollback", - ), - ) - ), - ) - - existing = session.predict(note_doc, "input:client-rollback:existing", {"body": "predicted-existing"}) - created = session.predict(note_doc, "input:client-rollback:new", {"body": "predicted-new"}) - self.assertEqual( - [prediction.key for prediction in session.pending_predictions()], - ["input:client-rollback:existing", "input:client-rollback:new"], - ) - self.assertEqual([prediction.key for prediction in session.resimulation_inputs()], [ - "input:client-rollback:existing", - "input:client-rollback:new", - ]) - - restored = session.rollback_prediction(existing) - self.assertIsNotNone(restored) - assert restored is not None - self.assertEqual(restored.change_kind, "rolled_back") - self.assertEqual(restored.value, {"body": "previous"}) - self.assertEqual(node.get_required(note_doc, "input:client-rollback:existing")["body"], "previous") - - rolled_back = session.rollback_predictions() - self.assertEqual([(change.change_kind, change.record_key, change.value) for change in rolled_back], [ - ("rolled_back", created.key, None), - ]) - self.assertIsNone(node.get(note_doc, created.key)) - self.assertEqual(session.pending_predictions(), ()) - - def test_cultmesh_game_session_authoritative_delete_rolls_back_prediction(self) -> None: - note_doc = define_database_entry_type( - "mesh.deleted_input", - [("body", 0)], - schema_id="mesh.deleted_input.v1", - ) - node = CultMesh.create_node(runtime_id="client-delete") - node.register_document(note_doc) - session = CultMesh.create_game_session( - node, - CultMeshGameSessionOptions( - client_authority_scopes=( - CultNetClientAuthorityScope( - "client-delete", - schema_ids=("mesh.deleted_input.v1",), - key_prefix="input:client-delete", - ), - ) - ), - ) - - prediction = session.predict(note_doc, "input:client-delete:move", {"body": "predicted"}) - delete = document_delete( - message_id="authoritative-delete", - schema_id=prediction.schema_id, - record_key=prediction.key, - shard_id="inputs", - shard_epoch=1, - ) - changes = session.apply_shard_log_response({ - "schemaVersion": "cultnet.shard_log_response.v0", - "messageId": "inputs-delete-log", - "shardId": "inputs", - "shardEpoch": 1, - "entries": [ - { - "sequence": 1, - "committedAt": "2026-06-13T00:00:00Z", - "changeKind": "removed", - "delete": delete.to_wire(), - } - ], - "resyncRequired": False, - }) - - self.assertEqual(changes[0].change_kind, "rolled_back") - self.assertIsNone(node.get(note_doc, prediction.key)) - self.assertEqual(session.pending_predictions(), ()) - - def test_cultmesh_database_watchers_observe_authoritative_shard_log_reconciliation(self) -> None: - note_doc = define_database_entry_type( - "mesh.watched_input", - [("body", 0)], - schema_id="mesh.watched_input.v1", - ) - node = CultMesh.create_node(runtime_id="client-watch") - node.database.register_document(note_doc) - seen: list[CultMeshDatabaseChange] = [] - node.database.watch_record(note_doc, "input:client-watch:move", seen.append) - session = CultMesh.create_game_session( - node, - CultMeshGameSessionOptions( - client_authority_scopes=( - CultNetClientAuthorityScope( - "client-watch", - schema_ids=("mesh.watched_input.v1",), - key_prefix="input:client-watch", - ), - ) - ), - ) - - prediction = session.predict(note_doc, "input:client-watch:move", {"body": "predicted"}) - put = document_put_raw( - message_id="authoritative-watch", - key=prediction.key, - schema_id=prediction.schema_id, - stored_at="2026-06-13T00:00:00Z", - payload=note_doc.encode_payload({"body": "authoritative"}), - shard_id="inputs", - shard_epoch=1, - ) - session_changes = session.apply_shard_log_response({ - "schemaVersion": "cultnet.shard_log_response.v0", - "messageId": "inputs-watch-log", - "shardId": "inputs", - "shardEpoch": 1, - "entries": [ - { - "sequence": 1, - "committedAt": "2026-06-13T00:00:00Z", - "changeKind": "updated", - "put": put.to_wire(), - } - ], - "resyncRequired": False, - }) - - self.assertEqual([change.change_kind for change in seen], ["added", "updated"]) - self.assertEqual(seen[1].value, {"body": "authoritative"}) - self.assertEqual(seen[1].previous_value, {"body": "predicted"}) - self.assertEqual(session_changes[0].change_kind, "reconciled") - - def test_cultmesh_verse_catalog_response_matches_schema_v0_wire_shape(self) -> None: - import msgpack # type: ignore - - catalog = CultMeshVerseCatalog() - catalog.upsert( - CultMeshVerseDescriptor( - verse_id="aetheria-main", - display_name="Aetheria", - authority_model="OperatorCluster", - compatibility=CultMeshVerseCompatibility( - transport_version="cultmesh.v0", - rules_hash="rules", - compatible_verse_ids=("aetheria-modded",), - required_plugin_ids=("core",), - optional_plugin_ids=("skylands",), - ), - discovery_endpoints=("cultmesh://aetheria.example.test:3075",), - authority_runtime_ids=("runtime-a",), - description="main branch", - ) - ) - - response = catalog.create_response(verse_catalog_request("verses-1")) - decoded = msgpack.unpackb(msgpack.packb(response, use_bin_type=True), raw=False) - self.assertEqual(decoded["schemaVersion"], "cultmesh.verse_catalog_response.v0") - self.assertEqual(decoded["messageId"], "verses-1") - self.assertEqual(decoded["verses"][0]["verseId"], "aetheria-main") - self.assertEqual(decoded["verses"][0]["compatibility"]["requiredPluginIds"], ["core"]) - - def test_cultmesh_verse_catalog_watches_updates_and_finds_transfer_targets(self) -> None: - source = CultMeshVerseDescriptor( - verse_id="aetheria-main", - display_name="Aetheria", - authority_model="federated", - compatibility=CultMeshVerseCompatibility( - transport_version="cultmesh.v0", - rules_hash="rules-main", - ), - ) - compatible = CultMeshVerseDescriptor( - verse_id="aetheria-modded", - display_name="Aetheria Modded", - authority_model="federated", - compatibility=CultMeshVerseCompatibility( - transport_version="cultmesh.v0", - rules_hash="rules-modded", - compatible_verse_ids=("aetheria-main",), - ), - ) - incompatible = CultMeshVerseDescriptor( - verse_id="old-world", - display_name="Old World", - authority_model="solo", - compatibility=CultMeshVerseCompatibility( - transport_version="cultmesh.v0", - rules_hash="rules-old", - ), - ) - catalog = CultMeshVerseCatalog() - seen: list[str] = [] - unsubscribe = catalog.watch(lambda verse: seen.append(verse.verse_id)) - - catalog.upsert(source) - catalog.apply_response({ - "schemaVersion": "cultmesh.verse_catalog_response.v0", - "messageId": "verses-watch", - "verses": [compatible.to_wire(), incompatible.to_wire()], - }) - unsubscribe() - catalog.upsert(CultMeshVerseDescriptor( - verse_id="after-unsubscribe", - display_name="After", - authority_model="none", - compatibility=CultMeshVerseCompatibility("cultmesh.v0", "rules-after"), - )) - - self.assertEqual(seen, ["aetheria-main", "aetheria-modded", "old-world"]) - self.assertEqual([verse.verse_id for verse in catalog.verses], ["aetheria-main", "aetheria-modded", "after-unsubscribe", "old-world"]) - self.assertEqual(catalog.get("aetheria-modded"), compatible) - self.assertEqual([verse.verse_id for verse in catalog.find_transfer_targets(source)], ["aetheria-modded"]) - - def test_cultmesh_peer_exchange_response_matches_schema_v0_wire_shape(self) -> None: - import msgpack # type: ignore - - catalog = CultMeshPeerCatalog() - catalog.upsert( - CultMeshPeerCard( - peer_id="peer-a", - verse_id="aetheria-main", - endpoints=("cultnet://peer-a.example.test:3075",), - roles=("discovery", "read-replica"), - shard_ids=("players",), - region="eu-west", - authority_lease_id="lease-1", - expires_at="2026-05-20T12:00:00.0000000Z", - signature="sig", - ) - ) - - response = catalog.create_response( - peer_exchange_request("pex-1", verse_id="aetheria-main", roles=["read-replica"]) - ) - decoded = msgpack.unpackb(msgpack.packb(response, use_bin_type=True), raw=False) - self.assertEqual(decoded["schemaVersion"], "cultmesh.peer_exchange_response.v0") - self.assertEqual(decoded["messageId"], "pex-1") - self.assertEqual(decoded["peers"][0]["peerId"], "peer-a") - self.assertIn("read-replica", decoded["peers"][0]["roles"]) - self.assertEqual(decoded["peers"][0]["authorityLeaseId"], "lease-1") - - def test_cultmesh_peer_catalog_watches_updates_and_gets_peers(self) -> None: - first = CultMeshPeerCard( - peer_id="peer-a", - verse_id="aetheria-main", - endpoints=("cultnet://peer-a.example.test:3075",), - roles=("discovery", "read-replica"), - ) - second = CultMeshPeerCard( - peer_id="peer-b", - verse_id="aetheria-main", - endpoints=("cultnet://peer-b.example.test:3075",), - roles=("shard-primary",), - ) - catalog = CultMeshPeerCatalog() - seen: list[str] = [] - unsubscribe = catalog.watch(lambda peer: seen.append(peer.peer_id)) - - catalog.upsert(first) - catalog.apply_response({ - "schemaVersion": "cultmesh.peer_exchange_response.v0", - "messageId": "peers-watch", - "peers": [second.to_wire()], - }) - unsubscribe() - catalog.upsert(CultMeshPeerCard( - peer_id="peer-c", - verse_id="aetheria-main", - endpoints=("cultnet://peer-c.example.test:3075",), - roles=("read-replica",), - )) - - self.assertEqual(seen, ["peer-a", "peer-b"]) - self.assertTrue(first.has_role("read-replica")) - self.assertEqual([peer.peer_id for peer in catalog.peers], ["peer-a", "peer-b", "peer-c"]) - self.assertEqual(catalog.get("peer-b"), second) - self.assertEqual([peer.peer_id for peer in catalog.find("aetheria-main", role="read-replica")], ["peer-a", "peer-c"]) - - def test_cultmesh_discovery_client_fetches_typed_catalogs_over_cultnet_frames(self) -> None: - import msgpack # type: ignore - from cultnet_py import read_frame, write_frame - - verses = CultMeshVerseCatalog() - verses.upsert( - CultMeshVerseDescriptor( - verse_id="aetheria-main", - display_name="Aetheria", - authority_model="federated", - compatibility=CultMeshVerseCompatibility( - transport_version="cultmesh.v0", - rules_hash="rules-1", - required_plugin_ids=("core",), - ), - discovery_endpoints=("cultmesh://aetheria.example.test:3075",), - authority_runtime_ids=("runtime-a",), - ) - ) - peers = CultMeshPeerCatalog() - peers.upsert( - CultMeshPeerCard( - peer_id="peer-a", - verse_id="aetheria-main", - endpoints=("cultnet://peer-a.example.test:3075",), - roles=("read-replica",), - shard_ids=("players",), - ) - ) - - ready = threading.Event() - server_error: list[BaseException] = [] - - def serve_requests() -> None: - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: - server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - server.bind(("127.0.0.1", 0)) - port_holder.append(server.getsockname()[1]) - server.listen(8) - ready.set() - for _ in range(8): - connection, _ = server.accept() - with connection: - stream = connection.makefile("rwb") - request = msgpack.unpackb(read_frame(stream), raw=False) - if request["schemaVersion"] == "cultmesh.verse_catalog_request.v0": - response = verses.create_response(request) - elif request["schemaVersion"] == "cultmesh.peer_exchange_request.v0": - response = peers.create_response(request) - else: - raise AssertionError(f"unexpected request {request['schemaVersion']}") - write_frame(stream, msgpack.packb(response, use_bin_type=True)) - stream.flush() - except BaseException as error: - server_error.append(error) - ready.set() - - port_holder: list[int] = [] - thread = threading.Thread(target=serve_requests, daemon=True) - thread.start() - self.assertTrue(ready.wait(2.0)) - self.assertFalse(server_error) - - client = CultMeshDiscoveryClient("127.0.0.1", port_holder[0], timeout_seconds=2.0) - fetched_verses = client.fetch_verses(transport_version="cultmesh.v0") - fetched_peers = client.fetch_peers(verse_id="aetheria-main", roles=["read-replica"]) - local_verses = CultMeshVerseCatalog() - local_peers = CultMeshPeerCatalog() - synced_verses = client.sync_verse_catalog(local_verses, transport_version="cultmesh.v0") - synced_peers = client.sync_peer_catalog(local_peers, verse_id="aetheria-main", roles=["read-replica"]) - discovered_verses = CultMeshVerseCatalog() - discovered_peers = CultMeshPeerCatalog() - endpoint = f"cultnet://127.0.0.1:{port_holder[0]}" - verse_discovery = CultMeshVerseDiscoveryClient.from_endpoint(endpoint, timeout_seconds=2.0) - peer_discovery = CultMeshPeerExchangeClient.from_endpoint(endpoint, timeout_seconds=2.0) - discovered_verse_count = verse_discovery.discover( - discovered_verses, - endpoints=[endpoint, endpoint, ""], - transport_version="cultmesh.v0", - ) - discovered_peer_count = peer_discovery.discover( - discovered_peers, - verse_id="aetheria-main", - endpoints=[endpoint, endpoint, ""], - roles=["read-replica"], - ) - fanout_verses = CultMeshVerseCatalog() - fanout_verses.upsert( - CultMeshVerseDescriptor( - verse_id="aetheria-main", - display_name="Known Aetheria", - authority_model="federated", - compatibility=CultMeshVerseCompatibility("cultmesh.v0", "rules-1"), - discovery_endpoints=(endpoint, endpoint, ""), - ) - ) - fanout_peers = CultMeshPeerCatalog() - fanout_peers.upsert( - CultMeshPeerCard( - peer_id="known-peer", - verse_id="aetheria-main", - endpoints=(endpoint, endpoint, ""), - roles=("read-replica",), - ) - ) - fanout_verse_count = verse_discovery.fanout(fanout_verses, transport_version="cultmesh.v0") - fanout_peer_count = peer_discovery.fanout( - fanout_peers, - verse_id="aetheria-main", - roles=["read-replica"], - ) - - thread.join(2.0) - self.assertFalse(server_error) - self.assertEqual(fetched_verses[0].verse_id, "aetheria-main") - self.assertEqual(fetched_verses[0].compatibility.required_plugin_ids, ("core",)) - self.assertEqual(fetched_peers[0].peer_id, "peer-a") - self.assertEqual(fetched_peers[0].shard_ids, ("players",)) - self.assertEqual(synced_verses[0].verse_id, "aetheria-main") - self.assertEqual(local_peers.find("aetheria-main", role="read-replica")[0].peer_id, "peer-a") - self.assertEqual(synced_peers[0].roles, ("read-replica",)) - self.assertEqual(discovered_verse_count, 1) - self.assertEqual(discovered_peer_count, 1) - self.assertEqual(discovered_verses.get("aetheria-main").display_name, "Aetheria") - self.assertEqual(discovered_peers.find("aetheria-main", role="read-replica")[0].peer_id, "peer-a") - self.assertEqual(fanout_verse_count, 1) - self.assertEqual(fanout_peer_count, 1) - self.assertEqual(fanout_verses.get("aetheria-main").display_name, "Aetheria") - self.assertIn( - "peer-a", - {peer.peer_id for peer in fanout_peers.find("aetheria-main", role="read-replica")}, - ) - - def test_cultmesh_discovery_client_fans_out_simulation_observations_to_peers(self) -> None: - first_hub = CultNetSimulationObservationHub( - CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0) - ) - second_hub = CultNetSimulationObservationHub( - CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0) - ) - first_server = CultMesh.serve_node( - CultMesh.create_node(runtime_id="sim-peer-a"), - observation_hub=first_hub, - ) - second_server = CultMesh.serve_node( - CultMesh.create_node(runtime_id="sim-peer-b"), - observation_hub=second_hub, - ) - try: - peers = CultMeshPeerCatalog() - peers.upsert(CultMeshPeerCard( - peer_id="sim-peer-a", - verse_id="arena", - endpoints=(f"cultnet://127.0.0.1:{first_server.port}",), - roles=("simulation-witness",), - )) - peers.upsert(CultMeshPeerCard( - peer_id="sim-peer-b", - verse_id="arena", - endpoints=(f"cultnet://127.0.0.1:{second_server.port}",), - roles=("simulation-witness",), - )) - claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:200") - message = simulation_observation( - message_id="fanout-observation", - witness_runtime_id="python-fanout", - shard_id="arena", - shard_epoch=1, - frame=200, - subject_id="bob", - claim_kind="hit", - claim_hash=claim_hash, - claim_summary="alice hit bob", - ).to_wire() - - client = CultMeshDiscoveryClient("127.0.0.1", first_server.port, timeout_seconds=2.0) - candidates = client.fanout_simulation_observation( - peers, - message, - verse_id="arena", - roles=["simulation-witness"], - ) - finally: - first_server.stop() - second_server.stop() - - self.assertEqual([candidate["schemaVersion"] for candidate in candidates], [ - "cultnet.simulation_consensus_candidate.v0", - "cultnet.simulation_consensus_candidate.v0", - ]) - self.assertEqual({candidate["claimHash"] for candidate in candidates}, {claim_hash}) - self.assertEqual({candidate["messageId"] for candidate in candidates}, {"fanout-observation"}) - self.assertEqual({candidate["hasQuorum"] for candidate in candidates}, {True}) - - def test_cultmesh_simulation_observation_fanout_flushes_queued_messages(self) -> None: - hub = CultNetSimulationObservationHub( - CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0) - ) - server = CultMesh.serve_node(CultMesh.create_node(runtime_id="sim-flush-peer"), observation_hub=hub) - try: - peers = CultMeshPeerCatalog() - peers.upsert(CultMeshPeerCard( - peer_id="sim-flush-peer", - verse_id="arena", - endpoints=(f"cultnet://127.0.0.1:{server.port}",), - roles=("simulation-witness",), - )) - claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:210") - seen_candidates: list[dict[str, object]] = [] - fanout = CultMeshSimulationObservationFanout( - CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0), - peers, - verse_id="arena", - roles=["simulation-witness"], - on_candidate=seen_candidates.append, - ) - fanout.enqueue(simulation_observation( - message_id="queued-observation", - witness_runtime_id="python-fanout", - shard_id="arena", - shard_epoch=1, - frame=210, - subject_id="bob", - claim_kind="hit", - claim_hash=claim_hash, - claim_summary="alice hit bob", - ).to_wire()) - - candidates = fanout.flush() - finally: - server.stop() - - self.assertEqual(fanout.pending_count(), 0) - self.assertEqual([candidate["claimHash"] for candidate in candidates], [claim_hash]) - self.assertEqual(seen_candidates[0]["messageId"], "queued-observation") - - def test_cultmesh_simulation_observation_fanout_keeps_failed_messages_pending(self) -> None: - closed_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - closed_socket.bind(("127.0.0.1", 0)) - closed_port = closed_socket.getsockname()[1] - closed_socket.close() - peers = CultMeshPeerCatalog() - peers.upsert(CultMeshPeerCard( - peer_id="closed-simulation-peer", - verse_id="arena", - endpoints=(f"cultnet://127.0.0.1:{closed_port}",), - roles=("simulation-witness",), - )) - errors: list[tuple[str, str]] = [] - fanout = CultMeshSimulationObservationFanout( - CultMeshDiscoveryClient("127.0.0.1", closed_port, timeout_seconds=0.2), - peers, - verse_id="arena", - roles=["simulation-witness"], - on_error=lambda endpoint, error: errors.append((endpoint, str(error))), - ) - fanout.enqueue(simulation_observation( - message_id="retry-observation", - witness_runtime_id="python-fanout", - shard_id="arena", - shard_epoch=1, - frame=212, - subject_id="bob", - claim_kind="miss", - claim_hash=compute_simulation_claim_hash("miss", "alice", "bob", "frame:212"), - claim_summary="alice missed bob", - ).to_wire()) - - candidates = fanout.flush() - - self.assertEqual(candidates, []) - self.assertEqual(fanout.pending_count(), 1) - self.assertEqual(len(errors), 1) - self.assertIn(f"127.0.0.1:{closed_port}", errors[0][0]) - - def test_cultmesh_simulation_observation_fanout_runs_background_loop(self) -> None: - hub = CultNetSimulationObservationHub( - CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0) - ) - server = CultMesh.serve_node(CultMesh.create_node(runtime_id="sim-loop-peer"), observation_hub=hub) - try: - peers = CultMeshPeerCatalog() - peers.upsert(CultMeshPeerCard( - peer_id="sim-loop-peer", - verse_id="arena", - endpoints=(f"cultnet://127.0.0.1:{server.port}",), - roles=("simulation-witness",), - )) - claim_hash = compute_simulation_claim_hash("block", "dana", "eve", "frame:211") - seen_candidates: list[dict[str, object]] = [] - fanout = CultMeshSimulationObservationFanout( - CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0), - peers, - verse_id="arena", - roles=["simulation-witness"], - interval_seconds=0.05, - on_candidate=seen_candidates.append, - ) - fanout.start() - fanout.enqueue(simulation_observation( - message_id="loop-observation", - witness_runtime_id="python-fanout", - shard_id="arena", - shard_epoch=1, - frame=211, - subject_id="eve", - claim_kind="block", - claim_hash=claim_hash, - claim_summary="dana blocked eve", - ).to_wire()) - deadline = time.monotonic() + 2.0 - while not seen_candidates and time.monotonic() < deadline: - time.sleep(0.01) - fanout.stop() - finally: - server.stop() - - self.assertEqual(fanout.pending_count(), 0) - self.assertTrue(seen_candidates) - self.assertEqual(seen_candidates[0]["claimHash"], claim_hash) - self.assertEqual(seen_candidates[0]["messageId"], "loop-observation") - - def test_cultmesh_discovery_client_fans_out_snapshots_to_peers(self) -> None: - document = define_database_entry_type( - "mesh.snapshot_fanout_note", - [("body", 0)], - schema_id="mesh.snapshot_fanout_note.v1", - ) - first = CultMesh.create_node(runtime_id="snapshot-peer-a") - first.database.register_document(document) - first.database.put_raw_message(document, "note:a", {"body": "from-a"}, shard_id="notes", shard_epoch=1) - second = CultMesh.create_node(runtime_id="snapshot-peer-b") - second.database.register_document(document) - second.database.put_raw_message(document, "note:b", {"body": "from-b"}, shard_id="notes", shard_epoch=1) - target = CultMesh.create_node(runtime_id="snapshot-target") - target.database.register_document(document) - first_server = CultMesh.serve_node(first) - second_server = CultMesh.serve_node(second) - try: - peers = CultMeshPeerCatalog() - peers.upsert(CultMeshPeerCard( - peer_id="snapshot-peer-a", - verse_id="mesh", - endpoints=(f"cultnet://127.0.0.1:{first_server.port}", f"cultnet://127.0.0.1:{first_server.port}"), - roles=("read-replica",), - )) - peers.upsert(CultMeshPeerCard( - peer_id="snapshot-peer-b", - verse_id="mesh", - endpoints=(f"cultnet://127.0.0.1:{second_server.port}",), - roles=("read-replica",), - )) - - client = CultMeshDiscoveryClient("127.0.0.1", first_server.port, timeout_seconds=2.0) - responses = client.fanout_snapshot_responses( - peers, - verse_id="mesh", - roles=["read-replica"], - schema_ids=["mesh.snapshot_fanout_note.v1"], - ) - applied = client.sync_snapshots( - target.database, - peers, - verse_id="mesh", - roles=["read-replica"], - schema_ids=["mesh.snapshot_fanout_note.v1"], - ) - finally: - first_server.stop() - second_server.stop() - - self.assertEqual(len(responses), 2) - self.assertTrue(all(isinstance(response, CultNetRawSnapshotResponse) for response in responses)) - self.assertEqual( - sorted(record.record_key for response in responses for record in response.documents), - ["note:a", "note:b"], - ) - self.assertEqual(sorted(record.record_key for record in applied), ["note:a", "note:b"]) - self.assertEqual(target.database.get_required(document, "note:a")["body"], "from-a") - self.assertEqual(target.database.get_required(document, "note:b")["body"], "from-b") - - def test_cultmesh_discovery_client_sync_documents_returns_requested_aliases(self) -> None: - @dataclass - class CanonicalNote: - body: str - - @dataclass - class UiNote: - body: str - - document = define_database_entry_type( - "mesh.snapshot_fanout_alias_note", - [("body", 0)], - cls=CanonicalNote, - schema_id="mesh.snapshot_fanout_alias_note.v1", - schema_name="mesh.snapshot_fanout_alias_note", - schema_version="mesh.snapshot_fanout_alias_note.v1", - ) - alias = define_database_entry_type( - "mesh.snapshot_fanout_alias_note.ui", - [("body", 0)], - cls=UiNote, - schema_id="mesh.snapshot_fanout_alias_note.v1", - schema_name="mesh.snapshot_fanout_alias_note", - schema_version="mesh.snapshot_fanout_alias_note.v1", - ) - source = CultMesh.create_node(runtime_id="snapshot-alias-source") - source.database.register_document(document) - source.database.put_raw_message(document, "note:alias", CanonicalNote("from-source")) - target = CultMesh.create_node(runtime_id="snapshot-alias-target") - target.database.register_document(document) - server = CultMesh.serve_node(source) - try: - peers = CultMeshPeerCatalog() - peers.upsert(CultMeshPeerCard( - peer_id="snapshot-alias-source", - verse_id="mesh", - endpoints=(f"cultnet://127.0.0.1:{server.port}",), - roles=("read-replica",), - )) - - values = CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0).sync_documents( - target.database, - peers, - verse_id="mesh", - roles=["read-replica"], - documents=[(alias, "note:alias")], - ) - finally: - server.stop() - - self.assertEqual(len(values), 1) - self.assertIsInstance(values[0], UiNote) - self.assertEqual(values[0].body, "from-source") - self.assertEqual(target.database.get_required(document, "note:alias").body, "from-source") - self.assertIsInstance(target.database.get_required(alias, "note:alias"), UiNote) - - def test_cultmesh_document_subscription_syncs_alias_snapshot_and_changes(self) -> None: - @dataclass - class CanonicalNote: - body: str - - @dataclass - class UiNote: - body: str - - document = define_database_entry_type( - "mesh.subscription_alias_note", - [("body", 0)], - cls=CanonicalNote, - schema_id="mesh.subscription_alias_note.v1", - schema_name="mesh.subscription_alias_note", - schema_version="mesh.subscription_alias_note.v1", - ) - alias = define_database_entry_type( - "mesh.subscription_alias_note.ui", - [("body", 0)], - cls=UiNote, - schema_id="mesh.subscription_alias_note.v1", - schema_name="mesh.subscription_alias_note", - schema_version="mesh.subscription_alias_note.v1", - ) - source = CultMesh.create_node(runtime_id="subscription-alias-source") - source.database.register_document(document) - source.database.put_raw_message(document, "note:live", CanonicalNote("initial")) - target = CultMesh.create_node(runtime_id="subscription-alias-target") - target.database.register_document(document) - server = CultMesh.serve_node(source) - try: - raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) - with CultMesh.subscribe_document(target, raw_client, alias, "note:live") as subscription: - initial = subscription.sync_initial() - subscription.send(document_put_raw( - message_id="subscription-alias-put", - key="note:live", - schema_id="mesh.subscription_alias_note.v1", - stored_at="2026-06-14T00:00:00Z", - payload=document.encode_payload(CanonicalNote("updated")), - )) - updated = subscription.read_next_change() - subscription.send(document_delete( - message_id="subscription-alias-delete", - schema_id="mesh.subscription_alias_note.v1", - record_key="note:live", - )) - removed = subscription.read_next_change() - finally: - server.stop() - - self.assertIsInstance(initial, UiNote) - self.assertEqual(initial.body, "initial") - self.assertEqual(updated.change_kind, "updated") - self.assertIsInstance(updated.value, UiNote) - self.assertEqual(updated.value.body, "updated") - self.assertIsInstance(updated.previous_value, UiNote) - self.assertEqual(updated.previous_value.body, "initial") - self.assertEqual(removed.change_kind, "removed") - self.assertIsNone(removed.value) - self.assertIsInstance(removed.previous_value, UiNote) - self.assertEqual(removed.previous_value.body, "updated") - self.assertIsNone(target.database.get(alias, "note:live")) - - def test_cultmesh_snapshot_fanout_syncs_once_and_reports_applied_records(self) -> None: - document = define_database_entry_type( - "mesh.snapshot_loop_note", - [("body", 0)], - schema_id="mesh.snapshot_loop_note.v1", - ) - source = CultMesh.create_node(runtime_id="snapshot-loop-source") - source.database.register_document(document) - source.database.put_raw_message(document, "note:sync-once", {"body": "from-source"}) - target = CultMesh.create_node(runtime_id="snapshot-loop-target") - target.database.register_document(document) - server = CultMesh.serve_node(source) - try: - peers = CultMeshPeerCatalog() - peers.upsert(CultMeshPeerCard( - peer_id="snapshot-loop-source", - verse_id="mesh", - endpoints=(f"cultnet://127.0.0.1:{server.port}",), - roles=("read-replica",), - )) - seen: list[str] = [] - fanout = CultMeshSnapshotFanout( - CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0), - target.database, - peers, - verse_id="mesh", - roles=["read-replica"], - schema_ids=["mesh.snapshot_loop_note.v1"], - on_applied=lambda record: seen.append(record.record_key), - ) - - applied = fanout.sync_once() - finally: - server.stop() - - self.assertEqual([record.record_key for record in applied], ["note:sync-once"]) - self.assertEqual(seen, ["note:sync-once"]) - self.assertEqual(target.database.get_required(document, "note:sync-once")["body"], "from-source") - - def test_cultmesh_snapshot_fanout_syncs_typed_documents_and_reports_alias_values(self) -> None: - @dataclass - class CanonicalNote: - body: str - - @dataclass - class UiNote: - body: str - - document = define_database_entry_type( - "mesh.snapshot_loop_alias_note", - [("body", 0)], - cls=CanonicalNote, - schema_id="mesh.snapshot_loop_alias_note.v1", - schema_name="mesh.snapshot_loop_alias_note", - schema_version="mesh.snapshot_loop_alias_note.v1", - ) - alias = define_database_entry_type( - "mesh.snapshot_loop_alias_note.ui", - [("body", 0)], - cls=UiNote, - schema_id="mesh.snapshot_loop_alias_note.v1", - schema_name="mesh.snapshot_loop_alias_note", - schema_version="mesh.snapshot_loop_alias_note.v1", - ) - source = CultMesh.create_node(runtime_id="snapshot-loop-alias-source") - source.database.register_document(document) - source.database.put_raw_message(document, "note:typed-loop", CanonicalNote("from-source")) - target = CultMesh.create_node(runtime_id="snapshot-loop-alias-target") - target.database.register_document(document) - server = CultMesh.serve_node(source) - try: - peers = CultMeshPeerCatalog() - peers.upsert(CultMeshPeerCard( - peer_id="snapshot-loop-alias-source", - verse_id="mesh", - endpoints=(f"cultnet://127.0.0.1:{server.port}",), - roles=("read-replica",), - )) - seen: list[UiNote] = [] - fanout = CultMeshSnapshotFanout( - CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0), - target.database, - peers, - verse_id="mesh", - roles=["read-replica"], - documents=[(alias, "note:typed-loop")], - on_document=seen.append, - ) - - values = fanout.sync_once() - finally: - server.stop() - - self.assertEqual(len(values), 1) - self.assertIsInstance(values[0], UiNote) - self.assertEqual(values[0].body, "from-source") - self.assertEqual(seen, [values[0]]) - self.assertEqual(target.database.get_required(document, "note:typed-loop").body, "from-source") - self.assertIsInstance(target.database.get_required(alias, "note:typed-loop"), UiNote) - - def test_cultmesh_snapshot_fanout_runs_background_loop(self) -> None: - document = define_database_entry_type( - "mesh.snapshot_background_note", - [("body", 0)], - schema_id="mesh.snapshot_background_note.v1", - ) - source = CultMesh.create_node(runtime_id="snapshot-background-source") - source.database.register_document(document) - source.database.put_raw_message(document, "note:background", {"body": "from-background"}) - target = CultMesh.create_node(runtime_id="snapshot-background-target") - target.database.register_document(document) - server = CultMesh.serve_node(source) - try: - peers = CultMeshPeerCatalog() - peers.upsert(CultMeshPeerCard( - peer_id="snapshot-background-source", - verse_id="mesh", - endpoints=(f"cultnet://127.0.0.1:{server.port}",), - roles=("read-replica",), - )) - seen: list[str] = [] - fanout = CultMeshSnapshotFanout( - CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0), - target.database, - peers, - verse_id="mesh", - roles=["read-replica"], - schema_ids=["mesh.snapshot_background_note.v1"], - interval_seconds=0.05, - on_applied=lambda record: seen.append(record.record_key), - ) - fanout.start() - deadline = time.monotonic() + 2.0 - while not seen and time.monotonic() < deadline: - time.sleep(0.01) - fanout.stop() - finally: - server.stop() - - self.assertTrue(seen) - self.assertEqual(target.database.get_required(document, "note:background")["body"], "from-background") - - def test_cultmesh_peer_health_monitor_probes_reachable_and_failed_endpoints(self) -> None: - document = define_database_entry_type( - "mesh.health_note", - [("body", 0)], - schema_id="mesh.health_note.v1", - ) - node = CultMesh.create_node(runtime_id="health-peer") - node.database.register_document(document) - server = CultMesh.serve_node(node) - closed_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - closed_socket.bind(("127.0.0.1", 0)) - closed_port = closed_socket.getsockname()[1] - closed_socket.close() - try: - peers = CultMeshPeerCatalog() - peers.upsert(CultMeshPeerCard( - peer_id="health-peer", - verse_id="mesh", - endpoints=(f"cultnet://127.0.0.1:{server.port}",), - roles=("read-replica",), - )) - peers.upsert(CultMeshPeerCard( - peer_id="closed-peer", - verse_id="mesh", - endpoints=(f"cultnet://127.0.0.1:{closed_port}",), - roles=("read-replica",), - )) - seen: list[tuple[str, bool]] = [] - monitor = CultMeshPeerHealthMonitor( - runtime_id="health-prober", - timeout_seconds=0.2, - on_update=lambda health: seen.append((health.peer_id, health.is_reachable)), - ) - - results = monitor.probe_catalog(peers, verse_id="mesh", roles=["read-replica"]) - finally: - server.stop() - - by_peer = {result.peer_id: result for result in results} - self.assertTrue(by_peer["health-peer"].is_reachable) - self.assertEqual(by_peer["health-peer"].runtime_id, "health-peer") - self.assertEqual(by_peer["health-peer"].runtime_kind, "python") - self.assertEqual(by_peer["health-peer"].display_name, "health-peer") - self.assertEqual(by_peer["health-peer"].supported_document_types, ("mesh.health_note",)) - self.assertIn("cultnet.hello.v0", by_peer["health-peer"].supported_message_versions) - self.assertIn("cultnet.error.v0", by_peer["health-peer"].supported_message_versions) - self.assertEqual(by_peer["health-peer"].supported_mutation_contracts[0]["documentType"], "mesh.health_note") - self.assertIn("shardLog", by_peer["health-peer"].supported_mutation_contracts[0]["operations"]) - self.assertFalse(by_peer["closed-peer"].is_reachable) - self.assertIsNone(by_peer["closed-peer"].runtime_kind) - self.assertEqual(by_peer["closed-peer"].supported_document_types, ()) - self.assertEqual(by_peer["closed-peer"].supported_message_versions, ()) - self.assertEqual(by_peer["closed-peer"].supported_mutation_contracts, ()) - self.assertTrue(by_peer["closed-peer"].error) - self.assertEqual({peer_id for peer_id, _ in seen}, {"health-peer", "closed-peer"}) - self.assertEqual(len(monitor.latest()), 2) - - def test_cultmesh_peer_health_monitor_runs_background_probe_loop(self) -> None: - server = CultMesh.serve_node(CultMesh.create_node(runtime_id="health-loop-peer")) - try: - peers = CultMeshPeerCatalog() - peers.upsert(CultMeshPeerCard( - peer_id="health-loop-peer", - verse_id="mesh", - endpoints=(f"cultnet://127.0.0.1:{server.port}",), - roles=("read-replica",), - )) - seen: list[str] = [] - monitor = CultMeshPeerHealthMonitor( - runtime_id="health-loop-prober", - timeout_seconds=0.2, - interval_seconds=0.05, - on_update=lambda health: seen.append(health.peer_id), - ) - monitor.start(peers, verse_id="mesh", roles=["read-replica"]) - deadline = time.monotonic() + 2.0 - while time.monotonic() < deadline: - latest = monitor.latest() - if latest and latest[0].is_reachable: - break - time.sleep(0.01) - monitor.stop() - finally: - server.stop() - - self.assertTrue(seen) - latest = monitor.latest() - self.assertEqual(latest[0].peer_id, "health-loop-peer") - self.assertTrue(latest[0].is_reachable) - - def test_cultmesh_daemon_entrypoint_serves_framed_cultnet_hello(self) -> None: - with tempfile.TemporaryDirectory() as temp: - ready_path = Path(temp) / "ready.json" - package_src = Path(__file__).resolve().parents[1] / "src" - env = dict(os.environ) - existing_pythonpath = env.get("PYTHONPATH") - env["PYTHONPATH"] = ( - str(package_src) - if not existing_pythonpath - else f"{package_src}{os.pathsep}{existing_pythonpath}" - ) - process = subprocess.Popen( - [ - sys.executable, - "-m", - "cultmesh_py.daemon", - "--runtime-id", - "daemon-test-peer", - "--display-name", - "Daemon Test Peer", - "--port", - "0", - "--ready-file", - str(ready_path), - "--seed-interop-note", - "--seed-shard-id", - "daemon-interop", - "--verse-id", - "daemon-verse", - "--verse-display-name", - "Daemon Verse", - "--role", - "shard-primary", - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env, - ) - try: - deadline = time.monotonic() + 5.0 - while not ready_path.exists() and time.monotonic() < deadline: - if process.poll() is not None: - break - time.sleep(0.05) - if not ready_path.exists(): - _, stderr = process.communicate(timeout=2.0) - self.fail(f"cultmesh daemon did not publish readiness: {stderr}") - ready = json.loads(ready_path.read_text(encoding="utf-8")) - self.assertEqual(ready["schemaVersion"], READY_SCHEMA_VERSION) - self.assertEqual(ready["runtimeId"], "daemon-test-peer") - self.assertEqual(ready["runtimeKind"], "python") - self.assertEqual(ready["displayName"], "Daemon Test Peer") - self.assertEqual(ready["endpoint"], f"cultnet://127.0.0.1:{ready['port']}") - self.assertEqual(ready["shardIds"], ["daemon-interop"]) - self.assertEqual(ready["supportedDocumentTypes"], ["cultcache.interop-note"]) - self.assertIn("cultnet.hello.v0", ready["supportedMessageVersions"]) - self.assertIn("cultnet.document_put_raw.v0", ready["supportedMessageVersions"]) - self.assertNotIn("cultnet.simulation_observation.v0", ready["supportedMessageVersions"]) - self.assertEqual(ready["supportedMutationContracts"][0]["documentType"], "cultcache.interop-note") - self.assertIn("shardLog", ready["supportedMutationContracts"][0]["operations"]) - self.assertEqual(ready["snapshotLimits"], {"maxSnapshotBytes": None, "maxSnapshotDocuments": None}) - self.assertEqual(ready["verses"][0]["verseId"], "daemon-verse") - self.assertEqual(ready["verses"][0]["displayName"], "Daemon Verse") - self.assertEqual(ready["verses"][0]["discoveryEndpoints"], [ready["endpoint"]]) - self.assertEqual(ready["verses"][0]["authorityRuntimeIds"], ["daemon-test-peer"]) - self.assertEqual(ready["peers"][0]["peerId"], "daemon-test-peer") - self.assertEqual(ready["peers"][0]["endpoints"], [ready["endpoint"]]) - self.assertEqual(ready["peers"][0]["roles"], ["shard-primary"]) - self.assertEqual(ready["peers"][0]["shardIds"], ["daemon-interop"]) - - client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) - hello_response = client.request( - hello(runtime_id="daemon-test-prober"), - expected_schema_version="cultnet.hello.v0", - ) - - self.assertEqual(hello_response["runtimeId"], "daemon-test-peer") - self.assertEqual(hello_response["displayName"], "Daemon Test Peer") - self.assertIn("cultnet.hello.v0", hello_response["supportedMessageVersions"]) - self.assertIn("cultnet.schema_catalog_request.v0", hello_response["supportedMessageVersions"]) - self.assertNotIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"]) - self.assertEqual(hello_response["supportedDocumentTypes"], ["cultcache.interop-note"]) - self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["protocol"], "tcp_framed") - self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["port"], int(ready["port"])) - self.assertEqual(ready["supportedMessageVersions"], hello_response["supportedMessageVersions"]) - self.assertEqual(ready["supportedMutationContracts"], hello_response["supportedMutationContracts"]) - self.assertEqual(ready["transportProfiles"], hello_response["transportProfiles"]) - - schema_response = client.fetch_schema_catalog(schema_ids=["cultcache.interop-note"], include_schema_json=True) - self.assertEqual(schema_response["schemas"][0]["schemaVersion"], INTEROP_SCHEMA_VERSION) - self.assertEqual(schema_response["schemas"][0]["documentType"], "cultcache.interop-note") - - snapshot_response = client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"]) - self.assertEqual(snapshot_response.documents[0].record_key, "note:daemon-test-peer") - note = interop_note_document.decode_payload(snapshot_response.documents[0].payload) - self.assertEqual(note["authorRuntimeId"], "daemon-test-peer") - self.assertIn("daemon", note["tags"]) - - shard_catalog = client.fetch_shard_catalog(schema_ids=["cultcache.interop-note"]) - self.assertEqual(shard_catalog["shards"][0]["shardId"], "daemon-interop") - self.assertEqual(shard_catalog["shards"][0]["schemaIds"], ["cultcache.interop-note"]) - - shard_log = client.fetch_shard_log_response(shard_id="daemon-interop", shard_epoch=1) - self.assertFalse(shard_log.resync_required) - self.assertEqual(shard_log.entries[0].sequence, 1) - self.assertEqual(shard_log.entries[0].change_kind, "added") - self.assertIsNotNone(shard_log.entries[0].raw_document) - assert shard_log.entries[0].raw_document is not None - self.assertEqual(shard_log.entries[0].raw_document.record_key, "note:daemon-test-peer") - - discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) - verses = discovery_client.fetch_verses(transport_version="cultmesh.v0") - self.assertEqual(verses[0].verse_id, "daemon-verse") - self.assertEqual(verses[0].display_name, "Daemon Verse") - self.assertEqual(verses[0].discovery_endpoints, (ready["endpoint"],)) - self.assertEqual(verses[0].authority_runtime_ids, ("daemon-test-peer",)) - self.assertEqual([verse.to_wire() for verse in verses], ready["verses"]) - - peers = discovery_client.fetch_peers(verse_id="daemon-verse", roles=["shard-primary"]) - self.assertEqual(peers[0].peer_id, "daemon-test-peer") - self.assertEqual(peers[0].endpoints, (ready["endpoint"],)) - self.assertEqual(peers[0].roles, ("shard-primary",)) - self.assertEqual(peers[0].shard_ids, ("daemon-interop",)) - self.assertEqual([peer.to_wire() for peer in peers], ready["peers"]) - - monitor = CultMeshPeerHealthMonitor(runtime_id="daemon-health-prober", timeout_seconds=2.0) - health = monitor.probe_peer(peers[0])[0] - self.assertTrue(health.is_reachable) - self.assertEqual(health.runtime_id, "daemon-test-peer") - self.assertEqual(health.supported_document_types, ("cultcache.interop-note",)) - self.assertEqual(health.supported_mutation_contracts[0]["documentType"], "cultcache.interop-note") - finally: - process.terminate() - try: - process.communicate(timeout=5.0) - except subprocess.TimeoutExpired: - process.kill() - process.communicate(timeout=5.0) - - def test_cultmesh_daemon_restarts_with_persisted_cache_and_shard_log(self) -> None: - with tempfile.TemporaryDirectory() as temp: - root = Path(temp) - cache_path = root / "mesh.cc" - shard_log_path = root / "shard-log" - env = dict(os.environ) - package_src = Path(__file__).resolve().parents[1] / "src" - existing_pythonpath = env.get("PYTHONPATH") - env["PYTHONPATH"] = ( - str(package_src) - if not existing_pythonpath - else f"{package_src}{os.pathsep}{existing_pythonpath}" - ) - - first_ready = root / "first-ready.json" - first = subprocess.Popen( - [ - sys.executable, - "-m", - "cultmesh_py.daemon", - "--runtime-id", - "durable-daemon-peer", - "--port", - "0", - "--cache-file", - str(cache_path), - "--enable-durable-shard-logs", - "--shard-log-file", - str(shard_log_path), - "--seed-interop-note", - "--seed-shard-id", - "durable-interop", - "--ready-file", - str(first_ready), - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env, - ) - try: - self._wait_for_ready_file(first, first_ready) - finally: - self._terminate_process(first) - - second_ready = root / "second-ready.json" - second = subprocess.Popen( - [ - sys.executable, - "-m", - "cultmesh_py.daemon", - "--runtime-id", - "durable-daemon-peer", - "--port", - "0", - "--cache-file", - str(cache_path), - "--enable-durable-shard-logs", - "--shard-log-file", - str(shard_log_path), - "--register-interop-note", - "--verse-id", - "durable-verse", - "--role", - "shard-primary", - "--ready-file", - str(second_ready), - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env, - ) - try: - ready = self._wait_for_ready_file(second, second_ready) - client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) - - snapshot_response = client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"]) - self.assertEqual(snapshot_response.documents[0].record_key, "note:durable-daemon-peer") - note = interop_note_document.decode_payload(snapshot_response.documents[0].payload) - self.assertEqual(note["authorRuntimeId"], "durable-daemon-peer") - - shard_log = client.fetch_shard_log_response(shard_id="durable-interop", shard_epoch=1) - self.assertFalse(shard_log.resync_required) - self.assertEqual(shard_log.entries[0].sequence, 1) - self.assertIsNotNone(shard_log.entries[0].raw_document) - assert shard_log.entries[0].raw_document is not None - self.assertEqual(shard_log.entries[0].raw_document.record_key, "note:durable-daemon-peer") - - discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) - peers = discovery_client.fetch_peers(verse_id="durable-verse", roles=["shard-primary"]) - self.assertEqual(peers[0].shard_ids, ("durable-interop",)) - finally: - self._terminate_process(second) - - def test_cultmesh_daemon_serves_live_database_subscription_mutations(self) -> None: - with tempfile.TemporaryDirectory() as temp: - ready_path = Path(temp) / "ready.json" - package_src = Path(__file__).resolve().parents[1] / "src" - env = dict(os.environ) - existing_pythonpath = env.get("PYTHONPATH") - env["PYTHONPATH"] = ( - str(package_src) - if not existing_pythonpath - else f"{package_src}{os.pathsep}{existing_pythonpath}" - ) - process = subprocess.Popen( - [ - sys.executable, - "-m", - "cultmesh_py.daemon", - "--runtime-id", - "live-daemon-peer", - "--port", - "0", - "--seed-interop-note", - "--seed-shard-id", - "live-interop", - "--ready-file", - str(ready_path), - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env, - ) - try: - ready = self._wait_for_ready_file(process, ready_path) - client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) - live_note = { - "schemaVersion": INTEROP_SCHEMA_VERSION, - "documentId": "note:daemon-live", - "authorRuntimeId": "daemon-live-client", - "title": "daemon live subscription note", - "body": "live subscription put from Python daemon smoke", - "tags": ["daemon", "subscription", "interop"], - } - - with client.subscribe_database( - subscription_id="daemon-live-sub", - schema_ids=["cultcache.interop-note"], - ) as subscription: - snapshot = subscription.read_next_snapshot_response() - subscription.send(document_put_raw( - message_id="daemon-live-put", - key="note:daemon-live", - schema_id="cultcache.interop-note", - stored_at="2026-06-14T00:00:00Z", - payload=interop_note_document.encode_payload(live_note), - source_runtime_id="daemon-live-client", - shard_id="live-interop", - shard_epoch=1, - )) - change = subscription.read_next_change() - - self.assertEqual(snapshot.documents[0].record_key, "note:live-daemon-peer") - self.assertEqual(change.change_kind, "added") - self.assertEqual(change.record_key, "note:daemon-live") - self.assertIsNotNone(change.raw_document) - assert change.raw_document is not None - self.assertEqual( - interop_note_document.decode_payload(change.raw_document.payload), - live_note, - ) - - shard_log = client.fetch_shard_log_response(shard_id="live-interop", shard_epoch=1) - self.assertFalse(shard_log.resync_required) - self.assertEqual([entry.sequence for entry in shard_log.entries], [1, 2]) - self.assertEqual(shard_log.entries[1].change_kind, "added") - self.assertIsNotNone(shard_log.entries[1].raw_document) - assert shard_log.entries[1].raw_document is not None - self.assertEqual(shard_log.entries[1].raw_document.record_key, "note:daemon-live") - finally: - self._terminate_process(process) - - def test_cultmesh_daemon_serves_compacted_shard_log_resync_boundary(self) -> None: - with tempfile.TemporaryDirectory() as temp: - root = Path(temp) - cache_path = root / "mesh.cc" - shard_log_path = root / "shard-log" - store = CultNetFileShardMutationLogStore(shard_log_path) - node = CultMesh.create_node( - cache_path, - runtime_id="compacted-daemon-peer", - enable_durable_shard_logs=True, - shard_log_path=shard_log_path, - ) - node.database.register_document(interop_note_document) - node.database.put_raw_message( - interop_note_document, - "note:compacted-one", - { - "schemaVersion": INTEROP_SCHEMA_VERSION, - "documentId": "note:compacted-one", - "authorRuntimeId": "compacted-daemon-peer", - "title": "compacted daemon note one", - "body": "first retained cache value", - "tags": ["daemon", "compaction"], - }, - message_id="compacted-put-1", - shard_id="compacted-interop", - shard_epoch=7, - ) - node.database.put_raw_message( - interop_note_document, - "note:compacted-two", - { - "schemaVersion": INTEROP_SCHEMA_VERSION, - "documentId": "note:compacted-two", - "authorRuntimeId": "compacted-daemon-peer", - "title": "compacted daemon note two", - "body": "second retained cache value", - "tags": ["daemon", "compaction"], - }, - message_id="compacted-put-2", - shard_id="compacted-interop", - shard_epoch=7, - ) - store.compact_through("compacted-interop", 1) - - ready_path = root / "ready.json" - package_src = Path(__file__).resolve().parents[1] / "src" - env = dict(os.environ) - existing_pythonpath = env.get("PYTHONPATH") - env["PYTHONPATH"] = ( - str(package_src) - if not existing_pythonpath - else f"{package_src}{os.pathsep}{existing_pythonpath}" - ) - process = subprocess.Popen( - [ - sys.executable, - "-m", - "cultmesh_py.daemon", - "--runtime-id", - "compacted-daemon-peer", - "--port", - "0", - "--cache-file", - str(cache_path), - "--enable-durable-shard-logs", - "--shard-log-file", - str(shard_log_path), - "--register-interop-note", - "--ready-file", - str(ready_path), - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env, - ) - try: - ready = self._wait_for_ready_file(process, ready_path) - client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) - - snapshot_response = client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"]) - self.assertEqual( - {document.record_key for document in snapshot_response.documents}, - {"note:compacted-one", "note:compacted-two"}, - ) - - compacted = client.fetch_shard_log_response( - shard_id="compacted-interop", - shard_epoch=7, - after_sequence=0, - ) - retained = client.fetch_shard_log_response( - shard_id="compacted-interop", - shard_epoch=7, - after_sequence=1, - ) - - self.assertTrue(compacted.resync_required) - self.assertEqual(compacted.reason, "compacted_history") - self.assertEqual(compacted.compacted_through, 1) - self.assertEqual(compacted.entries, ()) - self.assertFalse(retained.resync_required) - self.assertEqual([entry.sequence for entry in retained.entries], [2]) - self.assertIsNotNone(retained.entries[0].raw_document) - assert retained.entries[0].raw_document is not None - self.assertEqual(retained.entries[0].raw_document.record_key, "note:compacted-two") - finally: - self._terminate_process(process) - - def test_cultmesh_daemon_accepts_schema_write_forwarder_mutations(self) -> None: - with tempfile.TemporaryDirectory() as temp: - ready_path = Path(temp) / "ready.json" - package_src = Path(__file__).resolve().parents[1] / "src" - env = dict(os.environ) - existing_pythonpath = env.get("PYTHONPATH") - env["PYTHONPATH"] = ( - str(package_src) - if not existing_pythonpath - else f"{package_src}{os.pathsep}{existing_pythonpath}" - ) - process = subprocess.Popen( - [ - sys.executable, - "-m", - "cultmesh_py.daemon", - "--runtime-id", - "forwarder-daemon-peer", - "--port", - "0", - "--register-interop-note", - "--ready-file", - str(ready_path), - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env, - ) - try: - ready = self._wait_for_ready_file(process, ready_path) - endpoint = f"cultnet://127.0.0.1:{ready['port']}" - shard = CultNetShardDescriptor( - shard_id="forwarded-interop", - owner_runtime_id="forwarder-daemon-peer", - epoch=3, - is_primary=False, - schema_ids=("cultcache.interop-note",), - primary_endpoints=(endpoint,), - ) - forwarder: CultNetShardWriteForwarder = CultNetSchemaWriteForwarder(timeout_seconds=2.0) - note = { - "schemaVersion": INTEROP_SCHEMA_VERSION, - "documentId": "note:forwarded-daemon", - "authorRuntimeId": "forwarding-client", - "title": "forwarded daemon note", - "body": "forwarded through the schema write-forwarder", - "tags": ["daemon", "forwarded"], - } - - forwarder.forward_put(shard, document_put_raw( - message_id="forwarded-daemon-put", - key="note:forwarded-daemon", - schema_id="cultcache.interop-note", - stored_at="2026-06-14T00:00:00Z", - payload=interop_note_document.encode_payload(note), - source_runtime_id="forwarding-client", - ).to_wire()) - - client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) - self._eventually(lambda: bool(client.fetch_snapshot_response( - schema_ids=["cultcache.interop-note"], - record_keys=["note:forwarded-daemon"], - ).documents)) - snapshot = client.fetch_snapshot_response( - schema_ids=["cultcache.interop-note"], - record_keys=["note:forwarded-daemon"], - ) - self.assertEqual( - interop_note_document.decode_payload(snapshot.documents[0].payload), - note, - ) - - forwarder.forward_delete(shard, document_delete( - message_id="forwarded-daemon-delete", - schema_id="cultcache.interop-note", - record_key="note:forwarded-daemon", - ).to_wire()) - self._eventually(lambda: not client.fetch_snapshot_response( - schema_ids=["cultcache.interop-note"], - record_keys=["note:forwarded-daemon"], - ).documents) - - shard_log = client.fetch_shard_log_response(shard_id="forwarded-interop", shard_epoch=3) - self.assertEqual([entry.change_kind for entry in shard_log.entries], ["added", "removed"]) - self.assertEqual(shard_log.entries[0].put["shardId"], "forwarded-interop") - self.assertEqual(shard_log.entries[0].put["shardEpoch"], 3) - self.assertEqual(shard_log.entries[1].delete["shardId"], "forwarded-interop") - self.assertEqual(shard_log.entries[1].delete["shardEpoch"], 3) - finally: - self._terminate_process(process) - - def test_cultmesh_daemon_enforces_snapshot_document_limit_as_peer_error(self) -> None: - with tempfile.TemporaryDirectory() as temp: - ready_path = Path(temp) / "ready.json" - package_src = Path(__file__).resolve().parents[1] / "src" - env = dict(os.environ) - existing_pythonpath = env.get("PYTHONPATH") - env["PYTHONPATH"] = ( - str(package_src) - if not existing_pythonpath - else f"{package_src}{os.pathsep}{existing_pythonpath}" - ) - process = subprocess.Popen( - [ - sys.executable, - "-m", - "cultmesh_py.daemon", - "--runtime-id", - "limited-daemon-peer", - "--port", - "0", - "--seed-interop-note", - "--max-snapshot-documents", - "0", - "--ready-file", - str(ready_path), - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env, - ) - try: - ready = self._wait_for_ready_file(process, ready_path) - self.assertEqual(ready["snapshotLimits"], {"maxSnapshotBytes": None, "maxSnapshotDocuments": 0}) - client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) - - with self.assertRaisesRegex(CultNetPeerError, "Snapshot document limit exceeded") as raised: - client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"]) - - self.assertEqual(raised.exception.response["schemaVersion"], "cultnet.error.v0") - self.assertEqual(raised.exception.response["code"], "snapshot_document_limit_exceeded") - self.assertEqual(raised.exception.response["details"]["documentCount"], 1) - self.assertEqual(raised.exception.response["details"]["maxSnapshotDocuments"], 0) - finally: - self._terminate_process(process) - - def test_cultmesh_daemon_serves_opt_in_simulation_observations(self) -> None: - with tempfile.TemporaryDirectory() as temp: - ready_path = Path(temp) / "ready.json" - package_src = Path(__file__).resolve().parents[1] / "src" - env = dict(os.environ) - existing_pythonpath = env.get("PYTHONPATH") - env["PYTHONPATH"] = ( - str(package_src) - if not existing_pythonpath - else f"{package_src}{os.pathsep}{existing_pythonpath}" - ) - process = subprocess.Popen( - [ - sys.executable, - "-m", - "cultmesh_py.daemon", - "--runtime-id", - "simulation-daemon-peer", - "--port", - "0", - "--enable-simulation-observations", - "--simulation-minimum-witnesses", - "2", - "--simulation-quorum-ratio", - "1.0", - "--verse-id", - "simulation-verse", - "--ready-file", - str(ready_path), - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env, - ) - try: - ready = self._wait_for_ready_file(process, ready_path) - self.assertIn("cultnet.simulation_observation.v0", ready["supportedMessageVersions"]) - self.assertIn("cultnet.simulation_consensus_candidate.v0", ready["supportedMessageVersions"]) - client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) - hello_response = client.request( - hello(runtime_id="simulation-daemon-prober"), - expected_schema_version="cultnet.hello.v0", - ) - self.assertEqual(ready["supportedMessageVersions"], hello_response["supportedMessageVersions"]) - discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0) - peers = discovery_client.fetch_peers(verse_id="simulation-verse", roles=["simulation-observer"]) - self.assertEqual(peers[0].peer_id, "simulation-daemon-peer") - self.assertIn("simulation-observer", peers[0].roles) - self.assertIn("read-replica", peers[0].roles) - - claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100") - first = client.request( - simulation_observation( - message_id="daemon-sim-1", - witness_runtime_id="watcher-1", - shard_id="arena", - shard_epoch=4, - frame=100, - subject_id="bob", - claim_kind="hit", - claim_hash=claim_hash, - claim_summary="alice shot bob first", - ), - expected_schema_version="cultnet.simulation_consensus_candidate.v0", - ) - second = client.request( - simulation_observation( - message_id="daemon-sim-2", - witness_runtime_id="watcher-2", - shard_id="arena", - shard_epoch=4, - frame=100, - subject_id="bob", - claim_kind="hit", - claim_hash=claim_hash, - claim_summary="alice shot bob first", - ), - expected_schema_version="cultnet.simulation_consensus_candidate.v0", - ) - - self.assertEqual(first["messageId"], "daemon-sim-1") - self.assertFalse(first["hasQuorum"]) - self.assertEqual(first["witnessCount"], 1) - self.assertEqual(second["messageId"], "daemon-sim-2") - self.assertTrue(second["hasQuorum"]) - self.assertEqual(second["witnessCount"], 2) - self.assertEqual(second["claimHash"], claim_hash) - finally: - self._terminate_process(process) - - def _wait_for_ready_file(self, process: subprocess.Popen[str], ready_path: Path) -> dict[str, object]: - deadline = time.monotonic() + 5.0 - while not ready_path.exists() and time.monotonic() < deadline: - if process.poll() is not None: - break - time.sleep(0.05) - if not ready_path.exists(): - _, stderr = process.communicate(timeout=2.0) - self.fail(f"cultmesh daemon did not publish readiness: {stderr}") - return json.loads(ready_path.read_text(encoding="utf-8")) - - def _terminate_process(self, process: subprocess.Popen[str]) -> None: - process.terminate() - try: - process.communicate(timeout=5.0) - except subprocess.TimeoutExpired: - process.kill() - process.communicate(timeout=5.0) - - def test_cultmesh_local_server_serves_node_and_catalogs_over_clients(self) -> None: - document = define_database_entry_type( - "mesh.server_note", - [("body", 0)], - schema_id="mesh.server_note.v1", - ) - node = CultMesh.create_node(runtime_id="mesh-server") - node.database.register_document(document) - node.database.put_raw_message(document, "note:1", {"body": "served"}, shard_id="notes", shard_epoch=1) - observation_hub = CultNetSimulationObservationHub( - CultNetSimulationConsensusOptions(minimum_witnesses=2, quorum_ratio=1.0) - ) - seen_candidates: list[CultNetSimulationConsensusCandidate] = [] - unsubscribe_candidates = observation_hub.watch_candidates(seen_candidates.append) - claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100") - verses = CultMeshVerseCatalog() - verses.upsert( - CultMeshVerseDescriptor( - verse_id="server-verse", - display_name="Server Verse", - authority_model="local", - compatibility=CultMeshVerseCompatibility("cultmesh.v0", "rules"), - ) - ) - peers = CultMeshPeerCatalog() - peers.upsert( - CultMeshPeerCard( - peer_id="mesh-server", - verse_id="server-verse", - endpoints=("cultnet://127.0.0.1:0",), - roles=("read-replica",), - ) - ) - - server = CultMesh.serve_node( - node, - verse_catalog=verses, - peer_catalog=peers, - observation_hub=observation_hub, - display_name="Mesh Server", - ) - try: - raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) - hello_response = raw_client.request(hello(runtime_id="probe"), expected_schema_version="cultnet.hello.v0") - schema_response = raw_client.fetch_schema_catalog(schema_ids=["mesh.server_note.v1"], include_schema_json=True) - rudp_client = CultMesh.create_client( - endpoint=f"rudp://127.0.0.1:{server.port}", - timeout_seconds=2.0, - connection_id=server.rudp_connection_id, - runtime_id="rudp-probe", - ) - rudp_hello_response = rudp_client.request( - hello(runtime_id="rudp-probe"), - expected_schema_version="cultnet.hello.v0", - ) - rudp_schema_response = rudp_client.fetch_schema_catalog( - schema_ids=["mesh.server_note.v1"], - include_schema_json=True, - ) - wire_schema_response = raw_client.fetch_schema_catalog(kinds=["wire_message"], include_schema_json=True) - synced_schema_catalog = CultNetSchemaCatalog() - synced_wire_descriptors = raw_client.sync_schema_catalog( - synced_schema_catalog, - kinds=["wire_message"], - include_schema_json=True, - ) - snapshot_response = raw_client.fetch_snapshot(schema_ids=["mesh.server_note.v1"]) - typed_snapshot_response = raw_client.fetch_snapshot_response(schema_ids=["mesh.server_note.v1"]) - shard_catalog = raw_client.fetch_shard_catalog(schema_ids=["mesh.server_note.v1"]) - synced_shard_catalog = CultNetShardCatalog() - synced_shards = raw_client.sync_shard_catalog(synced_shard_catalog, schema_ids=["mesh.server_note.v1"]) - shard_log = raw_client.fetch_shard_log(shard_id="notes", shard_epoch=1) - typed_shard_log = raw_client.fetch_shard_log_response(shard_id="notes", shard_epoch=1) - self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["protocol"], "tcp_framed") - self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["port"], server.port) - stale_shard_log = raw_client.fetch_shard_log(shard_id="notes", shard_epoch=0) - discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", server.port, timeout_seconds=2.0) - fetched_verses = discovery_client.fetch_verses(transport_version="cultmesh.v0") - fetched_peers = discovery_client.fetch_peers(verse_id="server-verse", roles=["read-replica"]) - first_candidate = raw_client.request( - simulation_observation( - message_id="obs-1", - witness_runtime_id="watcher-1", - shard_id="arena", - shard_epoch=4, - frame=100, - subject_id="bob", - claim_kind="hit", - claim_hash=claim_hash, - claim_summary="alice shot bob first", - ), - expected_schema_version="cultnet.simulation_consensus_candidate.v0", - ) - quorum_candidate = raw_client.request( - simulation_observation( - message_id="obs-2", - witness_runtime_id="watcher-2", - shard_id="arena", - shard_epoch=4, - frame=100, - subject_id="bob", - claim_kind="hit", - claim_hash=claim_hash, - claim_summary="alice shot bob first", - ), - expected_schema_version="cultnet.simulation_consensus_candidate.v0", - ) - with raw_client.subscribe_database(subscription_id="sub-server", schema_ids=["mesh.server_note.v1"]) as subscription: - subscription_snapshot = subscription.read_next_snapshot_response() - subscription.send(document_put_raw( - message_id="server-put", - key="note:2", - schema_id="mesh.server_note.v1", - stored_at="2026-06-14T00:00:00Z", - payload=document.encode_payload({"body": "subscribed"}), - shard_id="primary", - shard_epoch=1, - )) - subscription_change = subscription.read_next_change() - subscription.send(document_delete( - message_id="server-delete", - schema_id="mesh.server_note.v1", - record_key="note:2", - shard_id="primary", - shard_epoch=1, - )) - subscription_delete = subscription.read_next_change() - finally: - unsubscribe_candidates() - server.stop() - - self.assertEqual(hello_response["runtimeId"], "mesh-server") - self.assertEqual(hello_response["displayName"], "Mesh Server") - self.assertIn("cultnet.database_subscribe.v0", hello_response["supportedMessageVersions"]) - self.assertIn("cultnet.error.v0", hello_response["supportedMessageVersions"]) - self.assertIn("cultnet.document_put_raw.v0", hello_response["supportedMessageVersions"]) - self.assertIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"]) - self.assertEqual(hello_response["supportedMutationContracts"][0]["documentType"], "mesh.server_note") - self.assertIn("documentDelete", hello_response["supportedMutationContracts"][0]["operations"]) - self.assertIn("shardLog", hello_response["supportedMutationContracts"][0]["operations"]) - advertised_transports = { - transport["protocol"]: transport - for profile in hello_response["transportProfiles"] - for transport in profile["transports"] - } - self.assertEqual(advertised_transports["tcp_framed"]["port"], server.port) - self.assertEqual(advertised_transports["rudp"]["port"], server.port) - self.assertEqual(rudp_hello_response["runtimeId"], "mesh-server") - self.assertEqual(rudp_schema_response["schemas"][0]["schemaId"], "mesh.server_note.v1") - self.assertEqual(schema_response["schemas"][0]["schemaId"], "mesh.server_note.v1") - self.assertIn("schemaJson", schema_response["schemas"][0]) - self.assertIn("cultnet.document_delete.v0", schema_response["schemas"][0]["wireContracts"]) - self.assertIn("cultnet.shard_log_response.v0", schema_response["schemas"][0]["wireContracts"]) - wire_descriptors = {schema["schemaVersion"]: schema for schema in wire_schema_response["schemas"]} - self.assertEqual(wire_descriptors["cultnet.error.v0"]["kind"], "wire_message") - self.assertEqual(wire_descriptors["cultnet.document_put_raw.v0"]["kind"], "wire_message") - self.assertIn("schemaJson", wire_descriptors["cultnet.document_put_raw.v0"]) - 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/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") - self.assertEqual(shard_catalog["shards"][0]["schemaIds"], ["mesh.server_note.v1"]) - self.assertEqual(shard_catalog["shards"][0]["epoch"], 1) - self.assertEqual(shard_catalog["shards"][0]["shardId"], "notes") - self.assertEqual(synced_shards[0].shard_id, "notes") - self.assertEqual(synced_shards[0].epoch, 1) - self.assertEqual(synced_shard_catalog.get("notes"), synced_shards[0]) - self.assertEqual(shard_log["entries"][0]["changeKind"], "added") - self.assertTrue(stale_shard_log["resyncRequired"]) - self.assertEqual(stale_shard_log["reason"], "stale_epoch") - self.assertEqual(stale_shard_log["shardEpoch"], 1) - self.assertEqual(stale_shard_log["entries"], []) - self.assertEqual(typed_shard_log.last_sequence, 1) - self.assertEqual(typed_shard_log.entries[0].put["document"]["recordKey"], "note:1") - self.assertIsInstance(typed_shard_log.entries[0].raw_document, CultNetRawDocumentRecord) - self.assertEqual(typed_shard_log.entries[0].raw_document.record_key, "note:1") - self.assertEqual(fetched_verses[0].verse_id, "server-verse") - self.assertEqual(fetched_peers[0].peer_id, "mesh-server") - self.assertEqual(first_candidate["messageId"], "obs-1") - self.assertFalse(first_candidate["hasQuorum"]) - self.assertEqual(quorum_candidate["messageId"], "obs-2") - self.assertEqual(quorum_candidate["claimHash"], claim_hash) - self.assertTrue(quorum_candidate["hasQuorum"]) - self.assertEqual(quorum_candidate["witnessCount"], 2) - self.assertEqual([candidate.claim_hash for candidate in seen_candidates], [claim_hash, claim_hash]) - self.assertIsInstance(subscription_snapshot, CultNetRawSnapshotResponse) - self.assertEqual(subscription_snapshot.to_wire()["schemaVersion"], "cultnet.snapshot_response_raw.v0") - self.assertEqual(subscription_change.change_kind, "added") - self.assertEqual(subscription_change.record_key, "note:2") - self.assertEqual(subscription_change.document["recordKey"], "note:2") - self.assertIsInstance(subscription_change.raw_document, CultNetRawDocumentRecord) - self.assertEqual(subscription_change.raw_document.schema_id, "mesh.server_note.v1") - self.assertEqual(subscription_delete.change_kind, "removed") - self.assertEqual(subscription_delete.record_key, "note:2") - self.assertIsNone(subscription_delete.raw_document) - self.assertIsNone(node.database.get(document, "note:2")) - - def test_cultmesh_local_server_resolves_snapshot_and_shard_catalog_schema_aliases(self) -> None: - document = define_database_entry_type( - "mesh.alias_server_note", - [("body", 0)], - schema_id="sha256:mesh-alias-server-note", - schema_name="mesh.alias_server_note", - schema_version="mesh.alias_server_note.v1", - ) - node = CultMesh.create_node(runtime_id="mesh-alias-server") - node.database.register_document(document) - node.database.put_raw_message( - document, - "note:alias", - {"body": "served through alias"}, - shard_id="notes", - shard_epoch=1, - ) - - server = CultMesh.serve_node(node) - try: - raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) - snapshot = raw_client.fetch_snapshot(schema_ids=["mesh.alias_server_note.v1"]) - typed_snapshot = raw_client.fetch_snapshot_response(schema_ids=["mesh.alias_server_note.v1"]) - shard_catalog = raw_client.fetch_shard_catalog(schema_ids=["mesh.alias_server_note.v1"]) - finally: - server.stop() - - local_schema_id = document.catalog_entry().schema_id - self.assertEqual(snapshot["documents"][0]["schemaId"], local_schema_id) - self.assertEqual(snapshot["documents"][0]["recordKey"], "note:alias") - self.assertEqual(typed_snapshot.documents[0].schema_id, local_schema_id) - self.assertEqual(shard_catalog["shards"][0]["schemaIds"], [local_schema_id]) - self.assertEqual(shard_catalog["shards"][0]["shardId"], "notes") - - def test_cultmesh_shard_snapshot_keeps_foreign_schema_alias_membership(self) -> None: - document = define_database_entry_type( - "mesh.alias_shard_note", - [ - ("schema_version", 0), - ("body", 1), - ], - schema_id="sha256:mesh-alias-shard-note", - schema_name="mesh.alias_shard_note", - schema_version="mesh.alias_shard_note.v1", - ) - node = CultMesh.create_node(runtime_id="mesh-alias-shard-server") - node.database.register_document(document) - - node.database.apply_raw_put_message(document_put_raw( - message_id="put-foreign-shard-alias", - key="note:foreign-shard", - schema_id="runtime.generated.mesh.alias_shard_note.ui.99", - stored_at="2026-06-27T00:00:00Z", - payload=document.encode_payload({ - "schema_version": "mesh.alias_shard_note.v1", - "body": "visible through shard snapshot", - }), - shard_id="notes", - shard_epoch=1, - ).to_wire()) - node.database.put_raw_message( - document, - "note:other-shard", - { - "schema_version": "mesh.alias_shard_note.v1", - "body": "hidden by shard filter", - }, - shard_id="other", - shard_epoch=1, - ) - - response = node.database.build_snapshot_response( - schema_ids=["mesh.alias_shard_note.v1"], - shard_id="notes", - shard_epoch=1, - ) - - local_schema_id = document.catalog_entry().schema_id - self.assertEqual(response.shard_id, "notes") - self.assertEqual(response.shard_epoch, 1) - self.assertEqual([(record.schema_id, record.record_key) for record in response.documents], [ - (local_schema_id, "note:foreign-shard"), - ]) - self.assertEqual(node.cache.get_required_envelope(document, "note:foreign-shard").schema_id, local_schema_id) - - node.database.apply_raw_delete_message(document_delete( - message_id="delete-foreign-shard-alias", - schema_id="mesh.alias_shard_note.v1", - record_key="note:foreign-shard", - shard_id="notes", - shard_epoch=1, - ).to_wire()) - deleted_response = node.database.build_snapshot_response( - schema_ids=["mesh.alias_shard_note.v1"], - shard_id="notes", - shard_epoch=1, - ) - self.assertEqual(deleted_response.documents, ()) - - def test_cultmesh_local_server_notifies_schema_alias_subscription_for_raw_put(self) -> None: - document = define_database_entry_type( - "mesh.alias_sub_note", - [ - ("schema_version", 0), - ("body", 1), - ], - schema_id="sha256:mesh-alias-sub-note", - schema_name="mesh.alias_sub_note", - schema_version="mesh.alias_sub_note.v1", - ) - node = CultMesh.create_node(runtime_id="mesh-alias-sub-server") - node.database.register_document(document) - - server = CultMesh.serve_node(node) - try: - raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) - with raw_client.subscribe_database( - subscription_id="sub-alias", - schema_ids=["mesh.alias_sub_note.v1"], - include_snapshot=False, - ) as subscription: - subscription.send(document_put_raw( - message_id="put-foreign-alias", - key="note:alias-sub", - schema_id="runtime.generated.mesh.alias_sub_note.ui.99", - stored_at="2026-06-27T00:00:00Z", - payload=document.encode_payload({ - "schema_version": "mesh.alias_sub_note.v1", - "body": "notified", - }), - )) - change = subscription.read_next_change() - finally: - server.stop() - - local_schema_id = document.catalog_entry().schema_id - self.assertEqual(change.subscription_id, "sub-alias") - self.assertEqual(change.change_kind, "added") - self.assertEqual(change.record_key, "note:alias-sub") - self.assertIsInstance(change.raw_document, CultNetRawDocumentRecord) - self.assertEqual(change.raw_document.schema_id, "runtime.generated.mesh.alias_sub_note.ui.99") - self.assertEqual(node.database.get_required(document, "note:alias-sub")["body"], "notified") - self.assertEqual(node.cache.get_required_envelope(document, "note:alias-sub").schema_id, local_schema_id) - - def test_cultmesh_local_server_notifies_schema_alias_subscription_for_delete(self) -> None: - document = define_database_entry_type( - "mesh.alias_delete_note", - [("body", 0)], - schema_id="sha256:mesh-alias-delete-note", - schema_name="mesh.alias_delete_note", - schema_version="mesh.alias_delete_note.v1", - ) - node = CultMesh.create_node(runtime_id="mesh-alias-delete-server") - node.database.register_document(document) - node.database.put_raw_message( - document, - "note:delete-alias", - {"body": "remove me"}, - shard_id="notes", - shard_epoch=1, - ) - - server = CultMesh.serve_node(node) - try: - raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) - with raw_client.subscribe_database( - subscription_id="sub-delete-alias", - schema_ids=[document.catalog_entry().schema_id], - include_snapshot=False, - ) as subscription: - subscription.send(document_delete( - message_id="delete-foreign-alias", - schema_id="mesh.alias_delete_note.v1", - record_key="note:delete-alias", - shard_id="notes", - shard_epoch=1, - )) - change = subscription.read_next_change() - finally: - server.stop() - - self.assertEqual(change.subscription_id, "sub-delete-alias") - self.assertEqual(change.change_kind, "removed") - self.assertEqual(change.schema_id, "mesh.alias_delete_note.v1") - self.assertEqual(change.record_key, "note:delete-alias") - self.assertIsNone(change.raw_document) - self.assertIsNone(node.database.get(document, "note:delete-alias")) - - def test_cultmesh_local_server_rejects_oversized_snapshot_responses(self) -> None: - document = define_database_entry_type( - "mesh.snapshot_limit_note", - [("body", 0)], - schema_id="mesh.snapshot_limit_note.v1", - ) - node = CultMesh.create_node(runtime_id="mesh-snapshot-limits") - node.database.register_document(document) - node.database.put_raw_message(document, "note:1", {"body": "first"}) - node.database.put_raw_message(document, "note:2", {"body": "second"}) - - server = CultMesh.serve_node(node, max_snapshot_documents=1) - try: - raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) - filtered_snapshot = raw_client.fetch_snapshot(record_keys=["note:1"]) - error = raw_client.request( - snapshot_request(schema_ids=["mesh.snapshot_limit_note.v1"]), - expected_schema_version="cultnet.error.v0", - ) - with self.assertRaisesRegex(CultNetPeerError, "Snapshot document limit exceeded"): - raw_client.fetch_snapshot(schema_ids=["mesh.snapshot_limit_note.v1"]) - finally: - server.stop() - - self.assertEqual(filtered_snapshot["schemaVersion"], "cultnet.snapshot_response_raw.v0") - self.assertEqual([document["recordKey"] for document in filtered_snapshot["documents"]], ["note:1"]) - self.assertEqual(error["schemaVersion"], "cultnet.error.v0") - self.assertIn("Snapshot document limit exceeded", error["error"]) - self.assertEqual(error["code"], "snapshot_document_limit_exceeded") - self.assertEqual(error["details"]["documentCount"], 2) - self.assertEqual(error["details"]["maxSnapshotDocuments"], 1) - - def test_cultmesh_local_server_returns_errors_for_bad_requests(self) -> None: - document = define_database_entry_type( - "mesh.error_note", - [("body", 0)], - schema_id="mesh.error_note.v1", - ) - node = CultMesh.create_node(runtime_id="mesh-error-server") - node.database.register_document(document) - - server = CultMesh.serve_node(node) - try: - raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) - unsupported = raw_client.request( - {"schemaVersion": "cultnet.nope.v0", "messageId": "bad-schema"}, - expected_schema_version="cultnet.error.v0", - ) - malformed_put = raw_client.request( - {"schemaVersion": "cultnet.document_put_raw.v0", "messageId": "bad-put"}, - expected_schema_version="cultnet.error.v0", - ) - malformed_delete = raw_client.request( - {"schemaVersion": "cultnet.document_delete.v0", "messageId": "bad-delete", "schemaId": "mesh.error_note.v1"}, - expected_schema_version="cultnet.error.v0", - ) - finally: - server.stop() - - self.assertEqual(unsupported["messageId"], "bad-schema") - self.assertEqual(unsupported["code"], "unsupported_schema_version") - self.assertEqual(unsupported["details"]["schemaVersion"], "cultnet.nope.v0") - self.assertIn("Unsupported CultNet message schema", unsupported["error"]) - self.assertEqual(malformed_put["messageId"], "bad-put") - self.assertEqual(malformed_put["code"], "malformed_document_put") - self.assertEqual(malformed_delete["messageId"], "bad-delete") - self.assertEqual(malformed_delete["code"], "malformed_document_delete") - - def test_cultmesh_authority_lease_requires_live_matching_lease(self) -> None: - peer = CultMeshPeerCard( - peer_id="voidbot-local", - verse_id="local", - endpoints=("cultmesh://localhost",), - roles=("shard-primary",), - authority_lease_id="lease:voidbot-local", - ) - leases = CultMeshAuthorityLeaseCatalog() - peers = CultMeshPeerCatalog() - peers.upsert(peer) - now = datetime.now(UTC) - self.assertFalse(leases.is_authorized(peer, "shard-primary", at=now)) - self.assertEqual(peers.find_authorized("local", "shard-primary", leases, at=now), []) - - leases.upsert( - CultMeshAuthorityLease( - lease_id="lease:voidbot-local", - verse_id="local", - peer_id="voidbot-local", - roles=("shard-primary", "shard-primary", ""), - valid_from=now - timedelta(seconds=1), - expires_at=now + timedelta(seconds=1), - shard_ids=("shard-a",), - issuer_runtime_id="odin", - signature="signed", - ) - ) - - lease = leases.get("lease:voidbot-local") - self.assertIsNotNone(lease) - assert lease is not None - self.assertEqual(leases.leases, (lease,)) - self.assertEqual(lease.roles, ("shard-primary",)) - self.assertEqual(lease.issuer_runtime_id, "odin") - self.assertEqual(lease.signature, "signed") - self.assertTrue(lease.is_valid_at(now)) - self.assertTrue(lease.covers(peer, "shard-primary", shard_id="shard-a", at=now)) - self.assertFalse(lease.covers(peer, "shard-primary", shard_id="shard-b", at=now)) - self.assertTrue(leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now)) - self.assertEqual( - peers.find_authorized("local", "shard-primary", leases, shard_id="shard-a", at=now), - [peer], - ) - self.assertEqual( - peers.first_authorized("local", "shard-primary", leases, shard_id="shard-a", at=now), - peer, - ) - self.assertIsNone(peers.first_authorized("local", "read-replica", leases, at=now)) - self.assertFalse(leases.is_authorized(peer, "shard-primary", shard_id="shard-b", at=now)) - self.assertFalse(leases.is_authorized(peer, "read-replica", at=now)) - self.assertIsNone(leases.get("missing")) - - def test_cultmesh_authority_lease_catalog_can_require_verified_signatures(self) -> None: - peer = CultMeshPeerCard( - peer_id="voidbot-local", - verse_id="local", - endpoints=("cultmesh://localhost",), - roles=("shard-primary",), - shard_ids=("shard-a",), - authority_lease_id="lease:voidbot-local", - ) - now = datetime.now(UTC) - unsigned = CultMeshAuthorityLease( - lease_id="lease:voidbot-local", - verse_id="local", - peer_id="voidbot-local", - roles=("shard-primary",), - valid_from=now - timedelta(seconds=1), - expires_at=now + timedelta(seconds=30), - shard_ids=("shard-a",), - issuer_runtime_id="odin", - ) - verifier = CultMeshHmacAuthorityLeaseVerifier({"odin": b"lease-secret"}) - signed = verifier.issue(unsigned) - strict_leases = CultMesh.create_authority_lease_catalog( - signature_verifier=verifier.verify, - require_verified_signatures=True, - ) - strict_leases.upsert(unsigned) - self.assertFalse(strict_leases.is_verified(unsigned.lease_id)) - self.assertFalse(strict_leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now)) - - strict_leases.upsert(signed) - self.assertTrue(strict_leases.is_verified(signed.lease_id)) - self.assertTrue(strict_leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now)) - - wrong_key = CultMesh.create_authority_lease_catalog( - signature_verifier=CultMeshHmacAuthorityLeaseVerifier({"odin": b"wrong-secret"}).verify, - require_verified_signatures=True, - ) - wrong_key.upsert(signed) - self.assertFalse(wrong_key.is_verified(signed.lease_id)) - self.assertFalse(wrong_key.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now)) - - tampered = CultMeshAuthorityLease( - lease_id=signed.lease_id, - verse_id=signed.verse_id, - peer_id=signed.peer_id, - roles=("shard-primary", "read-replica"), - valid_from=signed.valid_from, - expires_at=signed.expires_at, - shard_ids=signed.shard_ids, - issuer_runtime_id=signed.issuer_runtime_id, - signature=signed.signature, - ) - strict_leases.upsert(tampered) - self.assertFalse(strict_leases.is_verified(tampered.lease_id)) - self.assertFalse(strict_leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now)) - - def test_cultmesh_stream_catalog_negotiates_transport_and_latest_frame(self) -> None: - streams = CultMeshStreamCatalog() - streams.declare( - CultMeshStreamDescriptor( - stream_id="mimir:kiyo-pro", - verse_id="studio", - owner_peer_id="starfire", - kind="video", - label="Kiyo Pro", - clock={"clockDomainId": "starfire-qpc", "confidence": 0.25}, - video={"width": 1920, "height": 1080, "pixelFormat": "YUY2", "framesPerSecond": 30}, - preferred_transports=("shared-d3d12-texture", "shared-memory", "cultcache-page"), - max_in_flight_frames=3, - ) - ) - - negotiation = streams.negotiate( - "mimir:kiyo-pro", - CultMeshStreamConsumerProfile( - peer_id="fensalir", - verse_id="studio", - supported_transports=("shared-d3d12-texture", "cultcache-page"), - accepted_kinds=("video",), - can_import_gpu_handles=True, - max_in_flight_frames=2, - ), - ) - - self.assertEqual(negotiation.transport, "shared-d3d12-texture") - self.assertEqual(negotiation.max_in_flight_frames, 2) - self.assertEqual(negotiation.copy_budget, "zero-copy-target") - - streams.publish_frame( - CultMeshStreamFrameHandle( - stream_id="mimir:kiyo-pro", - sequence=42, - timestamp_ns=1_000_000_000, - duration_ns=33_333_334, - transport="shared-d3d12-texture", - native_handle="0xfeed", - fence_handle="0xbeef", - fence_value=7, - unavoidable_copy_count=0, - ) - ) - self.assertEqual(streams.latest_frame("mimir:kiyo-pro").sequence, 42) - - -if __name__ == "__main__": - unittest.main() diff --git a/packages/cultmesh-py/tools/reactive_performance_probe.py b/packages/cultmesh-py/tools/reactive_performance_probe.py deleted file mode 100644 index 86ab9441..00000000 --- a/packages/cultmesh-py/tools/reactive_performance_probe.py +++ /dev/null @@ -1,145 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import threading -import time -import tracemalloc -from typing import Any - -from cultcache_py import define_document_type -from cultmesh_py import CultMeshReactiveDocumentOptions, create_node - - -def percentile(ordered: list[float], ratio: float) -> float: - if not ordered: - return 0.0 - index = max(0, min(len(ordered) - 1, int(len(ordered) * ratio + 0.999999) - 1)) - return ordered[index] - - -def measure(document_count: int, idle_seconds: float, active_seconds: float) -> dict[str, Any]: - payload = "x" * (16 * 1024) - changed_document_count = max(1, document_count // 100) - document = define_document_type( - f"gamecult.mesh.python_performance_probe_{document_count}", - encode=lambda value: value, - decode=lambda value: value, - ) - node = create_node(runtime_id=f"python-performance-{document_count}") - node.register_document(document) - reactive_documents = [] - publish_events = [threading.Event() for _ in range(document_count)] - update_started_at = [0.0] * document_count - latencies: list[float] = [] - payload_bytes_published = 0 - publishes = 0 - measurement_lock = threading.Lock() - - for index in range(document_count): - key = f"performance:{index}" - node.put(document, key, {"id": key, "payload": payload, "revision": 1}) - writer = node.authoritative_writer(document, key) - original_write = writer.write - - def measured_write(value: Any, document_index: int = index, write=original_write) -> None: - nonlocal payload_bytes_published, publishes - encoded_bytes = len(document.encode_payload(value)) - latency_ms = (time.perf_counter() - update_started_at[document_index]) * 1000 - with measurement_lock: - payload_bytes_published += encoded_bytes - publishes += 1 - latencies.append(latency_ms) - write(value) - publish_events[document_index].set() - - writer.write = measured_write # type: ignore[method-assign] - reactive_documents.append( - writer.reactive(CultMeshReactiveDocumentOptions(flush_delay_seconds=0.0)) - ) - - tracemalloc.start() - idle_cpu_start = time.process_time() - time.sleep(idle_seconds) - idle_current_bytes, idle_peak_bytes = tracemalloc.get_traced_memory() - idle_cpu_ms = (time.process_time() - idle_cpu_start) * 1000 - tracemalloc.reset_peak() - active_current_start, _ = tracemalloc.get_traced_memory() - active_cpu_start = time.process_time() - active_started_at = time.perf_counter() - frames = 0 - while time.perf_counter() - active_started_at < active_seconds: - for index in range(changed_document_count): - publish_events[index].clear() - update_started_at[index] = time.perf_counter() - reactive_documents[index].update( - lambda draft: draft.__setitem__("revision", draft["revision"] + 1) - ) - for index in range(changed_document_count): - if not publish_events[index].wait(timeout=1.0): - raise RuntimeError(f"Python reactive publication timed out for document {index}") - frames += 1 - remaining = frames / 60 - (time.perf_counter() - active_started_at) - if remaining > 0: - time.sleep(remaining) - - active_elapsed_seconds = time.perf_counter() - active_started_at - active_cpu_ms = (time.process_time() - active_cpu_start) * 1000 - active_current_bytes, active_peak_bytes = tracemalloc.get_traced_memory() - tracemalloc.stop() - for reactive in reactive_documents: - reactive.dispose() - ordered = sorted(latencies) - return { - "documentCount": document_count, - "changedDocumentCount": changed_document_count, - "frames": frames, - "expectedPublishes": frames * changed_document_count, - "actualPublishes": publishes, - "payloadBytesPublished": payload_bytes_published, - "idleTracedCurrentBytes": idle_current_bytes, - "idleTracedPeakBytes": idle_peak_bytes, - "idleCpuMilliseconds": idle_cpu_ms, - "activeTracedRetainedGrowthBytes": active_current_bytes - active_current_start, - "activeTracedPeakGrowthBytes": active_peak_bytes - active_current_start, - "activeCpuMilliseconds": active_cpu_ms, - "publishedPayloadBytesPerSecond": payload_bytes_published / active_elapsed_seconds, - "p50PublishLatencyMilliseconds": percentile(ordered, 0.50), - "p95PublishLatencyMilliseconds": percentile(ordered, 0.95), - "p99PublishLatencyMilliseconds": percentile(ordered, 0.99), - "schedulerThreads": sum( - thread.name == "cultmesh-reactive-scheduler" for thread in threading.enumerate() - ), - } - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--quick", action="store_true") - args = parser.parse_args() - idle_seconds = 0.25 if args.quick else 10.0 - active_seconds = 0.5 if args.quick else 10.0 - results = [measure(count, idle_seconds, active_seconds) for count in (1, 100, 1000)] - print(json.dumps({ - "runtime": "python", - "workload": { - "documentCounts": [1, 100, 1000], - "payloadBytes": 16 * 1024, - "idleSeconds": idle_seconds, - "activeSeconds": active_seconds, - "updateRateHz": 60, - "changedFraction": 0.01, - }, - "results": results, - }, indent=2)) - return 0 if all( - result["actualPublishes"] == result["expectedPublishes"] - and result["p99PublishLatencyMilliseconds"] < 250 - and result["activeTracedPeakGrowthBytes"] < 128 * 1024 * 1024 - and result["schedulerThreads"] == 1 - for result in results - ) else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/packages/cultmesh-rs/Cargo.lock b/packages/cultmesh-rs/Cargo.lock deleted file mode 100644 index 945bc65b..00000000 --- a/packages/cultmesh-rs/Cargo.lock +++ /dev/null @@ -1,1183 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common", - "generic-array", -] - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - -[[package]] -name = "android_system_properties" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - -[[package]] -name = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "cc" -version = "1.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "typenum", -] - -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - -[[package]] -name = "cultcache-rs" -version = "0.2.0" -dependencies = [ - "anyhow", - "chrono", - "cultcache-rs-derive", - "fs2", - "redb", - "rmp-serde", - "serde", - "serde_bytes", - "serde_json", - "uuid", - "windows-sys 0.59.0", -] - -[[package]] -name = "cultcache-rs-derive" -version = "0.1.0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "cultmesh-rs" -version = "0.1.0" -dependencies = [ - "anyhow", - "cultcache-rs", - "cultnet-rs", - "pretty_assertions", - "rmp-serde", - "serde", - "tempfile", -] - -[[package]] -name = "cultnet-rs" -version = "0.1.0" -dependencies = [ - "aes-gcm", - "anyhow", - "base64", - "chrono", - "cultcache-rs", - "ed25519-dalek", - "hmac", - "rand", - "rmp-serde", - "rmpv", - "serde", - "serde_bytes", - "serde_json", - "sha2", - "socket2", - "uuid", - "windows-sys 0.61.2", - "zeroize", -] - -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures", - "curve25519-dalek-derive", - "digest", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "zeroize", -] - -[[package]] -name = "diff" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", - "subtle", -] - -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek", - "ed25519", - "serde", - "sha2", - "subtle", - "zeroize", -] - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - -[[package]] -name = "find-msvc-tools" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" - -[[package]] -name = "fs2" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", -] - -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "js-sys" -version = "0.3.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "log" -version = "0.4.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures", - "opaque-debug", - "universal-hash", -] - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "pretty_assertions" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" -dependencies = [ - "diff", - "yansi", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "redb" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de6c3b63e007e90ce536ec2ae4690826136a20ec8dbbbb400daef1bb999d2e36" -dependencies = [ - "libc", -] - -[[package]] -name = "rmp" -version = "0.8.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" -dependencies = [ - "num-traits", -] - -[[package]] -name = "rmp-serde" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" -dependencies = [ - "rmp", - "serde", -] - -[[package]] -name = "rmpv" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" -dependencies = [ - "rmp", - "serde", - "serde_bytes", -] - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_bytes" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.4", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "rand_core 0.6.4", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common", - "subtle", -] - -[[package]] -name = "uuid" -version = "1.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" -dependencies = [ - "getrandom 0.4.3", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.119", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - -[[package]] -name = "zerocopy" -version = "0.8.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/packages/cultmesh-rs/Cargo.toml b/packages/cultmesh-rs/Cargo.toml deleted file mode 100644 index 820050c3..00000000 --- a/packages/cultmesh-rs/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "cultmesh-rs" -version = "0.1.0" -edition = "2024" -license = "MIT" -publish = false - -[lib] -name = "cultmesh_rs" -path = "src/lib.rs" - -[dependencies] -anyhow = "1" -cultcache-rs = { path = "../cultcache-rs" } -cultnet-rs = { path = "../cultnet-rs" } -serde = { version = "1", features = ["derive"] } - -[dev-dependencies] -pretty_assertions = "1.4.1" -rmp-serde = "1" -tempfile = "3" diff --git a/packages/cultmesh-rs/src/lib.rs b/packages/cultmesh-rs/src/lib.rs deleted file mode 100644 index 8cfe8cec..00000000 --- a/packages/cultmesh-rs/src/lib.rs +++ /dev/null @@ -1,1609 +0,0 @@ -use anyhow::{Context, Result}; -use cultcache_rs::CultCache; -use cultcache_rs::CultCacheEnvelope; -use cultcache_rs::CultSoaTable; -use cultcache_rs::DatabaseEntry; -use cultcache_rs::SingleFileMessagePackBackingStore; -use cultcache_rs::SoaDocument; -use cultnet_rs::CultNetDocumentPutOptions; -use cultnet_rs::CultNetDocumentRegistry; -use cultnet_rs::CultNetRudpSocketTransportConnection; -use cultnet_rs::CultNetRudpSocketTransportOptions; -use cultnet_rs::CultNetWireContract; -use cultnet_rs::decode_cultnet_message_from_slice; -use cultnet_rs::encode_cultnet_message_to_vec; -use serde::Serialize; -use std::collections::BTreeMap; -use std::collections::hash_map::RandomState; -use std::env; -use std::hash::{BuildHasher, Hasher}; -use std::net::SocketAddr; -use std::net::UdpSocket; -use std::path::Path; -use std::thread; -use std::time::Duration; -use std::time::Instant; - -mod rudp_document_server; - -pub use rudp_document_server::*; - -pub const CULTMESH_RUDP_DOCUMENT_CATALOG_CONNECTION_ID: u32 = 0x0d1d_0002; - -pub trait CultMeshDocumentSet: Clone + Send + Sync + 'static { - fn register_cache(&self, cache: &mut CultCache) -> Result<()>; - fn register_documents(&self, registry: &mut CultNetDocumentRegistry) -> Result<()>; -} - -#[macro_export] -macro_rules! cultmesh_documents { - ($name:ident { $($entry:ty => $schema_version:expr),* $(,)? }) => { - #[derive(Clone, Copy, Debug, Default)] - pub struct $name; - - impl $crate::CultMeshDocumentSet for $name { - fn register_cache( - &self, - cache: &mut cultcache_rs::CultCache, - ) -> ::anyhow::Result<()> { - $( - cache.register_entry_type::<$entry>()?; - )* - Ok(()) - } - - fn register_documents( - &self, - registry: &mut cultnet_rs::CultNetDocumentRegistry, - ) -> ::anyhow::Result<()> { - $( - registry.register( - cultnet_rs::CultNetDocumentBinding::for_entry_with_schema_id::<$entry>( - $schema_version.to_string(), - $schema_version.to_string(), - ), - ); - )* - Ok(()) - } - } - }; -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CultMeshNodeOptions { - pub runtime_id: String, - pub pull_on_start: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CultMeshRudpDocumentPublishOptions { - pub target: SocketAddr, - pub runtime_id: String, - pub connect_timeout: Duration, - pub flush_timeout: Duration, - pub poll_interval: Duration, - pub resend_delay_ms: u64, - pub source_agent_id: Option, - pub source_role: Option, - pub tags: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CultMeshRudpSnapshotOptions { - pub target: SocketAddr, - pub runtime_id: String, - /// Exact correlation id for the SnapshotRequest. Raw callers must supply - /// this; `CultMeshNode::pull_rudp_catalog_snapshot` generates one when it - /// owns the request. - pub message_id: String, - pub connect_timeout: Duration, - pub response_timeout: Duration, - pub poll_interval: Duration, - pub resend_delay_ms: u64, - pub schema_ids: Option>, - pub record_keys: Option>, -} - -impl CultMeshRudpSnapshotOptions { - pub fn odin(target: SocketAddr, runtime_id: impl Into) -> Self { - Self { - target, - runtime_id: runtime_id.into(), - ..Self::default() - } - } -} - -impl Default for CultMeshRudpSnapshotOptions { - fn default() -> Self { - Self { - target: SocketAddr::from(([0, 0, 0, 0], 0)), - runtime_id: "cultmesh-rudp-snapshot-client".to_string(), - message_id: String::new(), - connect_timeout: Duration::from_secs(3), - response_timeout: Duration::from_secs(3), - poll_interval: Duration::from_millis(10), - resend_delay_ms: 50, - schema_ids: None, - record_keys: None, - } - } -} - -impl CultMeshRudpDocumentPublishOptions { - pub fn odin(target: SocketAddr, runtime_id: impl Into) -> Self { - Self { - target, - runtime_id: runtime_id.into(), - ..Self::default() - } - } -} - -impl Default for CultMeshRudpDocumentPublishOptions { - fn default() -> Self { - Self { - target: SocketAddr::from(([0, 0, 0, 0], 0)), - runtime_id: "cultmesh-rudp-document-publisher".to_string(), - connect_timeout: Duration::from_secs(3), - flush_timeout: Duration::from_millis(300), - poll_interval: Duration::from_millis(10), - resend_delay_ms: 50, - source_agent_id: None, - source_role: None, - tags: Vec::new(), - } - } -} - -impl Default for CultMeshNodeOptions { - fn default() -> Self { - Self { - runtime_id: "cultmesh-local".to_string(), - pull_on_start: true, - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CultMeshRudpEndpoint { - pub host: String, - pub port: u16, - pub addr: SocketAddr, -} - -pub struct CultMeshNode { - runtime_id: String, - cache: CultCache, - documents: CultNetDocumentRegistry, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CultMeshStreamKind { - Audio, - Video, - Tensor, - Bytes, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CultMeshStreamBodyTransport { - SharedMemory, - SharedD3d12Texture, - SharedD3d11Texture, - DmaBuf, - IoSurface, - AHardwareBuffer, - CultCachePage, - InlineBytes, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CultMeshStreamAccess { - Read, - Write, - ReadWrite, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CultMeshStreamCopyBudget { - ZeroCopyTarget, - OneCopyFallback, - OpaqueRuntime, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct CultMeshStreamClock { - pub clock_domain_id: String, - pub source_id: Option, - pub sample_rate: i32, - pub offset_to_verse_time_ns: i64, - pub confidence: f64, - pub evidence_kind: Option, -} - -impl CultMeshStreamClock { - pub fn new(clock_domain_id: impl Into) -> Result { - let clock_domain_id = require_non_empty(clock_domain_id.into(), "clock_domain_id")?; - Ok(Self { - clock_domain_id, - source_id: None, - sample_rate: 0, - offset_to_verse_time_ns: 0, - confidence: 0.0, - evidence_kind: None, - }) - } - - pub fn source_id(mut self, source_id: impl Into) -> Result { - self.source_id = Some(require_non_empty(source_id.into(), "source_id")?); - Ok(self) - } - - pub fn confidence(mut self, confidence: f64) -> Self { - self.confidence = confidence; - self - } - - pub fn evidence_kind(mut self, evidence_kind: impl Into) -> Result { - self.evidence_kind = Some(require_non_empty(evidence_kind.into(), "evidence_kind")?); - Ok(self) - } -} - -#[derive(Clone, Debug, PartialEq)] -pub struct CultMeshStreamDescriptor { - pub stream_id: String, - pub verse_id: String, - pub owner_peer_id: String, - pub kind: CultMeshStreamKind, - pub clock: CultMeshStreamClock, - pub preferred_transports: Vec, - pub label: Option, - pub required_access: CultMeshStreamAccess, - pub max_in_flight_frames: u32, - pub metadata_schema_id: Option, -} - -impl CultMeshStreamDescriptor { - pub fn new( - stream_id: impl Into, - verse_id: impl Into, - owner_peer_id: impl Into, - kind: CultMeshStreamKind, - clock: CultMeshStreamClock, - preferred_transports: Vec, - ) -> Result { - if preferred_transports.is_empty() { - anyhow::bail!("preferred_transports must not be empty"); - } - Ok(Self { - stream_id: require_non_empty(stream_id.into(), "stream_id")?, - verse_id: require_non_empty(verse_id.into(), "verse_id")?, - owner_peer_id: require_non_empty(owner_peer_id.into(), "owner_peer_id")?, - kind, - clock, - preferred_transports, - label: None, - required_access: CultMeshStreamAccess::Read, - max_in_flight_frames: 0, - metadata_schema_id: None, - }) - } - - pub fn label(mut self, label: impl Into) -> Result { - self.label = Some(require_non_empty(label.into(), "label")?); - Ok(self) - } - - pub fn max_in_flight_frames(mut self, max_in_flight_frames: u32) -> Self { - self.max_in_flight_frames = max_in_flight_frames; - self - } - - pub fn metadata_schema_id(mut self, metadata_schema_id: impl Into) -> Result { - self.metadata_schema_id = Some(require_non_empty( - metadata_schema_id.into(), - "metadata_schema_id", - )?); - Ok(self) - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CultMeshStreamConsumerProfile { - pub peer_id: String, - pub verse_id: String, - pub supported_transports: Vec, - pub accepted_kinds: Vec, - pub max_in_flight_frames: u32, -} - -impl CultMeshStreamConsumerProfile { - pub fn new( - peer_id: impl Into, - verse_id: impl Into, - supported_transports: Vec, - ) -> Result { - if supported_transports.is_empty() { - anyhow::bail!("supported_transports must not be empty"); - } - Ok(Self { - peer_id: require_non_empty(peer_id.into(), "peer_id")?, - verse_id: require_non_empty(verse_id.into(), "verse_id")?, - supported_transports, - accepted_kinds: Vec::new(), - max_in_flight_frames: 0, - }) - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CultMeshStreamNegotiation { - pub stream_id: String, - pub producer_peer_id: String, - pub consumer_peer_id: String, - pub transport: CultMeshStreamBodyTransport, - pub access: CultMeshStreamAccess, - pub max_in_flight_frames: u32, - pub copy_budget: CultMeshStreamCopyBudget, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CultMeshStreamFrameHandle { - pub stream_id: String, - pub sequence: u64, - pub timestamp_ns: i64, - pub duration_ns: i64, - pub transport: CultMeshStreamBodyTransport, - pub byte_length: usize, - pub resource_key: Option, - pub unavoidable_copy_count: u32, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CultMeshSharedMemoryFrameRingStats { - pub stream_id: String, - pub slot_count: usize, - pub slot_byte_length: usize, - pub published_frames: u64, - pub dropped_frames: u64, - pub blocked_writes: u64, - pub latest_sequence: u64, - pub unavoidable_copy_count: u64, -} - -pub struct CultMeshFrameReadLease<'a> { - pub handle: CultMeshStreamFrameHandle, - bytes: &'a [u8], -} - -impl CultMeshFrameReadLease<'_> { - pub fn bytes(&self) -> &[u8] { - self.bytes - } -} - -#[derive(Clone, Debug)] -pub struct CultMeshSharedMemoryFrameRing { - stream_id: String, - slot_byte_length: usize, - slots: Vec)>>, - write_cursor: usize, - latest_slot: Option, - next_sequence: u64, - published_frames: u64, - dropped_frames: u64, - blocked_writes: u64, - unavoidable_copy_count: u64, -} - -impl CultMeshSharedMemoryFrameRing { - pub fn new( - stream_id: impl Into, - slot_count: usize, - slot_byte_length: usize, - ) -> Result { - let stream_id = require_non_empty(stream_id.into(), "stream_id")?; - if slot_count == 0 { - anyhow::bail!("slot_count must be greater than zero"); - } - if slot_byte_length == 0 { - anyhow::bail!("slot_byte_length must be greater than zero"); - } - Ok(Self { - stream_id, - slot_byte_length, - slots: vec![None; slot_count], - write_cursor: 0, - latest_slot: None, - next_sequence: 0, - published_frames: 0, - dropped_frames: 0, - blocked_writes: 0, - unavoidable_copy_count: 0, - }) - } - - pub fn try_publish_copy( - &mut self, - bytes: &[u8], - timestamp_ns: i64, - duration_ns: i64, - ) -> Result> { - if bytes.len() > self.slot_byte_length { - anyhow::bail!("frame byte length exceeds slot size"); - } - if self.slots.is_empty() { - self.blocked_writes += 1; - return Ok(None); - } - - let slot_index = self.write_cursor; - if self.slots[slot_index].is_some() { - self.dropped_frames += 1; - } - let handle = CultMeshStreamFrameHandle { - stream_id: self.stream_id.clone(), - sequence: self.next_sequence, - timestamp_ns, - duration_ns, - transport: CultMeshStreamBodyTransport::SharedMemory, - byte_length: bytes.len(), - resource_key: Some(format!("{}:slot:{slot_index}", self.stream_id)), - unavoidable_copy_count: 1, - }; - self.slots[slot_index] = Some((handle.clone(), bytes.to_vec())); - self.latest_slot = Some(slot_index); - self.write_cursor = (slot_index + 1) % self.slots.len(); - self.next_sequence = self.next_sequence.saturating_add(1); - self.published_frames = self.published_frames.saturating_add(1); - self.unavoidable_copy_count = self.unavoidable_copy_count.saturating_add(1); - Ok(Some(handle)) - } - - pub fn try_acquire_latest_read(&self) -> Option> { - let slot_index = self.latest_slot?; - let (handle, bytes) = self.slots[slot_index].as_ref()?; - Some(CultMeshFrameReadLease { - handle: handle.clone(), - bytes, - }) - } - - pub fn stats(&self) -> CultMeshSharedMemoryFrameRingStats { - let latest_sequence = self - .latest_slot - .and_then(|slot| self.slots[slot].as_ref().map(|(handle, _)| handle.sequence)) - .unwrap_or(0); - CultMeshSharedMemoryFrameRingStats { - stream_id: self.stream_id.clone(), - slot_count: self.slots.len(), - slot_byte_length: self.slot_byte_length, - published_frames: self.published_frames, - dropped_frames: self.dropped_frames, - blocked_writes: self.blocked_writes, - latest_sequence, - unavoidable_copy_count: self.unavoidable_copy_count, - } - } -} - -#[derive(Clone, Debug, Default)] -pub struct CultMeshStreamCatalog { - streams: BTreeMap, - latest_frames: BTreeMap, - rings: BTreeMap, -} - -impl CultMeshStreamCatalog { - pub fn declare(&mut self, stream: CultMeshStreamDescriptor) { - self.streams.insert(stream.stream_id.clone(), stream); - } - - pub fn get(&self, stream_id: &str) -> Option<&CultMeshStreamDescriptor> { - self.streams.get(stream_id) - } - - pub fn streams(&self) -> Vec<&CultMeshStreamDescriptor> { - self.streams.values().collect() - } - - pub fn find( - &self, - verse_id: &str, - kind: Option, - ) -> Vec<&CultMeshStreamDescriptor> { - self.streams - .values() - .filter(|stream| stream.verse_id == verse_id && kind.is_none_or(|k| stream.kind == k)) - .collect() - } - - pub fn negotiate( - &self, - stream_id: &str, - consumer: &CultMeshStreamConsumerProfile, - ) -> Result { - let stream = self - .streams - .get(stream_id) - .ok_or_else(|| anyhow::anyhow!("unknown CultMesh stream '{stream_id}'"))?; - if stream.verse_id != consumer.verse_id { - anyhow::bail!("stream and consumer must belong to the same Verse"); - } - if !consumer.accepted_kinds.is_empty() && !consumer.accepted_kinds.contains(&stream.kind) { - anyhow::bail!("consumer does not accept stream kind"); - } - let transport = stream - .preferred_transports - .iter() - .copied() - .find(|candidate| consumer.supported_transports.contains(candidate)) - .ok_or_else(|| { - anyhow::anyhow!("stream and consumer have no compatible body transport") - })?; - let producer_max = if stream.max_in_flight_frames == 0 { - u32::MAX - } else { - stream.max_in_flight_frames - }; - let consumer_max = if consumer.max_in_flight_frames == 0 { - u32::MAX - } else { - consumer.max_in_flight_frames - }; - Ok(CultMeshStreamNegotiation { - stream_id: stream.stream_id.clone(), - producer_peer_id: stream.owner_peer_id.clone(), - consumer_peer_id: consumer.peer_id.clone(), - transport, - access: stream.required_access, - max_in_flight_frames: producer_max.min(consumer_max), - copy_budget: copy_budget_for(transport), - }) - } - - pub fn create_shared_memory_ring( - &mut self, - stream_id: &str, - slot_count: usize, - slot_byte_length: usize, - ) -> Result<&mut CultMeshSharedMemoryFrameRing> { - if !self.streams.contains_key(stream_id) { - anyhow::bail!("unknown CultMesh stream '{stream_id}'"); - } - self.rings.insert( - stream_id.to_string(), - CultMeshSharedMemoryFrameRing::new(stream_id, slot_count, slot_byte_length)?, - ); - Ok(self - .rings - .get_mut(stream_id) - .expect("ring was inserted for stream")) - } - - pub fn ring(&self, stream_id: &str) -> Option<&CultMeshSharedMemoryFrameRing> { - self.rings.get(stream_id) - } - - pub fn ring_mut(&mut self, stream_id: &str) -> Option<&mut CultMeshSharedMemoryFrameRing> { - self.rings.get_mut(stream_id) - } - - pub fn publish_frame(&mut self, handle: CultMeshStreamFrameHandle) -> Result<()> { - if !self.streams.contains_key(&handle.stream_id) { - anyhow::bail!("unknown CultMesh stream '{}'", handle.stream_id); - } - self.latest_frames.insert(handle.stream_id.clone(), handle); - Ok(()) - } - - pub fn latest_frame(&self, stream_id: &str) -> Option<&CultMeshStreamFrameHandle> { - self.latest_frames.get(stream_id) - } -} - -fn copy_budget_for(transport: CultMeshStreamBodyTransport) -> CultMeshStreamCopyBudget { - match transport { - CultMeshStreamBodyTransport::SharedMemory - | CultMeshStreamBodyTransport::SharedD3d12Texture - | CultMeshStreamBodyTransport::SharedD3d11Texture - | CultMeshStreamBodyTransport::DmaBuf - | CultMeshStreamBodyTransport::IoSurface - | CultMeshStreamBodyTransport::AHardwareBuffer => CultMeshStreamCopyBudget::ZeroCopyTarget, - CultMeshStreamBodyTransport::CultCachePage => CultMeshStreamCopyBudget::OneCopyFallback, - CultMeshStreamBodyTransport::InlineBytes => CultMeshStreamCopyBudget::OpaqueRuntime, - } -} - -fn require_non_empty(value: String, name: &str) -> Result { - if value.trim().is_empty() { - anyhow::bail!("{name} must be non-empty"); - } - Ok(value) -} - -impl CultMeshNode { - pub fn runtime_id(&self) -> &str { - &self.runtime_id - } - - pub fn cache(&self) -> &CultCache { - &self.cache - } - - pub fn documents(&self) -> &CultNetDocumentRegistry { - &self.documents - } - - pub fn get(&self, key: &str) -> Result> { - self.cache.get::(key) - } - - pub fn get_required(&self, key: &str) -> Result { - self.cache.get_required::(key) - } - - pub fn get_all_with_keys(&self) -> Result> { - self.cache.get_all_with_keys::() - } - - pub fn soa(&self) -> Result> { - self.cache.soa::() - } - - pub fn put(&mut self, key: impl Into, value: &T) -> Result { - self.cache.put(key, value) - } - - pub fn pull_all_backing_stores(&mut self) -> Result<()> { - self.cache.pull_all_backing_stores() - } - - pub fn delete(&mut self, key: &str) -> Result { - self.cache.delete::(key) - } - - pub fn flush(&mut self) -> Result<()> { - Ok(()) - } - - pub fn publish_document_to_rudp_catalog( - &self, - key: impl Into, - value: &T, - options: CultMeshRudpDocumentPublishOptions, - ) -> Result<()> - where - T: DatabaseEntry + Serialize, - { - let message = self.create_rudp_document_message(key, value, &options)?; - publish_cultnet_message_to_rudp_catalog(&message, options) - } - - pub fn create_rudp_document_message( - &self, - key: impl Into, - value: &T, - options: &CultMeshRudpDocumentPublishOptions, - ) -> Result - where - T: DatabaseEntry + Serialize, - { - let key = key.into(); - self.documents.create_raw_document_put_message( - format!("{}:{}:{}", options.runtime_id, T::TYPE, key), - key, - value, - CultNetDocumentPutOptions { - source_runtime_id: Some(options.runtime_id.clone()), - source_agent_id: options.source_agent_id.clone(), - source_role: options.source_role.clone(), - tags: if options.tags.is_empty() { - None - } else { - Some(options.tags.clone()) - }, - ..CultNetDocumentPutOptions::default() - }, - ) - } - - pub fn pull_rudp_catalog_snapshot( - &mut self, - mut options: CultMeshRudpSnapshotOptions, - ) -> Result { - if options.message_id.trim().is_empty() { - options.message_id = format!( - "{}:snapshot:{}:{}", - options.runtime_id, - unix_millis(), - fresh_rudp_connection_epoch() - ); - } - let response = request_raw_snapshot_from_rudp_catalog(options)?; - let cultnet_rs::CultNetMessage::SnapshotResponseRaw { documents, .. } = response else { - anyhow::bail!("expected cultnet.snapshot_response_raw.v0"); - }; - let mut applied = 0usize; - for document in documents { - let Some(document_type) = - registered_document_type_for_schema(&self.documents, &document.schema_id) - else { - continue; - }; - self.cache.put_raw_envelope(CultCacheEnvelope { - key: document.record_key, - r#type: document_type, - payload: document.payload, - stored_at: document.stored_at, - schema_id: Some(document.schema_id), - })?; - applied += 1; - } - Ok(applied) - } -} - -fn registered_document_type_for_schema( - documents: &CultNetDocumentRegistry, - schema_id: &str, -) -> Option { - let normalized = strip_schema_version_suffix(schema_id); - if normalized != schema_id { - return Some(normalized.to_string()); - } - documents - .binding_by_schema_id(schema_id) - .map(|binding| binding.document_type.clone()) - .or_else(|| documents.binding(schema_id).map(|_| schema_id.to_string())) -} - -fn strip_schema_version_suffix(value: &str) -> &str { - let Some((prefix, suffix)) = value.rsplit_once(".v") else { - return value; - }; - if suffix.chars().all(|character| character.is_ascii_digit()) { - prefix - } else { - value - } -} - -/// Request a raw CultNet snapshot without importing it into a local cache. -/// -/// The returned value is the exact validated `SnapshotResponseRaw`, including -/// its correlation `message_id`. Each call binds a fresh source port, chooses a -/// fresh connection epoch, and succeeds only after the reliable request is -/// acknowledged. -pub fn request_raw_snapshot_from_rudp_catalog( - options: CultMeshRudpSnapshotOptions, -) -> Result { - if options.runtime_id.trim().is_empty() { - anyhow::bail!("runtime_id must be non-empty"); - } - if options.message_id.trim().is_empty() { - anyhow::bail!("raw CultMesh RUDP snapshot requires a message_id"); - } - let message_id = options.message_id.clone(); - let mut client = connect_rudp_client( - options.target, - &options.runtime_id, - options.connect_timeout, - options.poll_interval, - options.resend_delay_ms, - "cultmesh-rudp-snapshot-client", - )?; - - let request = cultnet_rs::CultNetMessage::SnapshotRequest { - message_id: message_id.clone(), - schema_ids: options.schema_ids, - record_keys: options.record_keys, - }; - let payload = encode_cultnet_message_to_vec(&request, CultNetWireContract::CultNetSchemaV0)?; - let receipt = client.send_reliable("schema", payload)?; - let response_deadline = Instant::now() + options.response_timeout; - let mut snapshot_response = None; - while Instant::now() < response_deadline { - if let Some(frame) = client.receive_once()? { - if frame.channel_id == "schema" { - let message = decode_cultnet_message_from_slice( - &frame.payload, - CultNetWireContract::CultNetSchemaV0, - )?; - match &message { - cultnet_rs::CultNetMessage::SnapshotResponseRaw { - message_id: received, - .. - } if received == &message_id => { - snapshot_response = Some(message); - } - cultnet_rs::CultNetMessage::Error { error } => { - let _ = client.disconnect(b"snapshot-error".to_vec()); - anyhow::bail!("CultMesh RUDP snapshot failed: {error}"); - } - _ => {} - } - } - } - match client.reliable_send_status(&receipt) { - cultnet_rs::CultNetRudpReliableSendStatus::Acknowledged - if snapshot_response.is_some() => - { - let _ = client.disconnect(b"snapshot-complete".to_vec()); - return Ok(snapshot_response.expect("response was checked as present")); - } - cultnet_rs::CultNetRudpReliableSendStatus::Invalidated => { - anyhow::bail!("CultMesh RUDP snapshot request receipt was invalidated") - } - _ => {} - } - client.poll_resends()?; - thread::sleep(options.poll_interval); - } - let _ = client.disconnect(b"snapshot-timeout".to_vec()); - if snapshot_response.is_some() { - anyhow::bail!( - "timed out waiting for reliable acknowledgement of CultMesh RUDP snapshot request to {}", - options.target - ); - } - anyhow::bail!( - "timed out waiting for CultMesh RUDP snapshot from {}", - options.target - ) -} - -fn unix_millis() -> u128 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() -} - -/// Publish one raw CultNet schema message to a RUDP catalog. -/// -/// Each call owns a fresh connection epoch and returns only after the catalog -/// acknowledges reliable application admission. -pub fn publish_cultnet_message_to_rudp_catalog( - message: &cultnet_rs::CultNetMessage, - options: CultMeshRudpDocumentPublishOptions, -) -> Result<()> { - if options.runtime_id.trim().is_empty() { - anyhow::bail!("runtime_id must be non-empty"); - } - let mut client = connect_rudp_client( - options.target, - &options.runtime_id, - options.connect_timeout, - options.poll_interval, - options.resend_delay_ms, - "cultmesh-rudp-document-publisher", - )?; - - let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?; - let receipt = client.send_reliable("schema", payload)?; - let flush_deadline = Instant::now() + options.flush_timeout; - while Instant::now() < flush_deadline { - let _ = client.receive_once()?; - match client.reliable_send_status(&receipt) { - cultnet_rs::CultNetRudpReliableSendStatus::Acknowledged => { - let _ = client.disconnect(b"document-acknowledged".to_vec()); - return Ok(()); - } - cultnet_rs::CultNetRudpReliableSendStatus::Invalidated => { - anyhow::bail!("CultMesh RUDP document receipt was invalidated") - } - cultnet_rs::CultNetRudpReliableSendStatus::Pending => {} - } - client.poll_resends()?; - thread::sleep(options.poll_interval); - } - let _ = client.disconnect(b"document-unacknowledged".to_vec()); - anyhow::bail!( - "timed out waiting for reliable acknowledgement from CultMesh RUDP catalog {}", - options.target - ) -} - -/// Publish a batch of raw CultNet schema messages to a RUDP catalog. -/// -/// Unlike [`publish_cultnet_message_to_rudp_catalog`], which polls for -/// per-message application-level acknowledgement, this sends every message on -/// one connection and waits only for a transport-level `flush_reliable`. It -/// trades per-receipt confirmation for batch throughput; callers that need a -/// stronger delivery guarantee should use the singular publish path instead. -pub fn publish_cultnet_messages_to_rudp_catalog( - messages: &[cultnet_rs::CultNetMessage], - options: CultMeshRudpDocumentPublishOptions, -) -> Result<()> { - if options.runtime_id.trim().is_empty() { - anyhow::bail!("runtime_id must be non-empty"); - } - let mut client = connect_rudp_client( - options.target, - &options.runtime_id, - options.connect_timeout, - options.poll_interval, - options.resend_delay_ms, - "cultmesh-rudp-document-publisher", - )?; - - for message in messages { - let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?; - client.send("schema", payload)?; - } - client.flush_reliable(options.flush_timeout) -} - -fn connect_rudp_client( - target: SocketAddr, - runtime_id: &str, - connect_timeout: Duration, - poll_interval: Duration, - resend_delay_ms: u64, - transport_id: &str, -) -> Result { - let bind_addr = if target.is_ipv4() { - SocketAddr::from(([0, 0, 0, 0], 0)) - } else { - SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 0], 0)) - }; - let socket = UdpSocket::bind(bind_addr)?; - socket.set_read_timeout(Some(poll_interval))?; - let mut client = - CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { - media_delivery: None, - runtime_id: runtime_id.to_string(), - socket, - mode: cultnet_rs::CultNetRudpSocketMode::Client, - remote_addr: Some(target), - connection_id: fresh_rudp_connection_epoch(), - initial_sequence: 1, - resend_delay_ms, - transport_id: Some(transport_id.to_string()), - max_payload_bytes: None, - max_fragment_bytes: Some(1200), - max_pending_reliable_packets: Some(1024), - media_reliable_expire_after_ms: None, - reconnect_policy: None, - })?; - client.connect(Vec::new())?; - let connect_deadline = Instant::now() + connect_timeout; - while !client.connected() && Instant::now() < connect_deadline { - let _ = client.receive_once()?; - client.poll_resends()?; - thread::sleep(poll_interval); - } - if !client.connected() { - anyhow::bail!("timed out connecting CultMesh RUDP client {runtime_id} to {target}"); - } - Ok(client) -} - -fn fresh_rudp_connection_epoch() -> u32 { - let mut hasher = RandomState::new().build_hasher(); - hasher.write_u128(unix_millis()); - let epoch = hasher.finish() as u32; - if epoch == 0 { 1 } else { epoch } -} - -#[derive(Clone, Copy, Debug, Default)] -pub struct CultMesh; - -impl CultMesh { - pub fn resolve_rudp_endpoint(endpoint: &str) -> Result { - let text = require_non_empty(endpoint.trim().to_string(), "endpoint")?; - if text.starts_with("rudp://") { - return parse_rudp_socket_addr(&text); - } - if text.starts_with("cultmesh://") { - let resolved = default_cultmesh_rudp_endpoint_resolver(&text)?.ok_or_else(|| { - anyhow::anyhow!("CultMesh URI {text} did not resolve to a RUDP endpoint") - })?; - return parse_rudp_socket_addr(&resolved); - } - parse_socket_addr(&text) - } - - pub fn create_node( - store_path: impl AsRef, - documents: D, - options: CultMeshNodeOptions, - ) -> Result - where - D: CultMeshDocumentSet, - { - let mut cache = CultCache::new(); - documents.register_cache(&mut cache)?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(store_path.as_ref()))?; - if options.pull_on_start { - cache.pull_all_backing_stores()?; - } - - let mut registry = CultNetDocumentRegistry::new(); - documents.register_documents(&mut registry)?; - - Ok(CultMeshNode { - runtime_id: options.runtime_id, - cache, - documents: registry, - }) - } - - pub fn start_node( - store_path: impl AsRef, - documents: D, - options: CultMeshNodeOptions, - ) -> Result - where - D: CultMeshDocumentSet, - { - Self::create_node(store_path, documents, options) - } - - pub fn create_stream_catalog() -> CultMeshStreamCatalog { - CultMeshStreamCatalog::default() - } -} - -pub fn create_node( - store_path: impl AsRef, - documents: D, - options: CultMeshNodeOptions, -) -> Result -where - D: CultMeshDocumentSet, -{ - CultMesh::create_node(store_path, documents, options) -} - -pub fn start_node( - store_path: impl AsRef, - documents: D, - options: CultMeshNodeOptions, -) -> Result -where - D: CultMeshDocumentSet, -{ - CultMesh::start_node(store_path, documents, options) -} - -pub fn create_stream_catalog() -> CultMeshStreamCatalog { - CultMesh::create_stream_catalog() -} - -pub fn resolve_rudp_endpoint(endpoint: &str) -> Result { - CultMesh::resolve_rudp_endpoint(endpoint) -} - -fn parse_rudp_socket_addr(value: &str) -> Result { - let text = value.trim().strip_prefix("rudp://").unwrap_or(value.trim()); - parse_socket_addr(text) -} - -fn parse_socket_addr(value: &str) -> Result { - value - .parse() - .with_context(|| format!("RUDP endpoint must be a socket address, got {value:?}")) -} - -fn default_cultmesh_rudp_endpoint_resolver(uri: &str) -> Result> { - let authority = cultmesh_uri_authority(uri)?; - let slug = authority - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() { - ch.to_ascii_uppercase() - } else { - '_' - } - }) - .collect::() - .trim_matches('_') - .to_string(); - if slug.is_empty() { - return Ok(None); - } - - for key in [ - format!("CULTMESH_URI_{slug}_RUDP"), - format!("{slug}_CULTMESH_RUDP_ENDPOINT"), - ] { - if let Ok(value) = env::var(&key) { - let text = value.trim(); - if !text.is_empty() { - return Ok(Some(if text.starts_with("rudp://") { - text.to_string() - } else { - format!("rudp://{text}") - })); - } - } - } - Ok(None) -} - -fn cultmesh_uri_authority(uri: &str) -> Result { - let rest = uri - .strip_prefix("cultmesh://") - .ok_or_else(|| anyhow::anyhow!("CultMesh URI must start with cultmesh://"))?; - let authority = rest.split(['/', '?', '#']).next().unwrap_or("").trim(); - if authority.is_empty() { - anyhow::bail!("CultMesh URI must include an authority"); - } - Ok(authority.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[derive(Clone, Debug, PartialEq, Eq, cultcache_rs::DatabaseEntry)] - #[cultcache(type = "cultmesh.test.note", schema = "CultMeshTestNote")] - struct Note { - #[cultcache(key = 0)] - body: String, - } - - cultmesh_documents!(TestDocuments { - Note => "cultmesh.test.note.v0", - }); - - #[test] - fn node_round_trips_registered_documents() -> Result<()> { - let temp = tempfile::tempdir()?; - let store_path = temp.path().join("cultmesh.cc"); - let note = Note { - body: "blessed circuit".to_string(), - }; - - let mut node = CultMesh::create_node( - &store_path, - TestDocuments, - CultMeshNodeOptions { - runtime_id: "test-runtime".to_string(), - ..CultMeshNodeOptions::default() - }, - )?; - assert_eq!(node.runtime_id(), "test-runtime"); - node.put("note", ¬e)?; - node.flush()?; - - let reloaded = CultMesh::create_node(&store_path, TestDocuments, Default::default())?; - assert_eq!(reloaded.get_required::("note")?, note); - assert!( - reloaded - .documents() - .binding_by_schema_id("cultmesh.test.note.v0") - .is_some() - ); - Ok(()) - } - - #[test] - fn node_publishes_registered_document_to_rudp_catalog() -> Result<()> { - let temp = tempfile::tempdir()?; - let store_path = temp.path().join("cultmesh.cc"); - let note = Note { - body: "respect odin once".to_string(), - }; - let node = CultMesh::create_node( - &store_path, - TestDocuments, - CultMeshNodeOptions { - runtime_id: "muninn-test".to_string(), - ..CultMeshNodeOptions::default() - }, - )?; - - let socket = UdpSocket::bind("127.0.0.1:0")?; - socket.set_read_timeout(Some(Duration::from_millis(10)))?; - let target = socket.local_addr()?; - let (sender, receiver) = std::sync::mpsc::channel(); - let delivered = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let delivered_by_sink = delivered.clone(); - let server = thread::spawn(move || -> Result<()> { - let mut server = CultMeshRudpDocumentServer::new( - socket, - move |receipt: CultMeshRudpRawDocumentReceipt| { - sender - .send(( - receipt.document.schema_id, - receipt.document.record_key, - receipt.document.source_runtime_id, - )) - .ok(); - delivered_by_sink.store(true, std::sync::atomic::Ordering::SeqCst); - Ok(()) - }, - |_query: &CultMeshRudpSnapshotQuery| Ok(Vec::new()), - CultMeshSystemClock::default(), - CultMeshRudpDocumentServerOptions::default(), - )?; - let deadline = Instant::now() + Duration::from_secs(2); - while Instant::now() < deadline { - server.poll_once()?; - if delivered.load(std::sync::atomic::Ordering::SeqCst) { - return Ok(()); - } - thread::sleep(Duration::from_millis(1)); - } - anyhow::bail!("timed out waiting for RUDP document put") - }); - - node.publish_document_to_rudp_catalog( - "note", - ¬e, - CultMeshRudpDocumentPublishOptions::odin(target, "muninn-test"), - )?; - let received = receiver.recv_timeout(Duration::from_secs(2))?; - server.join().expect("server thread should not panic")?; - - assert_eq!(received.0, "cultmesh.test.note.v0"); - assert_eq!(received.1, "note"); - assert_eq!(received.2.as_deref(), Some("muninn-test")); - Ok(()) - } - - #[test] - fn document_publish_fails_when_catalog_is_unreachable() -> Result<()> { - let temp = tempfile::tempdir()?; - let node = CultMesh::create_node( - temp.path().join("cultmesh.cc"), - TestDocuments, - Default::default(), - )?; - let unused = UdpSocket::bind("127.0.0.1:0")?; - let target = unused.local_addr()?; - drop(unused); - - let error = node - .publish_document_to_rudp_catalog( - "note", - &Note { - body: "no false success".into(), - }, - CultMeshRudpDocumentPublishOptions { - target, - connect_timeout: Duration::from_millis(30), - poll_interval: Duration::from_millis(2), - ..CultMeshRudpDocumentPublishOptions::default() - }, - ) - .expect_err("unreachable catalog must fail closed"); - assert!(error.to_string().contains("timed out connecting")); - Ok(()) - } - - #[test] - fn document_publish_requires_application_acknowledgement() -> Result<()> { - let temp = tempfile::tempdir()?; - let node = CultMesh::create_node( - temp.path().join("cultmesh.cc"), - TestDocuments, - Default::default(), - )?; - let socket = UdpSocket::bind("127.0.0.1:0")?; - let target = socket.local_addr()?; - let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let server_stop = stop.clone(); - let server = thread::spawn(move || -> Result<()> { - let mut server = CultMeshRudpDocumentServer::new( - socket, - |_receipt: CultMeshRudpRawDocumentReceipt| { - anyhow::bail!("injected durable sink failure") - }, - |_query: &CultMeshRudpSnapshotQuery| Ok(Vec::new()), - CultMeshSystemClock::default(), - CultMeshRudpDocumentServerOptions::default(), - )?; - while !server_stop.load(std::sync::atomic::Ordering::SeqCst) { - let _ = server.poll_once(); - thread::sleep(Duration::from_millis(1)); - } - Ok(()) - }); - - let error = node - .publish_document_to_rudp_catalog( - "note", - &Note { - body: "still not accepted".into(), - }, - CultMeshRudpDocumentPublishOptions { - target, - connect_timeout: Duration::from_millis(100), - flush_timeout: Duration::from_millis(50), - poll_interval: Duration::from_millis(2), - resend_delay_ms: 5, - ..CultMeshRudpDocumentPublishOptions::default() - }, - ) - .expect_err("unacknowledged document must fail closed"); - stop.store(true, std::sync::atomic::Ordering::SeqCst); - server.join().expect("server thread should not panic")?; - assert!(error.to_string().contains("reliable acknowledgement")); - Ok(()) - } - - #[test] - fn raw_snapshot_preserves_caller_challenge_and_raw_response() -> Result<()> { - let expected = cultnet_rs::CultNetRawDocumentRecord { - schema_id: "cultmesh.test.raw.v0".into(), - record_key: "route-proof".into(), - stored_at: "2026-09-03T00:00:00Z".into(), - payload_encoding: cultnet_rs::CultNetRawPayloadEncoding::Messagepack, - payload: vec![0xc4, 0x03, 0, 0xff, 1], - source_runtime_id: Some("odin-test".into()), - source_agent_id: None, - source_role: None, - tags: None, - }; - let socket = UdpSocket::bind("127.0.0.1:0")?; - let target = socket.local_addr()?; - let challenge = "9aa63ad8-83b5-4cc4-ae4a-91d361d5f845".to_string(); - let server_document = expected.clone(); - let server_challenge = challenge.clone(); - let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let server_stop = stop.clone(); - let server = thread::spawn(move || -> Result<()> { - let mut server = CultMeshRudpDocumentServer::new( - socket, - |_receipt: CultMeshRudpRawDocumentReceipt| Ok(()), - move |query: &CultMeshRudpSnapshotQuery| { - if query.message_id != server_challenge { - anyhow::bail!("snapshot challenge changed") - } - Ok(vec![server_document.clone()]) - }, - CultMeshSystemClock::default(), - CultMeshRudpDocumentServerOptions::default(), - )?; - while !server_stop.load(std::sync::atomic::Ordering::SeqCst) { - server.poll_once()?; - thread::sleep(Duration::from_millis(1)); - } - Ok(()) - }); - - let response = request_raw_snapshot_from_rudp_catalog(CultMeshRudpSnapshotOptions { - target, - runtime_id: "route-proof-test".into(), - message_id: challenge.clone(), - connect_timeout: Duration::from_millis(100), - response_timeout: Duration::from_millis(500), - poll_interval: Duration::from_millis(2), - resend_delay_ms: 5, - schema_ids: Some(vec!["cultmesh.test.raw.v0".into()]), - record_keys: Some(vec!["route-proof".into()]), - }); - stop.store(true, std::sync::atomic::Ordering::SeqCst); - server.join().expect("server thread should not panic")?; - let response = response?; - assert_eq!( - response, - cultnet_rs::CultNetMessage::SnapshotResponseRaw { - message_id: challenge, - documents: vec![expected], - } - ); - Ok(()) - } - - #[test] - fn raw_snapshot_rejects_response_without_request_acknowledgement() -> Result<()> { - let socket = UdpSocket::bind("127.0.0.1:0")?; - socket.set_read_timeout(Some(Duration::from_millis(200)))?; - let target = socket.local_addr()?; - let server = thread::spawn(move || -> Result<()> { - let mut wire = vec![0_u8; 65_535]; - let (received, peer) = socket.recv_from(&mut wire)?; - let connect = cultnet_rs::decode_rudp_packet(&wire[..received])?; - let mut session = - cultnet_rs::CultNetRudpSession::new(cultnet_rs::CultNetRudpSessionOptions { - connection_id: connect.connection_id, - initial_sequence: 1, - resend_delay_ms: 5, - max_pending_reliable_packets: Some(64), - }); - let accept = session.accept_connect(&connect, 1, Vec::new())?; - socket.send_to(&cultnet_rs::encode_rudp_packet(&accept)?, peer)?; - - loop { - let (received, received_peer) = socket.recv_from(&mut wire)?; - if received_peer != peer { - continue; - } - let packet = cultnet_rs::decode_rudp_packet(&wire[..received])?; - let result = session.receive(&packet, 2)?; - let Some(frame) = result.delivered.into_iter().next() else { - continue; - }; - let request = decode_cultnet_message_from_slice( - &frame.payload, - CultNetWireContract::CultNetSchemaV0, - )?; - let cultnet_rs::CultNetMessage::SnapshotRequest { message_id, .. } = request else { - anyhow::bail!("expected snapshot request") - }; - let payload = encode_cultnet_message_to_vec( - &cultnet_rs::CultNetMessage::SnapshotResponseRaw { - message_id, - documents: Vec::new(), - }, - CultNetWireContract::CultNetSchemaV0, - )?; - let mut packets = session.send_many( - "schema", - payload, - cultnet_rs::CultNetRudpSendOptions { - reliable: true, - ordered: true, - sequenced: false, - now_ms: 2, - reliable_expire_after_ms: None, - }, - Some(1200), - )?; - for packet in &mut packets { - packet.ack = 0; - packet.ack_mask = 0; - socket.send_to(&cultnet_rs::encode_rudp_packet(packet)?, peer)?; - } - return Ok(()); - } - }); - - let error = request_raw_snapshot_from_rudp_catalog(CultMeshRudpSnapshotOptions { - target, - runtime_id: "unacknowledged-snapshot-test".into(), - message_id: "b7987efb-69c5-4c42-9355-b6e09535ad21".into(), - connect_timeout: Duration::from_millis(100), - response_timeout: Duration::from_millis(50), - poll_interval: Duration::from_millis(2), - resend_delay_ms: 5, - schema_ids: None, - record_keys: None, - }) - .expect_err("response without request ACK must fail closed"); - server.join().expect("server thread should not panic")?; - assert!(error.to_string().contains("reliable acknowledgement")); - Ok(()) - } - - #[test] - fn resolves_rudp_endpoint_from_cultmesh_uri_bootstrap_env() -> Result<()> { - let previous = env::var("CULTMESH_URI_ODIN_RUDP").ok(); - unsafe { - env::set_var("CULTMESH_URI_ODIN_RUDP", "127.0.0.1:17871"); - } - let result = CultMesh::resolve_rudp_endpoint("cultmesh://odin/rendezvous/provider-catalog"); - unsafe { - if let Some(value) = previous { - env::set_var("CULTMESH_URI_ODIN_RUDP", value); - } else { - env::remove_var("CULTMESH_URI_ODIN_RUDP"); - } - } - - assert_eq!(result?, "127.0.0.1:17871".parse()?); - Ok(()) - } - - #[test] - fn rejects_unresolved_cultmesh_rudp_endpoint() { - let previous = env::var("CULTMESH_URI_MISSING_ODIN_RUDP").ok(); - unsafe { - env::remove_var("CULTMESH_URI_MISSING_ODIN_RUDP"); - } - let error = - CultMesh::resolve_rudp_endpoint("cultmesh://missing-odin/rendezvous/provider-catalog") - .expect_err("unresolved CultMesh URI should fail"); - unsafe { - if let Some(value) = previous { - env::set_var("CULTMESH_URI_MISSING_ODIN_RUDP", value); - } - } - assert!(error.to_string().contains("did not resolve")); - } - - #[test] - fn raw_snapshot_application_uses_registered_document_type() -> Result<()> { - let temp = tempfile::tempdir()?; - let store_path = temp.path().join("cultmesh.cc"); - let mut node = CultMesh::create_node( - &store_path, - TestDocuments, - CultMeshNodeOptions { - runtime_id: "snapshot-apply-test".to_string(), - ..CultMeshNodeOptions::default() - }, - )?; - let payload = rmp_serde::to_vec(&Note { - body: "catalog truth".to_string(), - })?; - - node.cache - .put_raw_envelope(cultcache_rs::CultCacheEnvelope { - key: "note".to_string(), - r#type: node - .documents - .binding_by_schema_id("cultmesh.test.note.v0") - .expect("test binding") - .document_type - .clone(), - payload, - stored_at: "2026-07-03T00:00:00Z".to_string(), - schema_id: Some("cultmesh.test.note.v0".to_string()), - })?; - node.flush()?; - - let reloaded = CultMesh::create_node(&store_path, TestDocuments, Default::default())?; - assert_eq!(reloaded.get_required::("note")?.body, "catalog truth"); - assert_eq!(reloaded.cache().snapshot()[0].r#type, Note::TYPE); - Ok(()) - } - - #[test] - fn stream_catalog_negotiates_and_publishes_latest_shared_memory_frame() -> Result<()> { - let mut catalog = CultMesh::create_stream_catalog(); - let stream = CultMeshStreamDescriptor::new( - "muninn:nightwing:move-evidence", - "mimir-live", - "muninn:nightwing", - CultMeshStreamKind::Bytes, - CultMeshStreamClock::new("muninn:nightwing:clock")? - .source_id("muninn:nightwing:move-evidence")? - .confidence(1.0) - .evidence_kind("muninn-move-evidence")?, - vec![ - CultMeshStreamBodyTransport::SharedMemory, - CultMeshStreamBodyTransport::CultCachePage, - ], - )? - .label("Muninn Move evidence")? - .max_in_flight_frames(4) - .metadata_schema_id("mimir.muninn_move_evidence_stream_frame.v1")?; - catalog.declare(stream); - - let consumer = CultMeshStreamConsumerProfile::new( - "mimir:starfire", - "mimir-live", - vec![CultMeshStreamBodyTransport::SharedMemory], - )?; - let negotiation = catalog.negotiate("muninn:nightwing:move-evidence", &consumer)?; - - assert_eq!( - negotiation.transport, - CultMeshStreamBodyTransport::SharedMemory - ); - assert_eq!( - negotiation.copy_budget, - CultMeshStreamCopyBudget::ZeroCopyTarget - ); - - let handle = { - let ring = - catalog.create_shared_memory_ring("muninn:nightwing:move-evidence", 4, 128)?; - ring.try_publish_copy(&[1, 2, 3], 42, 0)? - .expect("frame should publish") - }; - catalog.publish_frame(handle.clone())?; - - assert_eq!( - catalog.latest_frame("muninn:nightwing:move-evidence"), - Some(&handle) - ); - let lease = catalog - .ring("muninn:nightwing:move-evidence") - .and_then(CultMeshSharedMemoryFrameRing::try_acquire_latest_read) - .expect("latest frame should be readable"); - assert_eq!(lease.handle.sequence, 0); - assert_eq!(lease.bytes(), &[1, 2, 3]); - Ok(()) - } -} diff --git a/packages/cultmesh-rs/src/rudp_document_server.rs b/packages/cultmesh-rs/src/rudp_document_server.rs deleted file mode 100644 index bebeb6c3..00000000 --- a/packages/cultmesh-rs/src/rudp_document_server.rs +++ /dev/null @@ -1,647 +0,0 @@ -use anyhow::{Result, anyhow}; -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, -}; -use std::collections::BTreeMap; -use std::io::ErrorKind; -use std::net::{SocketAddr, UdpSocket}; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; - -const MAX_UDP_DATAGRAM_BYTES: usize = 65_535; - -/// The transport identity of one remote CultNet RUDP session. -/// -/// Connection identifiers are scoped to a remote socket address. Two clients -/// may therefore use the same connection identifier without sharing ordering, -/// acknowledgement, resend, or fragment state. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub struct CultMeshRudpSessionKey { - pub remote_addr: SocketAddr, - pub connection_id: u32, -} - -/// A raw document plus the receipt fact owned by the receiving process. -/// -/// `document.payload` is the exact byte vector decoded from the CultNet raw -/// record. The trusted receipt time lives beside the sender-owned record so the -/// transport never rewrites its metadata or payload. -#[derive(Clone, Debug, PartialEq)] -pub struct CultMeshRudpRawDocumentReceipt { - pub session: CultMeshRudpSessionKey, - pub message_id: String, - pub transport_sequence: u32, - pub received_at_unix_millis: u64, - pub document: CultNetRawDocumentRecord, -} - -/// The caller-visible query for a read-only raw snapshot. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CultMeshRudpSnapshotQuery { - pub session: CultMeshRudpSessionKey, - pub message_id: String, - pub requested_at_unix_millis: u64, - pub schema_ids: Option>, - pub record_keys: Option>, -} - -/// Caller-owned admission/persistence port for received raw documents. -pub trait CultMeshRudpRawDocumentSink { - fn accept_raw_document(&mut self, receipt: CultMeshRudpRawDocumentReceipt) -> Result<()>; -} - -impl CultMeshRudpRawDocumentSink for F -where - F: FnMut(CultMeshRudpRawDocumentReceipt) -> Result<()>, -{ - fn accept_raw_document(&mut self, receipt: CultMeshRudpRawDocumentReceipt) -> Result<()> { - self(receipt) - } -} - -/// Caller-owned catalog port for serving raw snapshot requests. -/// -/// The caller decides which records the requester may see. CultMesh preserves -/// those records as raw CultNet documents and does not interpret their schemas. -pub trait CultMeshRudpSnapshotSource { - fn raw_snapshot( - &mut self, - query: &CultMeshRudpSnapshotQuery, - ) -> Result>; -} - -impl CultMeshRudpSnapshotSource for F -where - F: FnMut(&CultMeshRudpSnapshotQuery) -> Result>, -{ - fn raw_snapshot( - &mut self, - query: &CultMeshRudpSnapshotQuery, - ) -> Result> { - self(query) - } -} - -/// Trusted local clocks for receipt facts and transport maintenance. -/// -/// Receipt time is wall time. Expiry and resend decisions use the monotonic -/// value, so a wall-clock correction cannot resurrect or pin a session. -pub trait CultMeshRudpServerClock { - fn now_unix_millis(&self) -> u64; - fn now_monotonic_millis(&self) -> u64; -} - -#[derive(Clone, Debug)] -pub struct CultMeshSystemClock { - monotonic_origin: Instant, -} - -impl Default for CultMeshSystemClock { - fn default() -> Self { - Self { - monotonic_origin: Instant::now(), - } - } -} - -impl CultMeshRudpServerClock for CultMeshSystemClock { - fn now_unix_millis(&self) -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - .try_into() - .unwrap_or(u64::MAX) - } - - fn now_monotonic_millis(&self) -> u64 { - self.monotonic_origin - .elapsed() - .as_millis() - .try_into() - .unwrap_or(u64::MAX) - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CultMeshRudpDocumentServerOptions { - pub max_sessions: usize, - pub session_idle_timeout: Duration, - pub session_max_lifetime: Duration, - /// Conservative payload admission budget across all live sessions. - /// - /// Admitted ingress and queued snapshot-response bytes are charged until - /// the session ends even when CultNet releases them sooner. Together with - /// CultNet's per-session receive caps, this makes retained payload memory - /// finite without duplicating its fragment/ordering machinery here. - pub max_admitted_payload_bytes: usize, - pub max_admitted_payload_bytes_per_session: usize, - pub max_snapshot_response_bytes: usize, - pub max_snapshot_documents: usize, - pub resend_delay: Duration, - pub max_pending_reliable_packets_per_session: usize, - pub max_fragment_bytes: usize, -} - -impl Default for CultMeshRudpDocumentServerOptions { - fn default() -> Self { - Self { - max_sessions: 64, - session_idle_timeout: Duration::from_secs(30), - session_max_lifetime: Duration::from_secs(15 * 60), - max_admitted_payload_bytes: 32 * 1024 * 1024, - max_admitted_payload_bytes_per_session: 4 * 1024 * 1024, - max_snapshot_response_bytes: 1024 * 1024, - max_snapshot_documents: 4096, - resend_delay: Duration::from_millis(50), - max_pending_reliable_packets_per_session: 1024, - max_fragment_bytes: 1200, - } - } -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct CultMeshRudpMaintenance { - pub sessions_expired: usize, - pub packets_resent: usize, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CultMeshRudpApplicationOperation { - DocumentPutRaw, - SnapshotRequest, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CultMeshRudpApplicationRejection { - pub session: CultMeshRudpSessionKey, - pub operation: CultMeshRudpApplicationOperation, - pub message_id: String, - pub reason: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum CultMeshRudpPollOutcome { - Idle, - Handled, - ApplicationRejected(CultMeshRudpApplicationRejection), -} - -struct SessionEntry { - session: CultNetRudpSession, - created_at_monotonic_millis: u64, - last_activity_monotonic_millis: u64, - admitted_payload_bytes: usize, -} - -/// A synchronous, multi-session CultNet RUDP raw-document server. -/// -/// The server owns exactly one UDP socket. Call `poll_once` from an existing -/// daemon loop; no executor or background thread is created here. -pub struct CultMeshRudpDocumentServer { - socket: UdpSocket, - sessions: BTreeMap, - sink: S, - snapshot_source: Q, - clock: C, - options: CultMeshRudpDocumentServerOptions, -} - -impl CultMeshRudpDocumentServer -where - S: CultMeshRudpRawDocumentSink, - Q: CultMeshRudpSnapshotSource, - C: CultMeshRudpServerClock, -{ - pub fn new( - socket: UdpSocket, - sink: S, - snapshot_source: Q, - clock: C, - options: CultMeshRudpDocumentServerOptions, - ) -> Result { - validate_options(&options)?; - socket.local_addr().map_err(|error| { - anyhow!("CultMesh RUDP server requires a bound UDP socket: {error}") - })?; - socket.set_nonblocking(true)?; - Ok(Self { - socket, - sessions: BTreeMap::new(), - sink, - snapshot_source, - clock, - options, - }) - } - - pub fn local_addr(&self) -> Result { - Ok(self.socket.local_addr()?) - } - - pub fn session_count(&self) -> usize { - self.sessions.len() - } - - /// Poll transport maintenance and receive at most one UDP datagram. - /// - /// Application rejection invalidates only the responsible peer session and - /// is returned as data so a daemon can log it and keep serving. Local - /// socket and server-state failures remain errors. - pub fn poll_once(&mut self) -> Result { - self.maintain()?; - let mut wire = vec![0_u8; MAX_UDP_DATAGRAM_BYTES]; - let (received, remote_addr) = match self.socket.recv_from(&mut wire) { - Ok(value) => value, - Err(error) if error.kind() == ErrorKind::WouldBlock => { - return Ok(CultMeshRudpPollOutcome::Idle); - } - Err(error) => return Err(error.into()), - }; - wire.truncate(received); - - let packet = match decode_rudp_packet(&wire) { - Ok(packet) => packet, - Err(_) => return Ok(CultMeshRudpPollOutcome::Handled), - }; - let now_unix = self.clock.now_unix_millis(); - let now_monotonic = self.clock.now_monotonic_millis(); - let key = CultMeshRudpSessionKey { - remote_addr, - connection_id: packet.connection_id, - }; - - if packet.packet_type == CultNetRudpPacketType::Connect { - self.accept_connection(key, &packet, now_monotonic)?; - return Ok(CultMeshRudpPollOutcome::Handled); - } - - if !self.sessions.contains_key(&key) { - return Ok(CultMeshRudpPollOutcome::Handled); - } - if packet.packet_type == CultNetRudpPacketType::Data - && packet.channel_id == "schema" - && !packet.reliable - { - return Ok(CultMeshRudpPollOutcome::Handled); - } - let payload_charge = if packet.packet_type == CultNetRudpPacketType::Data { - packet.payload.len() - } else { - 0 - }; - if !self.payload_budget_allows(key, payload_charge) { - return Ok(CultMeshRudpPollOutcome::Handled); - } - - let result = { - let entry = self - .sessions - .get_mut(&key) - .expect("checked session must remain present"); - entry.last_activity_monotonic_millis = now_monotonic; - entry.admitted_payload_bytes = - entry.admitted_payload_bytes.saturating_add(payload_charge); - entry.session.receive(&packet, now_monotonic) - }; - let result = match result { - Ok(result) => result, - Err(_) => { - self.sessions.remove(&key); - return Ok(CultMeshRudpPollOutcome::Handled); - } - }; - - if let Some(reply) = result.reply { - self.send_packet(key.remote_addr, &reply)?; - } - - if result.disconnected { - self.sessions.remove(&key); - return Ok(CultMeshRudpPollOutcome::Handled); - } - - for frame in result.delivered { - if frame.channel_id != "schema" { - continue; - } - let message = match decode_cultnet_message_from_slice( - &frame.payload, - CultNetWireContract::CultNetSchemaV0, - ) { - Ok(message) => message, - Err(_) => continue, - }; - if let Some(rejection) = self.deliver_application_message( - key, - frame.sequence, - message, - now_unix, - now_monotonic, - )? { - self.sessions.remove(&key); - return Ok(CultMeshRudpPollOutcome::ApplicationRejected(rejection)); - } - } - - if packet.reliable { - let ack = self - .sessions - .get_mut(&key) - .ok_or_else(|| anyhow!("CultMesh RUDP session disappeared before ACK"))? - .session - .create_ack(); - self.send_packet(key.remote_addr, &ack)?; - } - Ok(CultMeshRudpPollOutcome::Handled) - } - - /// Expire idle sessions and resend reliable packets whose deadline passed. - pub fn maintain(&mut self) -> Result { - let now = self.clock.now_monotonic_millis(); - let idle_timeout_ms = duration_millis(self.options.session_idle_timeout); - let lifetime_ms = duration_millis(self.options.session_max_lifetime); - let before = self.sessions.len(); - self.sessions.retain(|_, entry| { - now.saturating_sub(entry.last_activity_monotonic_millis) <= idle_timeout_ms - && now.saturating_sub(entry.created_at_monotonic_millis) <= lifetime_ms - }); - let expired = before - self.sessions.len(); - - let mut resends = Vec::new(); - for (key, entry) in &mut self.sessions { - for packet in entry.session.due_resends(now) { - resends.push((key.remote_addr, packet)); - } - } - for (remote_addr, packet) in &resends { - self.send_packet(*remote_addr, packet)?; - } - Ok(CultMeshRudpMaintenance { - sessions_expired: expired, - packets_resent: resends.len(), - }) - } - - fn accept_connection( - &mut self, - key: CultMeshRudpSessionKey, - packet: &CultNetRudpPacket, - now: u64, - ) -> 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 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())? - }; - self.send_packet(key.remote_addr, &accept) - } - - fn deliver_application_message( - &mut self, - key: CultMeshRudpSessionKey, - transport_sequence: u32, - message: CultNetMessage, - now_unix: u64, - now: u64, - ) -> Result> { - match message { - CultNetMessage::DocumentPutRaw { - message_id, - document, - } => { - let receipt = CultMeshRudpRawDocumentReceipt { - session: key, - message_id: message_id.clone(), - transport_sequence, - received_at_unix_millis: now_unix, - document, - }; - if let Err(error) = self.sink.accept_raw_document(receipt) { - return Ok(Some(CultMeshRudpApplicationRejection { - session: key, - operation: CultMeshRudpApplicationOperation::DocumentPutRaw, - message_id, - reason: format!("{error:#}"), - })); - } - } - CultNetMessage::SnapshotRequest { - message_id, - schema_ids, - record_keys, - } => { - let query = CultMeshRudpSnapshotQuery { - session: key, - message_id: message_id.clone(), - requested_at_unix_millis: now_unix, - schema_ids, - record_keys, - }; - let documents = match self.snapshot_source.raw_snapshot(&query) { - Ok(documents) => documents, - Err(error) => { - return Ok(Some(CultMeshRudpApplicationRejection { - session: key, - operation: CultMeshRudpApplicationOperation::SnapshotRequest, - message_id, - reason: format!("{error:#}"), - })); - } - }; - if documents.len() > self.options.max_snapshot_documents { - return Ok(Some(CultMeshRudpApplicationRejection { - session: key, - operation: CultMeshRudpApplicationOperation::SnapshotRequest, - message_id, - reason: format!( - "snapshot returned {} documents; limit is {}", - documents.len(), - self.options.max_snapshot_documents - ), - })); - } - let response = CultNetMessage::SnapshotResponseRaw { - message_id: message_id.clone(), - documents, - }; - let payload = match encode_cultnet_message_to_vec( - &response, - CultNetWireContract::CultNetSchemaV0, - ) { - Ok(payload) => payload, - Err(error) => { - return Ok(Some(CultMeshRudpApplicationRejection { - session: key, - operation: CultMeshRudpApplicationOperation::SnapshotRequest, - message_id, - reason: format!("snapshot response could not be encoded: {error:#}"), - })); - } - }; - if payload.len() > self.options.max_snapshot_response_bytes { - return Ok(Some(CultMeshRudpApplicationRejection { - session: key, - operation: CultMeshRudpApplicationOperation::SnapshotRequest, - message_id, - reason: format!( - "snapshot response is {} bytes; limit is {}", - payload.len(), - self.options.max_snapshot_response_bytes - ), - })); - } - if !self.payload_budget_allows(key, payload.len()) { - return Ok(Some(CultMeshRudpApplicationRejection { - session: key, - operation: CultMeshRudpApplicationOperation::SnapshotRequest, - message_id, - reason: "retained payload budget is full".into(), - })); - } - let payload_bytes = payload.len(); - let entry = self - .sessions - .get_mut(&key) - .ok_or_else(|| anyhow!("CultMesh RUDP session disappeared before response"))?; - let packets = match entry.session.send_many( - "schema", - payload, - CultNetRudpSendOptions { - reliable: true, - ordered: true, - sequenced: false, - now_ms: now, - reliable_expire_after_ms: None, - }, - Some(self.options.max_fragment_bytes), - ) { - Ok(packets) => packets, - Err(error) => { - return Ok(Some(CultMeshRudpApplicationRejection { - session: key, - operation: CultMeshRudpApplicationOperation::SnapshotRequest, - message_id, - reason: format!("snapshot response could not be queued: {error:#}"), - })); - } - }; - entry.admitted_payload_bytes = - entry.admitted_payload_bytes.saturating_add(payload_bytes); - for packet in &packets { - self.send_packet(key.remote_addr, packet)?; - } - } - _ => {} - } - Ok(None) - } - - fn payload_budget_allows(&self, key: CultMeshRudpSessionKey, bytes: usize) -> bool { - let Some(session_bytes) = self - .sessions - .get(&key) - .map(|entry| entry.admitted_payload_bytes) - else { - return false; - }; - if session_bytes - .checked_add(bytes) - .is_none_or(|total| total > self.options.max_admitted_payload_bytes_per_session) - { - return false; - } - self.sessions - .values() - .try_fold(0_usize, |total, entry| { - total.checked_add(entry.admitted_payload_bytes) - }) - .and_then(|total| total.checked_add(bytes)) - .is_some_and(|total| total <= self.options.max_admitted_payload_bytes) - } - - fn send_packet(&mut self, remote_addr: SocketAddr, packet: &CultNetRudpPacket) -> Result<()> { - let wire = encode_rudp_packet(packet)?; - self.socket.send_to(&wire, remote_addr)?; - Ok(()) - } -} - -fn validate_options(options: &CultMeshRudpDocumentServerOptions) -> Result<()> { - if options.max_sessions == 0 { - return Err(anyhow!("max_sessions must be greater than zero")); - } - if options.session_idle_timeout.is_zero() { - return Err(anyhow!("session_idle_timeout must be greater than zero")); - } - if options.session_max_lifetime.is_zero() { - return Err(anyhow!("session_max_lifetime must be greater than zero")); - } - if options.max_admitted_payload_bytes == 0 { - return Err(anyhow!( - "max_admitted_payload_bytes must be greater than zero" - )); - } - if options.max_admitted_payload_bytes_per_session == 0 - || options.max_admitted_payload_bytes_per_session > options.max_admitted_payload_bytes - { - return Err(anyhow!( - "per-session admitted payload limit must be non-zero and no larger than the server limit" - )); - } - if options.max_snapshot_response_bytes == 0 - || options.max_snapshot_response_bytes > options.max_admitted_payload_bytes_per_session - { - return Err(anyhow!( - "snapshot response limit must be non-zero and fit in one session's payload budget" - )); - } - if options.max_snapshot_documents == 0 { - return Err(anyhow!("max_snapshot_documents must be greater than zero")); - } - if options.resend_delay.is_zero() { - return Err(anyhow!("resend_delay must be greater than zero")); - } - if options.max_pending_reliable_packets_per_session == 0 { - return Err(anyhow!( - "max_pending_reliable_packets_per_session must be greater than zero" - )); - } - if options.max_fragment_bytes == 0 { - return Err(anyhow!("max_fragment_bytes must be greater than zero")); - } - Ok(()) -} - -fn duration_millis(duration: Duration) -> u64 { - duration.as_millis().try_into().unwrap_or(u64::MAX) -} diff --git a/packages/cultmesh-rs/tests/rudp_document_server.rs b/packages/cultmesh-rs/tests/rudp_document_server.rs deleted file mode 100644 index df14b4ea..00000000 --- a/packages/cultmesh-rs/tests/rudp_document_server.rs +++ /dev/null @@ -1,555 +0,0 @@ -use anyhow::Result; -use cultmesh_rs::{ - CultMeshRudpApplicationOperation, CultMeshRudpDocumentServer, - CultMeshRudpDocumentServerOptions, CultMeshRudpPollOutcome, CultMeshRudpRawDocumentReceipt, - CultMeshRudpRawDocumentSink, CultMeshRudpServerClock, CultMeshRudpSnapshotQuery, - CultMeshRudpSnapshotSource, -}; -use cultnet_rs::{ - CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, - CultNetRudpReliableSendReceipt, CultNetRudpReliableSendStatus, CultNetRudpSocketMode, - CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions, CultNetWireContract, - decode_cultnet_message_from_slice, encode_cultnet_message_to_vec, -}; -use pretty_assertions::assert_eq; -use std::net::{SocketAddr, UdpSocket}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; -use std::thread; -use std::time::Duration; - -#[derive(Default)] -struct SinkState { - receipts: Vec, - failures_remaining: usize, -} - -#[derive(Clone, Default)] -struct Sink(Arc>); - -impl Sink { - fn fail_once() -> Self { - Self(Arc::new(Mutex::new(SinkState { - failures_remaining: 1, - ..Default::default() - }))) - } -} - -impl CultMeshRudpRawDocumentSink for Sink { - fn accept_raw_document(&mut self, receipt: CultMeshRudpRawDocumentReceipt) -> Result<()> { - let mut state = self.0.lock().unwrap(); - if state.failures_remaining > 0 { - state.failures_remaining -= 1; - anyhow::bail!("injected sink failure"); - } - state.receipts.push(receipt); - Ok(()) - } -} - -#[derive(Default)] -struct SourceState { - documents: Vec, - failures_remaining: usize, - calls: usize, -} - -#[derive(Clone, Default)] -struct Source(Arc>); - -impl Source { - fn documents(documents: Vec) -> Self { - Self(Arc::new(Mutex::new(SourceState { - documents, - ..Default::default() - }))) - } - - fn fail_once(documents: Vec) -> Self { - Self(Arc::new(Mutex::new(SourceState { - documents, - failures_remaining: 1, - calls: 0, - }))) - } -} - -impl CultMeshRudpSnapshotSource for Source { - fn raw_snapshot( - &mut self, - _: &CultMeshRudpSnapshotQuery, - ) -> Result> { - let mut state = self.0.lock().unwrap(); - state.calls += 1; - if state.failures_remaining > 0 { - state.failures_remaining -= 1; - anyhow::bail!("injected source failure"); - } - Ok(state.documents.clone()) - } -} - -#[derive(Clone)] -struct Clock { - unix: Arc, - monotonic: Arc, -} - -impl Clock { - fn new(now: u64) -> Self { - Self { - unix: Arc::new(AtomicU64::new(now)), - monotonic: Arc::new(AtomicU64::new(now)), - } - } - - fn set(&self, now: u64) { - self.unix.store(now, Ordering::SeqCst); - self.monotonic.store(now, Ordering::SeqCst); - } - - fn set_unix(&self, now: u64) { - self.unix.store(now, Ordering::SeqCst); - } - - fn set_monotonic(&self, now: u64) { - self.monotonic.store(now, Ordering::SeqCst); - } -} - -impl CultMeshRudpServerClock for Clock { - fn now_unix_millis(&self) -> u64 { - self.unix.load(Ordering::SeqCst) - } - - fn now_monotonic_millis(&self) -> u64 { - self.monotonic.load(Ordering::SeqCst) - } -} - -type Server = CultMeshRudpDocumentServer; - -fn document(key: &str, payload: Vec) -> 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, - source_runtime_id: Some(format!("runtime-{key}")), - source_agent_id: None, - source_role: None, - tags: None, - } -} - -fn server( - options: CultMeshRudpDocumentServerOptions, - clock: Clock, - sink: Sink, - source: Source, -) -> Result { - CultMeshRudpDocumentServer::new( - UdpSocket::bind("127.0.0.1:0")?, - sink, - source, - clock, - options, - ) -} - -fn client(target: SocketAddr, id: u32) -> Result { - let socket = UdpSocket::bind("127.0.0.1:0")?; - socket.set_nonblocking(true)?; - CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { - media_delivery: None, - runtime_id: "test-client".into(), - socket, - mode: CultNetRudpSocketMode::Client, - remote_addr: Some(target), - connection_id: id, - initial_sequence: 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, - }) -} - -fn connect( - server: &mut Server, - clients: &mut [&mut CultNetRudpSocketTransportConnection], -) -> Result<()> { - for client in &mut *clients { - client.connect(Vec::new())?; - } - for _ in 0..500 { - server.poll_once()?; - for client in &mut *clients { - client.receive_once()?; - } - if clients.iter().all(|client| client.connected()) { - for _ in 0..clients.len() { - server.poll_once()?; - } - return Ok(()); - } - thread::sleep(Duration::from_millis(1)); - } - anyhow::bail!("client connection timed out") -} - -fn send(client: &mut CultNetRudpSocketTransportConnection, message: &CultNetMessage) -> Result<()> { - client.send( - "schema", - encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?, - ) -} - -fn send_reliable( - client: &mut CultNetRudpSocketTransportConnection, - message: &CultNetMessage, -) -> Result { - client.send_reliable( - "schema", - encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?, - ) -} - -#[test] -fn same_connection_id_is_peer_scoped_and_raw_bytes_are_untouched() -> Result<()> { - let clock = Clock::new(42_000); - let sink = Sink::default(); - let mut server = server(Default::default(), clock, sink.clone(), Source::default())?; - let mut a = client(server.local_addr()?, 77)?; - let mut b = client(server.local_addr()?, 77)?; - connect(&mut server, &mut [&mut a, &mut b])?; - - let a_bytes = vec![0xc4, 0x04, 0x00, 0xff, 0x80, 0x01]; - let b_bytes = vec![0x92, 0x01, 0xc4, 0x02, 0x00, 0xff]; - send( - &mut a, - &CultNetMessage::DocumentPutRaw { - message_id: "a".into(), - document: document("a", a_bytes.clone()), - }, - )?; - send( - &mut b, - &CultNetMessage::DocumentPutRaw { - message_id: "b".into(), - document: document("b", b_bytes.clone()), - }, - )?; - for _ in 0..500 { - server.poll_once()?; - a.receive_once()?; - b.receive_once()?; - if sink.0.lock().unwrap().receipts.len() == 2 { - break; - } - } - - let receipts = sink.0.lock().unwrap(); - assert_eq!(server.session_count(), 2); - assert_eq!(receipts.receipts.len(), 2); - assert_ne!( - receipts.receipts[0].session.remote_addr, - receipts.receipts[1].session.remote_addr - ); - assert_eq!( - receipts - .receipts - .iter() - .find(|r| r.message_id == "a") - .unwrap() - .document - .payload, - a_bytes - ); - assert_eq!( - receipts - .receipts - .iter() - .find(|r| r.message_id == "b") - .unwrap() - .document - .payload, - b_bytes - ); - assert!( - receipts - .receipts - .iter() - .all(|r| r.received_at_unix_millis == 42_000) - ); - Ok(()) -} - -#[test] -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())?; - let target = server.local_addr()?; - let mut incumbent = client(target, 91)?; - connect(&mut server, &mut [&mut incumbent])?; - - incumbent.connect(Vec::new())?; - server.poll_once()?; - incumbent.receive_once()?; - assert_eq!(server.session_count(), 1); - - send( - &mut incumbent, - &CultNetMessage::DocumentPutRaw { - message_id: "after-duplicate".into(), - document: document("incumbent", vec![1, 2, 3]), - }, - )?; - 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); - - let mut fresh_epoch = client(target, 92)?; - connect(&mut server, &mut [&mut fresh_epoch])?; - assert_eq!(server.session_count(), 2); - Ok(()) -} - -#[test] -fn application_rejection_is_nonfatal_peer_scoped_and_unacknowledged() -> Result<()> { - let clock = Clock::new(48_000); - let sink = Sink::fail_once(); - let source = Source::fail_once(vec![document("snapshot", vec![4, 5, 6])]); - let mut server = server( - Default::default(), - clock.clone(), - sink.clone(), - source.clone(), - )?; - let target = server.local_addr()?; - - let mut publisher = client(target, 101)?; - let mut snapshot_client = client(target, 102)?; - let mut survivor = client(target, 103)?; - connect( - &mut server, - &mut [&mut publisher, &mut snapshot_client, &mut survivor], - )?; - let publish_receipt = send_reliable( - &mut publisher, - &CultNetMessage::DocumentPutRaw { - message_id: "rejected-put".into(), - document: document("rejected", vec![0, 255, 1]), - }, - )?; - let CultMeshRudpPollOutcome::ApplicationRejected(rejection) = server.poll_once()? else { - panic!("sink rejection must be returned as a nonfatal poll outcome"); - }; - assert_eq!(rejection.session.connection_id, 101); - assert_eq!( - rejection.operation, - CultMeshRudpApplicationOperation::DocumentPutRaw - ); - assert_eq!(rejection.message_id, "rejected-put"); - assert!(rejection.reason.contains("injected sink failure")); - assert_eq!(server.session_count(), 2); - publisher.receive_once()?; - assert_eq!( - publisher.reliable_send_status(&publish_receipt), - CultNetRudpReliableSendStatus::Pending - ); - - let snapshot_receipt = send_reliable( - &mut snapshot_client, - &CultNetMessage::SnapshotRequest { - message_id: "rejected-snapshot".into(), - schema_ids: None, - record_keys: None, - }, - )?; - let CultMeshRudpPollOutcome::ApplicationRejected(rejection) = server.poll_once()? else { - panic!("source rejection must be returned as a nonfatal poll outcome"); - }; - assert_eq!(rejection.session.connection_id, 102); - assert_eq!( - rejection.operation, - CultMeshRudpApplicationOperation::SnapshotRequest - ); - assert_eq!(rejection.message_id, "rejected-snapshot"); - assert!(rejection.reason.contains("injected source failure")); - assert_eq!(server.session_count(), 1); - snapshot_client.receive_once()?; - assert_eq!( - snapshot_client.reliable_send_status(&snapshot_receipt), - CultNetRudpReliableSendStatus::Pending - ); - - let survivor_receipt = send_reliable( - &mut survivor, - &CultNetMessage::DocumentPutRaw { - message_id: "accepted-after-rejections".into(), - document: document("survivor", vec![9, 8, 7]), - }, - )?; - assert_eq!(server.poll_once()?, CultMeshRudpPollOutcome::Handled); - for _ in 0..20 { - survivor.receive_once()?; - if survivor.reliable_send_status(&survivor_receipt) - == CultNetRudpReliableSendStatus::Acknowledged - { - break; - } - } - assert_eq!( - survivor.reliable_send_status(&survivor_receipt), - CultNetRudpReliableSendStatus::Acknowledged - ); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 1); - assert_eq!(source.0.lock().unwrap().calls, 1); - Ok(()) -} - -#[test] -fn snapshot_response_resends_until_acknowledged() -> Result<()> { - let clock = Clock::new(51_000); - let expected = document("catalog", vec![0x81, 0xa1, 0x78, 0x2a]); - let source = Source::documents(vec![expected.clone()]); - let options = CultMeshRudpDocumentServerOptions { - resend_delay: Duration::from_millis(10), - ..Default::default() - }; - let mut server = server(options, clock.clone(), Sink::default(), source)?; - let mut client = client(server.local_addr()?, 88)?; - connect(&mut server, &mut [&mut client])?; - send( - &mut client, - &CultNetMessage::SnapshotRequest { - message_id: "snapshot".into(), - schema_ids: Some(vec!["test.raw.v1".into()]), - record_keys: Some(vec!["catalog".into()]), - }, - )?; - server.poll_once()?; - - clock.set(51_011); - assert_eq!(server.maintain()?.packets_resent, 1); - let response = loop { - if let Some(frame) = client.receive_once()? { - break decode_cultnet_message_from_slice( - &frame.payload, - CultNetWireContract::CultNetSchemaV0, - )?; - } - }; - assert_eq!( - response, - CultNetMessage::SnapshotResponseRaw { - message_id: "snapshot".into(), - documents: vec![expected], - } - ); - - for _ in 0..20 { - server.poll_once()?; - } - clock.set(51_022); - assert_eq!(server.maintain()?.packets_resent, 0); - Ok(()) -} - -#[test] -fn payload_and_snapshot_output_budgets_fail_closed() -> Result<()> { - let clock = Clock::new(55_000); - let sink = Sink::default(); - let message = CultNetMessage::DocumentPutRaw { - message_id: "budget".into(), - document: document("budget", vec![7; 64]), - }; - let encoded = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0)?; - let options = CultMeshRudpDocumentServerOptions { - max_admitted_payload_bytes: encoded.len(), - max_admitted_payload_bytes_per_session: encoded.len(), - max_snapshot_response_bytes: encoded.len(), - ..Default::default() - }; - let mut budget_server = server(options, clock, sink.clone(), Source::default())?; - let target = budget_server.local_addr()?; - let mut first = client(target, 111)?; - let mut second = client(target, 112)?; - connect(&mut budget_server, &mut [&mut first, &mut second])?; - send(&mut first, &message)?; - budget_server.poll_once()?; - send(&mut second, &message)?; - budget_server.poll_once()?; - assert_eq!(sink.0.lock().unwrap().receipts.len(), 1); - - let oversized = Source::documents(vec![document("oversized", vec![8; 512])]); - let options = CultMeshRudpDocumentServerOptions { - max_snapshot_response_bytes: 128, - ..Default::default() - }; - let mut snapshot_server = server(options, Clock::new(56_000), Sink::default(), oversized)?; - let mut snapshot_client = client(snapshot_server.local_addr()?, 113)?; - connect(&mut snapshot_server, &mut [&mut snapshot_client])?; - let receipt = send_reliable( - &mut snapshot_client, - &CultNetMessage::SnapshotRequest { - message_id: "too-large".into(), - schema_ids: None, - record_keys: None, - }, - )?; - let CultMeshRudpPollOutcome::ApplicationRejected(rejection) = snapshot_server.poll_once()? - else { - panic!("oversized snapshot must reject only its session"); - }; - assert_eq!( - rejection.operation, - CultMeshRudpApplicationOperation::SnapshotRequest - ); - assert_eq!(rejection.message_id, "too-large"); - assert!(rejection.reason.contains("snapshot response is")); - assert_eq!(snapshot_server.session_count(), 0); - snapshot_client.receive_once()?; - assert_eq!( - snapshot_client.reliable_send_status(&receipt), - CultNetRudpReliableSendStatus::Pending - ); - Ok(()) -} - -#[test] -fn session_cap_and_expiry_use_monotonic_time() -> Result<()> { - let clock = Clock::new(60_000); - let options = CultMeshRudpDocumentServerOptions { - max_sessions: 1, - session_idle_timeout: Duration::from_secs(1), - session_max_lifetime: Duration::from_millis(100), - ..Default::default() - }; - let mut server = server(options, clock.clone(), Sink::default(), Source::default())?; - let target = server.local_addr()?; - let mut admitted = client(target, 1)?; - connect(&mut server, &mut [&mut admitted])?; - let mut rejected = client(target, 1)?; - rejected.connect(Vec::new())?; - server.poll_once()?; - assert_eq!(server.session_count(), 1); - assert!(!rejected.connected()); - - clock.set_unix(1); - clock.set_monotonic(60_101); - assert_eq!(server.maintain()?.sessions_expired, 1); - assert_eq!(server.session_count(), 0); - Ok(()) -} diff --git a/packages/cultmesh-ts/README.md b/packages/cultmesh-ts/README.md index d3fe565b..279e271e 100644 --- a/packages/cultmesh-ts/README.md +++ b/packages/cultmesh-ts/README.md @@ -99,13 +99,11 @@ const station = await CultMesh.startNode(statePath, { const stock = station.document(stationStockUiDocument, "station:starbridge:stock"); const current = await stock.latest(); -await stock.authoritativeWriter().write(updatedStockFromUi); +await stock.set(updatedStockFromUi); -const reactive = stock.authoritativeWriter().reactive(); +const reactive = station.reactiveDocument(stationStockUiDocument, "station:starbridge:stock"); await reactive.ready; -reactive.update(draft => { - draft.availableMissiles -= 4; -}); +reactive.current.availableMissiles -= 4; ``` State pointers are the same kind of managed surface for UI and tools. They can @@ -264,71 +262,6 @@ Schema and shard catalog factories delegate to `cultnet-ts`; the branded `CultMesh` entrypoint does not create a second owner for schema discovery or topology truth. -## Long-Lived Providers - -TypeScript provider daemons use `CultMeshProviderSession` to retain one logical -provider lifecycle across physical reconnects. The session owns registration, -lease renewal, desired typed publication replay, command dispatch, receipt -publication, and withdrawal. Provider code owns domain state and idempotent -command transactions; the injected transport owns CultNet wire details. - -```ts -import { - CultMeshProviderRudpTransport, - CultMeshProviderSession, -} from "cultmesh-ts"; - -const odinTransport = new CultMeshProviderRudpTransport({ - endpoint: "rudp://127.0.0.1:17871", - runtimeId: "voidbot-worker-7", - connectionId: 0x43554c54, - sessionToken: process.env.ODIN_PROVIDER_SESSION_TOKEN, -}); - -const session = new CultMeshProviderSession({ - identity: { - providerId: "voidbot.swarm", - serviceInstanceId: "voidbot-worker-7", - endpointId: "odin:voidbot-worker-7", - verseId: "voidbot.local", - }, - transport: odinTransport, - receiptStore: durableReceiptStore, - publications: [advertisementPublication, surfacePublication], - commandHandlers: { - "swarm.set_heat": applyHeatCommandTransaction, - }, -}); - -await session.start(); -await session.upsertPublication(nextSurfacePublication); -``` - -Provider, service-instance, endpoint, and body-producer identities are separate -contracts. The provider session never infers one from another. A durable -receipt store is required; `CultMeshMemoryProviderReceiptStore` is intended -only for tests and disposable tools. The store is a durable outbox: a receipt -is persisted before transport publication, remains pending across reconnects, -and is marked published only after the current connection accepts it. Receipt -store failure degrades command intake without inventing a network outage. - -The RUDP provider transport carries lifecycle payloads inside the existing -`cultnet.operation_request.v0` and `cultnet.operation_response.v0` envelopes. -RUDP acknowledgements prove byte delivery only. Registration, renewal, -publication changes, receipts, and withdrawal complete only after the -provider-session broker returns a correlated application response. The broker -owns lease fencing and accepted publication membership; the provider owns its -desired documents and durable receipt outbox. - -`CultMeshProviderRudpTransport` is currently a private-development transport. -Do not advertise it as a public provider boundary until the surrounding -CultNet session authenticates a claim authorizing the four-part provider -identity. Source address and successful contact are not authority. -The adapter carries `sessionToken` in a typed RUDP Connect-evidence map beside -a fresh `clientSessionId`. Packet retransmits reuse that id; an actual reconnect -mints another. The receiver must decode and validate the token as authorization -evidence for the requested provider identity. - ## RUDP Helpers Node runtimes can build the shared CultNet reliable-UDP transport from the @@ -420,8 +353,7 @@ Use `node.syncDocumentFromPeerSnapshot(...)` or local node from the remote snapshot. The helper requests the raw snapshot, applies it through the node's document registry, and returns the requested typed definition. Same-schema aliases stay local after the call: the caller can -keep using `node.document(aliasDefinition, key)` and explicitly select -`authoritativeWriter()`, `predictionWriter()`, or `observe()` +keep using `node.document(aliasDefinition, key)` or `node.reactiveDocument(...)` without re-threading the RUDP peer. The same typed-definition overload is available for @@ -435,7 +367,7 @@ sync came from local persistence or a remote CultNet snapshot. Use `node.syncDocumentFromPublication(...)` or `CultMesh.syncDocumentFromPublication(...)` for the same source-agnostic shape when the publication should hydrate a local node before the caller continues -through `node.document(...)` and its explicit writer or observer. +through `node.document(...)` or `node.reactiveDocument(...)`. ## Streaming Mode diff --git a/packages/cultmesh-ts/package.json b/packages/cultmesh-ts/package.json index 0ca8afb3..b233d448 100644 --- a/packages/cultmesh-ts/package.json +++ b/packages/cultmesh-ts/package.json @@ -1,24 +1,16 @@ { "name": "cultmesh-ts", - "version": "0.1.1", + "version": "0.1.0", "description": "CultMesh local typed state and mesh catalog primitives for TypeScript runtimes.", "main": "dist/index.js", "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "require": "./dist/index.js", - "default": "./dist/index.js" - } - }, "files": [ "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 --test dist-test/test/*.test.js" + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --pretty false", + "test": "npm run build && tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js" }, "keywords": [ "cultmesh", @@ -29,22 +21,10 @@ ], "author": "metacratic", "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/GameCult/CultLib.git", - "directory": "packages/cultmesh-ts" - }, - "bugs": { - "url": "https://github.com/GameCult/CultLib/issues" - }, - "homepage": "https://github.com/GameCult/CultLib/tree/main/packages/cultmesh-ts#readme", - "engines": { - "node": ">=18" - }, "dependencies": { "@msgpack/msgpack": "^3.1.2", - "@gamecult/cultcache-ts": "^0.14.0", - "cultnet-ts": "^0.2.0" + "cultcache-ts": "file:../cultcache-ts", + "cultnet-ts": "file:../cultnet-ts" }, "devDependencies": { "@types/node": "^24.0.0", diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index e9fbb8be..687f9b8a 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -8,7 +8,7 @@ import { type CacheBackingStore, type CultCacheEnvelope, type CultCacheDocumentValue, -} from "@gamecult/cultcache-ts"; +} from "cultcache-ts"; import { CultNetDocumentRegistry, CultNetRudpSession, @@ -25,8 +25,6 @@ import { type CultNetErrorMessage, type CultNetDocumentBinding, type CultNetMessage, - type CultNetOperationRequestMessage, - type CultNetOperationResponseMessage, type CultNetRawDocumentRecord, type CultNetReconnectPolicy, type CultNetSchemaCatalogOptions, @@ -34,11 +32,6 @@ import { type CultNetWireContract, } from "cultnet-ts"; -export * from "./provider-session"; -export * from "./provider-session-wire"; -export * from "./provider-rudp-transport"; -export * from "./provider-session-broker"; - export interface CultMeshVec2 { readonly x: number; readonly y: number; @@ -75,20 +68,6 @@ export interface CultMeshRouteRecord { readonly description: string; } -/** - * Consumer view of provider-session publications. The provider-session client - * owns ordering, replay, and reconnect; document handles only project that - * live publication state into typed latest/watch semantics. - */ -export interface CultMeshLivePublicationSource { - latest(schemaId: string, recordKey: string): Promise; - watch( - schemaId: string, - recordKey: string, - callback: (value: unknown) => void, - ): CultMeshUnsubscribe; -} - export type CultMeshDocumentPublicationSource = | { readonly kind: "single-file"; @@ -102,11 +81,6 @@ export type CultMeshDocumentPublicationSource = readonly kind: "peer-snapshot"; readonly peer: CultNetPeer | (() => CultNetPeer | Promise); readonly endpoint?: string; - } - | { - readonly kind: "live-publication"; - readonly source: CultMeshLivePublicationSource; - readonly endpoint?: string; }; export interface CultMeshPublicationDocumentBinding { @@ -232,7 +206,6 @@ export interface CultMeshReactiveDocumentOptions { readonly context?: CultMeshQueryContext | string; readonly watch?: boolean; readonly debounceMs?: number; - readonly replaceDirtyCurrentOnCanonicalSnapshot?: boolean; readonly onError?: (error: unknown) => void; } @@ -249,30 +222,15 @@ export interface CultMeshReactiveDocumentReconciliation { - readonly writer: CultMeshDocumentWriter; - readonly snapshot: TDocument; - readonly dirty: boolean; + readonly current: TDocument; readonly reconciliation: CultMeshReactiveDocumentReconciliation | undefined; readonly ready: Promise; readonly disposed: boolean; - update(update: (draft: TDocument) => void | TDocument): TDocument; - replaceLocal(value: TDocument): TDocument; - flush(): Promise; clearReconciliation(): void; refresh(): Promise; dispose(): void; } -export interface CultMeshObservedDocument { - readonly current: TDocument; - readonly ready: Promise; - readonly disposed: boolean; - refresh(): Promise; - dispose(): void; -} - -export type CultMeshDocumentWriteKind = "authoritative-replacement" | "prediction"; - export interface CultMeshNativeSliceColumn { readonly name: string; readonly valueType: string; @@ -300,13 +258,6 @@ export interface CultMeshStateBindingDescriptor { readonly sourceId?: string; readonly schemaId?: string; readonly routeHint: CultMeshRouteHint; - readonly bindingName?: string; - readonly documentId?: string; - readonly fieldPath?: string; - readonly valueKind?: "string" | "number" | "boolean" | "choice" | "string-list"; - readonly accessMode?: "read" | "write" | "read-write" | "local-draft"; - readonly authority?: string; - readonly writeCommand?: string; } export interface CultMeshStateBindingRecord { @@ -316,13 +267,6 @@ export interface CultMeshStateBindingRecord { readonly schemaId: string; readonly routeKind: string; readonly routeDescription: string; - readonly bindingName: string; - readonly documentId: string; - readonly fieldPath: string; - readonly valueKind: string; - readonly accessMode: string; - readonly authority: string; - readonly writeCommand: string; } export interface CultMeshOperationBindingDescriptor { @@ -872,6 +816,10 @@ export class CultMeshDocumentHandle { return this.submitPredictionDocument !== undefined; } + public get canSet(): boolean { + return this.canSubmitPrediction || this.canReplace; + } + public latest(context: CultMeshQueryContext | string = "local"): Promise { return this.snapshotDocument( this.resolveContext(typeof context === "string" ? cultMeshQueryContext(context) : context), @@ -958,44 +906,63 @@ export class CultMeshDocumentHandle { return this.submitPredictionDocument(this.resolveContext(context), value); } - public authoritativeWriter( - context: CultMeshQueryContext | string = "local", - ): CultMeshDocumentWriter { - if (!this.replaceDocument) { - throw new Error(`Document '${this.documentId}' does not support authoritative replacement.`); + public set(value: TDocument): Promise; + public set(context: CultMeshQueryContext | string, value: TDocument): Promise; + public set( + contextOrValue: CultMeshQueryContext | string | TDocument, + maybeValue?: TDocument, + ): Promise { + const hasContext = + typeof contextOrValue === "string" || isCultMeshQueryContext(contextOrValue); + const context = hasContext + ? typeof contextOrValue === "string" + ? cultMeshQueryContext(contextOrValue) + : contextOrValue as CultMeshQueryContext + : cultMeshQueryContext("local"); + const value = hasContext ? maybeValue : contextOrValue as TDocument; + if (value === undefined) { + throw new Error(`Document '${this.documentId}' requires a value.`); } - const resolved = this.resolveContext( - typeof context === "string" ? cultMeshQueryContext(context) : context, - ); - return new CultMeshDocumentWriter( - this as unknown as CultMeshDocumentHandle, - "authoritative-replacement", - resolved, - value => this.replaceDocument!(resolved, value as unknown as TDocument), - ); + + const resolved = this.resolveContext(context); + if (this.submitPredictionDocument) { + return this.submitPredictionDocument(resolved, value); + } + if (this.replaceDocument) { + return this.replaceDocument(resolved, value); + } + + throw new Error(`Document '${this.documentId}' does not support mutation.`); } - public predictionWriter( - context: CultMeshQueryContext | string = "local", - ): CultMeshDocumentWriter { - if (!this.submitPredictionDocument) { - throw new Error(`Document '${this.documentId}' does not support prediction submission.`); + public update(update: CultMeshDocumentUpdater): Promise; + public update( + context: CultMeshQueryContext | string, + update: CultMeshDocumentUpdater, + ): Promise; + public async update( + contextOrUpdate: CultMeshQueryContext | string | CultMeshDocumentUpdater, + maybeUpdate?: CultMeshDocumentUpdater, + ): Promise { + const hasContext = + typeof contextOrUpdate === "string" || isCultMeshQueryContext(contextOrUpdate); + const context = hasContext + ? contextOrUpdate as CultMeshQueryContext | string + : "local"; + const update = hasContext ? maybeUpdate : contextOrUpdate as CultMeshDocumentUpdater; + if (!update) { + throw new Error(`Document '${this.documentId}' requires an update function.`); } - const resolved = this.resolveContext( - typeof context === "string" ? cultMeshQueryContext(context) : context, - ); - return new CultMeshDocumentWriter( - this as unknown as CultMeshDocumentHandle, - "prediction", - resolved, - value => this.submitPredictionDocument!(resolved, value as unknown as TDocument), - ); + + const next = await update(await this.latest(context)); + await this.set(context, next); + return next; } - public observe( + public reactive( options: CultMeshReactiveDocumentOptions = {}, - ): CultMeshObservedDocument { - return new CultMeshObservedDocumentHandle( + ): CultMeshReactiveDocument { + return new CultMeshReactiveDocumentHandle( this as unknown as CultMeshDocumentHandle, options, ); @@ -1048,40 +1015,22 @@ export class CultMeshDocumentHandle { } } -export class CultMeshDocumentWriter { - public constructor( - public readonly document: CultMeshDocumentHandle, - public readonly kind: CultMeshDocumentWriteKind, - public readonly context: CultMeshQueryContext, - private readonly writeDocument: (value: TDocument) => Promise, - ) {} - - public write(value: TDocument): Promise { - return this.writeDocument(cloneCultMeshDocument(value)); - } - - public async update(update: CultMeshDocumentUpdater): Promise { - const next = await update(cloneCultMeshDocument(await this.document.latest(this.context))); - await this.write(next); - return next; - } - - public reactive( - options: Omit = {}, - ): CultMeshReactiveDocument { - return new CultMeshReactiveDocumentHandle(this, options); - } -} - -class CultMeshObservedDocumentHandle - implements CultMeshObservedDocument { +class CultMeshReactiveDocumentHandle + implements CultMeshReactiveDocument { readonly #document: CultMeshDocumentHandle; readonly #context: CultMeshQueryContext | string; readonly #onError: (error: unknown) => void; - #canonicalVersion = 0; - #current = {} as TDocument; + readonly #debounceMs: number; + #current: TDocument; #disposed = false; + #applyingRemote = false; + #pendingSet: Promise = Promise.resolve(); + #flushScheduled = false; + #flushTimer: ReturnType | undefined; #unsubscribe: CultMeshUnsubscribe | undefined; + #lastPredicted: TDocument | undefined; + #reconciliation: CultMeshReactiveDocumentReconciliation | undefined; + #reconciliationVersion = 0; public readonly ready: Promise; @@ -1091,98 +1040,18 @@ class CultMeshObservedDocumentHandle ) { this.#document = document; this.#context = options.context ?? "local"; + this.#debounceMs = options.debounceMs ?? 0; this.#onError = options.onError ?? ((error) => { queueMicrotask(() => { throw error; }); }); + this.#current = this.createProxy({} as TDocument); this.ready = this.refresh(); + if (options.watch !== false) { try { this.#unsubscribe = document.watch(this.#context, value => { - this.#current = cloneCultMeshDocument(value); - this.#canonicalVersion++; - }); - } catch (error) { - this.#onError(error); - } - } - } - - public get current(): TDocument { - this.throwIfDisposed(); - return this.#current; - } - - public get disposed(): boolean { - return this.#disposed; - } - - public async refresh(): Promise { - this.throwIfDisposed(); - const versionBeforeRead = this.#canonicalVersion; - const latest = cloneCultMeshDocument(await this.#document.latest(this.#context)); - if (versionBeforeRead === this.#canonicalVersion) { - this.#current = latest; - this.#canonicalVersion++; - } - return this.#current; - } - - public dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - this.#unsubscribe?.(); - this.#unsubscribe = undefined; - } - - private throwIfDisposed(): void { - if (this.#disposed) { - throw new Error(`Observed document '${this.#document.documentId}' is disposed.`); - } - } -} - -class CultMeshReactiveDocumentHandle - implements CultMeshReactiveDocument { - readonly #writer: CultMeshDocumentWriter; - readonly #document: CultMeshDocumentHandle; - readonly #onError: (error: unknown) => void; - readonly #debounceMs: number; - readonly #replaceDirtyCurrentOnCanonicalSnapshot: boolean; - #current = {} as TDocument; - #disposed = false; - #pendingSet: Promise = Promise.resolve(); - #flushScheduled = false; - #cancelScheduledFlush: (() => void) | undefined; - #unsubscribe: CultMeshUnsubscribe | undefined; - #dirty = false; - #lastPredicted: TDocument | undefined; - #reconciliation: CultMeshReactiveDocumentReconciliation | undefined; - #reconciliationVersion = 0; - #canonicalVersion = 0; - - public readonly ready: Promise; - - public constructor( - writer: CultMeshDocumentWriter, - options: Omit = {}, - ) { - this.#writer = writer; - this.#document = writer.document; - this.#debounceMs = options.debounceMs ?? 0; - this.#replaceDirtyCurrentOnCanonicalSnapshot = - options.replaceDirtyCurrentOnCanonicalSnapshot ?? false; - this.#onError = options.onError ?? ((error) => { - queueMicrotask(() => { - throw error; - }); - }); - this.ready = this.refresh(); - - if (options.watch !== false) { - try { - this.#unsubscribe = this.#document.watch(this.#writer.context, value => { this.applySnapshot(value); }); } catch (error) { @@ -1191,17 +1060,8 @@ class CultMeshReactiveDocumentHandle } } - public get writer(): CultMeshDocumentWriter { - return this.#writer; - } - - public get snapshot(): TDocument { - this.throwIfDisposed(); - return cloneCultMeshDocument(this.#current); - } - - public get dirty(): boolean { - return this.#dirty; + public get current(): TDocument { + return this.#current; } public get reconciliation(): CultMeshReactiveDocumentReconciliation | undefined { @@ -1213,37 +1073,9 @@ class CultMeshReactiveDocumentHandle } public async refresh(): Promise { - this.throwIfDisposed(); - const versionBeforeRead = this.#canonicalVersion; - const latest = await this.#document.latest(this.#writer.context); - if (versionBeforeRead === this.#canonicalVersion) { - this.applySnapshot(latest); - } - return this.snapshot; - } - - public update(update: (draft: TDocument) => void | TDocument): TDocument { - this.throwIfDisposed(); - const draft = cloneCultMeshDocument(this.#current); - const returned = update(draft); - this.#current = cloneCultMeshDocument(returned ?? draft); - this.#dirty = true; - this.queueSet(); - return this.snapshot; - } - - public replaceLocal(value: TDocument): TDocument { - this.throwIfDisposed(); - this.#current = cloneCultMeshDocument(value); - this.#dirty = true; - this.queueSet(); - return this.snapshot; - } - - public async flush(): Promise { - this.throwIfDisposed(); - this.flushNow(); - await this.#pendingSet; + const latest = await this.#document.latest(this.#context); + this.applySnapshot(latest); + return this.#current; } public clearReconciliation(): void { @@ -1252,8 +1084,10 @@ class CultMeshReactiveDocumentHandle public dispose(): void { this.#disposed = true; - this.#cancelScheduledFlush?.(); - this.#cancelScheduledFlush = undefined; + if (this.#flushTimer !== undefined) { + clearTimeout(this.#flushTimer); + this.#flushTimer = undefined; + } this.#flushScheduled = false; this.#unsubscribe?.(); this.#unsubscribe = undefined; @@ -1261,13 +1095,18 @@ class CultMeshReactiveDocumentHandle private applySnapshot(value: TDocument): void { const canonical = cloneCultMeshDocument(value); - this.#canonicalVersion++; this.captureReconciliation(canonical); - if (this.#dirty && !this.#replaceDirtyCurrentOnCanonicalSnapshot) { - return; + this.#applyingRemote = true; + try { + for (const key of Reflect.ownKeys(this.#current)) { + if (!Reflect.has(canonical, key)) { + Reflect.deleteProperty(this.#current, key); + } + } + Object.assign(this.#current, canonical); + } finally { + this.#applyingRemote = false; } - this.#current = canonical; - this.#dirty = false; } private captureReconciliation(canonical: TDocument): void { @@ -1293,6 +1132,27 @@ class CultMeshReactiveDocumentHandle this.#lastPredicted = undefined; } + private createProxy(seed: TDocument): TDocument { + return new Proxy(seed, { + set: (target, property, value) => { + const changed = !Object.is(Reflect.get(target, property), value); + const ok = Reflect.set(target, property, value); + if (ok && changed && !this.#applyingRemote) { + this.queueSet(); + } + return ok; + }, + deleteProperty: (target, property) => { + const hadProperty = Reflect.has(target, property); + const ok = Reflect.deleteProperty(target, property); + if (ok && hadProperty && !this.#applyingRemote) { + this.queueSet(); + } + return ok; + }, + }); + } + private queueSet(): void { if (this.#disposed) { return; @@ -1302,56 +1162,36 @@ class CultMeshReactiveDocumentHandle } this.#flushScheduled = true; - const flush = () => this.flushNow(); + const flush = () => { + this.#flushScheduled = false; + this.#flushTimer = undefined; + if (this.#disposed) { + return; + } + + const snapshot = cloneCultMeshDocument(this.#current); + this.#lastPredicted = snapshot; + this.#pendingSet = this.#pendingSet + .catch(() => undefined) + .then(() => this.#document.set(this.#context, snapshot)) + .catch(error => this.#onError(error)); + }; const debounceMs = this.#debounceMs; if (debounceMs > 0) { - const timer = setTimeout(flush, debounceMs); - this.#cancelScheduledFlush = () => clearTimeout(timer); + this.#flushTimer = setTimeout(flush, debounceMs); return; } const requestFrame = (globalThis as { requestAnimationFrame?: (callback: FrameRequestCallback) => number; - cancelAnimationFrame?: (handle: number) => void; }).requestAnimationFrame; if (typeof requestFrame === "function") { - const frame = requestFrame(() => flush()); - const cancelFrame = (globalThis as { - cancelAnimationFrame?: (handle: number) => void; - }).cancelAnimationFrame; - this.#cancelScheduledFlush = typeof cancelFrame === "function" - ? () => cancelFrame(frame) - : undefined; + requestFrame(() => flush()); return; } - let cancelled = false; - queueMicrotask(() => { - if (!cancelled) flush(); - }); - this.#cancelScheduledFlush = () => { cancelled = true; }; - } - - private flushNow(): void { - if (!this.#flushScheduled && !this.#dirty) return; - this.#flushScheduled = false; - this.#cancelScheduledFlush?.(); - this.#cancelScheduledFlush = undefined; - if (this.#disposed || !this.#dirty) return; - const snapshot = cloneCultMeshDocument(this.#current); - this.#dirty = false; - this.#lastPredicted = snapshot; - this.#pendingSet = this.#pendingSet - .catch(() => undefined) - .then(() => this.#writer.write(snapshot)) - .catch(error => this.#onError(error)); - } - - private throwIfDisposed(): void { - if (this.#disposed) { - throw new Error(`Reactive document '${this.#document.documentId}' is disposed.`); - } + this.#flushTimer = setTimeout(flush, 0); } } @@ -1429,6 +1269,10 @@ export class CultMeshBoundDocumentHandle { return this.document.canSubmitPrediction; } + public get canSet(): boolean { + return this.document.canSet; + } + public latest(): Promise { return this.document.latest(cultMeshQueryContextFromVerse(this.verse)); } @@ -1449,18 +1293,18 @@ export class CultMeshBoundDocumentHandle { return this.document.submitPrediction(cultMeshQueryContextFromVerse(this.verse), value); } - public authoritativeWriter(): CultMeshDocumentWriter { - return this.document.authoritativeWriter(cultMeshQueryContextFromVerse(this.verse)); + public set(value: TDocument): Promise { + return this.document.set(cultMeshQueryContextFromVerse(this.verse), value); } - public predictionWriter(): CultMeshDocumentWriter { - return this.document.predictionWriter(cultMeshQueryContextFromVerse(this.verse)); + public update(update: CultMeshDocumentUpdater): Promise { + return this.document.update(cultMeshQueryContextFromVerse(this.verse), update); } - public observe( + public reactive( options: Omit = {}, - ): CultMeshObservedDocument { - return this.document.observe({ + ): CultMeshReactiveDocument { + return this.document.reactive({ ...options, context: cultMeshQueryContextFromVerse(this.verse), }); @@ -1581,6 +1425,10 @@ export class CultMeshDocumentCatalog { return this.tryDocument(schema)?.canSubmitPrediction ?? false; } + public canSet(schema: CultMeshDocumentSchemaDescriptor): boolean { + return this.tryDocument(schema)?.canSet ?? false; + } + public submitPrediction( schema: CultMeshDocumentSchemaDescriptor, value: TDocument, @@ -1608,18 +1456,60 @@ export class CultMeshDocumentCatalog { return this.document(schema, options).submitPrediction(context, value); } - public authoritativeWriter( + public set( schema: CultMeshDocumentSchemaDescriptor, - options: { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string } = {}, - ): CultMeshDocumentWriter { - return this.document(schema, options).authoritativeWriter(options.context ?? "local"); + value: TDocument, + options?: { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string }, + ): Promise; + public set( + schema: CultMeshDocumentSchemaDescriptor, + context: CultMeshQueryContext | string, + value: TDocument, + options?: { parse?: (value: unknown) => TDocument }, + ): Promise; + public set( + schema: CultMeshDocumentSchemaDescriptor, + contextOrValue: CultMeshQueryContext | string | TDocument, + valueOrOptions?: TDocument | { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string }, + maybeOptions: { parse?: (value: unknown) => TDocument } = {}, + ): Promise { + const hasContext = + typeof contextOrValue === "string" || isCultMeshQueryContext(contextOrValue); + const context = hasContext + ? contextOrValue as CultMeshQueryContext | string + : (valueOrOptions as { context?: CultMeshQueryContext | string } | undefined)?.context ?? "local"; + const value = hasContext ? valueOrOptions as TDocument : contextOrValue as TDocument; + const options = hasContext ? maybeOptions : valueOrOptions as { parse?: (value: unknown) => TDocument } | undefined; + return this.document(schema, options).set(context, value); } - public predictionWriter( + public update( schema: CultMeshDocumentSchemaDescriptor, - options: { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string } = {}, - ): CultMeshDocumentWriter { - return this.document(schema, options).predictionWriter(options.context ?? "local"); + update: CultMeshDocumentUpdater, + options?: { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string }, + ): Promise; + public update( + schema: CultMeshDocumentSchemaDescriptor, + context: CultMeshQueryContext | string, + update: CultMeshDocumentUpdater, + options?: { parse?: (value: unknown) => TDocument }, + ): Promise; + public update( + schema: CultMeshDocumentSchemaDescriptor, + contextOrUpdate: CultMeshQueryContext | string | CultMeshDocumentUpdater, + updateOrOptions?: CultMeshDocumentUpdater | { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string }, + maybeOptions: { parse?: (value: unknown) => TDocument } = {}, + ): Promise { + const hasContext = + typeof contextOrUpdate === "string" || isCultMeshQueryContext(contextOrUpdate); + const context = hasContext + ? contextOrUpdate as CultMeshQueryContext | string + : (updateOrOptions as { context?: CultMeshQueryContext | string } | undefined)?.context ?? "local"; + const update = hasContext + ? updateOrOptions as CultMeshDocumentUpdater + : contextOrUpdate as CultMeshDocumentUpdater; + const options = hasContext ? maybeOptions : updateOrOptions as { parse?: (value: unknown) => TDocument } | undefined; + return this.document(schema, options).update(context, update); } public watch( @@ -2815,33 +2705,6 @@ export function cultMeshDocumentFromPublication( requireNonEmpty(recordKey, "recordKey"); switch (source.kind) { - case "live-publication": { - const schema = typeof schemaOrDefinition === "string" - ? { schemaId: schemaOrDefinition } - : cultMeshSchemaFromDefinition(schemaOrDefinition); - const schemaId = schema.schemaId ?? schema.type; - if (!schemaId) { - throw new Error("CultMesh live publication document requires a schema id or document type."); - } - const parse = (value: unknown) => parseCultMeshDocumentValue(schemaOrDefinition, value); - return cultMeshDocument( - options.documentId ?? recordKey, - schema, - async () => parse(await source.source.latest(schemaId, recordKey)), - { - routeHint: options.routeHint ?? cultMeshRouteHint( - "network", - source.endpoint ?? "CultMesh provider-session publication", - ), - sources: [cultMeshProjectionSource(options.sourceId ?? recordKey, { schemaId })], - watchDocument: (_context, callback) => source.source.watch( - schemaId, - recordKey, - value => callback(parse(value)), - ), - }, - ); - } case "single-file": return typeof schemaOrDefinition === "string" ? cultMeshDocumentFromSingleFile(source.path, schemaOrDefinition, { @@ -3365,15 +3228,6 @@ export function cultMeshDescribeStateRefResolver( }; } -function stateBindingMetadata( - options: Pick, -): Partial { - return Object.fromEntries( - Object.entries(options).filter(([, value]) => value !== undefined), - ) as Partial; -} - export function cultMeshStateBinding( targetProp: string, pointer: @@ -3387,13 +3241,6 @@ export function cultMeshStateBinding( sourceId?: string; schemaId?: string; routeHint?: CultMeshRouteHint; - bindingName?: string; - documentId?: string; - fieldPath?: string; - valueKind?: CultMeshStateBindingDescriptor["valueKind"]; - accessMode?: CultMeshStateBindingDescriptor["accessMode"]; - authority?: string; - writeCommand?: string; } = {}, ): CultMeshStateBindingDescriptor { requireNonEmpty(targetProp, "targetProp"); @@ -3405,7 +3252,6 @@ export function cultMeshStateBinding( sourceId: options.sourceId, schemaId: options.schemaId, routeHint: options.routeHint ?? cultMeshRouteHint(), - ...stateBindingMetadata(options), }; } @@ -3417,7 +3263,6 @@ export function cultMeshStateBinding( sourceId: options.sourceId ?? source?.sourceId, schemaId: options.schemaId ?? source?.schemaId, routeHint: options.routeHint ?? pointer.routeHint ?? cultMeshRouteHint(), - ...stateBindingMetadata(options), }; } @@ -3431,13 +3276,6 @@ export function cultMeshStateBindingRecord( schemaId?: string, routeKind?: string, routeDescription?: string, - bindingName?: string, - documentId?: string, - fieldPath?: string, - valueKind?: string, - accessMode?: string, - authority?: string, - writeCommand?: string, ): CultMeshStateBindingRecord; export function cultMeshStateBindingRecord( bindingOrTargetProp?: Partial | string, @@ -3446,13 +3284,6 @@ export function cultMeshStateBindingRecord( schemaId?: string, routeKind?: string, routeDescription?: string, - bindingName?: string, - documentId?: string, - fieldPath?: string, - valueKind?: string, - accessMode?: string, - authority?: string, - writeCommand?: string, ): CultMeshStateBindingRecord { if (typeof bindingOrTargetProp === "object" || bindingOrTargetProp === undefined) { const binding = bindingOrTargetProp; @@ -3464,13 +3295,6 @@ export function cultMeshStateBindingRecord( schemaId: binding?.schemaId ?? "", routeKind: route.kind, routeDescription: route.description, - bindingName: binding?.bindingName ?? "", - documentId: binding?.documentId ?? "", - fieldPath: binding?.fieldPath ?? "", - valueKind: binding?.valueKind ?? "", - accessMode: binding?.accessMode ?? "", - authority: binding?.authority ?? "", - writeCommand: binding?.writeCommand ?? "", }; } @@ -3481,13 +3305,6 @@ export function cultMeshStateBindingRecord( schemaId: schemaId ?? "", routeKind: routeKind ?? "", routeDescription: routeDescription ?? "", - bindingName: bindingName ?? "", - documentId: documentId ?? "", - fieldPath: fieldPath ?? "", - valueKind: valueKind ?? "", - accessMode: accessMode ?? "", - authority: authority ?? "", - writeCommand: writeCommand ?? "", }; } @@ -3511,15 +3328,6 @@ export function cultMeshStateBindingFromRecord( }, options.fallbackRouteHint, ), - ...stateBindingMetadata({ - bindingName: record.bindingName || undefined, - documentId: record.documentId || undefined, - fieldPath: record.fieldPath || undefined, - valueKind: (record.valueKind || undefined) as CultMeshStateBindingDescriptor["valueKind"], - accessMode: (record.accessMode || undefined) as CultMeshStateBindingDescriptor["accessMode"], - authority: record.authority || undefined, - writeCommand: record.writeCommand || undefined, - }), }; } @@ -4066,6 +3874,29 @@ export class CultMeshNode { ); } + public reactiveDocument( + definition: TDefinition, + key: string, + options: { + context?: CultMeshQueryContext | string; + watch?: boolean; + debounceMs?: number; + onError?: (error: unknown) => void; + documentId?: string; + routeHint?: CultMeshRouteHint; + pollMs?: number; + } = {}, + ): CultMeshReactiveDocument & object> { + const { + documentId, + routeHint, + pollMs, + ...reactiveOptions + } = options; + return this.document(definition, key, { documentId, routeHint, pollMs }) + .reactive & object>(reactiveOptions); + } + public async syncDocumentFromPeerSnapshot( peer: CultNetPeer | (() => CultNetPeer | Promise), definition: TDefinition, @@ -4276,22 +4107,10 @@ export interface CultMeshRudpDocumentServerOptions extends CultMeshRudpSocketOpt | CultMeshRudpDocumentReceipt | void | Promise; - onOperationRequest?: ( - request: CultNetOperationRequestMessage, - session: CultMeshRudpServerSession, - ) => CultNetOperationResponseMessage | Promise; - onSessionClosed?: (session: CultMeshRudpServerSession) => void | Promise; wireContract?: CultNetWireContract; sessionTimeoutMs?: number; } -export interface CultMeshRudpServerSession { - readonly sessionId: string; - readonly remote: { address: string; family: string; port: number }; - readonly connectPayload?: Uint8Array; - send(message: CultNetMessage): void; -} - export interface CultMeshRudpDocumentServer { readonly bind: { host: string; port: number }; start(): Promise; @@ -5121,6 +4940,23 @@ export class CultMesh { return cultMeshGlobalDocumentFromCache(cache, definition, options); } + public static reactiveDocument( + node: CultMeshNode, + definition: TDefinition, + key: string, + options: { + context?: CultMeshQueryContext | string; + watch?: boolean; + debounceMs?: number; + onError?: (error: unknown) => void; + documentId?: string; + routeHint?: CultMeshRouteHint; + pollMs?: number; + } = {}, + ): CultMeshReactiveDocument & object> { + return node.reactiveDocument(definition, key, options); + } + public static syncDocumentFromPeerSnapshot( node: CultMeshNode, peer: CultNetPeer | (() => CultNetPeer | Promise), @@ -5347,13 +5183,6 @@ export class CultMesh { sourceId?: string; schemaId?: string; routeHint?: CultMeshRouteHint; - bindingName?: string; - documentId?: string; - fieldPath?: string; - valueKind?: CultMeshStateBindingDescriptor["valueKind"]; - accessMode?: CultMeshStateBindingDescriptor["accessMode"]; - authority?: string; - writeCommand?: string; } = {}, ): CultMeshStateBindingDescriptor { return cultMeshStateBinding(targetProp, pointer, options); @@ -5369,13 +5198,6 @@ export class CultMesh { schemaId?: string, routeKind?: string, routeDescription?: string, - bindingName?: string, - documentId?: string, - fieldPath?: string, - valueKind?: string, - accessMode?: string, - authority?: string, - writeCommand?: string, ): CultMeshStateBindingRecord; public static stateBindingRecord( bindingOrTargetProp?: Partial | string, @@ -5384,13 +5206,6 @@ export class CultMesh { schemaId?: string, routeKind?: string, routeDescription?: string, - bindingName?: string, - documentId?: string, - fieldPath?: string, - valueKind?: string, - accessMode?: string, - authority?: string, - writeCommand?: string, ): CultMeshStateBindingRecord { return typeof bindingOrTargetProp === "object" || bindingOrTargetProp === undefined ? cultMeshStateBindingRecord(bindingOrTargetProp) @@ -5401,13 +5216,6 @@ export class CultMesh { schemaId, routeKind, routeDescription, - bindingName, - documentId, - fieldPath, - valueKind, - accessMode, - authority, - writeCommand, ); } @@ -5677,7 +5485,7 @@ export class CultMesh { const documents = new CultNetDocumentRegistry(options.bindings); for (const definition of options.documents ?? []) { - await cache.registerDocumentType(definition); + cache.registerDocumentType(definition); if (!documents.get(definition.type)) { documents.register(defineCultNetDocumentBinding({ definition })); } @@ -5750,14 +5558,9 @@ export class CultMesh { const bind = { host, port }; const socket = options.socket ?? createSocket(host.includes(":") ? "udp6" : "udp4"); const sessions = new Map; }>(); - let sessionSequence = 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"; @@ -5780,28 +5583,14 @@ export class CultMesh { let record = sessions.get(key); if (packet.packetType === "connect") { - const connectPayload = Uint8Array.from(packet.payload ?? []); - if (record && byteArraysEqual(record.connectPayload, connectPayload)) { - socket.send( - encodeRudpPacket(record.session.acceptConnect(packet, nowMs)), - remote.port, - remote.address, - ); - return; - } - if (record) closeSession(record); record = { - sessionId: `${runtimeId}:${++sessionSequence}`, remote: { address: remote.address, family: remote.family, port: remote.port }, - connectPayload, - closed: false, session: new CultNetRudpSession({ connectionId, initialSequence: options.initialSequence, resendDelayMs: options.resendDelayMs, maxPendingReliablePackets: options.maxPendingReliablePackets, }), - work: Promise.resolve(), }; sessions.set(key, record); socket.send(encodeRudpPacket(record.session.acceptConnect(packet, nowMs)), remote.port, remote.address); @@ -5816,28 +5605,20 @@ export class CultMesh { if (result.reply) { socket.send(encodeRudpPacket(result.reply), record.remote.port, record.remote.address); } - for (const ready of result.readyToSend ?? []) { - socket.send(encodeRudpPacket(ready), record.remote.port, record.remote.address); - } for (const frame of result.delivered) { if (frame.channelId !== "schema") { continue; } - record.work = record.work - .then(() => handleDocumentServerFrame(record, frame.payload)) - .catch(reportError); + handleDocumentServerFrame(record, frame.payload).catch((error) => { + reportError(error); + }); } if (result.disconnected) { sessions.delete(key); - closeSession(record); return; } - if (packet.reliable) { - socket.send( - encodeRudpPacket(record.session.createAckFor(packet.sequence)), - record.remote.port, - record.remote.address, - ); + if (packet.packetType === "accept" || result.delivered.length > 0) { + socket.send(encodeRudpPacket(record.session.createAck()), record.remote.port, record.remote.address); } } catch (error) { reportError(error); @@ -5846,10 +5627,9 @@ export class CultMesh { socket.on("error", reportError); async function handleDocumentServerFrame( - record: { sessionId: string; remote: { address: string; family: string; port: number }; session: CultNetRudpSession; connectPayload: Uint8Array; closed: boolean }, + record: { remote: { address: string; family: string; port: number }; session: CultNetRudpSession }, payload: Uint8Array, ): Promise { - if (record.closed) return; const message = parseCultNetMessage(decode(payload), wireContract); switch (message.schemaVersion) { case "cultnet.snapshot_request.v0": { @@ -5890,16 +5670,6 @@ export class CultMesh { } } return; - case "cultnet.operation_request.v0": - if (!options.onOperationRequest) { - sendSchemaMessage(record, { - schemaVersion: "cultnet.error.v0", - error: "CultMesh RUDP document server has no operation handler.", - }); - return; - } - sendSchemaMessage(record, await options.onOperationRequest(message, publicSession(record))); - return; default: sendSchemaMessage(record, { schemaVersion: "cultnet.error.v0", @@ -5908,52 +5678,10 @@ export class CultMesh { } } - 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 }, - connectPayload: Uint8Array.from(record.connectPayload), - send: message => { - if (record.closed) throw new Error(`CultMesh RUDP session ${record.sessionId} is closed.`); - sendSchemaMessage(record, message); - }, - }; - } - - 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: { - 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); - } - function sendSchemaMessage( - record: { sessionId: string; remote: { address: string; port: number }; session: CultNetRudpSession; closed: boolean }, + record: { remote: { address: string; port: number }; session: CultNetRudpSession }, message: CultNetMessage, ): void { - if (record.closed) throw new Error(`CultMesh RUDP session ${record.sessionId} is closed.`); const payload = encode(encodeCultNetMessageForWire(message, wireContract)); for (const packet of record.session.sendMany("schema", payload, { reliable: true, @@ -5983,7 +5711,6 @@ export class CultMesh { for (const [key, record] of sessions) { if (record.session.checkTimeout(nowMs, sessionTimeoutMs)) { sessions.delete(key); - closeSession(record); continue; } for (const packet of record.session.dueResends(nowMs)) { @@ -5998,44 +5725,12 @@ export class CultMesh { clearInterval(resendTimer); resendTimer = undefined; } - for (const record of sessions.values()) closeSession(record); sessions.clear(); socket.close(); }, }; } - public static resolveRudpEndpoint( - endpoint: string, - environment: Record = process.env, - ): CultMeshRudpEndpoint { - requireNonEmpty(endpoint, "endpoint"); - const text = endpoint.trim(); - if (text.toLowerCase().startsWith("rudp://")) { - return CultMesh.parseRudpEndpoint(text); - } - if (!text.toLowerCase().startsWith("cultmesh://")) { - return CultMesh.parseRudpEndpoint(`rudp://${text}`); - } - - const uri = new URL(text); - const authority = uri.hostname.trim(); - if (!authority) throw new Error("CultMesh URI must include an authority."); - const slug = authority - .replace(/[^a-z0-9]+/gi, "_") - .replace(/^_+|_+$/g, "") - .toUpperCase(); - for (const key of [`CULTMESH_URI_${slug}_RUDP`, `${slug}_CULTMESH_RUDP_ENDPOINT`]) { - const resolved = environment[key]?.trim(); - if (resolved) { - return CultMesh.parseRudpEndpoint( - resolved.toLowerCase().startsWith("rudp://") ? resolved : `rudp://${resolved}`, - ); - } - } - throw new Error(`CultMesh URI ${text} did not resolve to a RUDP endpoint.`); - } - public static parseRudpEndpoint(endpoint: string): CultMeshRudpEndpoint { requireNonEmpty(endpoint, "endpoint"); const parsed = new URL(endpoint); @@ -6081,7 +5776,7 @@ export class CultMesh { tags: options.tags, }, )); - await peer.flush(options.flushTimeoutMs ?? 30_000); + await new Promise((resolve) => setTimeout(resolve, Math.max(0, options.flushTimeoutMs ?? 150))); } finally { peer.close(); } @@ -6637,14 +6332,6 @@ 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/provider-rudp-transport.ts b/packages/cultmesh-ts/src/provider-rudp-transport.ts deleted file mode 100644 index 0557bf48..00000000 --- a/packages/cultmesh-ts/src/provider-rudp-transport.ts +++ /dev/null @@ -1,395 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { createSocket, type Socket } from "node:dgram"; -import { encode } from "@msgpack/msgpack"; -import { - CultNetPeer, - CultNetRudpSocketTransportConnection, - type CultNetMessage, - type CultNetOperationRequestMessage, - type CultNetOperationResponseMessage, - type CultNetRawDocumentRecord, -} from "cultnet-ts"; - -import type { - CultMeshProviderCommand, - CultMeshProviderCommandListener, - CultMeshProviderCommandReceipt, - CultMeshProviderConnection, - CultMeshProviderIdentity, - CultMeshProviderLease, - CultMeshProviderPublication, - CultMeshProviderRegistration, - CultMeshProviderTransport, - CultMeshProviderUnsubscribe, - CultMeshProviderWithdrawal, -} from "./provider-session"; -import { - CULTMESH_PROVIDER_SESSION_SERVICE_ID, - assertMutationAcceptance, - assertProviderLease, - cultMeshProviderSessionOperations, - cultMeshProviderSessionSchemas, - decodeProviderCommandDocument, - encodeProviderConnectEvidence, - decodeProviderSessionPayload, - encodeProviderSessionPayload, - type CultMeshProviderCommandReceiptWire, - type CultMeshProviderLeaseWire, - type CultMeshProviderMutationAcceptanceWire, -} from "./provider-session-wire"; - -export interface CultMeshProviderRudpTransportOptions { - readonly endpoint: string; - readonly runtimeId: string; - readonly connectionId: number; - readonly bindHost?: string; - readonly connectTimeoutMs?: number; - readonly operationTimeoutMs?: number; - readonly maxFragmentBytes?: number; - readonly sessionToken?: string; - readonly socketFactory?: () => Socket; -} - -/** - * Private-development provider transport. Public deployment additionally needs - * an authenticated CultNet session whose claims authorize the provider identity. - */ -export class CultMeshProviderRudpTransport implements CultMeshProviderTransport { - readonly #options: CultMeshProviderRudpTransportOptions; - - public constructor(options: CultMeshProviderRudpTransportOptions) { - if (!options.endpoint) throw new Error("CultMesh provider RUDP transport requires endpoint."); - if (!options.runtimeId) throw new Error("CultMesh provider RUDP transport requires runtimeId."); - if (!Number.isInteger(options.connectionId)) throw new Error("CultMesh provider RUDP transport requires integer connectionId."); - this.#options = options; - } - - public async connect( - identity: CultMeshProviderIdentity, - signal: AbortSignal, - ): Promise { - if (signal.aborted) throw abortError(); - const endpoint = parseEndpoint(this.#options.endpoint); - const socket = this.#options.socketFactory?.() ?? createSocket(endpoint.host.includes(":") ? "udp6" : "udp4"); - await bindSocket(socket, this.#options.bindHost ?? (endpoint.host.includes(":") ? "::" : "0.0.0.0"), signal); - const transport = new CultNetRudpSocketTransportConnection({ - mode: "client", - runtimeId: this.#options.runtimeId, - transportId: `${this.#options.runtimeId}.cultmesh-provider-session`, - socket, - remoteHost: endpoint.host, - remotePort: endpoint.port, - connectionId: this.#options.connectionId, - maxFragmentBytes: this.#options.maxFragmentBytes ?? 2_048, - }); - const peer = new CultNetPeer(transport, { wireContract: "cultnet.schema.v0" }); - transport.connect(Buffer.from(encodeProviderConnectEvidence({ - clientSessionId: randomUUID(), - sessionToken: this.#options.sessionToken ?? null, - }))); - try { - await waitUntilConnected(transport, signal, this.#options.connectTimeoutMs ?? 2_000); - return new RudpProviderConnection(identity, peer, transport, this.#options.operationTimeoutMs ?? 5_000); - } catch (error) { - transport.close(); - throw error; - } - } -} - -class RudpProviderConnection implements CultMeshProviderConnection { - readonly #identity: CultMeshProviderIdentity; - readonly #peer: CultNetPeer; - readonly #transport: CultNetRudpSocketTransportConnection; - readonly #timeoutMs: number; - readonly #listeners = new Set(); - readonly #pending = new Map void; - reject: (error: Error) => void; - timer: NodeJS.Timeout; - }>(); - readonly #publications = new Map(); - readonly #bufferedCommands = new Map(); - #closed = false; - #requestedLeaseDurationMs = 30_000; - - public constructor( - identity: CultMeshProviderIdentity, - peer: CultNetPeer, - transport: CultNetRudpSocketTransportConnection, - timeoutMs: number, - ) { - this.#identity = identity; - this.#peer = peer; - this.#transport = transport; - this.#timeoutMs = timeoutMs; - peer.on("message", message => this.#onMessage(message)); - peer.on("invalidMessage", error => this.#failPending(error)); - peer.on("close", () => this.#failPending(new Error("CultMesh provider RUDP connection closed."))); - peer.on("error", error => this.#failPending(error)); - } - - public async register( - registration: CultMeshProviderRegistration, - signal: AbortSignal, - ): Promise { - this.#requestedLeaseDurationMs = registration.requestedLeaseDurationMs; - const response = await this.#operation( - cultMeshProviderSessionOperations.register, - cultMeshProviderSessionSchemas.registration, - cultMeshProviderSessionSchemas.lease, - { ...registration.identity, requestedLeaseDurationMs: registration.requestedLeaseDurationMs }, - signal, - ); - const wire = decodeProviderSessionPayload(response.payload); - assertProviderLease(wire); - assertIdentity(this.#identity, wire); - return { leaseId: wire.leaseId, expiresAt: new Date(wire.expiresAtUtc) }; - } - - public async renew(lease: CultMeshProviderLease): Promise { - const response = await this.#operation( - cultMeshProviderSessionOperations.renew, - cultMeshProviderSessionSchemas.leaseRenewal, - cultMeshProviderSessionSchemas.lease, - { leaseId: lease.leaseId, requestedLeaseDurationMs: this.#requestedLeaseDurationMs }, - ); - const wire = decodeProviderSessionPayload(response.payload); - assertProviderLease(wire); - assertIdentity(this.#identity, wire); - return { leaseId: wire.leaseId, expiresAt: new Date(wire.expiresAtUtc) }; - } - - public async publish( - publication: CultMeshProviderPublication, - lease: CultMeshProviderLease, - ): Promise { - const document: CultNetRawDocumentRecord = { - schemaId: publication.schemaId, - recordKey: publication.recordKey, - storedAt: new Date().toISOString(), - payloadEncoding: "messagepack", - payload: encode(publication.value), - sourceRuntimeId: this.#identity.serviceInstanceId, - sourceAgentId: this.#identity.providerId, - sourceRole: "provider", - }; - await this.#accepted(cultMeshProviderSessionOperations.publicationPut, cultMeshProviderSessionSchemas.publicationPut, { - leaseId: lease.leaseId, - publicationId: publication.publicationId, - document, - }); - this.#publications.set(publication.publicationId, publication); - } - - public async withdrawPublication(publicationId: string, lease: CultMeshProviderLease): Promise { - const publication = this.#publications.get(publicationId); - if (!publication) throw new Error(`CultMesh provider publication ${publicationId} is not owned by this connection.`); - await this.#accepted(cultMeshProviderSessionOperations.publicationDelete, cultMeshProviderSessionSchemas.publicationDelete, { - leaseId: lease.leaseId, - publicationId, - schemaId: publication.schemaId, - recordKey: publication.recordKey, - }); - this.#publications.delete(publicationId); - } - - public watchCommands(listener: CultMeshProviderCommandListener): CultMeshProviderUnsubscribe { - this.#listeners.add(listener); - for (const [commandId, command] of this.#bufferedCommands) { - this.#bufferedCommands.delete(commandId); - void Promise.resolve(listener(command)).catch(() => undefined); - } - return () => { - this.#listeners.delete(listener); - }; - } - - public async publishReceipt( - receipt: CultMeshProviderCommandReceipt, - lease: CultMeshProviderLease, - ): Promise { - const wire: CultMeshProviderCommandReceiptWire = { - receiptId: receipt.receiptId, - commandId: receipt.commandId, - commandKind: receipt.commandKind, - providerId: receipt.providerId, - serviceInstanceId: receipt.serviceInstanceId, - state: receipt.state, - completedAtUtc: receipt.completedAt.toISOString(), - result: receipt.result, - error: receipt.error, - }; - await this.#accepted(cultMeshProviderSessionOperations.receiptPut, cultMeshProviderSessionSchemas.receiptPut, { - leaseId: lease.leaseId, - receipt: wire, - }); - } - - public async withdraw(withdrawal: CultMeshProviderWithdrawal): Promise { - await this.#accepted(cultMeshProviderSessionOperations.withdraw, cultMeshProviderSessionSchemas.withdrawal, { - leaseId: withdrawal.leaseId, - }); - this.#publications.clear(); - } - - public close(): void { - if (this.#closed) return; - this.#closed = true; - this.#failPending(new Error("CultMesh provider RUDP connection closed.")); - this.#listeners.clear(); - this.#transport.close(); - } - - async #accepted(operation: string, payloadSchema: string, payload: unknown): Promise { - const response = await this.#operation(operation, payloadSchema, cultMeshProviderSessionSchemas.mutationAcceptance, payload); - const acceptance = decodeProviderSessionPayload(response.payload); - assertMutationAcceptance(acceptance); - return acceptance; - } - - async #operation( - operation: string, - payloadSchema: string, - responsePayloadSchema: string, - payload: unknown, - signal?: AbortSignal, - ): Promise { - if (this.#closed) throw new Error("CultMesh provider RUDP connection is closed."); - if (signal?.aborted) throw abortError(); - const messageId = randomUUID(); - const request: CultNetOperationRequestMessage = { - schemaVersion: "cultnet.operation_request.v0", - messageId, - serviceId: CULTMESH_PROVIDER_SESSION_SERVICE_ID, - operation, - payloadSchema, - payloadEncoding: "messagepack-base64", - payload: encodeProviderSessionPayload(payload), - sourceRuntimeId: this.#identity.serviceInstanceId, - }; - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.#pending.delete(messageId); - reject(new Error(`CultMesh provider operation ${operation} timed out.`)); - }, this.#timeoutMs); - const abort = (): void => { - clearTimeout(timer); - this.#pending.delete(messageId); - reject(abortError()); - }; - signal?.addEventListener("abort", abort, { once: true }); - this.#pending.set(messageId, { - operation, - payloadSchema: responsePayloadSchema, - timer, - resolve: response => { - signal?.removeEventListener("abort", abort); - if (response.serviceId !== CULTMESH_PROVIDER_SESSION_SERVICE_ID || response.operation !== operation) { - reject(new Error(`CultMesh provider response correlation does not match ${operation}.`)); - return; - } - if (response.payloadEncoding !== "messagepack-base64") { - reject(new Error(`CultMesh provider response for ${operation} has incompatible payload encoding.`)); - return; - } - if (response.status !== "ok") { - reject(new Error(`CultMesh provider operation ${operation} was ${response.status}: ${(response.diagnostics ?? []).join("; ")}`)); - return; - } - if (response.payloadSchema !== responsePayloadSchema) { - reject(new Error(`CultMesh provider response for ${operation} has incompatible payload schema.`)); - return; - } - resolve(response); - }, - reject: error => { - signal?.removeEventListener("abort", abort); - reject(error); - }, - }); - this.#peer.send(request); - }); - } - - #onMessage(message: CultNetMessage): void { - if (message.schemaVersion === "cultnet.operation_response.v0") { - const pending = this.#pending.get(message.messageId); - if (!pending) return; - this.#pending.delete(message.messageId); - clearTimeout(pending.timer); - pending.resolve(message); - return; - } - if (message.schemaVersion !== "cultnet.document_put_raw.v0") return; - if (message.document.schemaId !== cultMeshProviderSessionSchemas.command) return; - const wire = decodeProviderCommandDocument(message.document); - if (wire.providerId !== this.#identity.providerId || wire.serviceInstanceId !== this.#identity.serviceInstanceId) return; - const command: CultMeshProviderCommand = { ...wire }; - if (this.#listeners.size === 0) { - this.#bufferedCommands.set(command.commandId, command); - return; - } - for (const listener of [...this.#listeners]) void Promise.resolve(listener(command)).catch(() => undefined); - } - - #failPending(error: Error): void { - for (const [messageId, pending] of this.#pending) { - this.#pending.delete(messageId); - clearTimeout(pending.timer); - pending.reject(error); - } - } -} - -function assertIdentity(identity: CultMeshProviderIdentity, lease: CultMeshProviderLeaseWire): void { - for (const key of ["providerId", "serviceInstanceId", "endpointId", "verseId"] as const) { - if (identity[key] !== lease[key]) throw new Error(`CultMesh provider lease ${key} does not match the registered identity.`); - } -} - -function parseEndpoint(endpoint: string): { host: string; port: number } { - const url = new URL(endpoint); - const port = Number(url.port); - if (url.protocol !== "rudp:" || !url.hostname || !Number.isInteger(port) || port <= 0 || port > 65_535) { - throw new Error(`Invalid CultMesh provider RUDP endpoint ${endpoint}.`); - } - return { host: url.hostname, port }; -} - -function bindSocket(socket: Socket, host: string, signal: AbortSignal): Promise { - return new Promise((resolve, reject) => { - const abort = (): void => { - socket.close(); - reject(abortError()); - }; - signal.addEventListener("abort", abort, { once: true }); - socket.once("error", reject); - socket.bind(0, host, () => { - signal.removeEventListener("abort", abort); - socket.removeListener("error", reject); - resolve(); - }); - }); -} - -async function waitUntilConnected( - transport: CultNetRudpSocketTransportConnection, - signal: AbortSignal, - timeoutMs: number, -): Promise { - const deadline = Date.now() + timeoutMs; - while (!transport.connected) { - if (signal.aborted) throw abortError(); - if (Date.now() >= deadline) throw new Error("CultMesh provider RUDP connection timed out."); - await new Promise(resolve => setTimeout(resolve, 5)); - } -} - -function abortError(): Error { - const error = new Error("CultMesh provider RUDP operation aborted."); - error.name = "AbortError"; - return error; -} diff --git a/packages/cultmesh-ts/src/provider-session-broker.ts b/packages/cultmesh-ts/src/provider-session-broker.ts deleted file mode 100644 index eb9e18b5..00000000 --- a/packages/cultmesh-ts/src/provider-session-broker.ts +++ /dev/null @@ -1,528 +0,0 @@ -import { encode } from "@msgpack/msgpack"; -import type { - CultNetDocumentPutRawMessage, - CultNetOperationRequestMessage, - CultNetOperationResponseMessage, - CultNetRawDocumentRecord, -} from "cultnet-ts"; - -import type { CultMeshRudpServerSession } from "./index"; -import { - CULTMESH_PROVIDER_SESSION_SERVICE_ID, - cultMeshProviderSessionOperations, - cultMeshProviderSessionSchemas, - decodeProviderSessionPayload, - encodeProviderSessionPayload, - type CultMeshProviderCommandReceiptWire, - type CultMeshProviderCommandWire, - type CultMeshProviderLeaseRenewalWire, - type CultMeshProviderLeaseWire, - type CultMeshProviderOperationStatus, - type CultMeshProviderPublicationDeleteWire, - type CultMeshProviderPublicationPutWire, - type CultMeshProviderReceiptPutWire, - type CultMeshProviderRegistrationWire, - type CultMeshProviderWithdrawalWire, -} from "./provider-session-wire"; - -export interface CultMeshProviderBrokerClock { - now(): Date; -} - -export interface CultMeshProviderSessionBrokerOptions { - readonly runtimeId: string; - readonly clock?: CultMeshProviderBrokerClock; - readonly createLeaseId?: () => string; - readonly maximumLeaseDurationMs?: number; - readonly expiryPollMs?: number; - readonly onError?: (error: Error) => void; - readonly authorizeRegistration: ( - identity: CultMeshProviderRegistrationWire, - session: CultMeshRudpServerSession, - ) => boolean | Promise; - readonly acceptPublication: ( - identity: CultMeshProviderRegistrationWire, - publicationId: string, - document: CultNetRawDocumentRecord, - ) => void | Promise; - readonly deletePublications: ( - identity: CultMeshProviderRegistrationWire, - publications: readonly { - publicationId: string; - document: Pick; - }[], - ) => void | Promise; - readonly acceptReceipt: ( - identity: CultMeshProviderRegistrationWire, - receipt: CultMeshProviderCommandReceiptWire, - ) => void | Promise; -} - -interface LeaseRecord { - identity: CultMeshProviderRegistrationWire; - lease: CultMeshProviderLeaseWire; - session?: CultMeshRudpServerSession; - publications: Map; -} - -export class CultMeshProviderSessionBroker { - readonly #options: CultMeshProviderSessionBrokerOptions; - readonly #clock: CultMeshProviderBrokerClock; - readonly #leases = new Map(); - readonly #identityLeases = new Map(); - readonly #sessionLeases = new Map>(); - readonly #commands = new Map(); - readonly #receipts = new Map(); - readonly #publicationOwners = new Map(); - readonly #closedSessions = new Set(); - readonly #inFlightBySession = new Map(); - readonly #expiryTimer: NodeJS.Timeout; - #work: Promise = Promise.resolve(); - #leaseSequence = 0; - - public constructor(options: CultMeshProviderSessionBrokerOptions) { - if (!options.runtimeId) throw new Error("CultMesh provider-session broker requires runtimeId."); - if (!options.authorizeRegistration) throw new Error("CultMesh provider-session broker requires an explicit registration authority."); - this.#options = options; - this.#clock = options.clock ?? { now: () => new Date() }; - this.#expiryTimer = setInterval(() => { - void this.expireLeases().catch(error => { - const normalized = error instanceof Error ? error : new Error(String(error)); - if (this.#options.onError) this.#options.onError(normalized); - else console.error(`CultMesh provider-session expiry failed: ${normalized.message}`); - }); - }, Math.max(10, options.expiryPollMs ?? 1_000)); - this.#expiryTimer.unref?.(); - } - - public close(): void { - clearInterval(this.#expiryTimer); - } - - public async handle( - request: CultNetOperationRequestMessage, - session: CultMeshRudpServerSession, - ): Promise { - this.#inFlightBySession.set( - session.sessionId, - (this.#inFlightBySession.get(session.sessionId) ?? 0) + 1, - ); - const work = this.#work.then(() => this.#handle(request, session)); - this.#work = work.then(() => undefined, () => undefined); - try { - return await work; - } finally { - const remaining = (this.#inFlightBySession.get(session.sessionId) ?? 1) - 1; - if (remaining > 0) { - this.#inFlightBySession.set(session.sessionId, remaining); - } else { - this.#inFlightBySession.delete(session.sessionId); - this.#closedSessions.delete(session.sessionId); - } - } - } - - async #handle( - request: CultNetOperationRequestMessage, - session: CultMeshRudpServerSession, - ): Promise { - if (this.#closedSessions.has(session.sessionId)) { - return this.#response(request, "denied", cultMeshProviderSessionSchemas.mutationAcceptance, {}, [ - "The physical provider session is closed or replaced.", - ]); - } - if (request.serviceId !== CULTMESH_PROVIDER_SESSION_SERVICE_ID) { - return this.#response(request, "denied", cultMeshProviderSessionSchemas.mutationAcceptance, {}, [ - `Unsupported service ${request.serviceId}.`, - ]); - } - if (request.payloadEncoding !== "messagepack-base64") { - return this.#response(request, "invalid", cultMeshProviderSessionSchemas.mutationAcceptance, {}, [ - "Provider-session operation payloads must use messagepack-base64.", - ]); - } - try { - switch (request.operation) { - case cultMeshProviderSessionOperations.register: - return await this.#register(request, session); - case cultMeshProviderSessionOperations.renew: - return await this.#renew(request, session); - case cultMeshProviderSessionOperations.publicationPut: - return await this.#publicationPut(request, session); - case cultMeshProviderSessionOperations.publicationDelete: - return await this.#publicationDelete(request, session); - case cultMeshProviderSessionOperations.receiptPut: - return await this.#receiptPut(request, session); - case cultMeshProviderSessionOperations.withdraw: - return await this.#withdraw(request, session); - default: - return this.#response(request, "invalid", cultMeshProviderSessionSchemas.mutationAcceptance, {}, [ - `Unsupported provider-session operation ${request.operation}.`, - ]); - } - } catch (error) { - if (error instanceof BrokerInfrastructureError) throw error.cause; - return this.#response(request, error instanceof BrokerStatusError ? error.status : "invalid", cultMeshProviderSessionSchemas.mutationAcceptance, {}, [ - error instanceof Error ? error.message : String(error), - ]); - } - } - - public sessionClosed(session: CultMeshRudpServerSession): void { - if ((this.#inFlightBySession.get(session.sessionId) ?? 0) > 0) { - this.#closedSessions.add(session.sessionId); - } - for (const leaseId of this.#sessionLeases.get(session.sessionId) ?? []) { - const record = this.#leases.get(leaseId); - if (record?.session?.sessionId === session.sessionId) record.session = undefined; - } - this.#sessionLeases.delete(session.sessionId); - } - - public enqueueCommand(command: CultMeshProviderCommandWire): void { - requireText(command.commandId, "commandId"); - requireText(command.providerId, "providerId"); - requireText(command.serviceInstanceId, "serviceInstanceId"); - const completed = this.#receipts.get(command.commandId); - if (completed) { - if (completed.commandKind !== command.commandKind || completed.providerId !== command.providerId || completed.serviceInstanceId !== command.serviceInstanceId) { - throw new Error(`Command id ${command.commandId} is already completed for another command transaction.`); - } - return; - } - const existing = this.#commands.get(command.commandId); - if (existing && !wireEquivalent(existing, command)) { - throw new Error(`Command id ${command.commandId} is already retained for another command transaction.`); - } - this.#commands.set(command.commandId, existing ?? command); - for (const record of this.#leases.values()) { - if (!this.#isActive(record)) continue; - if (record.identity.providerId !== command.providerId || record.identity.serviceInstanceId !== command.serviceInstanceId) continue; - this.#sendCommand(record, command); - } - } - - public get activeLeaseCount(): number { - return [...this.#leases.values()].filter(record => this.#isActive(record)).length; - } - - public async expireLeases(): Promise { - const work = this.#work.then(() => this.#expireLeases()); - this.#work = work.then(() => undefined, () => undefined); - await work; - } - - async #expireLeases(): Promise { - const now = this.#clock.now().getTime(); - for (const [leaseId, record] of [...this.#leases]) { - if (Date.parse(record.lease.expiresAtUtc) > now) continue; - await this.#options.deletePublications(record.identity, publicationEntries(record)); - this.#releasePublicationOwners(record); - this.#removeLease(leaseId, record); - } - } - - async #register(request: CultNetOperationRequestMessage, session: CultMeshRudpServerSession): Promise { - expectSchema(request, cultMeshProviderSessionSchemas.registration); - const identity = decodeProviderSessionPayload(request.payload); - validateRegistration(identity); - if (!await infrastructure(() => this.#options.authorizeRegistration(identity, session))) { - return this.#response(request, "denied", cultMeshProviderSessionSchemas.mutationAcceptance, {}, [ - "The CultNet session does not authorize this provider identity.", - ]); - } - if (this.#closedSessions.has(session.sessionId)) { - return this.#response(request, "denied", cultMeshProviderSessionSchemas.mutationAcceptance, {}, [ - "The physical provider session closed while registration authority was being checked.", - ]); - } - const identityKey = identityTuple(identity); - const previousLeaseId = this.#identityLeases.get(identityKey); - let publications = new Map(); - if (previousLeaseId) { - const previous = this.#leases.get(previousLeaseId); - if (previous) { - publications = previous.publications; - this.#removeLease(previousLeaseId, previous); - } - } - const lease = this.#createLease(identity, identity.requestedLeaseDurationMs); - const record: LeaseRecord = { identity, lease, session, publications }; - this.#leases.set(lease.leaseId, record); - this.#identityLeases.set(identityKey, lease.leaseId); - this.#attachSession(session, lease.leaseId); - this.#replayCommands(record); - return this.#response(request, "ok", cultMeshProviderSessionSchemas.lease, lease); - } - - async #renew(request: CultNetOperationRequestMessage, session: CultMeshRudpServerSession): Promise { - expectSchema(request, cultMeshProviderSessionSchemas.leaseRenewal); - const renewal = decodeProviderSessionPayload(request.payload); - const current = this.#requireLease(renewal.leaseId, session); - const lease = this.#createLease(current.identity, renewal.requestedLeaseDurationMs); - const replacement: LeaseRecord = { ...current, lease, session }; - this.#removeLease(renewal.leaseId, current); - this.#leases.set(lease.leaseId, replacement); - this.#identityLeases.set(identityTuple(current.identity), lease.leaseId); - this.#attachSession(session, lease.leaseId); - return this.#response(request, "ok", cultMeshProviderSessionSchemas.lease, lease); - } - - async #publicationPut(request: CultNetOperationRequestMessage, session: CultMeshRudpServerSession): Promise { - expectSchema(request, cultMeshProviderSessionSchemas.publicationPut); - const mutation = decodeProviderSessionPayload(request.payload); - const record = this.#requireLease(mutation.leaseId, session); - requireText(mutation.publicationId, "publicationId"); - validateDocument(mutation.document); - const previous = record.publications.get(mutation.publicationId); - if (previous && (previous.schemaId !== mutation.document.schemaId || previous.recordKey !== mutation.document.recordKey)) { - return this.#response(request, "conflict", cultMeshProviderSessionSchemas.mutationAcceptance, {}, [ - `Publication ${mutation.publicationId} already owns ${previous.schemaId}:${previous.recordKey}. Delete it before changing its tuple.`, - ]); - } - const tuple = documentTuple(mutation.document); - const owner = this.#publicationOwners.get(tuple); - const ownerIdentity = identityTuple(record.identity); - if (owner && (owner.identityKey !== ownerIdentity || owner.publicationId !== mutation.publicationId)) { - return this.#response(request, "conflict", cultMeshProviderSessionSchemas.mutationAcceptance, {}, [ - `Publication tuple ${mutation.document.schemaId}:${mutation.document.recordKey} is already owned.`, - ]); - } - const normalizedDocument: CultNetRawDocumentRecord = { - ...mutation.document, - sourceRuntimeId: record.identity.serviceInstanceId, - sourceAgentId: record.identity.providerId, - sourceRole: "provider", - }; - await infrastructure(() => this.#options.acceptPublication(record.identity, mutation.publicationId, normalizedDocument)); - record.publications.set(mutation.publicationId, normalizedDocument); - this.#publicationOwners.set(tuple, { identityKey: ownerIdentity, publicationId: mutation.publicationId }); - return this.#accepted(request, record.lease.leaseId, { publicationId: mutation.publicationId }); - } - - async #publicationDelete(request: CultNetOperationRequestMessage, session: CultMeshRudpServerSession): Promise { - expectSchema(request, cultMeshProviderSessionSchemas.publicationDelete); - const mutation = decodeProviderSessionPayload(request.payload); - const record = this.#requireLease(mutation.leaseId, session); - const owned = record.publications.get(mutation.publicationId); - if (!owned || owned.schemaId !== mutation.schemaId || owned.recordKey !== mutation.recordKey) { - return this.#response(request, "denied", cultMeshProviderSessionSchemas.mutationAcceptance, {}, [ - `Publication ${mutation.publicationId} is not owned by this lease with the supplied schema and key.`, - ]); - } - await infrastructure(() => this.#options.deletePublications(record.identity, [{ publicationId: mutation.publicationId, document: owned }])); - record.publications.delete(mutation.publicationId); - this.#publicationOwners.delete(documentTuple(owned)); - return this.#accepted(request, record.lease.leaseId, { publicationId: mutation.publicationId }); - } - - async #receiptPut(request: CultNetOperationRequestMessage, session: CultMeshRudpServerSession): Promise { - expectSchema(request, cultMeshProviderSessionSchemas.receiptPut); - const mutation = decodeProviderSessionPayload(request.payload); - const record = this.#requireLease(mutation.leaseId, session); - const receipt = mutation.receipt; - validateReceipt(receipt); - if (receipt.providerId !== record.identity.providerId || receipt.serviceInstanceId !== record.identity.serviceInstanceId) { - return this.#response(request, "denied", cultMeshProviderSessionSchemas.mutationAcceptance, {}, ["Receipt identity does not match its lease."]); - } - const previousReceipt = this.#receipts.get(receipt.commandId); - if (previousReceipt) { - if (!wireEquivalent(previousReceipt, receipt)) { - return this.#response(request, "conflict", cultMeshProviderSessionSchemas.mutationAcceptance, {}, [ - `Command ${receipt.commandId} already has a different accepted receipt.`, - ]); - } - return this.#accepted(request, record.lease.leaseId, { commandId: receipt.commandId, receiptId: receipt.receiptId }); - } - const command = this.#commands.get(receipt.commandId); - if (!command || command.commandKind !== receipt.commandKind || command.providerId !== receipt.providerId || command.serviceInstanceId !== receipt.serviceInstanceId) { - return this.#response(request, "denied", cultMeshProviderSessionSchemas.mutationAcceptance, {}, [ - `Receipt ${receipt.receiptId} does not match a retained command for this provider generation.`, - ]); - } - await infrastructure(() => this.#options.acceptReceipt(record.identity, receipt)); - this.#receipts.set(receipt.commandId, receipt); - this.#commands.delete(receipt.commandId); - return this.#accepted(request, record.lease.leaseId, { commandId: receipt.commandId, receiptId: receipt.receiptId }); - } - - async #withdraw(request: CultNetOperationRequestMessage, session: CultMeshRudpServerSession): Promise { - expectSchema(request, cultMeshProviderSessionSchemas.withdrawal); - const mutation = decodeProviderSessionPayload(request.payload); - const record = this.#requireLease(mutation.leaseId, session); - await infrastructure(() => this.#options.deletePublications(record.identity, publicationEntries(record))); - this.#releasePublicationOwners(record); - this.#removeLease(mutation.leaseId, record); - return this.#accepted(request, mutation.leaseId); - } - - #requireLease(leaseId: string, session: CultMeshRudpServerSession): LeaseRecord { - requireText(leaseId, "leaseId"); - const record = this.#leases.get(leaseId); - if (!record || !this.#isActive(record)) throw new BrokerStatusError("expired", `Provider lease ${leaseId} is expired or fenced.`); - if (record.session?.sessionId !== session.sessionId) throw new BrokerStatusError("denied", `Provider lease ${leaseId} belongs to another physical session.`); - return record; - } - - #isActive(record: LeaseRecord): boolean { - return Date.parse(record.lease.expiresAtUtc) > this.#clock.now().getTime(); - } - - #createLease(identity: CultMeshProviderRegistrationWire, requestedMs: number): CultMeshProviderLeaseWire { - if (!Number.isFinite(requestedMs) || requestedMs <= 0) throw new Error("requestedLeaseDurationMs must be positive."); - const durationMs = Math.min(requestedMs, this.#options.maximumLeaseDurationMs ?? 120_000); - const now = this.#clock.now(); - return { - providerId: identity.providerId, - serviceInstanceId: identity.serviceInstanceId, - endpointId: identity.endpointId, - verseId: identity.verseId, - leaseId: this.#options.createLeaseId?.() ?? `${this.#options.runtimeId}:lease:${++this.#leaseSequence}`, - validFromUtc: now.toISOString(), - expiresAtUtc: new Date(now.getTime() + durationMs).toISOString(), - }; - } - - #attachSession(session: CultMeshRudpServerSession, leaseId: string): void { - let leases = this.#sessionLeases.get(session.sessionId); - if (!leases) this.#sessionLeases.set(session.sessionId, leases = new Set()); - leases.add(leaseId); - } - - #removeLease(leaseId: string, record: LeaseRecord): void { - this.#leases.delete(leaseId); - if (this.#identityLeases.get(identityTuple(record.identity)) === leaseId) this.#identityLeases.delete(identityTuple(record.identity)); - this.#sessionLeases.get(record.session?.sessionId ?? "")?.delete(leaseId); - } - - #releasePublicationOwners(record: LeaseRecord): void { - const ownerIdentity = identityTuple(record.identity); - for (const [publicationId, document] of record.publications) { - const tuple = documentTuple(document); - const owner = this.#publicationOwners.get(tuple); - if (owner?.identityKey === ownerIdentity && owner.publicationId === publicationId) this.#publicationOwners.delete(tuple); - } - } - - #replayCommands(record: LeaseRecord): void { - for (const command of this.#commands.values()) { - if (command.providerId === record.identity.providerId && command.serviceInstanceId === record.identity.serviceInstanceId) this.#sendCommand(record, command); - } - } - - #sendCommand(record: LeaseRecord, command: CultMeshProviderCommandWire): void { - if (!record.session) return; - const message: CultNetDocumentPutRawMessage = { - schemaVersion: "cultnet.document_put_raw.v0", - messageId: `${this.#options.runtimeId}:command:${command.commandId}`, - document: { - schemaId: cultMeshProviderSessionSchemas.command, - recordKey: `${command.providerId}/${command.serviceInstanceId}/${command.commandId}`, - storedAt: this.#clock.now().toISOString(), - payloadEncoding: "messagepack", - payload: encode(command), - sourceRuntimeId: this.#options.runtimeId, - sourceRole: "provider-session-broker", - }, - }; - record.session.send(message); - } - - #accepted(request: CultNetOperationRequestMessage, leaseId: string, extra: Record = {}): CultNetOperationResponseMessage { - return this.#response(request, "ok", cultMeshProviderSessionSchemas.mutationAcceptance, { - acceptedAtUtc: this.#clock.now().toISOString(), - leaseId, - ...extra, - }); - } - - #response( - request: CultNetOperationRequestMessage, - status: CultMeshProviderOperationStatus, - payloadSchema: string, - payload: unknown, - diagnostics?: string[], - ): CultNetOperationResponseMessage { - const response: CultNetOperationResponseMessage = { - schemaVersion: "cultnet.operation_response.v0", - messageId: request.messageId, - serviceId: request.serviceId, - operation: request.operation, - status, - payloadSchema, - payloadEncoding: "messagepack-base64", - payload: encodeProviderSessionPayload(payload), - sourceRuntimeId: this.#options.runtimeId, - }; - if (diagnostics) response.diagnostics = diagnostics; - return response; - } -} - -class BrokerStatusError extends Error { - public constructor(public readonly status: CultMeshProviderOperationStatus, message: string) { - super(message); - } -} - -class BrokerInfrastructureError extends Error { - public constructor(public readonly cause: unknown) { - super(cause instanceof Error ? cause.message : String(cause)); - } -} - -async function infrastructure(action: () => T | Promise): Promise { - try { - return await action(); - } catch (error) { - throw new BrokerInfrastructureError(error); - } -} - -function expectSchema(request: CultNetOperationRequestMessage, schema: string): void { - if (request.payloadSchema !== schema) throw new Error(`Operation ${request.operation} requires payload schema ${schema}.`); -} - -function validateRegistration(value: CultMeshProviderRegistrationWire): void { - for (const field of ["providerId", "serviceInstanceId", "endpointId", "verseId"] as const) requireText(value[field], field); - if (!Number.isFinite(value.requestedLeaseDurationMs) || value.requestedLeaseDurationMs <= 0) throw new Error("requestedLeaseDurationMs must be positive."); -} - -function validateDocument(document: CultNetRawDocumentRecord): void { - requireText(document?.schemaId, "document.schemaId"); - requireText(document?.recordKey, "document.recordKey"); - if (document.payloadEncoding !== "messagepack" || !(document.payload instanceof Uint8Array)) throw new Error("Provider publications must carry a raw MessagePack document."); -} - -function validateReceipt(receipt: CultMeshProviderCommandReceiptWire): void { - requireText(receipt?.receiptId, "receipt.receiptId"); - requireText(receipt?.commandId, "receipt.commandId"); - requireText(receipt?.commandKind, "receipt.commandKind"); - requireText(receipt?.providerId, "receipt.providerId"); - requireText(receipt?.serviceInstanceId, "receipt.serviceInstanceId"); - if (receipt.state !== "applied" && receipt.state !== "rejected" && receipt.state !== "failed") throw new Error("CultMesh provider-session receipt.state is invalid."); - if (!Number.isFinite(Date.parse(receipt.completedAtUtc))) throw new Error("CultMesh provider-session receipt.completedAtUtc must be RFC3339."); -} - -function identityTuple(identity: CultMeshProviderRegistrationWire): string { - return `${identity.verseId}\u0000${identity.providerId}\u0000${identity.serviceInstanceId}\u0000${identity.endpointId}`; -} - -function documentTuple(document: Pick): string { - return `${document.schemaId}\u0000${document.recordKey}`; -} - -function wireEquivalent(left: unknown, right: unknown): boolean { - return Buffer.from(encode(left)).equals(Buffer.from(encode(right))); -} - -function publicationEntries(record: LeaseRecord): readonly { - publicationId: string; - document: Pick; -}[] { - return [...record.publications].map(([publicationId, document]) => ({ publicationId, document })); -} - -function requireText(value: unknown, name: string): asserts value is string { - if (typeof value !== "string" || value.trim().length === 0) throw new Error(`CultMesh provider-session ${name} must be non-empty.`); -} diff --git a/packages/cultmesh-ts/src/provider-session-wire.ts b/packages/cultmesh-ts/src/provider-session-wire.ts deleted file mode 100644 index e89a3148..00000000 --- a/packages/cultmesh-ts/src/provider-session-wire.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { decode, encode } from "@msgpack/msgpack"; -import type { CultNetRawDocumentRecord } from "cultnet-ts"; - -export const CULTMESH_PROVIDER_SESSION_SERVICE_ID = "gamecult.mesh.provider_session"; - -export const cultMeshProviderSessionOperations = { - register: "provider.register", - renew: "provider.renew", - publicationPut: "provider.publication.put", - publicationDelete: "provider.publication.delete", - receiptPut: "provider.receipt.put", - withdraw: "provider.withdraw", -} as const; - -export const cultMeshProviderSessionSchemas = { - registration: "gamecult.mesh.provider_registration.v1", - lease: "gamecult.mesh.provider_lease.v1", - leaseRenewal: "gamecult.mesh.provider_lease_renewal.v1", - publicationPut: "gamecult.mesh.provider_publication_put.v1", - publicationDelete: "gamecult.mesh.provider_publication_delete.v1", - command: "gamecult.mesh.provider_command.v1", - receiptPut: "gamecult.mesh.provider_receipt_put.v1", - withdrawal: "gamecult.mesh.provider_withdrawal.v1", - mutationAcceptance: "gamecult.mesh.provider_mutation_acceptance.v1", -} as const; - -export type CultMeshProviderOperationStatus = - | "ok" - | "conflict" - | "expired" - | "denied" - | "invalid"; - -/** - * Transport evidence carried by a provider's RUDP Connect packet. - * - * `clientSessionId` distinguishes an actual reconnect from retransmission of - * the same Connect packet. The token remains authorization evidence only; it - * is not a transport-session identifier. - */ -export interface CultMeshProviderConnectEvidenceWire { - clientSessionId: string; - sessionToken: string | null; -} - -export interface CultMeshProviderRegistrationWire { - providerId: string; - serviceInstanceId: string; - endpointId: string; - verseId: string; - requestedLeaseDurationMs: number; - authorityLeaseId?: string; -} - -export interface CultMeshProviderLeaseWire { - providerId: string; - serviceInstanceId: string; - endpointId: string; - verseId: string; - leaseId: string; - validFromUtc: string; - expiresAtUtc: string; -} - -export interface CultMeshProviderLeaseRenewalWire { - leaseId: string; - requestedLeaseDurationMs: number; -} - -export interface CultMeshProviderPublicationPutWire { - leaseId: string; - publicationId: string; - document: CultNetRawDocumentRecord; -} - -export interface CultMeshProviderPublicationDeleteWire { - leaseId: string; - publicationId: string; - schemaId: string; - recordKey: string; -} - -export interface CultMeshProviderCommandWire { - commandId: string; - commandKind: string; - providerId: string; - serviceInstanceId: string; - payload: unknown; -} - -export type CultMeshProviderReceiptStateWire = "applied" | "rejected" | "failed"; - -export interface CultMeshProviderCommandReceiptWire { - receiptId: string; - commandId: string; - commandKind: string; - providerId: string; - serviceInstanceId: string; - state: CultMeshProviderReceiptStateWire; - completedAtUtc: string; - result?: unknown; - error?: string; -} - -export interface CultMeshProviderReceiptPutWire { - leaseId: string; - receipt: CultMeshProviderCommandReceiptWire; -} - -export interface CultMeshProviderWithdrawalWire { - leaseId: string; -} - -export interface CultMeshProviderMutationAcceptanceWire { - acceptedAtUtc: string; - leaseId?: string; - publicationId?: string; - commandId?: string; - receiptId?: string; -} - -export function encodeProviderSessionPayload(value: unknown): string { - return Buffer.from(encode(value)).toString("base64"); -} - -export function decodeProviderSessionPayload(payload: string): T { - if (typeof payload !== "string" || payload.length === 0) { - throw new Error("CultMesh provider-session payload must be non-empty MessagePack base64."); - } - return decode(Buffer.from(payload, "base64")) as T; -} - -export function encodeProviderConnectEvidence( - evidence: CultMeshProviderConnectEvidenceWire, -): Uint8Array { - assertProviderConnectEvidence(evidence); - return encode(evidence); -} - -export function decodeProviderConnectEvidence( - payload: Uint8Array, -): CultMeshProviderConnectEvidenceWire { - const evidence = decode(payload) as unknown; - assertProviderConnectEvidence(evidence); - return evidence; -} - -export function assertProviderConnectEvidence( - value: unknown, -): asserts value is CultMeshProviderConnectEvidenceWire { - const evidence = requireObject(value, "provider Connect evidence"); - requireText(evidence.clientSessionId, "clientSessionId"); - if (evidence.sessionToken !== null) { - requireText(evidence.sessionToken, "sessionToken"); - } -} - -export function decodeProviderCommandDocument(document: CultNetRawDocumentRecord): CultMeshProviderCommandWire { - if (document.schemaId !== cultMeshProviderSessionSchemas.command) { - throw new Error(`Expected ${cultMeshProviderSessionSchemas.command}, received ${document.schemaId}.`); - } - if (document.payloadEncoding !== "messagepack") { - throw new Error(`Provider command ${document.recordKey} must use MessagePack.`); - } - const command = decode(document.payload) as Partial; - requireText(command.commandId, "commandId"); - requireText(command.commandKind, "commandKind"); - requireText(command.providerId, "providerId"); - requireText(command.serviceInstanceId, "serviceInstanceId"); - return command as CultMeshProviderCommandWire; -} - -export function assertProviderLease(value: unknown): asserts value is CultMeshProviderLeaseWire { - const lease = requireObject(value, "provider lease"); - requireText(lease.providerId, "providerId"); - requireText(lease.serviceInstanceId, "serviceInstanceId"); - requireText(lease.endpointId, "endpointId"); - requireText(lease.verseId, "verseId"); - requireText(lease.leaseId, "leaseId"); - requireTimestamp(lease.validFromUtc, "validFromUtc"); - requireTimestamp(lease.expiresAtUtc, "expiresAtUtc"); -} - -export function assertMutationAcceptance(value: unknown): asserts value is CultMeshProviderMutationAcceptanceWire { - const acceptance = requireObject(value, "provider mutation acceptance"); - requireTimestamp(acceptance.acceptedAtUtc, "acceptedAtUtc"); -} - -function requireObject(value: unknown, name: string): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error(`CultMesh ${name} must be a MessagePack map.`); - } - return value as Record; -} - -function requireText(value: unknown, name: string): asserts value is string { - if (typeof value !== "string" || value.trim().length === 0) { - throw new Error(`CultMesh provider-session ${name} must be non-empty.`); - } -} - -function requireTimestamp(value: unknown, name: string): asserts value is string { - requireText(value, name); - if (!Number.isFinite(Date.parse(value))) { - throw new Error(`CultMesh provider-session ${name} must be an RFC3339 timestamp.`); - } -} diff --git a/packages/cultmesh-ts/src/provider-session.ts b/packages/cultmesh-ts/src/provider-session.ts deleted file mode 100644 index f63d222c..00000000 --- a/packages/cultmesh-ts/src/provider-session.ts +++ /dev/null @@ -1,884 +0,0 @@ -export interface CultMeshProviderIdentity { - readonly providerId: string; - readonly serviceInstanceId: string; - readonly endpointId: string; - readonly verseId: string; -} - -export interface CultMeshProviderLease { - readonly leaseId: string; - readonly expiresAt: Date; -} - -export interface CultMeshProviderPublication { - readonly publicationId: string; - readonly documentType: string; - readonly schemaId: string; - readonly recordKey: string; - readonly value: TValue; -} - -export interface CultMeshProviderCommand { - readonly commandId: string; - readonly commandKind: string; - readonly providerId: string; - readonly serviceInstanceId: string; - readonly payload: TPayload; -} - -export type CultMeshProviderReceiptState = "applied" | "rejected" | "failed"; - -export interface CultMeshProviderCommandReceipt { - readonly receiptId: string; - readonly commandId: string; - readonly commandKind: string; - readonly providerId: string; - readonly serviceInstanceId: string; - readonly state: CultMeshProviderReceiptState; - readonly completedAt: Date; - readonly result?: TResult; - readonly error?: string; -} - -export interface CultMeshProviderCommandOutcome { - readonly state: CultMeshProviderReceiptState; - readonly result?: TResult; - readonly error?: string; -} - -export interface CultMeshProviderCommandContext { - readonly identity: CultMeshProviderIdentity; -} - -export type CultMeshProviderCommandHandler = ( - command: CultMeshProviderCommand, - context: CultMeshProviderCommandContext, -) => Promise; - -export interface CultMeshProviderReceiptStore { - get(commandId: string): Promise; - put(receipt: CultMeshProviderCommandReceipt): Promise; - listPending(): Promise; - markPublished(receiptId: string): Promise; -} - -export class CultMeshMemoryProviderReceiptStore implements CultMeshProviderReceiptStore { - readonly #receipts = new Map(); - readonly #pendingReceiptIds = new Set(); - - public async get(commandId: string): Promise { - return this.#receipts.get(commandId); - } - - public async put(receipt: CultMeshProviderCommandReceipt): Promise { - this.#receipts.set(receipt.commandId, receipt); - this.#pendingReceiptIds.add(receipt.receiptId); - } - - public async listPending(): Promise { - return [...this.#receipts.values()] - .filter(receipt => this.#pendingReceiptIds.has(receipt.receiptId)) - .sort((left, right) => left.receiptId.localeCompare(right.receiptId)); - } - - public async markPublished(receiptId: string): Promise { - this.#pendingReceiptIds.delete(receiptId); - } -} - -export interface CultMeshProviderRegistration { - readonly identity: CultMeshProviderIdentity; - readonly requestedLeaseDurationMs: number; -} - -export interface CultMeshProviderWithdrawal { - readonly identity: CultMeshProviderIdentity; - readonly leaseId: string; - readonly publicationIds: readonly string[]; -} - -export type CultMeshProviderCommandListener = ( - command: CultMeshProviderCommand, -) => void | Promise; - -export type CultMeshProviderUnsubscribe = () => void | Promise; - -export interface CultMeshProviderConnection { - register( - registration: CultMeshProviderRegistration, - signal: AbortSignal, - ): Promise; - renew(lease: CultMeshProviderLease): Promise; - publish( - publication: CultMeshProviderPublication, - lease: CultMeshProviderLease, - ): Promise; - withdrawPublication(publicationId: string, lease: CultMeshProviderLease): Promise; - watchCommands(listener: CultMeshProviderCommandListener): CultMeshProviderUnsubscribe; - publishReceipt( - receipt: CultMeshProviderCommandReceipt, - lease: CultMeshProviderLease, - ): Promise; - withdraw(withdrawal: CultMeshProviderWithdrawal): Promise; - close(): void | Promise; -} - -export interface CultMeshProviderTransport { - connect( - identity: CultMeshProviderIdentity, - signal: AbortSignal, - ): Promise; -} - -export interface CultMeshProviderScheduledTask { - cancel(): void; -} - -export interface CultMeshProviderScheduler { - now(): Date; - schedule(delayMs: number, action: () => void | Promise): CultMeshProviderScheduledTask; -} - -export class CultMeshSystemProviderScheduler implements CultMeshProviderScheduler { - public now(): Date { - return new Date(); - } - - public schedule(delayMs: number, action: () => void | Promise): CultMeshProviderScheduledTask { - const timer = setTimeout(() => { - void Promise.resolve(action()).catch(() => undefined); - }, Math.max(0, delayMs)); - return { cancel: () => clearTimeout(timer) }; - } -} - -export type CultMeshProviderSessionStatus = - | "stopped" - | "connecting" - | "active" - | "degraded" - | "reconnecting" - | "withdrawing"; - -export interface CultMeshProviderSessionState { - readonly status: CultMeshProviderSessionStatus; - readonly identity: CultMeshProviderIdentity; - readonly reconnectAttempt: number; - readonly leaseId?: string; - readonly leaseExpiresAt?: Date; - readonly lastError?: string; -} - -export interface CultMeshProviderSessionOptions { - readonly identity: CultMeshProviderIdentity; - readonly transport: CultMeshProviderTransport; - readonly publications?: Iterable; - readonly commandHandlers?: Readonly>; - readonly receiptStore: CultMeshProviderReceiptStore; - readonly scheduler?: CultMeshProviderScheduler; - readonly onObserverError?: (error: unknown) => void; - readonly leaseDurationMs?: number; - readonly renewalLeadMs?: number; - readonly reconnectBaseDelayMs?: number; - readonly reconnectMaxDelayMs?: number; -} - -export class CultMeshProviderSession { - readonly #identity: CultMeshProviderIdentity; - readonly #transport: CultMeshProviderTransport; - readonly #receiptStore: CultMeshProviderReceiptStore; - readonly #scheduler: CultMeshProviderScheduler; - readonly #onObserverError: (error: unknown) => void; - readonly #leaseDurationMs: number; - readonly #renewalLeadMs: number; - readonly #reconnectBaseDelayMs: number; - readonly #reconnectMaxDelayMs: number; - readonly #publications = new Map(); - readonly #handlers = new Map(); - readonly #subscribers = new Set<(state: CultMeshProviderSessionState) => void>(); - readonly #inFlightCommands = new Map; - }>(); - #state: CultMeshProviderSessionState; - #connection?: CultMeshProviderConnection; - #lease?: CultMeshProviderLease; - #unsubscribeCommands?: CultMeshProviderUnsubscribe; - #scheduled?: CultMeshProviderScheduledTask; - #abort?: AbortController; - #running = false; - #generation = 0; - #reconnectAttempt = 0; - #publicationWork: Promise = Promise.resolve(); - #startWork?: Promise; - #stopWork?: Promise; - #lossWork?: { generation: number; work: Promise }; - - public constructor(options: CultMeshProviderSessionOptions) { - this.#identity = validateIdentity(options.identity); - this.#transport = options.transport; - if (!options.receiptStore) { - throw new Error( - "CultMesh provider sessions require an explicit receiptStore so command receipts survive the provider lifecycle.", - ); - } - this.#receiptStore = options.receiptStore; - this.#scheduler = options.scheduler ?? new CultMeshSystemProviderScheduler(); - this.#onObserverError = options.onObserverError ?? (() => undefined); - this.#leaseDurationMs = positive(options.leaseDurationMs ?? 30_000, "leaseDurationMs"); - this.#renewalLeadMs = nonNegative(options.renewalLeadMs ?? 10_000, "renewalLeadMs"); - if (this.#renewalLeadMs >= this.#leaseDurationMs) { - throw new Error("CultMesh provider renewalLeadMs must be less than leaseDurationMs."); - } - this.#reconnectBaseDelayMs = positive( - options.reconnectBaseDelayMs ?? 250, - "reconnectBaseDelayMs", - ); - this.#reconnectMaxDelayMs = positive( - options.reconnectMaxDelayMs ?? 10_000, - "reconnectMaxDelayMs", - ); - if (this.#reconnectMaxDelayMs < this.#reconnectBaseDelayMs) { - throw new Error("CultMesh provider reconnectMaxDelayMs must not be less than reconnectBaseDelayMs."); - } - for (const publication of options.publications ?? []) { - this.#publications.set(validatePublication(publication).publicationId, publication); - } - for (const [kind, handler] of Object.entries(options.commandHandlers ?? {})) { - this.setCommandHandler(kind, handler); - } - this.#state = { - status: "stopped", - identity: this.#identity, - reconnectAttempt: 0, - }; - } - - public get identity(): CultMeshProviderIdentity { - return this.#identity; - } - - public get state(): CultMeshProviderSessionState { - return this.#state; - } - - public get publications(): readonly CultMeshProviderPublication[] { - return [...this.#publications.values()].sort((left, right) => - left.publicationId.localeCompare(right.publicationId), - ); - } - - public watchState(callback: (state: CultMeshProviderSessionState) => void): () => void { - this.#subscribers.add(callback); - try { - callback(this.#state); - } catch (error) { - this.#reportObserverError(error); - } - return () => this.#subscribers.delete(callback); - } - - public setCommandHandler(kind: string, handler: CultMeshProviderCommandHandler): void { - this.#handlers.set(required(kind, "command kind"), handler); - } - - public removeCommandHandler(kind: string): boolean { - return this.#handlers.delete(required(kind, "command kind")); - } - - public upsertPublication(publication: CultMeshProviderPublication): Promise { - const valid = validatePublication(publication); - this.#publications.set(valid.publicationId, valid); - if (this.#connection && this.#lease) { - return this.#enqueuePublication(() => this.#deliverPublication(valid, this.#generation)); - } - return Promise.resolve(); - } - - public async removePublication(publicationId: string): Promise { - const id = required(publicationId, "publicationId"); - const removed = this.#publications.delete(id); - if (removed && this.#connection && this.#lease) { - await this.#enqueuePublication(() => this.#withdrawPublication(id, this.#generation)); - } - return removed; - } - - public start(): Promise { - if (this.#stopWork) { - return Promise.reject(new Error("CultMesh provider cannot start while shutdown is in progress.")); - } - if (this.#startWork) return this.#startWork; - if (this.#running) return Promise.resolve(); - let tracked!: Promise; - tracked = Promise.resolve().then(() => this.#start()).finally(() => { - if (this.#startWork === tracked) this.#startWork = undefined; - }); - this.#startWork = tracked; - return tracked; - } - - async #start(): Promise { - this.#running = true; - this.#reconnectAttempt = 0; - const generation = ++this.#generation; - this.#abort = new AbortController(); - await this.#connect(generation, false); - } - - public stop(): Promise { - if (this.#stopWork) return this.#stopWork; - if (!this.#running && !this.#startWork && this.#state.status === "stopped") { - return Promise.resolve(); - } - let tracked!: Promise; - tracked = Promise.resolve().then(() => this.#stop()).finally(() => { - if (this.#stopWork === tracked) this.#stopWork = undefined; - }); - this.#stopWork = tracked; - return tracked; - } - - async #stop(): Promise { - this.#running = false; - const generation = ++this.#generation; - this.#cancelScheduled(); - this.#abort?.abort(); - this.#setState("withdrawing"); - const connection = this.#connection; - const lease = this.#lease; - const starting = this.#startWork; - const losing = this.#lossWork?.work; - this.#connection = undefined; - this.#lease = undefined; - const errors: unknown[] = []; - try { - await this.#unsubscribe(); - } catch (error) { - errors.push(error); - } - try { - await this.#publicationWork; - } catch (error) { - errors.push(error); - } - for (const lifecycleWork of [starting, losing]) { - if (!lifecycleWork) continue; - try { - await lifecycleWork; - } catch (error) { - errors.push(error); - } - } - this.#cancelScheduled(); - const commandWork = [...this.#inFlightCommands.values()].map(value => value.work); - const commandResults = await Promise.allSettled(commandWork); - for (const result of commandResults) { - if (result.status === "rejected") errors.push(result.reason); - } - if (connection && lease) { - try { - await connection.withdraw({ - identity: this.#identity, - leaseId: lease.leaseId, - publicationIds: this.publications.map(value => value.publicationId), - }); - } catch (error) { - errors.push(error); - } - } - if (connection) { - try { - await connection.close(); - } catch (error) { - errors.push(error); - } - } - if (generation === this.#generation) this.#setState("stopped"); - if (errors.length > 0) { - throw new AggregateError(errors, "CultMesh provider shutdown completed with cleanup failures."); - } - } - - async #connect(generation: number, reconnecting: boolean): Promise { - if (!this.#isCurrent(generation)) return; - this.#scheduled = undefined; - this.#setState(reconnecting ? "reconnecting" : "connecting"); - try { - const connection = await this.#transport.connect(this.#identity, this.#abort!.signal); - if (!this.#isCurrent(generation)) { - await connection.close(); - return; - } - this.#connection = connection; - const lease = validateLease( - await connection.register({ - identity: this.#identity, - requestedLeaseDurationMs: this.#leaseDurationMs, - }, this.#abort!.signal), - this.#scheduler.now(), - ); - if (!this.#isCurrent(generation)) { - await connection.close(); - return; - } - this.#lease = lease; - const initialPublications = this.publications; - await this.#enqueuePublication(async () => { - for (const publication of initialPublications) { - if (!this.#isCurrent(generation)) return; - requireUnexpiredLease(lease, this.#scheduler.now()); - await connection.publish(publication, lease); - } - }); - if (!this.#isCurrent(generation)) return; - const readyLease = await this.#renewIfNeeded(connection, lease, generation); - if (!readyLease || !this.#isCurrent(generation)) return; - this.#lease = readyLease; - this.#reconnectAttempt = 0; - const receiptsReady = await this.#drainPendingReceipts(connection, readyLease, generation); - if (!this.#isCurrent(generation)) return; - if (receiptsReady) { - this.#enableCommands(connection, generation); - this.#setState("active", undefined, readyLease); - } - this.#scheduleRenewal(generation, readyLease); - } catch (error) { - await this.#loseConnection(generation, error); - } - } - - #scheduleRenewal(generation: number, lease: CultMeshProviderLease): void { - if (!this.#isCurrent(generation)) return; - this.#scheduled?.cancel(); - const delay = Math.max( - 1, - lease.expiresAt.getTime() - this.#scheduler.now().getTime() - this.#renewalLeadMs, - ); - this.#scheduled = this.#scheduler.schedule(delay, () => this.#renew(generation)); - } - - async #renew(generation: number): Promise { - if (!this.#isCurrent(generation) || !this.#connection || !this.#lease) return; - this.#scheduled = undefined; - try { - let lease = validateLease( - await this.#connection.renew(this.#lease), - this.#scheduler.now(), - ); - if (!this.#isCurrent(generation)) return; - this.#lease = lease; - const receiptsReady = await this.#drainPendingReceipts(this.#connection, lease, generation); - if (!this.#isCurrent(generation)) return; - if (receiptsReady) { - lease = await this.#renewIfNeeded(this.#connection, lease, generation) ?? lease; - if (!this.#isCurrent(generation)) return; - this.#lease = lease; - this.#enableCommands(this.#connection, generation); - this.#setState("active", undefined, lease); - } - this.#scheduleRenewal(generation, lease); - } catch (error) { - await this.#loseConnection(generation, error); - } - } - - async #deliverPublication( - publication: CultMeshProviderPublication, - generation: number, - ): Promise { - const connection = this.#connection; - const lease = this.#lease; - if (!connection || !lease || !this.#isCurrent(generation)) return; - try { - requireUnexpiredLease(lease, this.#scheduler.now()); - await connection.publish(publication, lease); - } catch (error) { - await this.#loseConnection(generation, error); - } - } - - async #withdrawPublication(publicationId: string, generation: number): Promise { - const connection = this.#connection; - const lease = this.#lease; - if (!connection || !lease || !this.#isCurrent(generation)) return; - try { - requireUnexpiredLease(lease, this.#scheduler.now()); - await connection.withdrawPublication(publicationId, lease); - } catch (error) { - await this.#loseConnection(generation, error); - } - } - - async #receiveCommand(command: CultMeshProviderCommand, generation: number): Promise { - if (!this.#isCurrent(generation) || - command.providerId !== this.#identity.providerId || - command.serviceInstanceId !== this.#identity.serviceInstanceId) { - return; - } - try { - required(command.commandId, "commandId"); - required(command.commandKind, "commandKind"); - } catch (error) { - this.#setState("degraded", errorText(error), this.#lease); - return; - } - const inFlight = this.#inFlightCommands.get(command.commandId); - if (inFlight) { - if (!sameCommandIdentity(inFlight.command, command)) { - this.#setState( - "degraded", - `CultMesh command id '${command.commandId}' was reused with different identity while in flight.`, - this.#lease, - ); - return; - } - await inFlight.work; - return; - } - const work = this.#processCommand(command, generation); - const entry = { command, work }; - this.#inFlightCommands.set(command.commandId, entry); - try { - await work; - } finally { - if (this.#inFlightCommands.get(command.commandId) === entry) { - this.#inFlightCommands.delete(command.commandId); - } - } - } - - async #processCommand(command: CultMeshProviderCommand, generation: number): Promise { - let receipt: CultMeshProviderCommandReceipt; - try { - receipt = await this.#resolveCommand(command); - } catch (error) { - if (this.#isCurrent(generation)) { - await this.#degradeReceiptStore( - `Command receipt persistence failed: ${errorText(error)}`, - generation, - ); - } - return; - } - const connection = this.#connection; - const lease = this.#lease; - if (!connection || !lease || !this.#isCurrent(generation)) return; - try { - await connection.publishReceipt(receipt, lease); - } catch (error) { - await this.#loseConnection(generation, error); - return; - } - try { - await this.#receiptStore.markPublished(receipt.receiptId); - } catch (error) { - if (this.#isCurrent(generation)) { - await this.#degradeReceiptStore( - `Command receipt publication could not be committed: ${errorText(error)}`, - generation, - ); - } - } - } - - async #resolveCommand(command: CultMeshProviderCommand): Promise { - const prior = await this.#receiptStore.get(command.commandId); - if (prior) { - validateStoredReceipt(prior, this.#identity); - if (prior.commandKind !== command.commandKind) { - throw new Error(`CultMesh command id '${command.commandId}' was reused with different identity.`); - } - return prior; - } - const handler = this.#handlers.get(command.commandKind); - let outcome: CultMeshProviderCommandOutcome; - if (!handler) { - outcome = { state: "rejected", error: `No handler for '${command.commandKind}'.` }; - } else { - try { - outcome = await handler(command, { identity: this.#identity }); - validateOutcomeState(outcome?.state); - } catch (error) { - outcome = { state: "failed", error: errorText(error) }; - } - } - const receipt: CultMeshProviderCommandReceipt = { - receiptId: receiptId(this.#identity, command.commandId), - commandId: command.commandId, - commandKind: command.commandKind, - providerId: this.#identity.providerId, - serviceInstanceId: this.#identity.serviceInstanceId, - state: validateOutcomeState(outcome.state), - completedAt: this.#scheduler.now(), - result: outcome.result, - error: outcome.error, - }; - await this.#receiptStore.put(receipt); - return receipt; - } - - async #loseConnection(generation: number, error: unknown): Promise { - if (!this.#isCurrent(generation)) return; - const existing = this.#lossWork; - if (existing?.generation === generation) { - await existing.work; - return; - } - let tracked!: Promise; - tracked = this.#performConnectionLoss(generation, error).finally(() => { - if (this.#lossWork?.work === tracked) this.#lossWork = undefined; - }); - this.#lossWork = { generation, work: tracked }; - await tracked; - } - - async #performConnectionLoss(generation: number, error: unknown): Promise { - this.#cancelScheduled(); - const connection = this.#connection; - this.#connection = undefined; - this.#lease = undefined; - this.#reconnectAttempt++; - const failures = [errorText(error)]; - this.#setState("reconnecting", failures[0]); - try { - await this.#unsubscribe(); - } catch (cleanupError) { - failures.push(`unsubscribe failed: ${errorText(cleanupError)}`); - } - if (connection) { - try { - await connection.close(); - } catch (cleanupError) { - failures.push(`close failed: ${errorText(cleanupError)}`); - } - } - if (!this.#isCurrent(generation)) return; - this.#setState("reconnecting", failures.join("; ")); - const exponent = Math.max(0, this.#reconnectAttempt - 1); - const delay = Math.min( - this.#reconnectMaxDelayMs, - this.#reconnectBaseDelayMs * 2 ** exponent, - ); - this.#scheduled = this.#scheduler.schedule(delay, () => this.#connect(generation, true)); - } - - async #drainPendingReceipts( - connection: CultMeshProviderConnection, - lease: CultMeshProviderLease, - generation: number, - ): Promise { - let pending: readonly CultMeshProviderCommandReceipt[]; - try { - pending = await this.#receiptStore.listPending(); - } catch (error) { - if (this.#isCurrent(generation)) { - await this.#degradeReceiptStore( - `Pending command receipts could not be read: ${errorText(error)}`, - generation, - ); - } - return false; - } - for (const receipt of [...pending].sort((left, right) => left.receiptId.localeCompare(right.receiptId))) { - try { - validateStoredReceipt(receipt, this.#identity); - } catch (error) { - if (this.#isCurrent(generation)) { - await this.#degradeReceiptStore( - `Pending command receipt is invalid: ${errorText(error)}`, - generation, - ); - } - return false; - } - if (!this.#isCurrent(generation)) return false; - requireUnexpiredLease(lease, this.#scheduler.now()); - await connection.publishReceipt(receipt, lease); - try { - await this.#receiptStore.markPublished(receipt.receiptId); - } catch (error) { - if (this.#isCurrent(generation)) { - await this.#degradeReceiptStore( - `Command receipt publication could not be committed: ${errorText(error)}`, - generation, - ); - } - return false; - } - } - return true; - } - - async #renewIfNeeded( - connection: CultMeshProviderConnection, - lease: CultMeshProviderLease, - generation: number, - ): Promise { - if (lease.expiresAt.getTime() - this.#scheduler.now().getTime() > this.#renewalLeadMs) { - return lease; - } - const renewed = validateLease(await connection.renew(lease), this.#scheduler.now()); - return this.#isCurrent(generation) ? renewed : undefined; - } - - #enableCommands(connection: CultMeshProviderConnection, generation: number): void { - if (this.#unsubscribeCommands || !this.#isCurrent(generation)) return; - this.#unsubscribeCommands = connection.watchCommands(command => this.#receiveCommand(command, generation)); - } - - async #degradeReceiptStore(message: string, generation: number): Promise { - try { - await this.#unsubscribe(); - } catch (error) { - this.#reportObserverError(error); - } - if (this.#isCurrent(generation)) this.#setState("degraded", message, this.#lease); - } - - async #unsubscribe(): Promise { - const unsubscribe = this.#unsubscribeCommands; - this.#unsubscribeCommands = undefined; - if (unsubscribe) await unsubscribe(); - } - - #cancelScheduled(): void { - const scheduled = this.#scheduled; - this.#scheduled = undefined; - scheduled?.cancel(); - } - - #enqueuePublication(action: () => Promise): Promise { - const work = this.#publicationWork.then(action, action); - this.#publicationWork = work.catch(() => undefined); - return work; - } - - #isCurrent(generation: number): boolean { - return this.#running && generation === this.#generation; - } - - #setState( - status: CultMeshProviderSessionStatus, - lastError?: string, - lease?: CultMeshProviderLease, - ): void { - this.#state = { - status, - identity: this.#identity, - reconnectAttempt: this.#reconnectAttempt, - leaseId: lease?.leaseId, - leaseExpiresAt: lease?.expiresAt, - lastError, - }; - for (const subscriber of [...this.#subscribers]) { - try { - subscriber(this.#state); - } catch (error) { - this.#reportObserverError(error); - } - } - } - - #reportObserverError(error: unknown): void { - try { - this.#onObserverError(error); - } catch { - // Diagnostics cannot become a provider lifecycle authority. - } - } -} - -function validateIdentity(identity: CultMeshProviderIdentity): CultMeshProviderIdentity { - required(identity.providerId, "providerId"); - required(identity.serviceInstanceId, "serviceInstanceId"); - required(identity.endpointId, "endpointId"); - required(identity.verseId, "verseId"); - return identity; -} - -function validatePublication(publication: CultMeshProviderPublication): CultMeshProviderPublication { - required(publication.publicationId, "publicationId"); - required(publication.documentType, "documentType"); - required(publication.schemaId, "schemaId"); - required(publication.recordKey, "recordKey"); - return publication; -} - -function validateLease(lease: CultMeshProviderLease, now: Date): CultMeshProviderLease { - required(lease.leaseId, "leaseId"); - if (!(lease.expiresAt instanceof Date) || !Number.isFinite(lease.expiresAt.getTime())) { - throw new Error("CultMesh provider lease requires a valid expiresAt date."); - } - if (lease.expiresAt <= now) { - throw new Error("CultMesh provider lease must expire in the future."); - } - return lease; -} - -function requireUnexpiredLease(lease: CultMeshProviderLease, now: Date): void { - if (lease.expiresAt <= now) { - throw new Error(`CultMesh provider lease '${lease.leaseId}' expired during replay.`); - } -} - -function validateOutcomeState(state: CultMeshProviderReceiptState): CultMeshProviderReceiptState { - if (state !== "applied" && state !== "rejected" && state !== "failed") { - throw new Error(`CultMesh provider command handler returned invalid receipt state '${String(state)}'.`); - } - return state; -} - -function sameCommandIdentity( - left: CultMeshProviderCommand, - right: CultMeshProviderCommand, -): boolean { - return left.providerId === right.providerId && - left.serviceInstanceId === right.serviceInstanceId && - left.commandKind === right.commandKind; -} - -function validateStoredReceipt( - receipt: CultMeshProviderCommandReceipt, - identity: CultMeshProviderIdentity, -): void { - required(receipt.commandId, "stored receipt commandId"); - required(receipt.commandKind, "stored receipt commandKind"); - required(receipt.receiptId, "stored receipt receiptId"); - validateOutcomeState(receipt.state); - if (receipt.providerId !== identity.providerId || - receipt.serviceInstanceId !== identity.serviceInstanceId || - receipt.receiptId !== receiptId(identity, receipt.commandId)) { - throw new Error(`CultMesh stored receipt '${receipt.receiptId}' does not belong to this provider session.`); - } -} - -function receiptId(identity: CultMeshProviderIdentity, commandId: string): string { - const parts = [identity.providerId, identity.serviceInstanceId, commandId]; - return `receipt:${parts.map(value => `${value.length}:${value}`).join("")}`; -} - -function required(value: string, name: string): string { - if (!value || value.trim().length === 0) { - throw new Error(`CultMesh provider ${name} must be non-empty.`); - } - return value; -} - -function positive(value: number, name: string): number { - if (!Number.isFinite(value) || value <= 0) { - throw new Error(`CultMesh provider ${name} must be positive.`); - } - return value; -} - -function nonNegative(value: number, name: string): number { - if (!Number.isFinite(value) || value < 0) { - throw new Error(`CultMesh provider ${name} must be non-negative.`); - } - return value; -} - -function errorText(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 16d51e1e..285ee09d 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -5,7 +5,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { encode } from "@msgpack/msgpack"; import { z } from "zod"; -import { defineDocumentType } from "@gamecult/cultcache-ts"; +import { defineDocumentType } from "cultcache-ts"; import { CultNetDocumentRegistry, CultNetPeer, @@ -114,6 +114,7 @@ test("CultMesh TS document handles hide local cache plumbing behind typed reacti assert.equal(document.documentId, "cultmesh.note:note:1"); assert.equal(document.canReplace, true); assert.equal(document.canSubmitPrediction, false); + assert.equal(document.canSet, true); assert.equal((await bound.latest()).body, "initial"); await bound.replace({ @@ -130,12 +131,13 @@ test("CultMesh TS document handles hide local cache plumbing behind typed reacti const catalog = CultMesh.documents(document); assert.equal(catalog.canReplace(noteAliasDocument), true); - await catalog.authoritativeWriter(noteAliasDocument, { - parse: value => noteAliasDocument.schema.parse(value), - context: "browser-client", - }).write({ + assert.equal(catalog.canSet(noteAliasDocument), true); + await catalog.set(noteAliasDocument, { noteId: "note:1", body: "catalog-updated", + }, { + parse: value => noteAliasDocument.schema.parse(value), + context: "browser-client", }); assert.equal( (await catalog.latest(noteAliasDocument, "browser-client", { @@ -143,7 +145,7 @@ test("CultMesh TS document handles hide local cache plumbing behind typed reacti })).body, "catalog-updated", ); - const incremented = await bound.authoritativeWriter().update(value => ({ + const incremented = await bound.update(value => ({ ...value, body: `${value.body}:updated-through-bound-set`, })); @@ -181,15 +183,17 @@ test("CultMesh TS document handles submit predictions through configured authori assert.equal(document.canReplace, false); assert.equal(document.canSubmitPrediction, true); + assert.equal(document.canSet, true); assert.equal(bound.canSubmitPrediction, true); + assert.equal(bound.canSet, true); assert.equal(catalog.canSubmitPrediction(noteAliasDocument), true); + assert.equal(catalog.canSet(noteAliasDocument), true); - await catalog.predictionWriter(noteAliasDocument, { - parse: value => noteAliasDocument.schema.parse(value), - context: "pilot-a", - }).write({ + await catalog.set(noteAliasDocument, "pilot-a", { noteId: "note:prediction", body: "predicted", + }, { + parse: value => noteAliasDocument.schema.parse(value), }); assert.deepEqual(contexts, ["pilot-a:network"]); @@ -200,14 +204,14 @@ test("CultMesh TS document handles submit predictions through configured authori parse: value => noteAliasDocument.schema.parse(value), }); assert.equal(alias.canSubmitPrediction, true); - await alias.predictionWriter().write({ + await alias.set({ noteId: "note:prediction", body: "alias-predicted", }); assert.deepEqual(predictions, ["predicted", "alias-predicted"]); assert.equal((await document.latest("pilot-a")).body, "alias-predicted"); - const updated = await document.predictionWriter("pilot-a").update(value => ({ + const updated = await document.update("pilot-a", value => ({ ...value, body: "updated-as-prediction", })); @@ -223,34 +227,6 @@ test("CultMesh TS document handles submit predictions through configured authori ); }); -test("CultMesh TS documents with both capabilities require an immutable writer choice", async () => { - let current = { noteId: "note:dual", body: "initial" }; - const writes: string[] = []; - const document = CultMesh.document( - "cultmesh.note:dual", - noteDocument, - async () => current, - { - replaceDocument: async (_context, value) => { - writes.push(`authoritative:${value.body}`); - current = value; - }, - submitPrediction: async (_context, value) => { - writes.push(`prediction:${value.body}`); - current = value; - }, - }, - ); - - assert.equal((document as unknown as { set?: unknown }).set, undefined); - await document.authoritativeWriter().write({ noteId: "note:dual", body: "canonical" }); - const predictionWriter = document.predictionWriter(); - await predictionWriter.write({ noteId: "note:dual", body: "predicted" }); - - assert.equal(predictionWriter.kind, "prediction"); - assert.deepEqual(writes, ["authoritative:canonical", "prediction:predicted"]); -}); - test("CultMesh TS store document handles choose compatible foreign schema payloads", async () => { const store = { async pullAll() { @@ -304,7 +280,7 @@ test("CultMesh TS store document handles choose compatible foreign schema payloa }); }); -test("CultMesh TS reactive documents submit predictions from explicit updates", async () => { +test("CultMesh TS reactive documents submit predictions from member writes", async () => { let current = { noteId: "note:reactive", body: "initial", @@ -323,71 +299,23 @@ test("CultMesh TS reactive documents submit predictions from explicit updates", }, ); - const reactive = document.predictionWriter().reactive({ + const reactive = document.reactive({ watch: false, }); await reactive.ready; - reactive.update(value => { - value.body = "explicit-update-prediction"; - }); - reactive.update(value => { - value.body = "explicit-update-prediction-final"; - }); + reactive.current.body = "member-write-prediction"; + reactive.current.body = "member-write-prediction-final"; await waitFor( - () => predictions.includes("explicit-update-prediction-final"), - "reactive explicit update prediction", + () => predictions.includes("member-write-prediction-final"), + "reactive member write prediction", ); - assert.equal(current.body, "explicit-update-prediction-final"); - assert.deepEqual(predictions, ["explicit-update-prediction-final"]); + assert.equal(current.body, "member-write-prediction-final"); + assert.deepEqual(predictions, ["member-write-prediction-final"]); reactive.dispose(); - assert.throws(() => reactive.update(value => { - value.body = "after-dispose"; - }), /disposed/); - assert.deepEqual(predictions, ["explicit-update-prediction-final"]); -}); - -test("CultMesh TS reactive updates publish nested edits while snapshot mutation stays local", async () => { - let current = { - shipId: "ship:nested", - loadout: { - weapon: { - key: "weapon:laser", - tuning: { damage: 10 }, - }, - }, - }; - const predictions: typeof current[] = []; - const document = CultMesh.document( - "cultmesh.ship:nested", - { schemaId: "cultmesh.ship.nested.v0" }, - async () => current, - { - submitPrediction: async (_context, value) => { - current = structuredClone(value); - predictions.push(structuredClone(value)); - }, - }, - ); - const reactive = document.predictionWriter().reactive({ watch: false }); - await reactive.ready; - - reactive.update(draft => { - draft.loadout.weapon.tuning.damage = 12; - }); - await reactive.flush(); - - assert.equal(current.loadout.weapon.tuning.damage, 12); - assert.equal(predictions.length, 1); - - const borrowed = reactive.snapshot; - borrowed.loadout.weapon.tuning.damage = 99; + reactive.current.body = "after-dispose"; await new Promise(resolve => setTimeout(resolve, 20)); - - assert.equal(reactive.snapshot.loadout.weapon.tuning.damage, 12); - assert.equal(current.loadout.weapon.tuning.damage, 12); - assert.equal(predictions.length, 1); - reactive.dispose(); + assert.deepEqual(predictions, ["member-write-prediction-final"]); }); test("CultMesh TS node opens same-schema aliases with one call", async () => { @@ -405,7 +333,7 @@ test("CultMesh TS node opens same-schema aliases with one call", async () => { }); assert.equal(aliasHandle.documentId, "cultmesh.note.ui:note:node-reactive"); assert.equal((await aliasHandle.latest()).body, "initial"); - await aliasHandle.authoritativeWriter().write({ + await aliasHandle.set({ noteId: "note:node-reactive", body: "edited-through-alias-handle", }); @@ -427,29 +355,26 @@ test("CultMesh TS node opens same-schema aliases with one call", async () => { ["edited-through-alias-put"], ); - const reactive = aliasHandle.authoritativeWriter( - CultMesh.queryContext("browser-client"), - ).reactive({ + const reactive = node.reactiveDocument(noteAliasDocument, "note:node-reactive", { + context: CultMesh.queryContext("browser-client"), watch: false, }); await reactive.ready; - reactive.update(value => { - value.body = "edited-through-node-helper"; - }); + reactive.current.body = "edited-through-node-helper"; await waitFor( () => node.getRequired(noteDocument, "note:node-reactive").body === "edited-through-node-helper", "node-level reactive alias write", ); - assert.equal(reactive.snapshot.noteId, "note:node-reactive"); + assert.equal(reactive.current.noteId, "note:node-reactive"); assert.equal( node.document(noteDocument, "note:node-reactive").asSchemaAlias(noteAliasDocument, { parse: value => noteAliasDocument.schema.parse(value), - }).canReplace, + }).canSet, true, ); assert.equal( - (await node.document(noteAliasDocument, "note:node-reactive").observe({ + (await CultMesh.reactiveDocument(node, noteAliasDocument, "note:node-reactive", { watch: false, }).ready).body, "edited-through-node-helper", @@ -484,12 +409,10 @@ test("CultMesh TS reactive documents store reconciliation deltas after mispredic }, ); - const reactive = document.predictionWriter().reactive(); + const reactive = document.reactive(); await reactive.ready; - reactive.update(value => { - value.body = "predicted"; - value.revision = 7; - }); + reactive.current.body = "predicted"; + reactive.current.revision = 7; await waitFor(() => predictions.length === 1, "reactive reconciliation prediction"); watcher?.({ @@ -498,8 +421,8 @@ test("CultMesh TS reactive documents store reconciliation deltas after mispredic revision: 5, }); - assert.equal(reactive.snapshot.body, "authoritative"); - assert.equal(reactive.snapshot.revision, 5); + assert.equal(reactive.current.body, "authoritative"); + assert.equal(reactive.current.revision, 5); assert.equal(reactive.reconciliation?.version, 1); assert.deepEqual(reactive.reconciliation?.canonical, { noteId: "note:reconcile", @@ -521,7 +444,7 @@ test("CultMesh TS reactive documents store reconciliation deltas after mispredic reactive.dispose(); }); -test("CultMesh TS reactive documents coalesce same-frame explicit updates", async () => { +test("CultMesh TS reactive documents coalesce same-frame member writes", async () => { let current = { noteId: "note:reactive-frame", body: "initial", @@ -545,14 +468,14 @@ test("CultMesh TS reactive documents coalesce same-frame explicit updates", asyn }, ); - const reactive = document.predictionWriter().reactive({ + const reactive = document.reactive({ watch: false, }); try { await reactive.ready; - reactive.update(value => { value.body = "frame-1"; }); - reactive.update(value => { value.body = "frame-2"; }); - reactive.update(value => { value.body = "frame-3"; }); + reactive.current.body = "frame-1"; + reactive.current.body = "frame-2"; + reactive.current.body = "frame-3"; assert.equal(frameCallbacks.length, 1); frameCallbacks[0](performance.now()); @@ -572,118 +495,6 @@ test("CultMesh TS reactive documents coalesce same-frame explicit updates", asyn } }); -test("CultMesh TS headless reactive documents coalesce on one microtask", async () => { - const previousRequestAnimationFrame = globalThis.requestAnimationFrame; - delete (globalThis as unknown as { - requestAnimationFrame?: (callback: FrameRequestCallback) => number; - }).requestAnimationFrame; - let current = { noteId: "note:reactive-headless", body: "initial" }; - const predictions: string[] = []; - const document = CultMesh.document( - "cultmesh.note:reactive-headless", - noteDocument, - async () => current, - { - submitPrediction: async (_context, value) => { - predictions.push(value.body); - current = value; - }, - }, - ); - const reactive = document.predictionWriter().reactive({ watch: false }); - try { - await reactive.ready; - reactive.update(value => { value.body = "microtask-1"; }); - reactive.update(value => { value.body = "microtask-2"; }); - reactive.update(value => { value.body = "microtask-3"; }); - assert.equal(predictions.length, 0, "the current turn remains coalesced"); - - await waitFor(() => predictions.length === 1, "headless microtask prediction"); - assert.deepEqual(predictions, ["microtask-3"]); - } finally { - reactive.dispose(); - if (previousRequestAnimationFrame) { - globalThis.requestAnimationFrame = previousRequestAnimationFrame; - } - } -}); - -test("CultMesh TS reactive scheduling scales with changed documents only", async () => { - const previousRequestAnimationFrame = globalThis.requestAnimationFrame; - const previousCancelAnimationFrame = globalThis.cancelAnimationFrame; - const frameCallbacks = new Map(); - let nextFrame = 1; - globalThis.requestAnimationFrame = callback => { - const frame = nextFrame++; - frameCallbacks.set(frame, callback); - return frame; - }; - globalThis.cancelAnimationFrame = frame => { - frameCallbacks.delete(frame); - }; - - try { - for (const documentCount of [1, 100, 1000]) { - const reactiveDocuments = []; - for (let index = 0; index < documentCount; index++) { - let current = { - noteId: `note:scale:${index}`, - body: "initial", - }; - const document = CultMesh.document( - `cultmesh.note:scale:${index}`, - noteDocument, - async () => current, - { - submitPrediction: async (_context, value) => { - current = value; - }, - }, - ); - reactiveDocuments.push(document.predictionWriter().reactive({ watch: false })); - } - - await Promise.all(reactiveDocuments.map(document => document.ready)); - assert.equal(frameCallbacks.size, 0, `${documentCount} idle documents scheduled no work`); - - const changedDocumentCount = Math.max(1, Math.floor(documentCount / 100)); - for (let index = 0; index < changedDocumentCount; index++) { - reactiveDocuments[index].update(value => { - value.body = `changed-${index}`; - }); - } - - assert.equal( - frameCallbacks.size, - changedDocumentCount, - `${documentCount} documents scheduled only the ${changedDocumentCount} changed documents`, - ); - assert.equal( - reactiveDocuments.filter(document => document.dirty).length, - changedDocumentCount, - ); - - for (const reactive of reactiveDocuments) reactive.dispose(); - assert.equal(frameCallbacks.size, 0, "dispose cancels every scheduled frame flush"); - } - } finally { - if (previousRequestAnimationFrame) { - globalThis.requestAnimationFrame = previousRequestAnimationFrame; - } else { - delete (globalThis as unknown as { - requestAnimationFrame?: (callback: FrameRequestCallback) => number; - }).requestAnimationFrame; - } - if (previousCancelAnimationFrame) { - globalThis.cancelAnimationFrame = previousCancelAnimationFrame; - } else { - delete (globalThis as unknown as { - cancelAnimationFrame?: (handle: number) => void; - }).cancelAnimationFrame; - } - } -}); - test("CultMesh TS reactive documents queue edits made while a prediction is in flight", async () => { let current = { noteId: "note:reactive-in-flight", @@ -713,14 +524,14 @@ test("CultMesh TS reactive documents queue edits made while a prediction is in f }, ); - const reactive = document.predictionWriter().reactive({ + const reactive = document.reactive({ watch: false, debounceMs: 0, }); await reactive.ready; - reactive.update(value => { value.body = "first"; }); + reactive.current.body = "first"; await firstPredictionStarted; - reactive.update(value => { value.body = "second"; }); + reactive.current.body = "second"; releaseFirstPrediction(); await waitFor(() => predictions.length === 2, "in-flight queued prediction"); @@ -729,13 +540,13 @@ test("CultMesh TS reactive documents queue edits made while a prediction is in f reactive.dispose(); }); -test("CultMesh TS observed documents never publish borrowed snapshot mutation and keep watching", async () => { +test("CultMesh TS reactive read-only documents surface mutation failures and keep watching", async () => { let current = { noteId: "note:reactive-readonly", body: "initial", }; let watcher: ((value: typeof current) => void) | undefined; - const writes: typeof current[] = []; + const errors: string[] = []; const document = CultMesh.document( "cultmesh.note:reactive-readonly", noteDocument, @@ -747,17 +558,15 @@ test("CultMesh TS observed documents never publish borrowed snapshot mutation an watcher = undefined; }; }, - submitPrediction: async (_context, value) => { - writes.push(value); - }, }, ); - const observed = document.observe(); - await observed.ready; - observed.current.body = "borrowed-local-edit"; - await new Promise(resolve => setTimeout(resolve, 20)); - assert.deepEqual(writes, []); + const reactive = document.reactive({ + onError: error => errors.push(String((error as Error).message ?? error)), + }); + await reactive.ready; + reactive.current.body = "local-readonly-edit"; + await waitFor(() => errors.length === 1, "read-only mutation error"); current = { noteId: "note:reactive-readonly", @@ -765,8 +574,9 @@ test("CultMesh TS observed documents never publish borrowed snapshot mutation an }; watcher?.(current); - assert.equal(observed.current.body, "canonical-after-error"); - observed.dispose(); + assert.match(errors[0], /does not support mutation/); + assert.equal(reactive.current.body, "canonical-after-error"); + reactive.dispose(); }); test("CultMesh TS document handles read schema publications from single-file stores", async () => { @@ -816,76 +626,6 @@ test("CultMesh TS document handles read schema publications from single-file sto ]); }); -test("CultMesh TS projects live provider publications as typed document handles", async () => { - let current: unknown = { - noteId: "note:live-provider", - body: "initial publication", - }; - let watcher: ((value: unknown) => void) | undefined; - const calls: string[] = []; - - const document = CultMesh.documentFromPublication( - { - kind: "live-publication", - endpoint: "provider-session:aetheria", - source: { - latest: async (schemaId, recordKey) => { - calls.push(`latest:${schemaId}:${recordKey}`); - return current; - }, - watch: (schemaId, recordKey, callback) => { - calls.push(`watch:${schemaId}:${recordKey}`); - watcher = callback; - return () => { - calls.push("unsubscribed"); - watcher = undefined; - }; - }, - }, - }, - noteDocument, - "note:live-provider", - { documentId: "aetheria.note.live" }, - ); - - assert.deepEqual(await document.latest(), current); - assert.equal(document.routeHint.kind, "network"); - assert.equal(document.routeHint.description, "provider-session:aetheria"); - - const observed: string[] = []; - const unsubscribe = document.watch(value => observed.push(value.body)); - current = { - noteId: "note:live-provider", - body: "ordered live upsert", - }; - watcher?.(current); - unsubscribe(); - - assert.deepEqual(observed, ["ordered live upsert"]); - assert.deepEqual(calls, [ - "latest:cultmesh.note.v0:note:live-provider", - "watch:cultmesh.note.v0:note:live-provider", - "unsubscribed", - ]); - assert.equal(watcher, undefined); -}); - -test("CultMesh TS validates live provider publications through typed document schemas", async () => { - const document = CultMesh.documentFromPublication( - { - kind: "live-publication", - source: { - latest: async () => ({ noteId: 42, body: "invalid" }), - watch: () => () => undefined, - }, - }, - noteDocument, - "note:invalid-live-provider", - ); - - await assert.rejects(document.latest(), /noteId/); -}); - test("CultMesh TS binds publication document catalogs from source resolvers", async () => { const root = await mkdtemp(join(tmpdir(), "cultmesh-ts-publication-catalog-")); const firstPath = join(root, "first.ccmp"); @@ -986,7 +726,7 @@ test("CultMesh TS syncs configured publications into local node aliases", async assert.deepEqual(facadeSynced, synced); assert.equal(target.getRequired(noteDocument, "note:published").body, "publication source hydrates local alias"); assert.equal(target.getRequired(noteAliasDocument, "note:published").body, "publication source hydrates local alias"); - assert.equal((await target.document(noteAliasDocument, "note:published").observe({ watch: false }).ready).body, "publication source hydrates local alias"); + assert.equal((await target.reactiveDocument(noteAliasDocument, "note:published", { watch: false }).ready).body, "publication source hydrates local alias"); }); test("CultMesh TS syncs configured publication catalogs into local node aliases", async () => { @@ -2147,13 +1887,6 @@ test("CultMesh TS flattens and rehydrates UI state binding records", () => { schemaId: "gamecult.aetheria.daemon_frame.v1", routeKind: "shared-memory", routeDescription: "co-located frame slab", - bindingName: "", - documentId: "", - fieldPath: "", - valueKind: "", - accessMode: "", - authority: "", - writeCommand: "", }); assert.deepEqual(CultMesh.stateBindingFromRecord({ @@ -2175,26 +1908,6 @@ test("CultMesh TS flattens and rehydrates UI state binding records", () => { }); }); -test("CultMesh TS preserves editable binding identity and authority through transport records", () => { - const binding = CultMesh.stateBinding("value", "ghostlight.session-zero.composer", { - sourceId: "session-zero:draft-7", - schemaId: "session_zero.v1", - routeHint: CultMesh.routeHint("network", "Ghostlight provider"), - bindingName: "composer.message", - documentId: "session-zero:draft-7", - fieldPath: "channels.private.messages.pending", - valueKind: "string", - accessMode: "local-draft", - authority: "SessionZeroKernel", - writeCommand: "edit.value", - }); - const record = CultMesh.stateBindingRecord(binding); - assert.equal(record.bindingName, "composer.message"); - assert.equal(record.fieldPath, "channels.private.messages.pending"); - assert.equal(record.accessMode, "local-draft"); - assert.deepEqual(CultMesh.stateBindingFromRecord(record), binding); -}); - test("CultMesh TS describes UI command bindings from typed operations", async () => { const operation = CultMesh.operation< { actor: string }, @@ -2862,19 +2575,6 @@ test("CultMesh TS publishes one registered document to a RUDP catalog", async () } }); -test("CultMesh TS resolves a CultMesh URI through its authority bootstrap", () => { - assert.deepEqual( - CultMesh.resolveRudpEndpoint("cultmesh://odin/rendezvous/provider-catalog", { - CULTMESH_URI_ODIN_RUDP: "127.0.0.1:17871", - }), - { host: "127.0.0.1", port: 17871, uri: "rudp://127.0.0.1:17871" }, - ); - assert.throws( - () => CultMesh.resolveRudpEndpoint("cultmesh://missing/rendezvous/provider-catalog", {}), - /did not resolve/, - ); -}); - test("CultMesh TS returns a correlated provider receipt after a RUDP document mutation", async () => { const connectionId = 0x10203050; const commandBinding = defineCultNetDocumentBinding({ definition: noteDocument }); diff --git a/packages/cultmesh-ts/test/provider-rudp-transport.test.ts b/packages/cultmesh-ts/test/provider-rudp-transport.test.ts deleted file mode 100644 index 5b44c84b..00000000 --- a/packages/cultmesh-ts/test/provider-rudp-transport.test.ts +++ /dev/null @@ -1,334 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createSocket } from "node:dgram"; -import { decode } from "@msgpack/msgpack"; -import { - CultNetDocumentRegistry, - CultNetRudpSession, - decodeRudpPacket, - encodeRudpPacket, - type CultNetOperationRequestMessage, -} from "cultnet-ts"; - -import { - CultMesh, - CultMeshProviderRudpTransport, - CultMeshProviderSessionBroker, - CULTMESH_PROVIDER_SESSION_SERVICE_ID, - cultMeshProviderSessionOperations, - cultMeshProviderSessionSchemas, - decodeProviderConnectEvidence, - encodeProviderConnectEvidence, - encodeProviderSessionPayload, - type CultMeshProviderCommand, - type CultMeshProviderIdentity, - type CultMeshProviderPublication, -} from "../src"; - -const identity: CultMeshProviderIdentity = { - providerId: "voidbot.swarm", - serviceInstanceId: "voidbot-worker-7", - endpointId: "voidbot-worker-7.rudp", - verseId: "voidbot.local", -}; - -test("RUDP provider transport requires broker acceptance for the full lifecycle", async () => { - const accepted = new Map(); - const acceptedProvenance = new Map(); - const deleted: string[] = []; - const receipts: string[] = []; - let leaseSequence = 0; - const broker = new CultMeshProviderSessionBroker({ - runtimeId: "odin-test", - createLeaseId: () => `lease-${++leaseSequence}`, - authorizeRegistration: (_identity, session) => - session.connectPayload !== undefined - && decodeProviderConnectEvidence(session.connectPayload).sessionToken === "signed-provider-token", - acceptPublication: (_identity, publicationId, document) => { - accepted.set(publicationId, decode(document.payload)); - acceptedProvenance.set(publicationId, { - runtime: document.sourceRuntimeId, - agent: document.sourceAgentId, - }); - }, - deletePublications: (_identity, publications) => { - for (const { publicationId } of publications) { - deleted.push(publicationId); - accepted.delete(publicationId); - } - }, - acceptReceipt: (_identity, receipt) => { - receipts.push(receipt.receiptId); - }, - }); - const server = CultMesh.createRudpDocumentServer("odin-test", 0x43554c54, { - bindPort: 0, - documents: new CultNetDocumentRegistry(), - onOperationRequest: (request, session) => broker.handle(request, session), - onSessionClosed: session => broker.sessionClosed(session), - }); - await server.start(); - - const unauthorizedConnection = await new CultMeshProviderRudpTransport({ - endpoint: `rudp://127.0.0.1:${server.bind.port}`, - runtimeId: "unauthorized-provider", - connectionId: 0x43554c54, - sessionToken: "not-authority", - }).connect(identity, new AbortController().signal); - try { - await assert.rejects( - unauthorizedConnection.register( - { identity, requestedLeaseDurationMs: 10_000 }, - new AbortController().signal, - ), - /denied|not authorized/i, - ); - assert.equal( - broker.activeLeaseCount, - 0, - "a successful RUDP handshake does not grant provider authority", - ); - } finally { - unauthorizedConnection.close(); - } - - const providerSocket = createSocket("udp4"); - const rawSend = providerSocket.send.bind(providerSocket); - let originalConnectWire: Buffer | undefined; - providerSocket.send = ((message: Uint8Array, ...args: unknown[]) => { - if (decodeRudpPacket(message).packetType === "connect" && !originalConnectWire) { - originalConnectWire = Buffer.from(message); - } - return (rawSend as (...parameters: unknown[]) => boolean)(message, ...args); - }) as typeof providerSocket.send; - const transport = new CultMeshProviderRudpTransport({ - endpoint: `rudp://127.0.0.1:${server.bind.port}`, - runtimeId: identity.serviceInstanceId, - connectionId: 0x43554c54, - sessionToken: "signed-provider-token", - socketFactory: () => providerSocket, - }); - const connection = await transport.connect(identity, new AbortController().signal); - const secondIdentity: CultMeshProviderIdentity = { - providerId: "aetheria", - serviceInstanceId: "aetheria-daemon-2", - endpointId: "aetheria-daemon-2.rudp", - verseId: "aetheria.local", - }; - const secondConnection = await new CultMeshProviderRudpTransport({ - endpoint: `rudp://127.0.0.1:${server.bind.port}`, - runtimeId: secondIdentity.serviceInstanceId, - connectionId: 0x43554c54, - sessionToken: "signed-provider-token", - }).connect(secondIdentity, new AbortController().signal); - try { - broker.enqueueCommand({ - commandId: "refresh-1", - commandKind: "voidbot.refresh", - providerId: identity.providerId, - serviceInstanceId: identity.serviceInstanceId, - payload: { force: true }, - }); - const lease = await connection.register({ identity, requestedLeaseDurationMs: 10_000 }, new AbortController().signal); - assert.equal(lease.leaseId, "lease-1"); - assert.ok(originalConnectWire); - await new Promise((resolve, reject) => { - rawSend(originalConnectWire!, server.bind.port, "127.0.0.1", error => error ? reject(error) : resolve()); - }); - await new Promise(resolve => setTimeout(resolve, 25)); - assert.equal(broker.activeLeaseCount, 1, "a duplicate Connect cannot revoke the live provider lease"); - const secondLease = await secondConnection.register({ - identity: secondIdentity, - requestedLeaseDurationMs: 10_000, - }, new AbortController().signal); - assert.equal(secondLease.leaseId, "lease-2"); - assert.equal(broker.activeLeaseCount, 2, "the broker demultiplexes simultaneous physical provider sessions"); - - const publication: CultMeshProviderPublication = { - publicationId: "voidbot.surface", - documentType: "gamecult.eve.surface_state", - schemaId: "gamecult.eve.surface_state.v1", - recordKey: "voidbot.swarm", - value: { generation: 3 }, - }; - await connection.publish(publication, lease); - assert.deepEqual(accepted.get(publication.publicationId), publication.value); - assert.deepEqual(acceptedProvenance.get(publication.publicationId), { - runtime: identity.serviceInstanceId, - agent: identity.providerId, - }, "the broker derives provenance from the fenced identity"); - const secondPublication: CultMeshProviderPublication = { - publicationId: "aetheria.surface", - documentType: "gamecult.eve.surface_state", - schemaId: "gamecult.eve.surface_state.v1", - recordKey: "aetheria.pilot", - value: { generation: 8 }, - }; - await secondConnection.publish(secondPublication, secondLease); - await assert.rejects(secondConnection.publish({ - ...secondPublication, - publicationId: "aetheria.stolen-tuple", - recordKey: publication.recordKey, - }, secondLease), /already owned/); - await assert.rejects( - secondConnection.publish({ ...secondPublication, value: { generation: 9 } }, lease), - /belongs to another physical session/, - ); - - const commandArrived = deferred(); - connection.watchCommands(command => commandArrived.resolve(command)); - const command = await commandArrived.promise; - assert.equal(command.commandId, "refresh-1"); - await connection.publishReceipt({ - receiptId: "receipt-refresh-1", - commandId: command.commandId, - commandKind: command.commandKind, - providerId: identity.providerId, - serviceInstanceId: identity.serviceInstanceId, - state: "applied", - completedAt: new Date(), - result: { refreshed: true }, - }, lease); - assert.deepEqual(receipts, ["receipt-refresh-1"]); - - const renewed = await connection.renew(lease); - assert.equal(renewed.leaseId, "lease-3"); - await assert.rejects(connection.withdrawPublication(publication.publicationId, lease), /expired|fenced/); - await connection.withdraw({ identity, leaseId: renewed.leaseId, publicationIds: [publication.publicationId] }); - await secondConnection.withdraw({ - identity: secondIdentity, - leaseId: secondLease.leaseId, - publicationIds: [secondPublication.publicationId], - }); - assert.deepEqual(deleted, [publication.publicationId, secondPublication.publicationId]); - assert.equal(accepted.size, 0); - assert.equal(broker.activeLeaseCount, 0); - } finally { - connection.close(); - secondConnection.close(); - server.close(); - broker.close(); - } -}); - -test("closed physical sessions cannot finish delayed registration", async () => { - const authorization = deferred(); - const broker = new CultMeshProviderSessionBroker({ - runtimeId: "odin-test", - authorizeRegistration: () => authorization.promise, - acceptPublication: () => undefined, - deletePublications: () => undefined, - acceptReceipt: () => undefined, - }); - const session = { - sessionId: "closed-generation", - remote: { address: "127.0.0.1", family: "IPv4", port: 40000 }, - connectPayload: new Uint8Array(), - send: () => undefined, - }; - const request: CultNetOperationRequestMessage = { - schemaVersion: "cultnet.operation_request.v0", - messageId: "late-register", - serviceId: CULTMESH_PROVIDER_SESSION_SERVICE_ID, - operation: cultMeshProviderSessionOperations.register, - payloadSchema: cultMeshProviderSessionSchemas.registration, - payloadEncoding: "messagepack-base64", - payload: encodeProviderSessionPayload({ ...identity, requestedLeaseDurationMs: 10_000 }), - }; - try { - const pending = broker.handle(request, session); - await new Promise(resolve => setTimeout(resolve, 0)); - broker.sessionClosed(session); - authorization.resolve(true); - const response = await pending; - assert.equal(response.status, "denied"); - assert.equal(broker.activeLeaseCount, 0); - } finally { - broker.close(); - } -}); - -test("a replaced RUDP generation drops operations queued behind an active handler", async () => { - const connectionId = 0x43554c54; - const handlerGate = deferred(); - let handlerCalls = 0; - let closedSessions = 0; - const server = CultMesh.createRudpDocumentServer("odin-queue-test", connectionId, { - bindPort: 0, - documents: new CultNetDocumentRegistry(), - onOperationRequest: async request => { - handlerCalls += 1; - if (handlerCalls === 1) await handlerGate.promise; - return { - schemaVersion: "cultnet.operation_response.v0", - messageId: request.messageId, - serviceId: request.serviceId, - operation: request.operation, - status: "denied", - payloadSchema: cultMeshProviderSessionSchemas.mutationAcceptance, - payloadEncoding: "messagepack-base64", - payload: encodeProviderSessionPayload({}), - }; - }, - onSessionClosed: () => { closedSessions += 1; }, - onError: () => undefined, - }); - await server.start(); - - const socket = createSocket("udp4"); - const rawSend = socket.send.bind(socket); - const connection = await new CultMeshProviderRudpTransport({ - endpoint: `rudp://127.0.0.1:${server.bind.port}`, - runtimeId: identity.serviceInstanceId, - connectionId, - sessionToken: "signed-provider-token", - operationTimeoutMs: 100, - socketFactory: () => socket, - }).connect(identity, new AbortController().signal); - try { - const first = connection.register( - { identity, requestedLeaseDurationMs: 10_000 }, - new AbortController().signal, - ); - const second = connection.register( - { identity, requestedLeaseDurationMs: 10_000 }, - new AbortController().signal, - ); - await waitFor(() => handlerCalls === 1, "first queued provider operation"); - - const replacement = new CultNetRudpSession({ connectionId }); - const replacementWire = encodeRudpPacket(replacement.createConnect( - Date.now(), - Buffer.from(encodeProviderConnectEvidence({ - clientSessionId: "replacement-generation", - sessionToken: "signed-provider-token", - })), - )); - await new Promise((resolve, reject) => { - rawSend(replacementWire, server.bind.port, "127.0.0.1", error => error ? reject(error) : resolve()); - }); - await waitFor(() => closedSessions === 1, "old RUDP generation closure"); - handlerGate.resolve(); - await new Promise(resolve => setTimeout(resolve, 20)); - assert.equal(handlerCalls, 1, "transport-queued work from the closed generation must be dropped"); - - connection.close(); - await Promise.allSettled([first, second]); - } finally { - connection.close(); - server.close(); - } -}); - -function deferred(): { promise: Promise; resolve(value: T): void } { - let resolve!: (value: T) => void; - return { promise: new Promise(done => resolve = done), resolve }; -} - -async function waitFor(predicate: () => boolean, description: string): Promise { - const startedAt = Date.now(); - while (!predicate()) { - if (Date.now() - startedAt > 1_000) throw new Error(`Timed out waiting for ${description}.`); - await new Promise(resolve => setTimeout(resolve, 5)); - } -} diff --git a/packages/cultmesh-ts/test/provider-session-wire.test.ts b/packages/cultmesh-ts/test/provider-session-wire.test.ts deleted file mode 100644 index ac607163..00000000 --- a/packages/cultmesh-ts/test/provider-session-wire.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -import { - decodeProviderConnectEvidence, - encodeProviderConnectEvidence, - encodeProviderSessionPayload, -} from "../src/provider-session-wire"; - -const REGISTRATION_GOLDEN = "hqpwcm92aWRlcklkqGFldGhlcmlhsXNlcnZpY2VJbnN0YW5jZUlkq2FldGhlcmlhLTQyqmVuZHBvaW50SWSvYWV0aGVyaWEtcHVibGljp3ZlcnNlSWSmcHVibGljuHJlcXVlc3RlZExlYXNlRHVyYXRpb25Nc811MLBhdXRob3JpdHlMZWFzZUlkq2F1dGhvcml0eS03"; -const CONNECT_EVIDENCE_GOLDEN = "gq9jbGllbnRTZXNzaW9uSWSyYWV0aGVyaWEtY2xpZW50LTQyrHNlc3Npb25Ub2tlbrJvZGluLXNlc3Npb24tdG9rZW4="; -const TOKENLESS_CONNECT_EVIDENCE_GOLDEN = "gq9jbGllbnRTZXNzaW9uSWSyYWV0aGVyaWEtY2xpZW50LTQyrHNlc3Npb25Ub2tlbsA="; - -test("provider registration matches the shared C# MessagePack golden payload", () => { - assert.equal(encodeProviderSessionPayload({ - providerId: "aetheria", - serviceInstanceId: "aetheria-42", - endpointId: "aetheria-public", - verseId: "public", - requestedLeaseDurationMs: 30_000, - authorityLeaseId: "authority-7", - }), REGISTRATION_GOLDEN); -}); - -test("provider Connect evidence keeps transport generation separate from authority", () => { - const evidence = { - clientSessionId: "aetheria-client-42", - sessionToken: "odin-session-token", - }; - const encoded = encodeProviderConnectEvidence(evidence); - assert.equal(Buffer.from(encoded).toString("base64"), CONNECT_EVIDENCE_GOLDEN); - assert.deepEqual( - decodeProviderConnectEvidence(encoded), - evidence, - ); - assert.equal( - Buffer.from(encodeProviderConnectEvidence({ - clientSessionId: "aetheria-client-42", - sessionToken: null, - })).toString("base64"), - TOKENLESS_CONNECT_EVIDENCE_GOLDEN, - ); -}); diff --git a/packages/cultmesh-ts/test/provider-session.test.ts b/packages/cultmesh-ts/test/provider-session.test.ts deleted file mode 100644 index 8e1c9d50..00000000 --- a/packages/cultmesh-ts/test/provider-session.test.ts +++ /dev/null @@ -1,791 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { - CultMeshMemoryProviderReceiptStore, - CultMeshProviderSession, - type CultMeshProviderCommand, - type CultMeshProviderCommandListener, - type CultMeshProviderCommandReceipt, - type CultMeshProviderConnection, - type CultMeshProviderIdentity, - type CultMeshProviderLease, - type CultMeshProviderPublication, - type CultMeshProviderRegistration, - type CultMeshProviderScheduledTask, - type CultMeshProviderScheduler, - type CultMeshProviderTransport, - type CultMeshProviderWithdrawal, - type CultMeshProviderUnsubscribe, -} from "../src/provider-session"; - -const identity: CultMeshProviderIdentity = { - providerId: "voidbot.swarm", - serviceInstanceId: "voidbot-worker-7", - endpointId: "odin:voidbot-worker-7", - verseId: "voidbot.local", -}; - -const surface: CultMeshProviderPublication = { - publicationId: "voidbot.swarm.surface", - documentType: "gamecult.eve.surface_state", - schemaId: "gamecult.eve.surface_state.v1", - recordKey: "voidbot.swarm", - value: { version: 1 }, -}; - -const advertisement: CultMeshProviderPublication = { - publicationId: "voidbot.swarm.advertisement", - documentType: "gamecult.eve.provider_advertisement", - schemaId: "gamecult.eve.provider_advertisement.v1", - recordKey: "voidbot.swarm", - value: { providerId: "voidbot.swarm" }, -}; - -test("provider session owns registration, renewal, desired publication, and withdrawal", async () => { - const scheduler = new ManualScheduler(); - const transport = new FakeTransport(scheduler); - const states: string[] = []; - const session = new CultMeshProviderSession({ - identity, - transport, - scheduler, - receiptStore: new CultMeshMemoryProviderReceiptStore(), - publications: [surface, advertisement], - leaseDurationMs: 1_000, - renewalLeadMs: 200, - reconnectBaseDelayMs: 100, - reconnectMaxDelayMs: 1_000, - }); - session.watchState(state => states.push(state.status)); - - await session.start(); - - const connection = transport.connections[0]!; - assert.equal(session.state.status, "active"); - assert.deepEqual(connection.registrations[0]?.identity, identity); - assert.deepEqual( - connection.publications.map(value => value.publicationId), - [advertisement.publicationId, surface.publicationId], - "initial desired state is republished in stable order", - ); - assert.notEqual(identity.providerId, identity.serviceInstanceId); - assert.notEqual(identity.serviceInstanceId, identity.endpointId); - - await scheduler.advance(800); - assert.equal(connection.renewals.length, 1); - assert.equal(session.state.status, "active"); - - const updatedSurface = { ...surface, value: { version: 2 } }; - const finalSurface = { ...surface, value: { version: 3 } }; - const publishBarrier = new Deferred(); - connection.publishBarrier = publishBarrier; - const updating = session.upsertPublication(updatedSurface); - const finalizing = session.upsertPublication(finalSurface); - await settle(); - assert.deepEqual(connection.publications.at(-1), surface, "the second update waits behind the first delivery"); - publishBarrier.resolve(); - await Promise.all([updating, finalizing]); - assert.deepEqual( - connection.publications.slice(-2), - [updatedSurface, finalSurface], - "desired publication updates preserve caller order on the live connection", - ); - - assert.equal(await session.removePublication(surface.publicationId), true); - assert.deepEqual(connection.withdrawnPublications, [surface.publicationId]); - - await session.stop(); - assert.equal(connection.closed, true); - assert.deepEqual(connection.withdrawals[0]?.identity, identity); - assert.deepEqual(connection.withdrawals[0]?.publicationIds, [advertisement.publicationId]); - assert.equal(session.state.status, "stopped"); - assert.ok(states.includes("connecting")); - assert.ok(states.includes("withdrawing")); -}); - -test("stopping during registration prevents a stale connection from publishing", async () => { - const scheduler = new ManualScheduler(); - const transport = new FakeTransport(scheduler); - transport.registerBarrier = new Deferred(); - const session = new CultMeshProviderSession({ - identity, - transport, - scheduler, - receiptStore: new CultMeshMemoryProviderReceiptStore(), - publications: [advertisement], - leaseDurationMs: 1_000, - renewalLeadMs: 200, - }); - - const starting = session.start(); - await settle(); - const connection = transport.connections[0]!; - const stopping = session.stop(); - transport.registerBarrier.resolve(); - await Promise.all([starting, stopping]); - - assert.equal(session.state.status, "stopped"); - assert.equal(connection.closed, true); - assert.deepEqual(connection.publications, []); - assert.deepEqual(connection.withdrawals, []); -}); - -test("provider session reconnects and republishes desired state after transient failure", async () => { - const scheduler = new ManualScheduler(); - const transport = new FakeTransport(scheduler); - transport.failConnectCount = 2; - const session = new CultMeshProviderSession({ - identity, - transport, - scheduler, - receiptStore: new CultMeshMemoryProviderReceiptStore(), - publications: [advertisement], - leaseDurationMs: 1_000, - renewalLeadMs: 200, - reconnectBaseDelayMs: 100, - reconnectMaxDelayMs: 400, - }); - - await session.start(); - assert.equal(session.state.status, "reconnecting"); - assert.equal(session.state.reconnectAttempt, 1); - - await scheduler.advance(100); - assert.equal(session.state.status, "reconnecting"); - assert.equal(session.state.reconnectAttempt, 2); - await scheduler.advance(200); - assert.equal(session.state.status, "active"); - assert.equal(transport.connections.length, 1); - assert.deepEqual( - transport.connections[0]?.publications.map(value => value.publicationId), - [advertisement.publicationId], - ); - - transport.connections[0]!.failNextPublish = new Error("partition"); - await session.upsertPublication(surface); - assert.equal(session.state.status, "reconnecting"); - assert.match(session.state.lastError ?? "", /partition/); - assert.equal(transport.connections[0]?.closed, true); - - await scheduler.advance(100); - assert.equal(session.state.status, "active"); - assert.deepEqual( - transport.connections[1]?.publications.map(value => value.publicationId), - [advertisement.publicationId, surface.publicationId], - ); - await session.stop(); -}); - -test("receipt persistence failure degrades the provider without inventing a transport outage", async () => { - const scheduler = new ManualScheduler(); - const transport = new FakeTransport(scheduler); - const session = new CultMeshProviderSession({ - identity, - transport, - scheduler, - receiptStore: { - get: async () => { throw new Error("receipt store offline"); }, - put: async () => undefined, - listPending: async () => [], - markPublished: async () => undefined, - }, - leaseDurationMs: 1_000, - renewalLeadMs: 200, - commandHandlers: { - "swarm.set_heat": async () => ({ state: "applied" }), - }, - }); - await session.start(); - const connection = transport.connections[0]!; - await connection.emit({ - commandId: "heat-store-failure", - commandKind: "swarm.set_heat", - providerId: identity.providerId, - serviceInstanceId: identity.serviceInstanceId, - payload: 0.5, - }); - - assert.equal(session.state.status, "degraded"); - assert.match(session.state.lastError ?? "", /receipt store offline/); - assert.equal(connection.closed, false); - assert.equal(transport.connections.length, 1); - await session.stop(); -}); - -test("provider session dispatches commands once and republishes typed receipts", async () => { - const scheduler = new ManualScheduler(); - const transport = new FakeTransport(scheduler); - let applyCount = 0; - const session = new CultMeshProviderSession({ - identity, - transport, - scheduler, - receiptStore: new CultMeshMemoryProviderReceiptStore(), - leaseDurationMs: 1_000, - renewalLeadMs: 200, - commandHandlers: { - "swarm.set_heat": async command => { - applyCount++; - await settle(); - return { state: "applied", result: { heat: command.payload } }; - }, - }, - }); - await session.start(); - const connection = transport.connections[0]!; - const command: CultMeshProviderCommand = { - commandId: "heat-1", - commandKind: "swarm.set_heat", - providerId: identity.providerId, - serviceInstanceId: identity.serviceInstanceId, - payload: 0.75, - }; - - await Promise.all([connection.emit(command), connection.emit(command)]); - await connection.emit(command); - - assert.equal(applyCount, 1); - assert.equal(connection.receipts.length, 2, "concurrent duplicates share one delivery; later retries republish"); - assert.ok(connection.receipts.every(receipt => - receipt.receiptId === "receipt:13:voidbot.swarm16:voidbot-worker-76:heat-1")); - assert.ok(connection.receipts.every(receipt => receipt.state === "applied")); - - await connection.emit({ ...command, commandId: "unknown-1", commandKind: "swarm.unknown" }); - assert.equal(connection.receipts.at(-1)?.state, "rejected"); - - await connection.emit({ ...command, commandId: "wrong-instance", serviceInstanceId: "somebody-else" }); - assert.equal(connection.receipts.length, 3, "commands for another service instance are ignored"); - await session.stop(); -}); - -test("persisted receipts survive a transport failure and leave the outbox only after replay", async () => { - const scheduler = new ManualScheduler(); - const transport = new FakeTransport(scheduler); - const receipts = new CultMeshMemoryProviderReceiptStore(); - let applyCount = 0; - const session = new CultMeshProviderSession({ - identity, - transport, - scheduler, - receiptStore: receipts, - leaseDurationMs: 1_000, - renewalLeadMs: 200, - reconnectBaseDelayMs: 100, - commandHandlers: { - "swarm.set_heat": async () => { - applyCount++; - return { state: "applied" }; - }, - }, - }); - await session.start(); - transport.connections[0]!.failNextReceipt = new Error("receipt route lost"); - await transport.connections[0]!.emit({ - commandId: "durable-1", - commandKind: "swarm.set_heat", - providerId: identity.providerId, - serviceInstanceId: identity.serviceInstanceId, - payload: 0.25, - }); - - assert.equal(session.state.status, "reconnecting"); - assert.equal((await receipts.listPending()).length, 1); - await scheduler.advance(100); - assert.equal(session.state.status, "active"); - assert.equal(transport.connections[1]!.receipts.length, 1); - assert.equal((await receipts.listPending()).length, 0); - assert.equal(applyCount, 1); - - transport.connections[1]!.failNextPublish = new Error("state route lost"); - await session.upsertPublication(surface); - await scheduler.advance(100); - assert.equal(transport.connections[2]!.receipts.length, 0, "marked receipts are not replayed again"); - await session.stop(); -}); - -test("initial replay and live publication changes share one ordered lane", async () => { - const scheduler = new ManualScheduler(); - const transport = new FakeTransport(scheduler); - transport.publishBarrier = new Deferred(); - const session = new CultMeshProviderSession({ - identity, - transport, - scheduler, - receiptStore: new CultMeshMemoryProviderReceiptStore(), - publications: [surface], - leaseDurationMs: 1_000, - renewalLeadMs: 200, - }); - - const starting = session.start(); - await settle(); - const connection = transport.connections[0]!; - const updated = { ...surface, value: { version: 2 } }; - const updating = session.upsertPublication(updated); - const removing = session.removePublication(surface.publicationId); - transport.publishBarrier.resolve(); - await Promise.all([starting, updating, removing]); - - assert.deepEqual(connection.publications, [surface, updated]); - assert.deepEqual(connection.withdrawnPublications, [surface.publicationId]); - assert.deepEqual(session.publications, []); - await session.stop(); -}); - -test("state observers cannot become lifecycle authorities", async () => { - const scheduler = new ManualScheduler(); - const transport = new FakeTransport(scheduler); - const observerErrors: unknown[] = []; - const observed: string[] = []; - const session = new CultMeshProviderSession({ - identity, - transport, - scheduler, - receiptStore: new CultMeshMemoryProviderReceiptStore(), - leaseDurationMs: 1_000, - renewalLeadMs: 200, - onObserverError: error => observerErrors.push(error), - }); - session.watchState(() => { throw new Error("display broken"); }); - session.watchState(state => observed.push(state.status)); - - await session.start(); - await session.stop(); - - assert.equal(session.state.status, "stopped"); - assert.ok(observerErrors.length >= 3); - assert.deepEqual(observed, ["stopped", "connecting", "active", "withdrawing", "stopped"]); -}); - -test("shutdown drains command persistence and always closes after cleanup failures", async () => { - const scheduler = new ManualScheduler(); - const transport = new FakeTransport(scheduler); - const handlerBarrier = new Deferred(); - const session = new CultMeshProviderSession({ - identity, - transport, - scheduler, - receiptStore: new CultMeshMemoryProviderReceiptStore(), - leaseDurationMs: 1_000, - renewalLeadMs: 200, - commandHandlers: { - "swarm.set_heat": async () => { - await handlerBarrier.promise; - return { state: "applied" }; - }, - }, - }); - await session.start(); - const connection = transport.connections[0]!; - const command = connection.emit({ - commandId: "stop-drain-1", - commandKind: "swarm.set_heat", - providerId: identity.providerId, - serviceInstanceId: identity.serviceInstanceId, - payload: 0.5, - }); - connection.unsubscribeError = new Error("unsubscribe failed"); - connection.withdrawError = new Error("withdraw failed"); - connection.closeError = new Error("close failed"); - let stopped = false; - const stopping = session.stop().finally(() => { stopped = true; }); - await settle(); - assert.equal(stopped, false, "shutdown waits for the authoritative command transaction"); - handlerBarrier.resolve(); - await command; - await assert.rejects(stopping, AggregateError); - - assert.equal(connection.closeAttempts, 1); - assert.equal(session.state.status, "stopped"); -}); - -test("concurrent reuse of a command id with another kind cannot join the first transaction", async () => { - const scheduler = new ManualScheduler(); - const transport = new FakeTransport(scheduler); - const barrier = new Deferred(); - let heatCount = 0; - let otherCount = 0; - const session = new CultMeshProviderSession({ - identity, - transport, - scheduler, - receiptStore: new CultMeshMemoryProviderReceiptStore(), - leaseDurationMs: 1_000, - renewalLeadMs: 200, - commandHandlers: { - "swarm.set_heat": async () => { - heatCount++; - await barrier.promise; - return { state: "applied" }; - }, - "swarm.other": async () => { - otherCount++; - return { state: "applied" }; - }, - }, - }); - await session.start(); - const connection = transport.connections[0]!; - const first = connection.emit({ - commandId: "collision-1", - commandKind: "swarm.set_heat", - providerId: identity.providerId, - serviceInstanceId: identity.serviceInstanceId, - payload: 0.5, - }); - await settle(); - await connection.emit({ - commandId: "collision-1", - commandKind: "swarm.other", - providerId: identity.providerId, - serviceInstanceId: identity.serviceInstanceId, - payload: null, - }); - assert.equal(session.state.status, "degraded"); - barrier.resolve(); - await first; - assert.equal(heatCount, 1); - assert.equal(otherCount, 0); - await session.stop(); -}); - -test("registration failure closes the connection before retry", async () => { - const scheduler = new ManualScheduler(); - const transport = new FakeTransport(scheduler); - transport.registerError = new Error("registration rejected"); - const session = new CultMeshProviderSession({ - identity, - transport, - scheduler, - receiptStore: new CultMeshMemoryProviderReceiptStore(), - leaseDurationMs: 1_000, - renewalLeadMs: 200, - reconnectBaseDelayMs: 100, - }); - await session.start(); - assert.equal(transport.connections[0]!.closed, true); - assert.equal(session.state.status, "reconnecting"); - await session.stop(); -}); - -test("an expired replay lease never carries the next pending receipt", async () => { - const scheduler = new ManualScheduler(); - const transport = new FakeTransport(scheduler); - const receipts = new CultMeshMemoryProviderReceiptStore(); - const pending = (commandId: string): CultMeshProviderCommandReceipt => ({ - receiptId: `receipt:13:voidbot.swarm16:voidbot-worker-7${commandId.length}:${commandId}`, - commandId, - commandKind: "swarm.set_heat", - providerId: identity.providerId, - serviceInstanceId: identity.serviceInstanceId, - state: "applied", - completedAt: scheduler.now(), - }); - await receipts.put(pending("one")); - await receipts.put(pending("two")); - const session = new CultMeshProviderSession({ - identity, - transport, - scheduler, - receiptStore: receipts, - leaseDurationMs: 1_000, - renewalLeadMs: 200, - reconnectBaseDelayMs: 100, - }); - transport.onFirstConnection = connection => { - connection.afterPublishReceipt = async () => scheduler.advance(1_001); - }; - - await session.start(); - assert.equal(session.state.status, "reconnecting"); - assert.deepEqual( - transport.connections[0]!.receipts.map(value => value.commandId), - ["one"], - "the expired lease is rejected before the next receipt", - ); - assert.deepEqual((await receipts.listPending()).map(value => value.commandId), ["two"]); - await scheduler.advance(100); - assert.equal(session.state.status, "active"); - assert.deepEqual(transport.connections[1]!.receipts.map(value => value.commandId), ["two"]); - assert.equal((await receipts.listPending()).length, 0); - await session.stop(); -}); - -test("overlapping shutdown callers share cleanup and restart waits for its boundary", async () => { - const scheduler = new ManualScheduler(); - const transport = new FakeTransport(scheduler); - const session = new CultMeshProviderSession({ - identity, - transport, - scheduler, - receiptStore: new CultMeshMemoryProviderReceiptStore(), - leaseDurationMs: 1_000, - renewalLeadMs: 200, - }); - await session.start(); - const connection = transport.connections[0]!; - connection.withdrawBarrier = new Deferred(); - - const firstStop = session.stop(); - const secondStop = session.stop(); - assert.equal(firstStop, secondStop, "concurrent callers own one shutdown operation"); - await assert.rejects(session.start(), /shutdown is in progress/); - await settle(); - assert.equal(connection.closed, false); - connection.withdrawBarrier.resolve(); - await firstStop; - assert.equal(session.state.status, "stopped"); - - await session.start(); - assert.equal(session.state.status, "active"); - assert.equal(transport.connections.length, 2); - await session.stop(); -}); - -test("loss cleanup failures cannot suppress reconnect scheduling", async () => { - const scheduler = new ManualScheduler(); - const transport = new FakeTransport(scheduler); - const session = new CultMeshProviderSession({ - identity, - transport, - scheduler, - receiptStore: new CultMeshMemoryProviderReceiptStore(), - leaseDurationMs: 1_000, - renewalLeadMs: 200, - reconnectBaseDelayMs: 100, - }); - await session.start(); - const connection = transport.connections[0]!; - connection.unsubscribeError = new Error("watch cleanup failed"); - connection.closeError = new Error("route close failed"); - connection.failNextPublish = new Error("route lost"); - - await session.upsertPublication(surface); - assert.equal(session.state.status, "reconnecting"); - assert.match(session.state.lastError ?? "", /watch cleanup failed/); - assert.match(session.state.lastError ?? "", /route close failed/); - await scheduler.advance(100); - assert.equal(session.state.status, "active"); - await session.stop(); -}); - -test("stop during receipt replay cannot publish active state or leave a renewal timer", async () => { - const scheduler = new ManualScheduler(); - const transport = new FakeTransport(scheduler); - const listBarrier = new Deferred(); - const states: string[] = []; - const session = new CultMeshProviderSession({ - identity, - transport, - scheduler, - receiptStore: { - get: async () => undefined, - put: async () => undefined, - listPending: async () => { - await listBarrier.promise; - return []; - }, - markPublished: async () => undefined, - }, - leaseDurationMs: 1_000, - renewalLeadMs: 200, - }); - session.watchState(state => states.push(state.status)); - const starting = session.start(); - await settle(); - const stopping = session.stop(); - await settle(); - const withdrawingIndex = states.lastIndexOf("withdrawing"); - listBarrier.resolve(); - await Promise.all([starting, stopping]); - - assert.equal(session.state.status, "stopped"); - assert.equal(states.slice(withdrawingIndex + 1).includes("active"), false); - assert.equal(scheduler.pendingTaskCount, 0); -}); - -class ManualScheduler implements CultMeshProviderScheduler { - #nowMs = Date.parse("2026-07-14T00:00:00.000Z"); - readonly #tasks: Array<{ - dueAt: number; - cancelled: boolean; - action: () => void | Promise; - }> = []; - - public now(): Date { - return new Date(this.#nowMs); - } - - public get pendingTaskCount(): number { - return this.#tasks.filter(value => !value.cancelled).length; - } - - public schedule(delayMs: number, action: () => void | Promise): CultMeshProviderScheduledTask { - const task = { dueAt: this.#nowMs + delayMs, cancelled: false, action }; - this.#tasks.push(task); - return { cancel: () => { task.cancelled = true; } }; - } - - public async advance(delayMs: number): Promise { - this.#nowMs += delayMs; - while (true) { - const task = this.#tasks - .filter(value => !value.cancelled && value.dueAt <= this.#nowMs) - .sort((left, right) => left.dueAt - right.dueAt)[0]; - if (!task) return; - task.cancelled = true; - await task.action(); - } - } -} - -class FakeTransport implements CultMeshProviderTransport { - public failConnectCount = 0; - public registerBarrier?: Deferred; - public registerError?: Error; - public publishBarrier?: Deferred; - public onFirstConnection?: (connection: FakeConnection) => void; - public readonly connections: FakeConnection[] = []; - - public constructor(private readonly scheduler: CultMeshProviderScheduler) {} - - public async connect( - connectedIdentity: CultMeshProviderIdentity, - signal: AbortSignal, - ): Promise { - assert.deepEqual(connectedIdentity, identity); - assert.equal(signal.aborted, false); - if (this.failConnectCount-- > 0) throw new Error("bootstrap unavailable"); - const connection = new FakeConnection(this.scheduler); - connection.registerBarrier = this.registerBarrier; - connection.registerError = this.registerError; - connection.publishBarrier = this.publishBarrier; - this.connections.push(connection); - if (this.connections.length === 1) this.onFirstConnection?.(connection); - return connection; - } -} - -class FakeConnection implements CultMeshProviderConnection { - public readonly registrations: CultMeshProviderRegistration[] = []; - public readonly renewals: CultMeshProviderLease[] = []; - public readonly publications: CultMeshProviderPublication[] = []; - public readonly withdrawnPublications: string[] = []; - public readonly receipts: CultMeshProviderCommandReceipt[] = []; - public readonly withdrawals: CultMeshProviderWithdrawal[] = []; - public failNextPublish?: Error; - public failNextReceipt?: Error; - public registerError?: Error; - public registerBarrier?: Deferred; - public publishBarrier?: Deferred; - public unsubscribeError?: Error; - public withdrawError?: Error; - public closeError?: Error; - public closeAttempts = 0; - public afterPublishReceipt?: () => void | Promise; - public withdrawBarrier?: Deferred; - public closed = false; - #listener?: CultMeshProviderCommandListener; - #leaseSequence = 0; - - public constructor(private readonly scheduler: CultMeshProviderScheduler) {} - - public async register( - registration: CultMeshProviderRegistration, - signal: AbortSignal, - ): Promise { - this.registrations.push(registration); - if (this.registerBarrier) { - await Promise.race([ - this.registerBarrier.promise, - new Promise((_resolve, reject) => { - signal.addEventListener("abort", () => reject(new Error("registration aborted")), { once: true }); - }), - ]); - } - if (this.registerError) throw this.registerError; - return this.lease(registration.requestedLeaseDurationMs); - } - - public async renew(lease: CultMeshProviderLease): Promise { - this.renewals.push(lease); - return this.lease(1_000); - } - - public async publish(publication: CultMeshProviderPublication): Promise { - if (this.failNextPublish) { - const error = this.failNextPublish; - this.failNextPublish = undefined; - throw error; - } - const barrier = this.publishBarrier; - this.publishBarrier = undefined; - await barrier?.promise; - this.publications.push(publication); - } - - public async withdrawPublication(publicationId: string): Promise { - this.withdrawnPublications.push(publicationId); - } - - public watchCommands(listener: CultMeshProviderCommandListener): CultMeshProviderUnsubscribe { - this.#listener = listener; - return () => { - this.#listener = undefined; - if (this.unsubscribeError) throw this.unsubscribeError; - }; - } - - public async publishReceipt(receipt: CultMeshProviderCommandReceipt): Promise { - if (this.failNextReceipt) { - const error = this.failNextReceipt; - this.failNextReceipt = undefined; - throw error; - } - this.receipts.push(receipt); - const afterPublish = this.afterPublishReceipt; - this.afterPublishReceipt = undefined; - await afterPublish?.(); - } - - public async withdraw(withdrawal: CultMeshProviderWithdrawal): Promise { - this.withdrawals.push(withdrawal); - await this.withdrawBarrier?.promise; - if (this.withdrawError) throw this.withdrawError; - } - - public close(): void { - this.closeAttempts++; - this.closed = true; - if (this.closeError) throw this.closeError; - } - - public async emit(command: CultMeshProviderCommand): Promise { - await this.#listener?.(command); - } - - private lease(durationMs: number): CultMeshProviderLease { - this.#leaseSequence++; - return { - leaseId: `lease-${this.#leaseSequence}`, - expiresAt: new Date(this.scheduler.now().getTime() + durationMs), - }; - } -} - -async function settle(): Promise { - await new Promise(resolve => setImmediate(resolve)); -} - -class Deferred { - public readonly promise: Promise; - #resolve!: (value: T | PromiseLike) => void; - - public constructor() { - this.promise = new Promise(resolve => { this.#resolve = resolve; }); - } - - public resolve(value?: T): void { - this.#resolve(value as T); - } -} diff --git a/packages/cultnet-py/README.md b/packages/cultnet-py/README.md deleted file mode 100644 index cca39ba7..00000000 --- a/packages/cultnet-py/README.md +++ /dev/null @@ -1,156 +0,0 @@ -# cultnet-py - -`cultnet-py` is the Python CultNet runtime: schema-v0 MessagePack wire helpers, -4-byte big-endian framing, the raw client, database subscriptions, shard -catch-up and replication, simulation observations and consensus, witness -artifact bundles, the RUDP codec/session/socket transport, and the CultMesh -wire contracts that both a node and a bare peer have to agree on. - -It depends on `cultcache-py` and `msgpack`; AES-GCM helpers on `CultNetSecret` -light up with the optional `crypto` extra. `cultmesh-py` builds on it. The -layering mirrors `cultnet-rs` and `cultnet-ts`. - -## Example - -```python -from datetime import UTC, datetime - -from cultnet_py import ( - CultNetSecret, - CultNetServerSecurityOptions, - CultNetRawClient, - CultNetSimulationObservation, - CultNetWitnessArtifactBundle, - apply_raw_snapshot, - apply_shard_log_response, - compute_simulation_claim_hash, - database_subscribe, - decode_frame, - encode_frame, - hello, - parse_message, - shard_catalog_request, - simulation_observation, - witness_artifact_bundle, -) - -payload = hello(runtime_id="python-runtime").to_bytes() -message = parse_message(decode_frame(encode_frame(payload))) - -client = CultNetRawClient("127.0.0.1", 3075) -catalog = client.fetch_schema_catalog(kinds=["wire_message"]) -snapshot = client.fetch_snapshot_response(schema_ids=["cultnet.interop-note"]) -shard_catalog = client.fetch_shard_catalog(schema_ids=["cultnet.interop-note"]) -# With a local CultCache and the matching registered document definitions: -# applied = apply_raw_snapshot(cache, [note_doc], snapshot) - -subscription = database_subscribe(subscription_id="ui", schema_ids=["cultnet.interop-note"]) -shard_request = shard_catalog_request(message_id="shards", schema_ids=["cultnet.interop-note"]) -shard_log = client.fetch_shard_log_response(shard_id="interop", shard_epoch=1, after_sequence=0) -# log_changes = apply_shard_log_response(cache, [note_doc], shard_log) -with client.subscribe_database(subscription_id="ui", schema_ids=["cultnet.interop-note"]) as live: - initial_snapshot = live.read_next_snapshot_response() - -claim_hash = compute_simulation_claim_hash("frame:42", "subject:player-1", "hit") -observation = simulation_observation( - message_id="obs-1", - witness_runtime_id="python-runtime", - shard_id="interop", - shard_epoch=1, - frame=42, - subject_id="player-1", - claim_kind="hit", - claim_hash=claim_hash, -) -typed_observation = CultNetSimulationObservation.from_wire(observation.to_wire()) -witness = witness_artifact_bundle( - bundle_id="bundle-1", - witness_kind="interop-proof", - captured_at="2026-06-13T00:00:00Z", - subject={"documentType": "cultnet.interop-note", "subjectId": "note:python"}, - contracts=[{"role": "payload", "schemaId": "cultnet.interop-note"}], - artifacts=[{"role": "log", "uri": "cultcache://bundle-1/log", "mediaType": "text/plain"}], - provenance={"pipelineId": "interop", "runId": "run-1", "runtimeId": "python-runtime"}, -) -typed_witness = CultNetWitnessArtifactBundle.from_wire(witness) -witness_payload = typed_witness.to_payload() - -security = CultNetServerSecurityOptions.development() -token = CultNetSecret.create_session_token( - "318fb4b6-ff5e-4c4f-b911-d81807de53a8", - datetime(2035, 1, 1, tzinfo=UTC), - security, - session_version=1, -) -session = CultNetSecret.try_validate_session_token(token, security) -``` - -The raw-client request surface can ride RUDP when the peer advertises a -`rudp://` endpoint and shares the connection id for that lane; -`create_rudp_schema_transport(...)` builds that transport, and -`cultmesh_py.CultMesh.create_client(endpoint="rudp://...")` selects it from an -endpoint string. - -## CultMesh wire contracts - -`cultnet_py.cultmesh_contracts` carries the Verse catalog, peer exchange, -authority lease, stream negotiation, and committed simulation fact contracts. -They live here rather than in `cultmesh-py` for the same reason -`CultMeshPeerCard` lives in `cultnet-rs`: the interop peer has to speak them -without owning a node. `cultmesh_py` re-exports every one of them, so callers -that already import from there keep working. - -## RUDP - -`CultNetRudpSession` is the shared reliable/unreliable/sequenced/ordered -session over the RUDP packet codec, with fragmentation above -`max_fragment_bytes`, a bounded reliable window, reliable expiry, and a -bounded fragment reassembly map that evicts the oldest stranded set and counts -it in `fragment_sets_evicted` rather than refusing new payloads. The -socket-backed transport, reconnect policy/controller, and reconnect loop follow -the Rust and TypeScript entrypoint pattern. - -## Interop peer - -The peer can serve, dial, and probe the same raw-state interop lane used by the -TypeScript, Rust, and C# test peers, and answers Verse catalog and peer exchange -requests without a CultMesh node: - -```powershell -python -m cultnet_py.interop_peer serve --runtime-id python-peer --runtime-kind python --display-name "Python Peer" --agent-id python-agent --advertise-host 127.0.0.1 --tcp-port 3075 --discovery-port 4075 --discovery-group 239.77.44.11 --schema-path ..\cultnet-ts\integration\contracts\cultnet.interop-note.schema.json -python -m cultnet_py.interop_peer dial --runtime-id python-client --runtime-kind python --display-name "Python Client" --agent-id python-client --target-host 127.0.0.1 --target-port 3075 --schema-path ..\cultnet-ts\integration\contracts\cultnet.interop-note.schema.json -python -m cultnet_py.interop_peer probe --runtime-id python-prober --discovery-port 4075 --discovery-group 239.77.44.11 -``` - -`packages/cultnet-ts/test/interop/cultnet-interop.test.ts` drives it inside the -live TS/Rust/C#/Python peer ring. - -## Performance baseline - -The package ships a lightweight benchmark for Python-owned hot paths. In a -CultLib source checkout, it can also run the C# reference benchmark beside it: - -```powershell -python -m cultnet_py.benchmark --records 1000 --json -python -m cultnet_py.compare_csharp --records 1000 --json -``` - -It reports slot-indexed `DatabaseEntry` encode/decode throughput, framed -CultNet MessagePack parse throughput, raw snapshot application into a -registered cache, and public `CultCache` upsert/get throughput. The C# compare -command also runs `packages/cultnet-py/tools/GameCult.Caching.Benchmark` and -reports median Python-to-C# ratios for the shared public cache operations. It -uses three samples by default; pass `--samples` when you need a different -evidence shape. Treat the numbers as a local baseline, not a claim that Python -matches the C# reference in every workload. - -## Tests - -```powershell -$env:PYTHONPATH="$PWD\packages\cultcache-py\src;$PWD\packages\cultnet-py\src" -python -m unittest discover -s packages\cultnet-py\tests -``` - -The suite runs without `cultmesh_py` on the path. See -[docs/python-runtime-parity.md](../../docs/python-runtime-parity.md) for the -evidence map across the three Python packages. diff --git a/packages/cultnet-py/pyproject.toml b/packages/cultnet-py/pyproject.toml deleted file mode 100644 index 41c4e8f6..00000000 --- a/packages/cultnet-py/pyproject.toml +++ /dev/null @@ -1,32 +0,0 @@ -[build-system] -requires = ["setuptools>=68"] -build-backend = "setuptools.build_meta" - -[project] -name = "cultnet-py" -version = "0.1.0" -description = "Python CultNet transport, wire contracts, and replication for GameCult typed state." -readme = "README.md" -requires-python = ">=3.10" -license = "MIT" -authors = [{ name = "GameCult" }] -classifiers = [ - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", -] -dependencies = ["cultcache-py>=0.2.0", "msgpack>=1.0"] -[project.optional-dependencies] -crypto = ["cryptography>=42"] - -[project.scripts] -cultnet-py-interop = "cultnet_py.interop_peer:main" -cultnet-py-benchmark = "cultnet_py.benchmark:main" -cultnet-py-compare-csharp = "cultnet_py.compare_csharp:main" - -[tool.setuptools.packages.find] -where = ["src"] - -[tool.setuptools.package-data] -cultnet_py = ["py.typed"] diff --git a/packages/cultnet-py/tests/test_cultnet.py b/packages/cultnet-py/tests/test_cultnet.py deleted file mode 100644 index 171a34b9..00000000 --- a/packages/cultnet-py/tests/test_cultnet.py +++ /dev/null @@ -1,2046 +0,0 @@ -from __future__ import annotations - -import json -import socket -import threading -import time -import unittest -import hashlib -import hmac -import io -from dataclasses import dataclass -from datetime import UTC, datetime -from pathlib import Path -from typing import Callable -from uuid import uuid4 -from cultcache_py import CultCache, define_database_entry_type -from cultnet_py.benchmark import run_benchmark -from cultnet_py.compare_csharp import ( - DEFAULT_PARITY_THRESHOLD, - DEFAULT_SAMPLE_COUNT, - _compare_common_metrics, - _median_result, - _parity_status, -) -from cultnet_py.interop_peer import append_shard_log_put, build_state, raw_snapshot_response -from cultnet_py import ( - compute_simulation_claim_hash, - CultNetDatabaseChange, - CultNetRawClient, - CultNetRawDocumentRecord, - CultNetRawSnapshotResponse, - CultNetSchemaCatalog, - CultNetSchemaDescriptor, - CultNetShardCatalog, - CultNetShardDescriptor, - CultNetShardLogEntry, - CultNetShardLogResponse, - CultNetPeerError, - CultNetClientSecurityOptions, - CultNetSecret, - CultNetServerSecurityOptions, - CultNetSimulationConsensusOptions, - CultNetSimulationConsensusCandidate, - CultNetSimulationObservation, - CultNetRudpPacket, - CultNetRudpPacketType, - CultNetRudpSendOptions, - CultNetRudpSession, - CultNetRudpSessionOptions, - CultNetRudpSocketMode, - CultNetRudpReconnectLoop, - CultNetRudpSocketTransportConnection, - CultNetRudpSocketTransportOptions, - TcpFramedTransportConnection, - CultNetWitnessArtifactBundle, - apply_raw_document_record, - apply_raw_snapshot, - apply_shard_log_response, - create_rudp_transport_profile, - create_tcp_framed_transport_profile, - create_reconnect_policy, - CultNetReconnectController, - database_subscribe, - database_unsubscribe, - decode_frame, - decode_rudp_packet, - decode_witness_artifact_bundle_payload, - document_delete, - document_put_raw, - encode_frame, - encode_rudp_packet, - encode_witness_artifact_bundle_payload, - hello, - login, - login_success, - parse_message, - register, - schema_catalog_request, - schema_document_map, - shard_catalog_request, - shard_log_request, - simulation_observation, - snapshot_request, - verify_session, - wire_message_schema_catalog, - wire_message_schema_descriptors, - witness_artifact_bundle, - compute_reconnect_delay_ms, -) - - -def bind_udp_socket() -> socket.socket: - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.bind(("127.0.0.1", 0)) - sock.settimeout(0.02) - return sock - - -def pump_rudp_handshake( - client: CultNetRudpSocketTransportConnection, - server: CultNetRudpSocketTransportConnection, -) -> None: - for _ in range(20): - server.receive_once() - client.receive_once() - server.receive_once() - if client.connected and server.connected: - return - time.sleep(0.005) - raise AssertionError("RUDP socket handshake did not complete") - - -def receive_rudp_frame(transport: CultNetRudpSocketTransportConnection): - for _ in range(20): - frame = transport.receive_once() - if frame is not None: - return frame - time.sleep(0.005) - raise AssertionError("RUDP socket frame was not delivered") - - -@dataclass -class Item: - name: str - category: str - value: int - - -class CultNetTests(unittest.TestCase): - def test_benchmark_reports_core_hot_path_metrics(self) -> None: - result = run_benchmark(8) - metric_names = {metric["name"] for metric in result["metrics"]} - self.assertEqual(result["records"], 8) - self.assertEqual(result["wireMessageCount"], 8) - self.assertEqual(metric_names, { - "database_entry_encode", - "database_entry_decode", - "cultnet_frame_parse", - "raw_snapshot_apply", - "cache_upsert", - "cache_get", - }) - self.assertTrue(all(metric["opsPerSecond"] > 0 for metric in result["metrics"])) - - def test_compare_csharp_median_result_summarizes_samples(self) -> None: - self.assertEqual(DEFAULT_SAMPLE_COUNT, 3) - self.assertEqual(DEFAULT_PARITY_THRESHOLD, 0.90) - samples = [ - { - "runtime": "python", - "records": 3, - "metrics": [ - {"name": "cache_get", "operations": 3, "elapsedMs": 30.0, "opsPerSecond": 100.0}, - {"name": "cache_upsert", "operations": 3, "elapsedMs": 60.0, "opsPerSecond": 50.0}, - ], - }, - { - "runtime": "python", - "records": 3, - "metrics": [ - {"name": "cache_get", "operations": 3, "elapsedMs": 10.0, "opsPerSecond": 300.0}, - {"name": "cache_upsert", "operations": 3, "elapsedMs": 20.0, "opsPerSecond": 150.0}, - ], - }, - { - "runtime": "python", - "records": 3, - "metrics": [ - {"name": "cache_get", "operations": 3, "elapsedMs": 20.0, "opsPerSecond": 200.0}, - {"name": "cache_upsert", "operations": 3, "elapsedMs": 40.0, "opsPerSecond": 75.0}, - ], - }, - ] - - summarized = _median_result(samples) - - self.assertEqual(summarized["sampleCount"], 3) - self.assertEqual(summarized["metrics"][0]["name"], "cache_get") - self.assertEqual(summarized["metrics"][0]["elapsedMs"], 20.0) - self.assertEqual(summarized["metrics"][0]["opsPerSecond"], 200.0) - self.assertEqual(summarized["metrics"][1]["opsPerSecond"], 75.0) - - def test_compare_csharp_marks_threshold_status_per_metric(self) -> None: - python_result = { - "metrics": [ - {"name": "cache_get", "operations": 3, "elapsedMs": 2.0, "opsPerSecond": 50.0}, - {"name": "cache_upsert", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 95.0}, - ], - } - csharp_result = { - "metrics": [ - {"name": "cache_get", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 100.0}, - {"name": "cache_upsert", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 100.0}, - ], - } - - comparison = _compare_common_metrics(python_result, csharp_result, parity_threshold=0.90) - by_name = {item["name"]: item for item in comparison} - - self.assertFalse(by_name["cache_get"]["meetsParityThreshold"]) - self.assertTrue(by_name["cache_upsert"]["meetsParityThreshold"]) - self.assertEqual(_parity_status(comparison, "ok"), "below-threshold") - self.assertEqual(_parity_status([by_name["cache_upsert"]], "ok"), "meets-threshold") - self.assertEqual(_parity_status(comparison, "failed"), "unknown") - - def test_compare_csharp_marks_meets_threshold_when_all_common_metrics_pass(self) -> None: - comparison = _compare_common_metrics( - { - "metrics": [ - {"name": "cache_get", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 95.0}, - {"name": "cache_upsert", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 120.0}, - ], - }, - { - "metrics": [ - {"name": "cache_get", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 100.0}, - {"name": "cache_upsert", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 100.0}, - ], - }, - parity_threshold=0.90, - ) - - self.assertEqual(_parity_status(comparison, "ok"), "meets-threshold") - - def test_cultnet_security_options_match_cultlib_defaults_and_environment_rules(self) -> None: - client = CultNetClientSecurityOptions.development() - server = CultNetServerSecurityOptions.development() - - self.assertEqual(client.connection_key, "gamecult-dev-connection-key") - self.assertEqual(server.connection_key, "gamecult-dev-connection-key") - self.assertTrue(server.is_development) - self.assertEqual(server.to_client_options().connection_key, client.connection_key) - self.assertEqual(client.encryption_key(), hashlib.sha256(b"gamecult-dev-connection-key").digest()) - self.assertEqual(server.session_signing_key(), hashlib.sha256(b"gamecult-dev-session-signing-secret").digest()) - self.assertEqual( - CultNetServerSecurityOptions.from_environment({}, allow_development_defaults=True), - server, - ) - with self.assertRaisesRegex(ValueError, "not configured"): - CultNetServerSecurityOptions.from_environment({}) - with self.assertRaisesRegex(ValueError, "GAMECULT_SESSION_SIGNING_SECRET"): - CultNetServerSecurityOptions.from_environment({"GAMECULT_CONNECTION_KEY": "key"}) - configured = CultNetServerSecurityOptions.from_environment({ - "GAMECULT_CONNECTION_KEY": "prod-key", - "GAMECULT_SESSION_SIGNING_SECRET": "prod-secret", - }) - self.assertEqual(configured.connection_key, "prod-key") - self.assertFalse(configured.is_development) - - def test_cultnet_secret_helpers_validate_csharp_versioned_session_tokens(self) -> None: - security = CultNetServerSecurityOptions("connection-key", "session-secret") - user_id = uuid4() - expires = datetime(2035, 1, 2, 3, 4, 5, tzinfo=UTC) - - token = CultNetSecret.create_session_token(user_id, expires, security, session_version=7) - payload_b64, signature_b64 = token.split(".") - self.assertEqual( - CultNetSecret.from_base64url(payload_b64).decode("utf-8"), - f"{user_id.hex}|{int(expires.timestamp())}|7", - ) - self.assertEqual( - CultNetSecret.from_base64url(signature_b64), - hmac.new( - hashlib.sha256(b"session-secret").digest(), - CultNetSecret.from_base64url(payload_b64), - hashlib.sha256, - ).digest(), - ) - - validated = CultNetSecret.try_validate_session_token( - token, - security, - now_utc=datetime(2035, 1, 2, 3, 4, 4, tzinfo=UTC), - ) - self.assertIsNotNone(validated) - assert validated is not None - self.assertEqual(validated.user_id, user_id) - self.assertEqual(validated.expires_at_utc, expires) - self.assertEqual(validated.session_version, 7) - - tampered_payload = CultNetSecret.to_base64url(CultNetSecret.from_base64url(payload_b64) + b"tamper") - tampered = f"{tampered_payload}.{signature_b64}" - self.assertIsNone(CultNetSecret.try_validate_session_token(tampered, security)) - self.assertIsNone(CultNetSecret.try_validate_session_token(token, security, now_utc=expires)) - - def test_cultnet_secret_helpers_validate_legacy_two_field_session_tokens(self) -> None: - security = CultNetServerSecurityOptions.development() - user_id = uuid4() - expires_at_seconds = int(datetime(2036, 1, 1, tzinfo=UTC).timestamp()) - payload = f"{user_id.hex}|{expires_at_seconds}".encode("utf-8") - signature = hmac.new(security.session_signing_key(), payload, hashlib.sha256).digest() - token = f"{CultNetSecret.to_base64url(payload)}.{CultNetSecret.to_base64url(signature)}" - - validated = CultNetSecret.try_validate_session_token( - token, - security, - now_utc=datetime(2035, 12, 31, tzinfo=UTC), - ) - - self.assertIsNotNone(validated) - assert validated is not None - self.assertEqual(validated.user_id, user_id) - self.assertEqual(validated.session_version, 0) - - def test_cultnet_secret_encryption_helpers_are_optional_without_crypto_dependency(self) -> None: - nonce = b"123456789012" - security = CultNetServerSecurityOptions.development() - try: - encrypted = CultNetSecret.encrypt_string("hello", nonce, security.to_client_options()) - except ImportError as exc: - self.assertIn("cryptography", str(exc)) - return - self.assertEqual(CultNetSecret.decrypt_string(encrypted, nonce, security), "hello") - - def test_cultnet_schema_message_frame_round_trip(self) -> None: - message = hello( - runtime_id="python-test", - supported_schema_versions=["cultnet.hello.v0"], - transport_profiles=[ - { - "schemaVersion": "cultnet.transport_profile.v0", - "runtimeId": "python-test", - "transports": [ - { - "transportId": "test", - "protocol": "tcp_framed", - "channels": [{"channelId": "schema", "delivery": "reliable", "ordering": "ordered"}], - } - ], - } - ], - ) - parsed = parse_message(decode_frame(encode_frame(message.to_bytes()))) - self.assertEqual(parsed.schema_version, "cultnet.hello.v0") - self.assertEqual(parsed.body["runtimeId"], "python-test") - self.assertEqual(parsed.body["transportProfiles"][0]["transports"][0]["protocol"], "tcp_framed") - - def test_cultnet_tcp_framed_transport_carries_schema_payloads_with_stats(self) -> None: - payload = b"cultnet-payload" - output = io.BytesIO() - sender = TcpFramedTransportConnection( - output, - profile=create_tcp_framed_transport_profile("sender", transport_id="test-tcp"), - ) - sender.send("schema", payload) - self.assertEqual(sender.stats.frames_sent, 1) - self.assertEqual(sender.stats.bytes_sent, len(payload) + 4) - with self.assertRaisesRegex(ValueError, "only supports the schema channel"): - sender.send("unreliable", b"") - - receiver = TcpFramedTransportConnection( - io.BytesIO(output.getvalue()), - profile=create_tcp_framed_transport_profile("receiver", transport_id="test-tcp"), - ) - frame = receiver.receive() - self.assertEqual(frame.channel_id, "schema") - self.assertEqual(frame.payload, payload) - self.assertEqual(receiver.stats.frames_received, 1) - self.assertEqual(receiver.stats.bytes_received, len(payload) + 4) - self.assertEqual(receiver.profile["transports"][0]["protocol"], "tcp_framed") - - def test_cultnet_raw_client_uses_schema_transport_factory(self) -> None: - import msgpack # type: ignore - - sent_messages: list[dict[str, object]] = [] - test_case = self - - class FakeSchemaTransport: - profile = create_tcp_framed_transport_profile("fake-client") - - def __enter__(self) -> "FakeSchemaTransport": - return self - - def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: - self.close() - - def send(self, channel_id: str, payload: bytes) -> None: - test_case.assertEqual(channel_id, "schema") - sent_messages.append(msgpack.unpackb(payload, raw=False)) - - def receive(self) -> object: - response = { - "schemaVersion": "cultnet.schema_catalog_response.v0", - "messageId": "transport-factory", - "schemas": [], - } - return type("Frame", (), {"channel_id": "schema", "payload": msgpack.packb(response, use_bin_type=True)})() - - def close(self) -> None: - pass - - client = CultNetRawClient( - "unused.example.test", - 1, - create_transport=FakeSchemaTransport, - ) - - response = client.fetch_schema_catalog(message_id="transport-factory") - - self.assertEqual(response["schemaVersion"], "cultnet.schema_catalog_response.v0") - self.assertEqual(sent_messages[0]["schemaVersion"], "cultnet.schema_catalog_request.v0") - self.assertEqual(sent_messages[0]["messageId"], "transport-factory") - - def test_cultnet_rudp_packet_codec_uses_deterministic_reliable_ordered_fixture(self) -> None: - encoded = encode_rudp_packet( - CultNetRudpPacket( - packet_type=CultNetRudpPacketType.DATA, - connection_id=0x01020304, - sequence=0x0000002A, - ack=0x00000029, - ack_mask=0x80000001, - channel_id="schema", - reliable=True, - ordered=True, - fragment_id=7, - fragment_index=1, - fragment_count=3, - payload=b"hello", - ) - ) - - self.assertEqual( - encoded.hex(), - "434e523000030b2a010203040000002a0000002980000001000700010003000000050600736368656d6168656c6c6f", - ) - - decoded = decode_rudp_packet(encoded) - self.assertEqual(decoded.packet_type, CultNetRudpPacketType.DATA) - self.assertEqual(decoded.connection_id, 0x01020304) - self.assertEqual(decoded.sequence, 0x0000002A) - self.assertEqual(decoded.ack, 0x00000029) - self.assertEqual(decoded.ack_mask, 0x80000001) - self.assertEqual(decoded.channel_id, "schema") - self.assertTrue(decoded.reliable) - self.assertTrue(decoded.ordered) - self.assertFalse(decoded.sequenced) - self.assertEqual(decoded.fragment_id, 7) - self.assertEqual(decoded.fragment_index, 1) - self.assertEqual(decoded.fragment_count, 3) - self.assertEqual(decoded.payload, b"hello") - - def test_cultnet_rudp_transport_profile_advertises_state_and_realtime_channels(self) -> None: - profile = create_rudp_transport_profile( - "python-rudp", - transport_id="public-rudp", - host="127.0.0.1", - port=7777, - max_payload_bytes=1200, - max_fragment_bytes=1000, - ) - - self.assertEqual(profile["transports"][0]["protocol"], "rudp") - self.assertEqual(profile["transports"][0]["reconnectPolicy"]["schemaVersion"], "cultnet.reconnect_policy.v0") - self.assertEqual(profile["transports"][0]["reconnectPolicy"]["baseDelayMs"], 1_000) - self.assertEqual( - [ - (channel["channelId"], channel["delivery"], channel["ordering"]) - for channel in profile["transports"][0]["channels"] - ], - [ - ("schema", "reliable", "ordered"), - ("latest", "unreliable", "sequenced"), - ("realtime", "unreliable", "unordered"), - ], - ) - - def test_cultnet_reconnect_policy_exposes_portable_delay_contract(self) -> None: - policy = create_reconnect_policy(policy_id="rudp-default", max_attempts=8) - - self.assertEqual(policy.schema_version, "cultnet.reconnect_policy.v0") - self.assertEqual(policy.policy_id, "rudp-default") - self.assertEqual(policy.max_attempts, 8) - self.assertEqual(policy.to_wire()["policyId"], "rudp-default") - self.assertEqual(policy.to_wire()["maxAttempts"], 8) - self.assertEqual(compute_reconnect_delay_ms(policy, 1), 1_000) - self.assertEqual(compute_reconnect_delay_ms(policy, 3, 17), 4_017) - self.assertEqual(compute_reconnect_delay_ms(policy, 9, 999), 30_250) - self.assertEqual(compute_reconnect_delay_ms(policy, 0, -5), 1_000) - - def test_cultnet_reconnect_controller_schedules_attempts_and_reset(self) -> None: - controller = CultNetReconnectController(create_reconnect_policy(max_attempts=2)) - - first = controller.record_failure(10_000) - self.assertEqual(first.attempt, 1) - self.assertTrue(first.should_retry) - self.assertEqual(first.delay_ms, 1_000) - self.assertEqual(first.next_attempt_at_ms, 11_000) - self.assertFalse(first.exhausted) - self.assertFalse(controller.can_attempt(10_999)) - self.assertTrue(controller.can_attempt(11_000)) - - second = controller.record_failure(11_000, 17) - self.assertEqual(second.attempt, 2) - self.assertEqual(second.delay_ms, 2_017) - self.assertEqual(second.next_attempt_at_ms, 13_017) - self.assertTrue(second.should_retry) - - exhausted = controller.record_failure(13_017) - self.assertEqual(exhausted.attempt, 2) - self.assertFalse(exhausted.should_retry) - self.assertEqual(exhausted.delay_ms, 0) - self.assertIsNone(exhausted.next_attempt_at_ms) - self.assertTrue(exhausted.exhausted) - self.assertFalse(controller.can_attempt(99_000)) - - controller.reset() - self.assertEqual(controller.attempt, 0) - self.assertIsNone(controller.next_attempt_at_ms) - self.assertFalse(controller.exhausted) - self.assertTrue(controller.can_attempt(99_000)) - - def test_cultnet_rudp_reconnect_loop_consumes_shared_controller(self) -> None: - class FakeRudpReconnectTransport: - def __init__(self) -> None: - self.connect_calls = 0 - self.close_calls = 0 - self.last_payload: bytes | None = None - - def connect(self, payload: bytes = b"") -> None: - self.connect_calls += 1 - self.last_payload = bytes(payload) - - def close(self) -> None: - self.close_calls += 1 - - now_ms = 10_000 - captured_callback: list[Callable[[], None]] = [] - transports: list[FakeRudpReconnectTransport] = [] - - def create_transport() -> FakeRudpReconnectTransport: - transport = FakeRudpReconnectTransport() - transports.append(transport) - return transport - - def scheduler(delay_ms: int, callback: Callable[[], None]) -> Callable[[], None]: - self.assertEqual(delay_ms, 1_017) - captured_callback[:] = [callback] - - def cancel() -> None: - captured_callback.clear() - - return cancel - - loop = CultNetRudpReconnectLoop( - create_transport, - reconnect_policy=create_reconnect_policy(max_attempts=2), - connect_payload=b"join", - now_ms=lambda: now_ms, - jitter_ms=lambda: 17, - scheduler=scheduler, - ) - - first = loop.start() - self.assertEqual(first.connect_calls, 1) - self.assertEqual(first.last_payload, b"join") - self.assertIs(loop.transport, first) - - decision = loop.handle_closed() - self.assertIsNotNone(decision) - self.assertEqual(decision.attempt, 1) - self.assertTrue(decision.should_retry) - self.assertEqual(decision.delay_ms, 1_017) - self.assertEqual(decision.next_attempt_at_ms, 11_017) - self.assertEqual(loop.reconnect_controller.attempt, 1) - self.assertEqual(loop.reconnect_controller.next_attempt_at_ms, 11_017) - self.assertEqual(len(captured_callback), 1) - - now_ms = 11_017 - captured_callback[0]() - self.assertEqual(len(transports), 2) - self.assertEqual(transports[1].connect_calls, 1) - self.assertIs(loop.transport, transports[1]) - - loop.mark_connected() - self.assertEqual(loop.reconnect_controller.attempt, 0) - - loop.stop() - self.assertEqual(transports[1].close_calls, 1) - self.assertIsNone(loop.transport) - - def test_cultnet_rudp_session_handshake_acks_reliable_connect_and_accept_packets(self) -> None: - client = CultNetRudpSession( - CultNetRudpSessionOptions(connection_id=0x0A0B0C0D, initial_sequence=1, resend_delay_ms=50) - ) - server = CultNetRudpSession( - CultNetRudpSessionOptions(connection_id=0x0A0B0C0D, initial_sequence=100, resend_delay_ms=50) - ) - - connect = client.create_connect(0, b"join") - self.assertEqual(connect.packet_type, CultNetRudpPacketType.CONNECT) - self.assertEqual(connect.sequence, 1) - self.assertEqual(client.pending_reliable_sequences, (1,)) - - accept = server.accept_connect(connect, 10, b"ok") - self.assertEqual(accept.packet_type, CultNetRudpPacketType.ACCEPT) - self.assertEqual(accept.ack, 1) - self.assertTrue(server.connected) - self.assertEqual(server.pending_reliable_sequences, (100,)) - - client.receive(accept, 20) - self.assertTrue(client.connected) - self.assertEqual(client.pending_reliable_sequences, ()) - - ack = client.create_ack() - self.assertEqual(ack.ack, 100) - server.receive(ack, 30) - self.assertEqual(server.pending_reliable_sequences, ()) - - def test_cultnet_rudp_session_computes_ack_masks_and_clears_pending_reliable_packets(self) -> None: - sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=7, initial_sequence=10, resend_delay_ms=100)) - receiver = CultNetRudpSession( - CultNetRudpSessionOptions(connection_id=7, initial_sequence=200, resend_delay_ms=100) - ) - sender.receive( - CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 7, 1, 0, 0, "control") - ) - receiver.receive( - CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 7, 2, 0, 0, "control") - ) - - first = sender.send( - "schema", - b"first", - CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=0), - ) - second = sender.send( - "schema", - b"second", - CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=0), - ) - third = sender.send( - "schema", - b"third", - CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=0), - ) - self.assertEqual(sender.pending_reliable_sequences, (10, 11, 12)) - - receiver.receive(first) - receiver.receive(third) - ack_with_gap = receiver.create_ack() - self.assertEqual(ack_with_gap.ack, 12) - self.assertEqual(ack_with_gap.ack_mask, 0b10 | (1 << 9)) - sender.receive(ack_with_gap) - self.assertEqual(sender.pending_reliable_sequences, (11,)) - - receiver.receive(second) - full_ack = receiver.create_ack() - self.assertEqual(full_ack.ack, 12) - self.assertEqual(full_ack.ack_mask, 0b11 | (1 << 9)) - sender.receive(full_ack) - self.assertEqual(sender.pending_reliable_sequences, ()) - - def test_cultnet_rudp_session_schedules_reliable_resends_until_acked(self) -> None: - session = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=99, initial_sequence=1, resend_delay_ms=100)) - session.receive( - CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 99, 50, 0, 0, "control") - ) - sent = session.send( - "schema", - b"payload", - CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=10), - ) - - self.assertEqual(session.due_resends(90), ()) - self.assertEqual(tuple(packet.sequence for packet in session.due_resends(110)), (sent.sequence,)) - self.assertEqual(session.due_resends(150), ()) - - session.receive( - CultNetRudpPacket(CultNetRudpPacketType.ACK, 99, 51, sent.sequence, 0, "control") - ) - self.assertEqual(session.due_resends(250), ()) - - def test_cultnet_rudp_session_pings_and_detects_receive_timeout(self) -> None: - client = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=101, initial_sequence=1)) - server = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=101, initial_sequence=100)) - connect = client.create_connect(0, b"join") - accept = server.accept_connect(connect, 10) - client.receive(accept, 20) - - ping = client.create_ping(b"pulse") - ping_result = server.receive(ping, 30) - self.assertIsNotNone(ping_result.reply) - self.assertEqual(ping_result.reply.packet_type, CultNetRudpPacketType.PONG) - self.assertEqual(ping_result.reply.payload, b"pulse") - - pong_result = client.receive(ping_result.reply, 40) - self.assertTrue(pong_result.pong) - self.assertEqual(pong_result.pong_payload, b"pulse") - self.assertFalse(client.check_timeout(90, 50)) - self.assertTrue(client.check_timeout(91, 50)) - self.assertFalse(client.connected) - - def test_cultnet_rudp_session_bounds_pending_reliable_packets_before_enqueue(self) -> None: - session = CultNetRudpSession( - CultNetRudpSessionOptions(connection_id=102, initial_sequence=1, max_pending_reliable_packets=2) - ) - session.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 102, 50, 0, 0, "control")) - session.send("schema", b"first", CultNetRudpSendOptions(reliable=True, ordered=True)) - session.send("schema", b"second", CultNetRudpSendOptions(reliable=True, ordered=True)) - with self.assertRaisesRegex(ValueError, "reliable send queue is full"): - session.send("schema", b"third", CultNetRudpSendOptions(reliable=True, ordered=True)) - self.assertEqual(session.pending_reliable_sequences, (1, 2)) - - fragmented = CultNetRudpSession( - CultNetRudpSessionOptions(connection_id=103, initial_sequence=1, max_pending_reliable_packets=3) - ) - fragmented.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 103, 50, 0, 0, "control")) - with self.assertRaisesRegex(ValueError, "reliable send queue is full"): - fragmented.send_many( - "schema", - b"fragment-me", - CultNetRudpSendOptions(reliable=True, ordered=True), - max_fragment_bytes=3, - ) - self.assertEqual(fragmented.pending_reliable_sequences, ()) - - def test_cultnet_rudp_session_suppresses_duplicates_and_delivers_reliable_ordered_payloads(self) -> None: - sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=123, initial_sequence=1)) - receiver = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=123, initial_sequence=100)) - sender.receive( - CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 123, 90, 0, 0, "control") - ) - receiver.receive( - CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 123, 91, 0, 0, "control") - ) - - first = sender.send("schema", b"first", CultNetRudpSendOptions(reliable=True, ordered=True)) - second = sender.send("schema", b"second", CultNetRudpSendOptions(reliable=True, ordered=True)) - third = sender.send("schema", b"third", CultNetRudpSendOptions(reliable=True, ordered=True)) - - self.assertEqual([frame.payload.decode("utf-8") for frame in receiver.receive(first).delivered], ["first"]) - self.assertEqual(receiver.receive(third).delivered, ()) - self.assertEqual(receiver.receive(first).delivered, ()) - self.assertEqual( - [frame.payload.decode("utf-8") for frame in receiver.receive(second).delivered], - ["second", "third"], - ) - - def test_cultnet_rudp_lossy_packets_cannot_create_reliable_ordered_gaps(self) -> None: - sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=198, initial_sequence=1)) - receiver = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=198, initial_sequence=100)) - sender.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 198, 90, 0, 0, "control")) - receiver.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 198, 91, 0, 0, "control")) - - realtime = sender.send("realtime", b"discarded realtime", CultNetRudpSendOptions()) - latest = sender.send("latest", b"discarded latest state", CultNetRudpSendOptions(sequenced=True)) - schema = sender.send("schema", b"committed response", CultNetRudpSendOptions(reliable=True, ordered=True)) - - self.assertEqual(realtime.sequence, 0) - self.assertEqual(latest.sequence, 1) - self.assertEqual( - [frame.payload.decode("utf-8") for frame in receiver.receive(schema).delivered], - ["committed response"], - ) - - def test_cultnet_rudp_unreliable_sequenced_delivery_is_scoped_to_its_channel(self) -> None: - sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=197, initial_sequence=50)) - receiver = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=197, initial_sequence=100)) - sender.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 197, 90, 0, 0, "control")) - receiver.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 197, 91, 0, 0, "control")) - - older = sender.send("latest", b"older", CultNetRudpSendOptions(sequenced=True)) - newer = sender.send("latest", b"newer", CultNetRudpSendOptions(sequenced=True)) - - self.assertEqual(older.sequence, 1) - self.assertEqual(newer.sequence, 2) - self.assertEqual( - [frame.payload.decode("utf-8") for frame in receiver.receive(newer).delivered], - ["newer"], - ) - self.assertEqual(receiver.receive(older).delivered, ()) - - def test_cultnet_rudp_rejects_unreliable_ordered_delivery(self) -> None: - session = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=196, initial_sequence=1)) - session.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 196, 50, 0, 0, "control")) - - with self.assertRaises(ValueError) as raised: - session.send( - "schema", - b"cannot order what will not retransmit", - CultNetRudpSendOptions(ordered=True), - ) - self.assertIn("ordered delivery requires reliability", str(raised.exception)) - - def test_cultnet_rudp_session_skips_control_packets_while_ordering_schema_payloads(self) -> None: - sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=124, initial_sequence=1)) - receiver = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=124, initial_sequence=100)) - sender.receive( - CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 124, 90, 0, 0, "control") - ) - receiver.receive( - CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 124, 91, 0, 0, "control") - ) - - first = sender.send("schema", b"first", CultNetRudpSendOptions(reliable=True, ordered=True)) - control = sender.create_ack() - second = sender.send("schema", b"second", CultNetRudpSendOptions(reliable=True, ordered=True)) - - self.assertEqual([frame.payload.decode("utf-8") for frame in receiver.receive(first).delivered], ["first"]) - self.assertEqual(receiver.receive(control).delivered, ()) - self.assertEqual([frame.payload.decode("utf-8") for frame in receiver.receive(second).delivered], ["second"]) - - def test_cultnet_rudp_session_fragments_and_reassembles_reliable_ordered_payloads(self) -> None: - sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=456, initial_sequence=1)) - receiver = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=456, initial_sequence=100)) - sender.receive( - CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 456, 90, 0, 0, "control") - ) - receiver.receive( - CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 456, 91, 0, 0, "control") - ) - - packets = sender.send_many( - "schema", - b"fragment-me-please", - CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=10), - max_fragment_bytes=5, - ) - self.assertEqual(len(packets), 4) - self.assertEqual(tuple(packet.fragment_count for packet in packets), (4, 4, 4, 4)) - self.assertEqual(tuple(packet.fragment_index for packet in packets), (0, 1, 2, 3)) - self.assertTrue(all(packet.fragment_id == packets[0].fragment_id for packet in packets)) - - self.assertEqual(receiver.receive(packets[0]).delivered, ()) - self.assertEqual(receiver.receive(packets[1]).delivered, ()) - self.assertEqual(receiver.receive(packets[2]).delivered, ()) - delivered = receiver.receive(packets[3]).delivered - self.assertEqual(len(delivered), 1) - self.assertEqual(delivered[0].payload, b"fragment-me-please") - self.assertEqual(delivered[0].sequence, packets[0].sequence) - - def test_cultnet_rudp_session_advances_large_fragment_sets_through_bounded_window(self) -> None: - connection_id = 457 - sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=connection_id, initial_sequence=1)) - receiver = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=connection_id, initial_sequence=100)) - sender.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, connection_id, 0, 0, 0, "control")) - receiver.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, connection_id, 0, 0, 0, "control")) - - fragment_count = CultNetRudpSession.RELIABLE_SEND_WINDOW_PACKETS + 17 - payload = bytes(index % 251 for index in range(fragment_count * 8)) - wire = list(sender.send_many( - "schema", - payload, - CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=1), - max_fragment_bytes=8, - )) - self.assertEqual(len(wire), CultNetRudpSession.RELIABLE_SEND_WINDOW_PACKETS) - oldest_sequence = wire[0].sequence - self.assertEqual(len(sender.pending_reliable_sequences), len(wire)) - self.assertEqual(sender.queued_reliable_packet_count, 17) - - delivered = [] - while wire: - admitted = len(wire) - for _ in range(admitted): - packet = wire.pop(0) - delivered.extend(receiver.receive(packet, 2).delivered) - acknowledged = sender.receive(receiver.create_ack(), 3) - wire.extend(acknowledged.ready_to_send) - - self.assertEqual(sender.outstanding_reliable_packet_count, 0) - self.assertEqual(len(delivered), 1) - self.assertEqual(delivered[0].payload, payload) - old_ack = receiver.create_ack_for_received(oldest_sequence) - self.assertEqual(old_ack.ack, oldest_sequence) - self.assertEqual(old_ack.ack_mask, 0) - - def test_cultnet_rudp_socket_transport_handshakes_and_carries_reliable_ordered_schema_frames(self) -> None: - server_socket = bind_udp_socket() - client_socket = bind_udp_socket() - connection_id = 0x10203040 - server = CultNetRudpSocketTransportConnection( - CultNetRudpSocketTransportOptions( - runtime_id="python-rudp-server", - socket=server_socket, - mode=CultNetRudpSocketMode.SERVER, - connection_id=connection_id, - initial_sequence=100, - resend_delay_ms=25, - ) - ) - client = CultNetRudpSocketTransportConnection( - CultNetRudpSocketTransportOptions( - runtime_id="python-rudp-client", - socket=client_socket, - mode=CultNetRudpSocketMode.CLIENT, - remote_addr=server_socket.getsockname(), - connection_id=connection_id, - initial_sequence=1, - resend_delay_ms=25, - ) - ) - - try: - client.connect(b"join") - pump_rudp_handshake(client, server) - self.assertTrue(client.connected) - self.assertTrue(server.connected) - - client.send("schema", b"client-state") - server_frame = receive_rudp_frame(server) - self.assertEqual(server_frame.channel_id, "schema") - self.assertEqual(server_frame.payload, b"client-state") - - server.send("schema", b"server-state") - client_frame = receive_rudp_frame(client) - self.assertEqual(client_frame.channel_id, "schema") - self.assertEqual(client_frame.payload, b"server-state") - self.assertEqual(server.profile["transports"][0]["protocol"], "rudp") - self.assertEqual(client.stats.frames_sent, 1) - self.assertEqual(server.stats.frames_received, 1) - finally: - client.close() - server.close() - - def test_cultnet_rudp_socket_transport_carries_fragmented_reliable_ordered_schema_frames(self) -> None: - server_socket = bind_udp_socket() - client_socket = bind_udp_socket() - connection_id = 0x10203041 - server = CultNetRudpSocketTransportConnection( - CultNetRudpSocketTransportOptions( - runtime_id="python-rudp-fragment-server", - socket=server_socket, - mode=CultNetRudpSocketMode.SERVER, - connection_id=connection_id, - initial_sequence=100, - resend_delay_ms=25, - max_fragment_bytes=8, - ) - ) - client = CultNetRudpSocketTransportConnection( - CultNetRudpSocketTransportOptions( - runtime_id="python-rudp-fragment-client", - socket=client_socket, - mode=CultNetRudpSocketMode.CLIENT, - remote_addr=server_socket.getsockname(), - connection_id=connection_id, - initial_sequence=1, - resend_delay_ms=25, - max_fragment_bytes=8, - ) - ) - - try: - payload = b"this-schema-frame-is-larger-than-one-rudp-fragment" - client.connect(b"join") - pump_rudp_handshake(client, server) - client.send("schema", payload) - server_frame = receive_rudp_frame(server) - self.assertEqual(server_frame.channel_id, "schema") - self.assertEqual(server_frame.payload, payload) - self.assertEqual(client.stats.frames_sent, 1) - self.assertEqual(server.stats.frames_received, 1) - finally: - client.close() - server.close() - - def test_cultnet_database_subscription_helpers_match_schema_v0_shape(self) -> None: - subscribe = database_subscribe( - message_id="sub-message", - subscription_id="sub-1", - schema_ids=["schema-a"], - record_keys=["record-a"], - include_snapshot=False, - ).to_wire() - self.assertEqual(subscribe["schemaVersion"], "cultnet.database_subscribe.v0") - self.assertEqual(subscribe["messageId"], "sub-message") - self.assertEqual(subscribe["subscriptionId"], "sub-1") - self.assertEqual(subscribe["schemaIds"], ["schema-a"]) - self.assertEqual(subscribe["recordKeys"], ["record-a"]) - self.assertFalse(subscribe["includeSnapshot"]) - - unsubscribe = database_unsubscribe(message_id="unsub-message", subscription_id="sub-1").to_wire() - self.assertEqual(unsubscribe["schemaVersion"], "cultnet.database_unsubscribe.v0") - self.assertEqual(unsubscribe["messageId"], "unsub-message") - self.assertEqual(unsubscribe["subscriptionId"], "sub-1") - - def test_cultnet_database_change_parses_put_and_delete_shapes(self) -> None: - put_change = CultNetDatabaseChange.from_wire({ - "schemaVersion": "cultnet.database_change_raw.v0", - "messageId": "change-put", - "subscriptionId": "sub-1", - "changeKind": "added", - "document": { - "schemaId": "schema-note", - "recordKey": "note:1", - "payload": b"payload", - }, - }) - self.assertEqual(put_change.schema_id, "schema-note") - self.assertEqual(put_change.record_key, "note:1") - self.assertIsInstance(put_change.raw_document, CultNetRawDocumentRecord) - self.assertEqual(put_change.raw_document.payload, b"payload") - self.assertEqual(put_change.to_wire()["document"]["recordKey"], "note:1") - - delete_change = CultNetDatabaseChange.from_wire({ - "schemaVersion": "cultnet.database_change_raw.v0", - "messageId": "change-delete", - "subscriptionId": "sub-1", - "changeKind": "removed", - "schemaId": "schema-note", - "recordKey": "note:1", - }) - self.assertIsNone(delete_change.document) - self.assertIsNone(delete_change.raw_document) - self.assertEqual(delete_change.schema_id, "schema-note") - self.assertEqual(delete_change.to_wire()["recordKey"], "note:1") - - def test_cultnet_raw_put_helper_carries_message_id(self) -> None: - put = document_put_raw( - message_id="put-1", - key="record-a", - schema_id="schema-a", - stored_at="2026-06-13T00:00:00Z", - payload=b"payload", - source_runtime_id="python-test", - shard_id="interop", - shard_epoch=1, - ).to_wire() - self.assertEqual(put["schemaVersion"], "cultnet.document_put_raw.v0") - self.assertEqual(put["messageId"], "put-1") - self.assertEqual(put["document"]["recordKey"], "record-a") - self.assertEqual(put["document"]["sourceRuntimeId"], "python-test") - self.assertEqual(put["shardId"], "interop") - self.assertEqual(put["shardEpoch"], 1) - - def test_cultnet_catalog_and_snapshot_helpers_accept_filters(self) -> None: - catalog = schema_catalog_request( - message_id="catalog-1", - include_schema_json=True, - schema_ids=["schema-a"], - kinds=["wire_message"], - ).to_wire() - self.assertEqual(catalog["schemaVersion"], "cultnet.schema_catalog_request.v0") - self.assertEqual(catalog["messageId"], "catalog-1") - self.assertTrue(catalog["includeSchemaJson"]) - self.assertEqual(catalog["schemaIds"], ["schema-a"]) - self.assertEqual(catalog["kinds"], ["wire_message"]) - - snapshot = snapshot_request( - message_id="snapshot-1", - schema_ids=["schema-a"], - record_keys=["record-a"], - shard_id="interop", - shard_epoch=1, - ).to_wire() - self.assertEqual(snapshot["schemaVersion"], "cultnet.snapshot_request.v0") - self.assertEqual(snapshot["messageId"], "snapshot-1") - self.assertEqual(snapshot["schemaIds"], ["schema-a"]) - self.assertEqual(snapshot["recordKeys"], ["record-a"]) - self.assertEqual(snapshot["shardId"], "interop") - self.assertEqual(snapshot["shardEpoch"], 1) - - def test_cultnet_raw_snapshot_response_filters_and_roundtrips_records(self) -> None: - response = CultNetRawSnapshotResponse.from_wire({ - "schemaVersion": "cultnet.snapshot_response_raw.v0", - "messageId": "snapshot-1", - "shardId": "notes", - "shardEpoch": 3, - "shardLogSequence": 8, - "documents": [ - CultNetRawDocumentRecord( - schema_id="schema-note", - record_key="note:1", - stored_at="2026-06-14T00:00:00Z", - payload=b"note", - source_runtime_id="python-test", - tags=("snapshot",), - ).to_wire(), - { - "schemaId": "schema-fact", - "recordKey": "fact:1", - "payloadEncoding": "messagepack", - "payload": b"fact", - }, - ], - }) - self.assertEqual(response.shard_id, "notes") - self.assertEqual(response.shard_epoch, 3) - self.assertEqual(response.shard_log_sequence, 8) - self.assertEqual(response.documents[0].source_runtime_id, "python-test") - self.assertEqual(response.filter(schema_ids=["schema-note"])[0].record_key, "note:1") - self.assertEqual(response.filter(record_keys=["fact:1"])[0].schema_id, "schema-fact") - self.assertEqual(response.to_wire()["documents"][0]["tags"], ["snapshot"]) - - def test_cultnet_raw_client_fetches_schema_snapshot_and_shard_reads(self) -> None: - import msgpack # type: ignore - from cultnet_py import read_frame, write_frame - - received_versions: list[str] = [] - ready = threading.Event() - server_error: list[BaseException] = [] - port_holder: list[int] = [] - - def serve_requests() -> None: - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: - server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - server.bind(("127.0.0.1", 0)) - port_holder.append(server.getsockname()[1]) - server.listen(4) - ready.set() - for _ in range(4): - connection, _ = server.accept() - with connection: - stream = connection.makefile("rwb") - request = msgpack.unpackb(read_frame(stream), raw=False) - received_versions.append(request["schemaVersion"]) - if request["schemaVersion"] == "cultnet.schema_catalog_request.v0": - self.assertEqual(request["kinds"], ["wire_message"]) - response = {"schemaVersion": "cultnet.schema_catalog_response.v0", "messageId": request["messageId"], "schemas": []} - elif request["schemaVersion"] == "cultnet.snapshot_request.v0": - self.assertEqual(request["shardId"], "interop") - response = {"schemaVersion": "cultnet.snapshot_response_raw.v0", "messageId": request["messageId"], "documents": []} - elif request["schemaVersion"] == "cultnet.shard_catalog_request.v0": - response = { - "schemaVersion": "cultnet.shard_catalog_response.v0", - "messageId": request["messageId"], - "shards": [{ - "shardId": "interop", - "ownerRuntimeId": "socket-test", - "epoch": 1, - "schemaIds": request["schemaIds"], - "keyPrefix": "record-", - "primaryEndpoints": ["cultnet://127.0.0.1:1"], - }], - } - elif request["schemaVersion"] == "cultnet.shard_log_request.v0": - self.assertEqual(request["afterSequence"], 7) - response = { - "schemaVersion": "cultnet.shard_log_response.v0", - "messageId": request["messageId"], - "shardId": request["shardId"], - "shardEpoch": request["shardEpoch"], - "entries": [], - "resyncRequired": False, - } - else: - raise AssertionError(f"unexpected request {request['schemaVersion']}") - write_frame(stream, msgpack.packb(response, use_bin_type=True)) - stream.flush() - except BaseException as error: - server_error.append(error) - ready.set() - - thread = threading.Thread(target=serve_requests, daemon=True) - thread.start() - self.assertTrue(ready.wait(2.0)) - self.assertFalse(server_error) - - client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0) - self.assertEqual(client.fetch_schema_catalog(kinds=["wire_message"])["schemaVersion"], "cultnet.schema_catalog_response.v0") - typed_snapshot = client.fetch_snapshot_response(shard_id="interop", shard_epoch=1) - self.assertEqual(typed_snapshot.shard_id, None) - self.assertEqual(typed_snapshot.documents, ()) - shard_catalog = CultNetShardCatalog() - synced_shards = client.sync_shard_catalog(shard_catalog, schema_ids=["schema-a"]) - self.assertEqual(synced_shards[0].shard_id, "interop") - self.assertEqual(shard_catalog.get("interop"), synced_shards[0]) - shard_log = client.fetch_shard_log_response(shard_id="interop", shard_epoch=1, after_sequence=7) - self.assertEqual(shard_log.shard_id, "interop") - self.assertFalse(shard_log.resync_required) - self.assertEqual(shard_log.last_sequence, 0) - - thread.join(2.0) - self.assertFalse(server_error) - self.assertEqual(received_versions, [ - "cultnet.schema_catalog_request.v0", - "cultnet.snapshot_request.v0", - "cultnet.shard_catalog_request.v0", - "cultnet.shard_log_request.v0", - ]) - - def test_cultnet_raw_client_raises_typed_peer_error_response(self) -> None: - import msgpack # type: ignore - from cultnet_py import read_frame, write_frame - - ready = threading.Event() - port_holder: list[int] = [] - - def serve_error() -> None: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: - server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - server.bind(("127.0.0.1", 0)) - port_holder.append(server.getsockname()[1]) - server.listen(1) - ready.set() - connection, _ = server.accept() - with connection: - stream = connection.makefile("rwb") - read_frame(stream) - write_frame(stream, msgpack.packb({ - "schemaVersion": "cultnet.error.v0", - "messageId": "error-1", - "error": "Snapshot document limit exceeded", - }, use_bin_type=True)) - stream.flush() - - thread = threading.Thread(target=serve_error, daemon=True) - thread.start() - self.assertTrue(ready.wait(2.0)) - - client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0) - with self.assertRaisesRegex(CultNetPeerError, "Snapshot document limit exceeded") as raised: - client.fetch_snapshot() - - self.assertEqual(raised.exception.peer_error, "Snapshot document limit exceeded") - self.assertEqual(raised.exception.response["schemaVersion"], "cultnet.error.v0") - thread.join(2.0) - - def test_cultnet_database_subscription_reads_snapshot_and_change(self) -> None: - import msgpack # type: ignore - from cultnet_py import read_frame, write_frame - - document = define_database_entry_type( - "sub.item", - [ - ("name", 0), - ("category", 1), - ("value", 2, 0), - ], - cls=Item, - ) - schema_id = document.catalog_entry().schema_id - ready = threading.Event() - server_error: list[BaseException] = [] - port_holder: list[int] = [] - received_versions: list[str] = [] - - def serve_subscription() -> None: - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: - server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - server.bind(("127.0.0.1", 0)) - port_holder.append(server.getsockname()[1]) - server.listen(1) - ready.set() - connection, _ = server.accept() - with connection: - stream = connection.makefile("rwb") - subscribe = msgpack.unpackb(read_frame(stream), raw=False) - received_versions.append(subscribe["schemaVersion"]) - self.assertEqual(subscribe["subscriptionId"], "sub-1") - snapshot = { - "schemaVersion": "cultnet.snapshot_response_raw.v0", - "messageId": subscribe["messageId"], - "documents": [], - } - write_frame(stream, msgpack.packb(snapshot, use_bin_type=True)) - stream.flush() - - put = msgpack.unpackb(read_frame(stream), raw=False) - received_versions.append(put["schemaVersion"]) - change = { - "schemaVersion": "cultnet.database_change_raw.v0", - "messageId": "change-1", - "subscriptionId": "sub-1", - "changeKind": "added", - "document": put["document"], - } - write_frame(stream, msgpack.packb(change, use_bin_type=True)) - stream.flush() - - unsubscribe = msgpack.unpackb(read_frame(stream), raw=False) - received_versions.append(unsubscribe["schemaVersion"]) - except BaseException as error: - server_error.append(error) - ready.set() - - thread = threading.Thread(target=serve_subscription, daemon=True) - thread.start() - self.assertTrue(ready.wait(2.0)) - self.assertFalse(server_error) - - client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0) - put = document_put_raw( - message_id="put-sub", - key="item:sub", - schema_id=schema_id, - stored_at="2026-06-14T00:00:00Z", - payload=document.encode_payload(Item("orb", "gear", 8)), - ) - with client.subscribe_database(subscription_id="sub-1", schema_ids=[schema_id]) as subscription: - snapshot = subscription.read_next_snapshot_response() - subscription.send(put) - change = subscription.read_next_change() - - thread.join(2.0) - self.assertFalse(server_error) - self.assertIsInstance(snapshot, CultNetRawSnapshotResponse) - self.assertEqual(snapshot.message_id, "cultnet-python-subscribe") - self.assertEqual(snapshot.to_wire()["schemaVersion"], "cultnet.snapshot_response_raw.v0") - self.assertEqual(change.change_kind, "added") - self.assertEqual(change.record_key, "item:sub") - self.assertIsInstance(change.raw_document, CultNetRawDocumentRecord) - self.assertEqual(change.raw_document.record_key, "item:sub") - self.assertEqual(received_versions, [ - "cultnet.database_subscribe.v0", - "cultnet.document_put_raw.v0", - "cultnet.database_unsubscribe.v0", - ]) - - def test_cultnet_database_subscription_raises_typed_peer_error(self) -> None: - import msgpack # type: ignore - from cultnet_py import read_frame, write_frame - - ready = threading.Event() - port_holder: list[int] = [] - - def serve_subscription_error() -> None: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: - server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - server.bind(("127.0.0.1", 0)) - port_holder.append(server.getsockname()[1]) - server.listen(1) - ready.set() - connection, _ = server.accept() - with connection: - stream = connection.makefile("rwb") - read_frame(stream) - write_frame(stream, msgpack.packb({ - "schemaVersion": "cultnet.error.v0", - "messageId": "subscription-error", - "error": "subscription rejected", - }, use_bin_type=True)) - stream.flush() - - thread = threading.Thread(target=serve_subscription_error, daemon=True) - thread.start() - self.assertTrue(ready.wait(2.0)) - - client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0) - with client.subscribe_database(subscription_id="sub-error") as subscription: - with self.assertRaisesRegex(CultNetPeerError, "subscription rejected") as raised: - subscription.read_next() - self.assertEqual(raised.exception.response["messageId"], "subscription-error") - thread.join(2.0) - - def test_cultnet_replication_helpers_apply_raw_snapshot_and_shard_log(self) -> None: - document = define_database_entry_type( - "replica.item", - [ - ("name", 0), - ("category", 1), - ("value", 2, 0), - ], - cls=Item, - ) - cache = CultCache() - cache.register_document_type(document) - schema_id = document.catalog_entry().schema_id - - snapshot = { - "schemaVersion": "cultnet.snapshot_response_raw.v0", - "messageId": "snapshot-1", - "documents": [ - { - "schemaId": schema_id, - "recordKey": "item:1", - "storedAt": "2026-06-13T00:00:00Z", - "payloadEncoding": "messagepack", - "payload": document.encode_payload(Item("sword", "gear", 3)), - } - ], - } - applied_snapshot = apply_raw_snapshot(cache, [document], snapshot) - self.assertEqual(applied_snapshot[0].record_key, "item:1") - self.assertEqual(cache.get_required(document, "item:1").value, 3) - typed_snapshot = CultNetRawSnapshotResponse.from_wire(snapshot) - direct_applied = apply_raw_document_record(cache, schema_document_map([document]), typed_snapshot.documents[0]) - self.assertEqual(direct_applied.schema_id, schema_id) - self.assertEqual(direct_applied.record_key, "item:1") - typed_snapshot_applied = apply_raw_snapshot(cache, [document], typed_snapshot) - self.assertEqual(typed_snapshot_applied[0].record_key, "item:1") - - shard_log = { - "schemaVersion": "cultnet.shard_log_response.v0", - "messageId": "log-1", - "shardId": "interop", - "shardEpoch": 1, - "resyncRequired": False, - "entries": [ - { - "sequence": 0, - "changeKind": "updated", - "put": { - "schemaVersion": "cultnet.document_put_raw.v0", - "messageId": "put-unknown", - "document": { - "schemaId": "replica.unknown.v1", - "recordKey": "unknown:1", - "storedAt": "2026-06-13T00:00:01Z", - "payloadEncoding": "messagepack", - "payload": document.encode_payload(Item("ignored", "unknown", 0)), - }, - "shardId": "interop", - "shardEpoch": 1, - }, - }, - { - "sequence": 1, - "changeKind": "updated", - "put": { - "schemaVersion": "cultnet.document_put_raw.v0", - "messageId": "put-1", - "document": { - "schemaId": schema_id, - "recordKey": "item:1", - "storedAt": "2026-06-13T00:00:01Z", - "payloadEncoding": "messagepack", - "payload": document.encode_payload(Item("sword", "gear", 12)), - }, - "shardId": "interop", - "shardEpoch": 1, - }, - }, - { - "sequence": 2, - "changeKind": "removed", - "delete": { - "schemaVersion": "cultnet.document_delete.v0", - "messageId": "delete-1", - "schemaId": schema_id, - "recordKey": "item:1", - "shardId": "interop", - "shardEpoch": 1, - }, - }, - ], - } - applied_log = apply_shard_log_response(cache, [document], shard_log) - self.assertEqual([change.change_kind for change in applied_log], ["updated", "removed"]) - self.assertIsNone(cache.get(document, "item:1")) - - cache.put(document, "item:1", Item("sword", "gear", 3)) - typed_shard_log = CultNetShardLogResponse.from_wire({ - **shard_log, - "entries": shard_log["entries"][1:], - }) - typed_applied_log = apply_shard_log_response(cache, [document], typed_shard_log) - self.assertEqual([change.change_kind for change in typed_applied_log], ["updated", "removed"]) - self.assertIsNone(cache.get(document, "item:1")) - - def test_cultnet_replication_helpers_recover_schema_stamped_raw_records(self) -> None: - document = define_database_entry_type( - "replica.runtime-policy", - [ - ("schema_version", 0), - ("name", 1), - ("value", 2), - ], - schema_id="replica.runtime-policy.current", - schema_name="replica.runtime_policy", - schema_version="replica.runtime_policy.v1", - ) - cache = CultCache() - cache.register_document_type(document) - stale_schema_id = "sha256:stale-replica-runtime-policy" - payload = document.encode_payload({ - "schema_version": "replica.runtime_policy.v1", - "name": "policy", - "value": "still synced", - }) - record = { - "schemaId": stale_schema_id, - "recordKey": "policy:1", - "storedAt": "2026-06-25T00:00:00Z", - "payloadEncoding": "messagepack", - "payload": payload, - } - snapshot = { - "schemaVersion": "cultnet.snapshot_response_raw.v0", - "messageId": "snapshot-stale", - "documents": [record], - } - - applied_snapshot = apply_raw_snapshot(cache, [document], snapshot) - local_schema_id = document.catalog_entry().schema_id - self.assertEqual(applied_snapshot[0].schema_id, local_schema_id) - self.assertEqual(cache.get_required(document, "policy:1")["value"], "still synced") - self.assertEqual(cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id) - direct_applied = apply_raw_document_record(cache, schema_document_map([document]), record) - self.assertEqual(direct_applied.schema_id, local_schema_id) - self.assertEqual(cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id) - - shard_log = { - "schemaVersion": "cultnet.shard_log_response.v0", - "messageId": "log-stale", - "shardId": "interop", - "shardEpoch": 1, - "resyncRequired": False, - "entries": [ - { - "sequence": 0, - "changeKind": "updated", - "put": { - "schemaVersion": "cultnet.document_put_raw.v0", - "messageId": "put-stale", - "document": { - **record, - "payload": document.encode_payload({ - "schema_version": "replica.runtime_policy.v1", - "name": "policy", - "value": "updated through log", - }), - }, - "shardId": "interop", - "shardEpoch": 1, - }, - } - ], - } - - applied_log = apply_shard_log_response(cache, [document], shard_log) - self.assertEqual(applied_log[0].schema_id, local_schema_id) - self.assertEqual(cache.get_required(document, "policy:1")["value"], "updated through log") - self.assertEqual(cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id) - - delete_log = { - "schemaVersion": "cultnet.shard_log_response.v0", - "messageId": "log-delete-alias", - "shardId": "interop", - "shardEpoch": 1, - "resyncRequired": False, - "entries": [ - { - "sequence": 1, - "changeKind": "removed", - "delete": { - "schemaVersion": "cultnet.document_delete.v0", - "messageId": "delete-version-alias", - "schemaId": "replica.runtime_policy.v1", - "recordKey": "policy:1", - "shardId": "interop", - "shardEpoch": 1, - }, - } - ], - } - applied_delete = apply_shard_log_response(cache, [document], delete_log) - self.assertEqual(applied_delete[0].schema_id, local_schema_id) - self.assertEqual(applied_delete[0].change_kind, "removed") - self.assertIsNone(cache.get(document, "policy:1")) - - def test_cultnet_document_delete_helper_matches_schema_v0_shape(self) -> None: - message = document_delete( - message_id="delete-1", - schema_id="schema-a", - record_key="record-a", - shard_id="interop", - shard_epoch=1, - ).to_wire() - self.assertEqual(message["schemaVersion"], "cultnet.document_delete.v0") - self.assertEqual(message["messageId"], "delete-1") - self.assertEqual(message["schemaId"], "schema-a") - self.assertEqual(message["recordKey"], "record-a") - self.assertEqual(message["shardId"], "interop") - self.assertEqual(message["shardEpoch"], 1) - - def test_cultnet_auth_session_helpers_match_schema_v0_shape(self) -> None: - self.assertEqual( - login(nonce="nonce", auth="encrypted-auth", password="encrypted-password").to_wire(), - { - "schemaVersion": "cultnet.login.v0", - "nonce": "nonce", - "auth": "encrypted-auth", - "password": "encrypted-password", - }, - ) - self.assertEqual( - register( - nonce="nonce", - email="encrypted-email", - password="encrypted-password", - name="encrypted-name", - ).to_wire(), - { - "schemaVersion": "cultnet.register.v0", - "nonce": "nonce", - "email": "encrypted-email", - "password": "encrypted-password", - "name": "encrypted-name", - }, - ) - self.assertEqual( - verify_session(nonce="nonce", session="encrypted-session").to_wire(), - { - "schemaVersion": "cultnet.verify.v0", - "nonce": "nonce", - "session": "encrypted-session", - }, - ) - self.assertEqual( - login_success(nonce="nonce", session="encrypted-session").to_wire(), - { - "schemaVersion": "cultnet.login_success.v0", - "nonce": "nonce", - "session": "encrypted-session", - }, - ) - - def test_cultnet_wire_catalog_describes_python_handled_messages(self) -> None: - descriptors = wire_message_schema_descriptors(include_schema_json=True) - by_version = {descriptor["schemaVersion"]: descriptor for descriptor in descriptors} - for schema_version in [ - "cultnet.error.v0", - "cultnet.login.v0", - "cultnet.register.v0", - "cultnet.verify.v0", - "cultnet.login_success.v0", - "cultnet.transport_profile.v0", - "cultnet.document_delete.v0", - "cultnet.database_change_raw.v0", - "cultnet.shard_log_response.v0", - "cultnet.simulation_consensus_candidate.v0", - "cultmesh.peer_exchange_response.v0", - ]: - self.assertIn(schema_version, by_version) - expected_kind = "shared_contract" if schema_version == "cultnet.transport_profile.v0" else "wire_message" - self.assertEqual(by_version[schema_version]["kind"], expected_kind) - self.assertIn("cultnet.schema.v0", by_version[schema_version]["wireContracts"]) - self.assertIn(schema_version, by_version[schema_version]["schemaJson"]) - self.assertEqual(len(by_version[schema_version]["contentHash"]), 64) - - delete_schema = json.loads(by_version["cultnet.document_delete.v0"]["schemaJson"]) - self.assertEqual( - delete_schema["required"], - ["schemaVersion", "messageId", "schemaId", "recordKey"], - ) - self.assertEqual(delete_schema["properties"]["recordKey"]["type"], "string") - - error_schema = json.loads(by_version["cultnet.error.v0"]["schemaJson"]) - self.assertEqual(error_schema["required"], ["schemaVersion", "error"]) - self.assertEqual(error_schema["properties"]["error"]["type"], "string") - self.assertIn("details", error_schema["properties"]) - - login_schema = json.loads(by_version["cultnet.login.v0"]["schemaJson"]) - self.assertEqual(login_schema["required"], ["schemaVersion", "nonce", "auth", "password"]) - self.assertEqual(login_schema["properties"]["auth"]["type"], "string") - - register_schema = json.loads(by_version["cultnet.register.v0"]["schemaJson"]) - self.assertEqual(register_schema["required"], ["schemaVersion", "nonce", "email", "password", "name"]) - self.assertEqual(register_schema["properties"]["email"]["type"], "string") - - verify_schema = json.loads(by_version["cultnet.verify.v0"]["schemaJson"]) - self.assertEqual(verify_schema["required"], ["schemaVersion", "nonce", "session"]) - self.assertEqual(verify_schema["properties"]["session"]["type"], "string") - - success_schema = json.loads(by_version["cultnet.login_success.v0"]["schemaJson"]) - self.assertEqual(success_schema["required"], ["schemaVersion", "nonce", "session"]) - - transport_profile_schema = json.loads(by_version["cultnet.transport_profile.v0"]["schemaJson"]) - self.assertEqual(transport_profile_schema["required"], ["schemaVersion", "runtimeId", "transports"]) - transport = transport_profile_schema["properties"]["transports"]["items"] - self.assertEqual(transport["properties"]["protocol"]["enum"], ["tcp_framed", "litenetlib", "websocket", "rudp"]) - self.assertEqual( - transport["properties"]["reconnectPolicy"]["properties"]["schemaVersion"]["const"], - "cultnet.reconnect_policy.v0", - ) - channel = transport["properties"]["channels"]["items"] - self.assertEqual(channel["properties"]["ordering"]["enum"], ["ordered", "unordered", "sequenced"]) - - change_schema = json.loads(by_version["cultnet.database_change_raw.v0"]["schemaJson"]) - self.assertEqual(change_schema["properties"]["changeKind"]["enum"], ["added", "updated", "removed"]) - self.assertIn("document", change_schema["properties"]) - self.assertIn("schemaId", change_schema["properties"]) - self.assertIn("recordKey", change_schema["properties"]) - - log_schema = json.loads(by_version["cultnet.shard_log_response.v0"]["schemaJson"]) - self.assertIn("entries", log_schema["required"]) - self.assertIn("resyncRequired", log_schema["required"]) - self.assertIn("compactedThrough", log_schema["properties"]) - - hello_schema = json.loads(by_version["cultnet.hello.v0"]["schemaJson"]) - mutation_contract = hello_schema["properties"]["supportedMutationContracts"]["items"] - self.assertEqual(mutation_contract["type"], "object") - self.assertIn("operations", mutation_contract["properties"]) - self.assertEqual( - hello_schema["properties"]["transportProfiles"]["items"]["$ref"], - "https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json", - ) - - consensus_schema = json.loads(by_version["cultnet.simulation_consensus_candidate.v0"]["schemaJson"]) - for required_field in ["witnessCount", "supportWeight", "totalWeight", "hasQuorum", "confidence"]: - self.assertIn(required_field, consensus_schema["required"]) - - def test_cultnet_schema_catalog_applies_filters_and_responses(self) -> None: - catalog = wire_message_schema_catalog(include_schema_json=True) - descriptor = catalog.get("https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put-raw.schema.json") - self.assertIsNotNone(descriptor) - assert descriptor is not None - self.assertEqual(descriptor.kind, "wire_message") - self.assertEqual(descriptor.schema_version, "cultnet.document_put_raw.v0") - self.assertIsNotNone(descriptor.schema_json) - - filtered = catalog.list(kinds=["wire_message"], schema_ids=[descriptor.schema_id]) - self.assertEqual(filtered, [descriptor]) - - response = catalog.create_response( - message_id="catalog-response", - include_schema_json=False, - schema_ids=[descriptor.schema_id], - kinds=["wire_message"], - ) - self.assertEqual(response["schemaVersion"], "cultnet.schema_catalog_response.v0") - self.assertNotIn("schemaJson", response["schemas"][0]) - - remote = CultNetSchemaCatalog() - applied = remote.apply_response({ - "schemaVersion": "cultnet.schema_catalog_response.v0", - "messageId": "remote", - "schemas": [ - CultNetSchemaDescriptor( - schema_id="schema:custom", - kind="shared_contract", - schema_version="custom.v0", - wire_contracts=("cultnet.schema.v0",), - content_hash="hash", - schema_json="{}", - ).to_wire() - ], - }) - self.assertEqual(applied[0].schema_id, "schema:custom") - self.assertEqual(remote.get("schema:custom"), applied[0]) - - def test_cultnet_shard_helpers_match_schema_v0_shape(self) -> None: - catalog = shard_catalog_request( - message_id="catalog-1", - schema_ids=["schema-a"], - record_keys=["record-a"], - ).to_wire() - self.assertEqual(catalog["schemaVersion"], "cultnet.shard_catalog_request.v0") - self.assertEqual(catalog["messageId"], "catalog-1") - self.assertEqual(catalog["schemaIds"], ["schema-a"]) - self.assertEqual(catalog["recordKeys"], ["record-a"]) - - log = shard_log_request( - message_id="log-1", - shard_id="interop", - shard_epoch=7, - after_sequence=3, - limit=2, - ).to_wire() - self.assertEqual(log["schemaVersion"], "cultnet.shard_log_request.v0") - self.assertEqual(log["messageId"], "log-1") - self.assertEqual(log["shardId"], "interop") - self.assertEqual(log["shardEpoch"], 7) - self.assertEqual(log["afterSequence"], 3) - self.assertEqual(log["limit"], 2) - - def test_cultnet_shard_catalog_applies_filters_and_responses(self) -> None: - catalog = CultNetShardCatalog() - primary = catalog.upsert(CultNetShardDescriptor( - shard_id="notes", - owner_runtime_id="python-runtime", - epoch=2, - is_primary=True, - schema_ids=("schema-note.v1",), - key_prefix="note:", - primary_endpoints=("cultnet://127.0.0.1:3075",), - read_replica_endpoints=("cultnet://127.0.0.1:3075",), - region="local", - )) - catalog.upsert({ - "shardId": "facts", - "ownerRuntimeId": "python-runtime", - "epoch": 1, - "schemaIds": ["schema-fact"], - "keyPrefix": "fact:", - "primaryEndpoints": ["cultnet://127.0.0.1:3076"], - }) - - self.assertTrue(primary.serves(schema_id="schema-note.v1", record_key="note:1")) - self.assertTrue(primary.serves(schema_id="schema-note", record_key="note:1")) - self.assertFalse(primary.serves(schema_id="schema-note", record_key="fact:1")) - self.assertEqual(catalog.list(schema_ids=["schema-note.v1"]), [primary]) - self.assertEqual(catalog.list(schema_ids=["schema-note"]), [primary]) - self.assertEqual(catalog.list(record_keys=["note:1"]), [primary]) - - response = catalog.create_response( - message_id="shards", - schema_ids=["schema-note"], - record_keys=["note:1"], - ) - self.assertEqual(response["schemaVersion"], "cultnet.shard_catalog_response.v0") - self.assertEqual(response["shards"][0]["shardId"], "notes") - self.assertEqual(response["shards"][0]["ownerRuntimeId"], "python-runtime") - - remote = CultNetShardCatalog() - applied = remote.apply_response(response) - self.assertEqual(applied[0].shard_id, "notes") - self.assertEqual(remote.get("notes"), applied[0]) - - def test_cultnet_shard_log_response_tracks_cursor_and_resync_state(self) -> None: - response = CultNetShardLogResponse.from_wire({ - "schemaVersion": "cultnet.shard_log_response.v0", - "messageId": "log", - "shardId": "notes", - "shardEpoch": 2, - "entries": [ - CultNetShardLogEntry( - sequence=2, - change_kind="updated", - put={"schemaVersion": "cultnet.document_put_raw.v0", "messageId": "put", "document": {"schemaId": "schema-note", "recordKey": "note:1", "payload": b"p"}}, - committed_at="2026-06-14T00:00:00Z", - ).to_wire(), - {"sequence": 3, "changeKind": "removed", "delete": {"schemaId": "schema-note", "recordKey": "note:1"}}, - ], - "resyncRequired": False, - }) - self.assertEqual(response.last_sequence, 3) - self.assertFalse(response.resync_required) - self.assertEqual(response.entries[0].put["document"]["recordKey"], "note:1") - self.assertIsInstance(response.entries[0].raw_document, CultNetRawDocumentRecord) - self.assertEqual(response.entries[0].raw_document.record_key, "note:1") - self.assertEqual(response.entries[1].delete_schema_id, "schema-note") - self.assertEqual(response.entries[1].delete_record_key, "note:1") - self.assertEqual(response.to_wire()["entries"][1]["delete"]["recordKey"], "note:1") - self.assertIs(response.require_usable(), response) - - resync = CultNetShardLogResponse.from_wire({ - "schemaVersion": "cultnet.shard_log_response.v0", - "messageId": "log-resync", - "shardId": "notes", - "shardEpoch": 2, - "entries": [], - "resyncRequired": True, - "reason": "compacted", - "compactedThrough": 12, - }) - self.assertEqual(resync.last_sequence, 12) - with self.assertRaisesRegex(ValueError, "compacted"): - resync.require_usable() - - def test_cultnet_simulation_observation_helper_matches_schema_v0_shape(self) -> None: - claim_hash = compute_simulation_claim_hash("frame:42", "subject:player-1", "hit") - self.assertEqual(len(claim_hash), 64) - message = simulation_observation( - message_id="obs-1", - witness_runtime_id="python-test", - shard_id="interop", - shard_epoch=1, - frame=42, - subject_id="player-1", - claim_kind="hit", - claim_hash=claim_hash, - claim_summary="player-1 hit target-a", - observed_at="2026-06-13T00:00:02Z", - ).to_wire() - self.assertEqual(message["schemaVersion"], "cultnet.simulation_observation.v0") - self.assertEqual(message["messageId"], "obs-1") - self.assertEqual(message["observation"]["witnessRuntimeId"], "python-test") - self.assertEqual(message["observation"]["claimHash"], claim_hash) - self.assertEqual(message["observation"]["weight"], 1.0) - observation = CultNetSimulationObservation.from_wire(message) - self.assertEqual(observation.witness_runtime_id, "python-test") - self.assertEqual(observation.claim_summary, "player-1 hit target-a") - self.assertEqual(observation.to_message_wire(message_id="obs-2")["observation"], message["observation"]) - - def test_cultnet_witness_artifact_bundle_uses_csharp_slot_order(self) -> None: - import msgpack # type: ignore - - bundle = witness_artifact_bundle( - bundle_id="bundle-1", - witness_kind="interop-proof", - captured_at="2026-06-13T00:00:03Z", - subject={"documentType": "cultnet.interop-note", "subjectId": "note:python"}, - contracts=[{"role": "payload", "schemaId": "schema-a"}], - artifacts=[{"role": "log", "uri": "cultcache://bundle-1/log", "mediaType": "text/plain"}], - timing_witnesses=[{"stage": "roundtrip", "startedAt": "2026-06-13T00:00:03Z", "completedAt": "2026-06-13T00:00:04Z", "latencyMs": 1.0}], - provenance={"pipelineId": "interop", "runId": "run-1", "runtimeId": "python-test"}, - ) - payload = encode_witness_artifact_bundle_payload(bundle) - decoded = decode_witness_artifact_bundle_payload(payload) - typed = CultNetWitnessArtifactBundle.from_wire(bundle) - typed_payload = typed.to_payload() - typed_decoded = CultNetWitnessArtifactBundle.from_payload(typed_payload) - slots = msgpack.unpackb(payload, raw=False) - - self.assertEqual(payload, typed_payload) - self.assertEqual(slots[0], "bundle-1") - self.assertEqual(slots[1], "interop-proof") - self.assertEqual(slots[2], "2026-06-13T00:00:03Z") - self.assertEqual(slots[3]["subjectId"], "note:python") - self.assertEqual(slots[4][0]["schemaId"], "schema-a") - self.assertEqual(slots[5][0]["uri"], "cultcache://bundle-1/log") - self.assertEqual(slots[6][0]["latencyMs"], 1.0) - self.assertEqual(slots[7]["runtimeId"], "python-test") - self.assertEqual(decoded["bundleId"], "bundle-1") - self.assertEqual(decoded["witnessKind"], "interop-proof") - self.assertEqual(decoded["subject"]["subjectId"], "note:python") - self.assertEqual(decoded["contracts"][0]["schemaId"], "schema-a") - self.assertEqual(decoded["artifacts"][0]["mediaType"], "text/plain") - self.assertEqual(decoded["provenance"]["runtimeId"], "python-test") - self.assertEqual(typed.bundle_id, "bundle-1") - self.assertEqual(typed_decoded.to_wire(), bundle) - - def test_python_interop_peer_filters_shard_snapshot_by_logged_membership(self) -> None: - runtime_id = f"python-interop-test-{uuid4().hex}" - state = build_state( - runtime_id=runtime_id, - runtime_kind="python", - display_name="Python Interop Test", - agent_id="python-interop-test-agent", - schema_path=str(Path("packages/cultnet-ts/integration/contracts/cultnet.interop-note.schema.json")), - ) - binding = state.bindings["note"] - logged_value = { - "schemaVersion": "cultnet.interop_note.v0", - "documentId": "note:logged", - "authorRuntimeId": runtime_id, - "title": "Logged", - "body": "This record belongs to the shard log.", - "tags": ["interop", "logged"], - } - unlogged_value = { - **logged_value, - "documentId": "note:unlogged", - "title": "Unlogged", - "body": "This record is in cache but not in the shard log.", - } - state.cache.put(binding.document, "note:logged", logged_value) - state.cache.put(binding.document, "note:unlogged", unlogged_value) - logged_record = { - "schemaId": state.note_schema_id, - "recordKey": "note:logged", - "storedAt": "2026-06-14T00:00:00Z", - "payloadEncoding": "messagepack", - "payload": binding.document.encode_payload(logged_value), - } - append_shard_log_put( - state, - {"messageId": "logged-put"}, - logged_record, - ) - - response = raw_snapshot_response( - state, - { - "schemaVersion": "cultnet.snapshot_request.v0", - "messageId": "interop-shard-snapshot", - "schemaIds": [state.note_schema_id], - "shardId": state.shard_id, - }, - ) - - self.assertEqual(response["shardId"], state.shard_id) - self.assertEqual(response["shardLogSequence"], 1) - self.assertEqual([record["recordKey"] for record in response["documents"]], ["note:logged"]) - - def test_cultnet_simulation_consensus_dedupes_witnesses_and_requires_quorum(self) -> None: - from cultnet_py import CultNetSimulationConsensus - - consensus = CultNetSimulationConsensus( - CultNetSimulationConsensusOptions(minimum_witnesses=2, quorum_ratio=1.0) - ) - claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100") - observations = [ - { - "witnessRuntimeId": "watcher-1", - "shardId": "arena", - "shardEpoch": 4, - "frame": 100, - "subjectId": "bob", - "claimKind": "hit", - "claimHash": claim_hash, - "claimSummary": "alice shot bob first", - "weight": 1.0, - }, - { - "witnessRuntimeId": "watcher-1", - "shardId": "arena", - "shardEpoch": 4, - "frame": 100, - "subjectId": "bob", - "claimKind": "hit", - "claimHash": "stale-duplicate", - "weight": 0.5, - }, - { - "witnessRuntimeId": "watcher-2", - "shardId": "arena", - "shardEpoch": 4, - "frame": 100, - "subjectId": "bob", - "claimKind": "hit", - "claimHash": claim_hash, - "weight": 1.0, - }, - ] - - candidates = consensus.build_candidates(observations) - typed_candidates = consensus.build_candidate_objects([ - CultNetSimulationObservation.from_wire(observation) - for observation in observations - ]) - - self.assertEqual(len(candidates), 1) - self.assertEqual(candidates[0]["claimHash"], claim_hash) - self.assertEqual(candidates[0]["witnessCount"], 2) - self.assertEqual(candidates[0]["supportWeight"], 2.0) - self.assertTrue(candidates[0]["hasQuorum"]) - self.assertEqual(len(typed_candidates), 1) - self.assertIsInstance(typed_candidates[0], CultNetSimulationConsensusCandidate) - self.assertEqual(typed_candidates[0].claim_hash, claim_hash) - self.assertEqual(typed_candidates[0].to_wire(), candidates[0]) - self.assertEqual(CultNetSimulationConsensusCandidate.from_wire(candidates[0]), typed_candidates[0]) - - -class CultNetRudpFragmentBoundTests(unittest.TestCase): - def _pair(self) -> tuple[CultNetRudpSession, CultNetRudpSession]: - sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=311, initial_sequence=1)) - receiver = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=311, initial_sequence=100)) - sender.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 311, 90, 0, 0, "control")) - receiver.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 311, 91, 0, 0, "control")) - return sender, receiver - - def test_stranded_fragment_sets_are_bounded_and_evicted_oldest_first(self) -> None: - sender, receiver = self._pair() - receiver._max_pending_fragment_sets = 4 - - # Eight payloads each lose their last fragment; the map must not grow past four. - stranded = [] - for fill in range(1, 9): - packets = sender.send_many("media", bytes([fill]) * 2500, max_fragment_bytes=1000) - self.assertEqual(len(packets), 3) - for packet in packets[:2]: - self.assertEqual(receiver.receive(packet).delivered, ()) - stranded.append(packets[2]) - self.assertEqual(len(receiver._fragment_buffers), 4) - self.assertEqual(receiver.fragment_sets_evicted, 4) - - # A complete payload still lands. - delivered = [] - for packet in sender.send_many("media", bytes([9]) * 2500, max_fragment_bytes=1000): - delivered.extend(receiver.receive(packet).delivered) - self.assertEqual([frame.payload for frame in delivered], [bytes([9]) * 2500]) - self.assertEqual(receiver.fragment_sets_evicted, 5) - - # Oldest-first: set 8 still pends and completes late; set 1 was evicted. - self.assertEqual(receiver.receive(stranded[7]).delivered[0].payload, bytes([8]) * 2500) - self.assertEqual(receiver.receive(stranded[0]).delivered, ()) - - -if __name__ == "__main__": - unittest.main() diff --git a/packages/cultnet-rs/Cargo.toml b/packages/cultnet-rs/Cargo.toml index f289d86e..41d14b52 100644 --- a/packages/cultnet-rs/Cargo.toml +++ b/packages/cultnet-rs/Cargo.toml @@ -16,7 +16,6 @@ anyhow = "1" base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } cultcache-rs = { path = "../cultcache-rs" } -ed25519-dalek = "2.2" hmac = "0.12" rand = "0.9" rmp-serde = "1.3" @@ -25,13 +24,9 @@ serde = { version = "1", features = ["derive"] } serde_bytes = "0.11" serde_json = "1" sha2 = "0.10" -zeroize = "1" socket2 = "0.5" uuid = { version = "1", features = ["v4"] } -[target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security_Cryptography"] } - [dev-dependencies] pretty_assertions = "1.4" tempfile = "3" diff --git a/packages/cultnet-rs/examples/cultnet_interop_peer.rs b/packages/cultnet-rs/examples/cultnet_interop_peer.rs index 07f6f400..ab00c6c8 100644 --- a/packages/cultnet-rs/examples/cultnet_interop_peer.rs +++ b/packages/cultnet-rs/examples/cultnet_interop_peer.rs @@ -256,7 +256,7 @@ fn serve(config: PeerConfig) -> Result<()> { register_capability_entry_types(&mut cache)?; cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(runtime_store_path( &config.runtime_id, - )))?; + ))); cache.pull_all_backing_stores()?; let note = build_note(&config.runtime_id, &config.display_name); cache.put(¬e.document_id, ¬e)?; @@ -380,7 +380,7 @@ fn dial(config: DialConfig) -> Result<()> { register_capability_entry_types(&mut cache)?; cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(runtime_store_path( &format!("{}-dial", config.runtime_id), - )))?; + ))); cache.pull_all_backing_stores()?; let mut document_registry = CultNetDocumentRegistry::new(); @@ -986,9 +986,6 @@ fn run_rudp_server( if let Some(reply) = result.reply { send_rudp_packet(&socket, remote_addr, &reply)?; } - for ready in result.ready_to_send { - send_rudp_packet(&socket, remote_addr, &ready)?; - } if packet.packet_type == CultNetRudpPacketType::Data { let ack = peer.session.create_ack(); send_rudp_packet(&socket, remote_addr, &ack)?; @@ -1474,7 +1471,6 @@ impl SchemaMessageSender for RudpSessionSender<'_> { ordered: true, sequenced: false, now_ms: rudp_now_ms(), - reliable_expire_after_ms: None, }, Some(RUDP_INTEROP_MAX_FRAGMENT_BYTES), )?; diff --git a/packages/cultnet-rs/examples/media_delivery_probe.rs b/packages/cultnet-rs/examples/media_delivery_probe.rs deleted file mode 100644 index 397eb656..00000000 --- a/packages/cultnet-rs/examples/media_delivery_probe.rs +++ /dev/null @@ -1,183 +0,0 @@ -//! Measures what a media channel actually costs on the wire under loss. -//! -//! The claim under test: a reliable media channel retransmits lost packets, so -//! it adds load exactly when the link has least to give, while an unreliable one -//! drops them and holds its rate. Run both halves through `cultnet-impair` with -//! a `loss_basis_points` profile and compare bytes on the wire against bytes of -//! payload offered. -//! -//! ```text -//! media_delivery_probe recv -//! media_delivery_probe send -//! ``` -//! -//! The sender reports amplification: wire bytes divided by offered payload -//! bytes. At 1.0 nothing was retransmitted. Well above 1.0 under loss is the -//! pathology. - -use std::env; -use std::net::{SocketAddr, UdpSocket}; -use std::time::{Duration, Instant}; - -use anyhow::{Result, anyhow}; -use cultnet_rs::{ - CultNetRudpServerHubOptions, CultNetRudpSocketTransportConnection, - CultNetRudpSocketTransportOptions, CultNetTransportDelivery, -}; - -const CHANNEL: &str = "media"; -const DRAIN: Duration = Duration::from_secs(5); - -fn main() -> Result<()> { - let args: Vec = env::args().collect(); - match args.get(1).map(String::as_str) { - Some("recv") => recv( - args.get(2).ok_or_else(|| anyhow!("bind addr"))?.parse()?, - args.get(3).ok_or_else(|| anyhow!("expect count"))?.parse()?, - ), - Some("send") => send( - args.get(2).ok_or_else(|| anyhow!("endpoint"))?.parse()?, - match args.get(3).map(String::as_str) { - Some("unreliable") => CultNetTransportDelivery::Unreliable, - Some("reliable") => CultNetTransportDelivery::Reliable, - other => return Err(anyhow!("delivery must be reliable|unreliable, got {other:?}")), - }, - args.get(4).ok_or_else(|| anyhow!("payload bytes"))?.parse()?, - args.get(5).ok_or_else(|| anyhow!("count"))?.parse()?, - args.get(6).map(|v| v.parse()).transpose()?.unwrap_or(12.0), - args.get(7).map(|v| v.parse()).transpose()?, - ), - _ => Err(anyhow!( - "usage: media_delivery_probe recv | send [mbps] [max_fragment_bytes]" - )), - } -} - -fn recv(bind: SocketAddr, expect: u64) -> Result<()> { - let socket = UdpSocket::bind(bind)?; - socket.set_nonblocking(true)?; - let mut hub = cultnet_rs::CultNetRudpServerHub::new(CultNetRudpServerHubOptions::new( - "probe-recv", - socket, - 0x0BE0_0001, - ))?; - - println!("listening on {bind}, expecting {expect} frames"); - let started = Instant::now(); - let mut frames = 0u64; - let mut payload_bytes = 0u64; - let mut last_progress = Instant::now(); - - loop { - while let Some(event) = hub.receive_event_once()? { - if let cultnet_rs::CultNetRudpServerEvent::Frame { frame, .. } = event - && frame.channel_id == CHANNEL - { - frames += 1; - payload_bytes += frame.payload.len() as u64; - last_progress = Instant::now(); - } - } - if frames >= expect || last_progress.elapsed() > DRAIN { - break; - } - std::thread::sleep(Duration::from_micros(200)); - } - - let stats = hub.stats(); - let elapsed = started.elapsed().as_secs_f64(); - println!("RESULT recv frames={frames} expected={expect} payload_bytes={payload_bytes}"); - println!( - "RESULT recv wire_bytes_in={} wire_bytes_out={} elapsed_s={elapsed:.2} delivered_pct={:.2} fragment_sets_evicted={}", - stats.bytes_received, - stats.bytes_sent, - (frames as f64 / expect as f64) * 100.0, - stats.fragment_sets_evicted - ); - Ok(()) -} - -fn send( - endpoint: SocketAddr, - delivery: CultNetTransportDelivery, - payload_bytes: usize, - count: u64, - target_mbps: f64, - max_fragment_bytes: Option, -) -> Result<()> { - let socket = UdpSocket::bind("0.0.0.0:0")?; - socket.set_nonblocking(true)?; - let mut options = - CultNetRudpSocketTransportOptions::client("probe-send", socket, endpoint, 0x0BE0_0001); - options.media_delivery = Some(delivery); - options.media_reliable_expire_after_ms = None; - // Fragment above this many bytes so a lost datagram costs a whole payload; - // None sends each payload as one datagram, which loopback carries whole. - options.max_fragment_bytes = max_fragment_bytes; - - let mut transport = CultNetRudpSocketTransportConnection::new(options)?; - transport.connect(b"probe".to_vec())?; - let deadline = Instant::now() + Duration::from_secs(10); - while !transport.connected() { - let _ = transport.receive_once()?; - transport.poll_resends()?; - if Instant::now() >= deadline { - return Err(anyhow!("timed out connecting to {endpoint}")); - } - std::thread::sleep(Duration::from_millis(2)); - } - - // Pace to a realistic media rate. An unpaced blast overruns the receiver's - // socket buffer and manufactures loss that has nothing to do with the link. - let per_packet = Duration::from_secs_f64((payload_bytes as f64 * 8.0) / (target_mbps * 1e6)); - let payload = vec![0xA5u8; payload_bytes]; - let offered = payload_bytes as u64 * count; - let started = Instant::now(); - let mut sent = 0u64; - let mut would_block = 0u64; - - while sent < count { - match transport.send(CHANNEL, payload.clone()) { - Ok(()) => sent += 1, - Err(error) if error.to_string().contains("would block") => { - would_block += 1; - transport.poll_resends()?; - let _ = transport.receive_once()?; - std::thread::sleep(Duration::from_micros(500)); - } - Err(error) => return Err(error), - } - if sent % 64 == 0 { - let _ = transport.receive_once()?; - transport.poll_resends()?; - } - let due = started + per_packet.mul_f64(sent as f64); - if let Some(wait) = due.checked_duration_since(Instant::now()) { - std::thread::sleep(wait); - } - } - let send_elapsed = started.elapsed(); - - // Let retransmission run: this is where a reliable channel spends what an - // unreliable one does not. - let drain_start = Instant::now(); - while drain_start.elapsed() < DRAIN { - let _ = transport.receive_once()?; - transport.poll_resends()?; - std::thread::sleep(Duration::from_millis(1)); - } - - let stats = transport.stats(); - let amplification = stats.bytes_sent as f64 / offered as f64; - println!( - "RESULT send delivery={delivery:?} payload_bytes={payload_bytes} count={count} offered={offered} target_mbps={target_mbps}" - ); - println!( - "RESULT send wire_bytes={} amplification={amplification:.3} send_s={:.2} total_s={:.2} would_block={would_block} reliable_expired={}", - stats.bytes_sent, - send_elapsed.as_secs_f64(), - started.elapsed().as_secs_f64(), - stats.reliable_packets_expired - ); - Ok(()) -} diff --git a/packages/cultnet-rs/examples/runtime_presence_vector.rs b/packages/cultnet-rs/examples/runtime_presence_vector.rs deleted file mode 100644 index 04571a8b..00000000 --- a/packages/cultnet-rs/examples/runtime_presence_vector.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! Emit canonical bytes for one fixed runtime-presence record. -//! -//! This is the reference the TypeScript fork is tested against. The two forks -//! agree only if `gamecult.runtime_presence_health.v2` means the same bytes in -//! both, and the only way to know that is to have one produce the vector and -//! the other reproduce it. A fixture written by hand for both sides would agree -//! with itself and prove nothing. - -use cultnet_rs::{GameCultRuntimeCapability, GameCultRuntimePresenceHealthRecord}; - -fn main() -> anyhow::Result<()> { - let record = GameCultRuntimePresenceHealthRecord { - schema_version: "gamecult.runtime_presence_health.v2".into(), - target: "heimdall".into(), - expected_projection_sha256: format!("sha256-{}", "a".repeat(64)), - plan_id: format!("sha256-{}", "b".repeat(64)), - incarnation_id: "service-incarnation-1".into(), - sealed_release_id: format!("sha256-{}", "c".repeat(64)), - activation_witness_sha256: format!("sha256-{}", "d".repeat(64)), - state_schema_generation: Some("v1".into()), - state_contract_sha256: Some(format!("sha256-{}", "e".repeat(64))), - runtime_id: "heimdall-yggdrasil".into(), - runtime_instance_id: format!("sha256-{}", "f".repeat(64)), - bound_endpoint: Some("http://127.0.0.1:14101".into()), - capabilities: vec![ - GameCultRuntimeCapability { - capability: "heimdall.access".into(), - schema: "heimdall.access.v1".into(), - compatibility: "v1".into(), - capacity: 2, - }, - GameCultRuntimeCapability { - capability: "zeta.runtime".into(), - schema: "zeta.runtime.v1".into(), - compatibility: "v1".into(), - capacity: 1, - }, - ], - health_contract: "heimdall.cultnet-rudp-provider-health".into(), - state: "warming".into(), - detail: "Heimdall candidate warming".into(), - write_lease_sha256: None, - signer_identity_id: "1".repeat(64), - publisher_sequence: 7, - observed_at_unix_millis: 1_757_000_000_000, - signature_algorithm: "ed25519".into(), - signature: Vec::new(), - activation_signer_identity_id: "2".repeat(64), - activation_signature: Vec::new(), - }; - - // The proof payload: both signature fields empty. This is what both keys - // sign, so it is the vector that matters most. - let proof = record.canonical_proof_payload()?; - println!("proof {}", hex(&proof)); - - // And the complete record, with both proofs filled, to pin the signed shape. - let mut signed = record; - signed.signature = vec![0x11; 64]; - signed.activation_signature = vec![0x22; 64]; - println!("signed {}", hex(&rmp_serde::to_vec(&signed)?)); - Ok(()) -} - -fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|byte| format!("{byte:02x}")).collect() -} diff --git a/packages/cultnet-rs/src/contracts.rs b/packages/cultnet-rs/src/contracts.rs index b5c89ff7..e637883c 100644 --- a/packages/cultnet-rs/src/contracts.rs +++ b/packages/cultnet-rs/src/contracts.rs @@ -142,12 +142,6 @@ pub struct CultNetTransportChannel { pub max_fragment_bytes: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub max_pending_reliable_packets: Option, - /// Drop a reliable packet on this channel instead of retransmitting it once - /// this many milliseconds have passed since the send. Omitted when absent, - /// so a profile that does not set it is byte-identical to one from a - /// runtime that does not know the field. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reliable_expire_after_ms: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/packages/cultnet-rs/src/cultmesh.rs b/packages/cultnet-rs/src/cultmesh.rs index 14998396..20d21232 100644 --- a/packages/cultnet-rs/src/cultmesh.rs +++ b/packages/cultnet-rs/src/cultmesh.rs @@ -318,8 +318,6 @@ impl CultMesh { ) -> Result { let socket = bind_rudp_socket(&options)?; CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { - media_reliable_expire_after_ms: None, - media_delivery: None, runtime_id: runtime_id.into(), socket, mode: CultNetRudpSocketMode::Server, @@ -343,8 +341,6 @@ impl CultMesh { ) -> Result { let socket = bind_rudp_socket(&options)?; CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { - media_reliable_expire_after_ms: None, - media_delivery: None, runtime_id: runtime_id.into(), socket, mode: CultNetRudpSocketMode::Client, diff --git a/packages/cultnet-rs/src/idunn_deployment_brake.rs b/packages/cultnet-rs/src/idunn_deployment_brake.rs deleted file mode 100644 index e790f2a1..00000000 --- a/packages/cultnet-rs/src/idunn_deployment_brake.rs +++ /dev/null @@ -1,492 +0,0 @@ -use anyhow::{Result, bail}; -use cultcache_rs::DatabaseEntry; - -use crate::{ - IdunnDeploymentBrakeOperatorIdentity, IdunnDeploymentBrakeReleasePurpose, - ServiceIdentitySignature, ServiceIdentityTrustAnchor, verify_service_identity_signature, -}; - -pub const IDUNN_DEPLOYMENT_BRAKE_SCHEMA: &str = "idunn.deployment_brake.v1"; -pub const IDUNN_DEPLOYMENT_BRAKE_TYPE: &str = "idunn.deployment_brake"; -pub const IDUNN_DEPLOYMENT_BRAKE_ID: &str = "idunn/deployment-brake"; -pub const IDUNN_DEPLOYMENT_BRAKE_AUTHORITY: &str = "idunn.deployment-brake"; -pub const IDUNN_DEPLOYMENT_BRAKE_SCOPE: &str = "deployment"; -pub const IDUNN_DEPLOYMENT_RELEASE_PURPOSE: &str = "idunn.deployment-release.v1"; -pub const IDUNN_DEPLOYMENT_RELEASE_MAX_LIFETIME_MILLIS: u64 = 15 * 60 * 1_000; - -/// Canonical deployment-facing brake projection consumed by Idunn. A released -/// status is not permission by itself: the authorization fields bind one -/// release and one deployment attempt for a short, explicit interval. -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "idunn.deployment_brake", schema = "idunn.deployment_brake.v1")] -pub struct IdunnDeploymentBrakeRecord { - #[cultcache(key = 0)] - pub schema_version: String, - #[cultcache(key = 1)] - pub brake_id: String, - #[cultcache(key = 2)] - pub authority: String, - #[cultcache(key = 3)] - pub runtime_id: String, - #[cultcache(key = 4)] - pub status: String, - #[cultcache(key = 5)] - pub scope: String, - #[cultcache(key = 6)] - pub reason: String, - #[cultcache(key = 7)] - pub observed_at_unix_millis: u64, - #[cultcache(key = 8)] - pub expires_at_unix_millis: Option, - #[cultcache(key = 9)] - pub authorization_id: Option, - #[cultcache(key = 10)] - pub authorization_purpose: Option, - #[cultcache(key = 11)] - pub authorized_release_id: Option, - #[cultcache(key = 12)] - pub authorized_deployment_id: Option, - #[cultcache(key = 13)] - pub authorized_by: Option, - #[cultcache(key = 14)] - pub authorization_issued_at_unix_millis: Option, - #[cultcache(key = 15)] - pub authorization_expires_at_unix_millis: Option, - #[cultcache(key = 16)] - pub signature_algorithm: Option, - #[cultcache(key = 17)] - pub signature: Option>, - #[cultcache(key = 18)] - pub private_state_exposed: bool, - /// Human/operator principal responsible for the latest transition. This is - /// distinct from `authorized_by`, which is the cryptographic identity id. - #[cultcache(key = 19)] - pub updated_by: String, -} - -impl IdunnDeploymentBrakeRecord { - pub fn validate(&self) -> Result<()> { - if self.schema_version != IDUNN_DEPLOYMENT_BRAKE_SCHEMA { - bail!("deployment brake schema is unsupported"); - } - identifier(&self.brake_id, "brake id")?; - identifier(&self.authority, "authority")?; - identifier(&self.runtime_id, "runtime id")?; - identifier(&self.reason, "reason")?; - identifier(&self.scope, "scope")?; - identifier(&self.updated_by, "updating operator")?; - if !matches!(self.status.as_str(), "engaged" | "released") - || self.observed_at_unix_millis == 0 - || self - .expires_at_unix_millis - .is_some_and(|v| v <= self.observed_at_unix_millis) - || self.private_state_exposed - { - bail!("deployment brake shape, lifetime, or privacy is invalid"); - } - - let authorization = [ - self.authorization_id.as_ref(), - self.authorization_purpose.as_ref(), - self.authorized_release_id.as_ref(), - self.authorized_deployment_id.as_ref(), - self.authorized_by.as_ref(), - self.signature_algorithm.as_ref(), - ]; - let has_authorization = authorization.iter().any(|v| v.is_some()) - || self.authorization_issued_at_unix_millis.is_some() - || self.authorization_expires_at_unix_millis.is_some() - || self.signature.is_some(); - if self.status == "engaged" { - if has_authorization { - bail!("engaged deployment brake must not carry release authorization"); - } - return Ok(()); - } - if authorization.iter().any(|v| v.is_none()) - || self.authorization_issued_at_unix_millis.is_none() - || self.authorization_expires_at_unix_millis.is_none() - || self.signature.is_none() - { - bail!("released deployment brake authorization is incomplete"); - } - for (value, label) in [ - (&self.authorization_id, "authorization id"), - (&self.authorized_release_id, "authorized release id"), - (&self.authorized_deployment_id, "authorized deployment id"), - (&self.authorized_by, "authorizer"), - ] { - identifier(value.as_deref().unwrap(), label)?; - } - let issued = self.authorization_issued_at_unix_millis.unwrap(); - let expires = self.authorization_expires_at_unix_millis.unwrap(); - if self.authorization_purpose.as_deref() != Some(IDUNN_DEPLOYMENT_RELEASE_PURPOSE) - || self.signature_algorithm.as_deref() != Some("ed25519") - || self.signature.as_ref().is_none_or(|v| v.len() != 64) - || issued < self.observed_at_unix_millis - || expires <= issued - || expires - issued > IDUNN_DEPLOYMENT_RELEASE_MAX_LIFETIME_MILLIS - || self - .expires_at_unix_millis - .is_some_and(|brake_expiry| expires > brake_expiry) - { - bail!("deployment release authorization purpose, signature, or lifetime is invalid"); - } - Ok(()) - } -} - -#[derive(Clone, Copy, Debug)] -pub enum IdunnDeploymentBrakeObservation<'a> { - Missing, - Corrupt, - Present(&'a IdunnDeploymentBrakeRecord), -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum IdunnDeploymentBrakeDenial { - Missing, - Corrupt, - Foreign, - WrongRuntime, - WrongScope, - Engaged, - Expired, - ReleaseMismatch, - DeploymentMismatch, - InvalidSignature, -} - -fn classify_idunn_deployment_brake( - observation: IdunnDeploymentBrakeObservation<'_>, - runtime_id: &str, - release_id: &str, - deployment_id: &str, - now_unix_millis: u64, -) -> std::result::Result<(), IdunnDeploymentBrakeDenial> { - let record = match observation { - IdunnDeploymentBrakeObservation::Missing => { - return Err(IdunnDeploymentBrakeDenial::Missing); - } - IdunnDeploymentBrakeObservation::Corrupt => { - return Err(IdunnDeploymentBrakeDenial::Corrupt); - } - IdunnDeploymentBrakeObservation::Present(record) => record, - }; - if record.validate().is_err() { - return Err(IdunnDeploymentBrakeDenial::Corrupt); - } - if record.brake_id != IDUNN_DEPLOYMENT_BRAKE_ID - || record.authority != IDUNN_DEPLOYMENT_BRAKE_AUTHORITY - { - return Err(IdunnDeploymentBrakeDenial::Foreign); - } - if record.runtime_id != runtime_id { - return Err(IdunnDeploymentBrakeDenial::WrongRuntime); - } - if !matches!(record.scope.as_str(), "all" | IDUNN_DEPLOYMENT_BRAKE_SCOPE) { - return Err(IdunnDeploymentBrakeDenial::WrongScope); - } - if record.status == "engaged" { - return Err(IdunnDeploymentBrakeDenial::Engaged); - } - if record - .expires_at_unix_millis - .is_some_and(|v| now_unix_millis >= v) - || record - .authorization_expires_at_unix_millis - .is_none_or(|v| now_unix_millis >= v) - || record - .authorization_issued_at_unix_millis - .is_none_or(|v| now_unix_millis < v) - { - return Err(IdunnDeploymentBrakeDenial::Expired); - } - if record.authorized_release_id.as_deref() != Some(release_id) { - return Err(IdunnDeploymentBrakeDenial::ReleaseMismatch); - } - if record.authorized_deployment_id.as_deref() != Some(deployment_id) { - return Err(IdunnDeploymentBrakeDenial::DeploymentMismatch); - } - Ok(()) -} - -pub fn evaluate_idunn_deployment_brake( - observation: IdunnDeploymentBrakeObservation<'_>, - operator_anchor: &ServiceIdentityTrustAnchor, - runtime_id: &str, - release_id: &str, - deployment_id: &str, - now_unix_millis: u64, -) -> std::result::Result<(), IdunnDeploymentBrakeDenial> { - classify_idunn_deployment_brake( - observation, - runtime_id, - release_id, - deployment_id, - now_unix_millis, - )?; - let IdunnDeploymentBrakeObservation::Present(record) = observation else { - unreachable!("successful classification always has a present record") - }; - verify_idunn_deployment_brake_authorization(record, operator_anchor) - .map_err(|_| IdunnDeploymentBrakeDenial::InvalidSignature) -} - -/// Verify the release grant using the dedicated operator public anchor. The -/// signed payload is the complete positional record with the signature field -/// empty, so authority, runtime, target IDs, and both time bounds are covered. -pub fn verify_idunn_deployment_brake_authorization( - record: &IdunnDeploymentBrakeRecord, - anchor: &ServiceIdentityTrustAnchor, -) -> Result<()> { - record.validate()?; - if record.status != "released" { - bail!("engaged deployment brake has no release authorization to verify"); - } - let identity_id = record - .authorized_by - .as_ref() - .ok_or_else(|| anyhow::anyhow!("deployment brake authorizer is absent"))?; - let signature = record - .signature - .as_ref() - .ok_or_else(|| anyhow::anyhow!("deployment brake signature is absent"))?; - let mut unsigned = record.clone(); - unsigned.signature = None; - verify_service_identity_signature::< - IdunnDeploymentBrakeOperatorIdentity, - IdunnDeploymentBrakeReleasePurpose, - >( - anchor, - &rmp_serde::to_vec(&unsigned)?, - &ServiceIdentitySignature { - identity_id: identity_id.clone(), - signature: signature.clone(), - }, - ) -} - -fn identifier(value: &str, label: &str) -> Result<()> { - if value.trim().is_empty() || value.len() > 256 || value.chars().any(char::is_control) { - bail!("{label} is empty, oversized, or contains control characters"); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::enroll_service_identity_at; - - fn released() -> IdunnDeploymentBrakeRecord { - IdunnDeploymentBrakeRecord { - schema_version: IDUNN_DEPLOYMENT_BRAKE_SCHEMA.into(), - brake_id: IDUNN_DEPLOYMENT_BRAKE_ID.into(), - authority: IDUNN_DEPLOYMENT_BRAKE_AUTHORITY.into(), - runtime_id: "yggdrasil".into(), - status: "released".into(), - scope: "deployment".into(), - reason: "operator authorized one attempt".into(), - observed_at_unix_millis: 100, - expires_at_unix_millis: Some(1000), - authorization_id: Some("auth/r4".into()), - authorization_purpose: Some(IDUNN_DEPLOYMENT_RELEASE_PURPOSE.into()), - authorized_release_id: Some("release-4".into()), - authorized_deployment_id: Some("deployment-4".into()), - authorized_by: Some("operator/metacrat".into()), - authorization_issued_at_unix_millis: Some(100), - authorization_expires_at_unix_millis: Some(900), - signature_algorithm: Some("ed25519".into()), - signature: Some(vec![7; 64]), - private_state_exposed: false, - updated_by: "operator/metacrat".into(), - } - } - - #[test] - fn only_exact_bounded_release_is_permitted() { - let value = released(); - assert_eq!( - classify_idunn_deployment_brake( - IdunnDeploymentBrakeObservation::Present(&value), - "yggdrasil", - "release-4", - "deployment-4", - 500 - ), - Ok(()) - ); - assert_eq!( - classify_idunn_deployment_brake( - IdunnDeploymentBrakeObservation::Missing, - "yggdrasil", - "release-4", - "deployment-4", - 500 - ), - Err(IdunnDeploymentBrakeDenial::Missing) - ); - assert_eq!( - classify_idunn_deployment_brake( - IdunnDeploymentBrakeObservation::Corrupt, - "yggdrasil", - "release-4", - "deployment-4", - 500 - ), - Err(IdunnDeploymentBrakeDenial::Corrupt) - ); - assert_eq!( - classify_idunn_deployment_brake( - IdunnDeploymentBrakeObservation::Present(&value), - "yggdrasil", - "other", - "deployment-4", - 500 - ), - Err(IdunnDeploymentBrakeDenial::ReleaseMismatch) - ); - assert_eq!( - classify_idunn_deployment_brake( - IdunnDeploymentBrakeObservation::Present(&value), - "yggdrasil", - "release-4", - "other", - 500 - ), - Err(IdunnDeploymentBrakeDenial::DeploymentMismatch) - ); - assert_eq!( - classify_idunn_deployment_brake( - IdunnDeploymentBrakeObservation::Present(&value), - "yggdrasil", - "release-4", - "deployment-4", - 900 - ), - Err(IdunnDeploymentBrakeDenial::Expired) - ); - } - - #[test] - fn hostile_authority_scope_and_shape_substitutions_fail_closed() { - let mut value = released(); - value.authority = "idunn".into(); - assert_eq!( - classify_idunn_deployment_brake( - IdunnDeploymentBrakeObservation::Present(&value), - "yggdrasil", - "release-4", - "deployment-4", - 500 - ), - Err(IdunnDeploymentBrakeDenial::Foreign) - ); - let mut value = released(); - value.runtime_id = "nightwing".into(); - assert_eq!( - classify_idunn_deployment_brake( - IdunnDeploymentBrakeObservation::Present(&value), - "yggdrasil", - "release-4", - "deployment-4", - 500 - ), - Err(IdunnDeploymentBrakeDenial::WrongRuntime) - ); - let mut value = released(); - value.scope = "persona.public_speech".into(); - assert_eq!( - classify_idunn_deployment_brake( - IdunnDeploymentBrakeObservation::Present(&value), - "yggdrasil", - "release-4", - "deployment-4", - 500 - ), - Err(IdunnDeploymentBrakeDenial::WrongScope) - ); - let mut value = released(); - value.signature = Some(vec![0; 63]); - assert_eq!( - classify_idunn_deployment_brake( - IdunnDeploymentBrakeObservation::Present(&value), - "yggdrasil", - "release-4", - "deployment-4", - 500 - ), - Err(IdunnDeploymentBrakeDenial::Corrupt) - ); - let mut value = released(); - value.authorization_expires_at_unix_millis = - Some(100 + IDUNN_DEPLOYMENT_RELEASE_MAX_LIFETIME_MILLIS + 1); - assert!(value.validate().is_err()); - } - - #[test] - fn engaged_record_cannot_smuggle_authorization() { - let mut value = released(); - value.status = "engaged".into(); - assert!(value.validate().is_err()); - value.authorization_id = None; - value.authorization_purpose = None; - value.authorized_release_id = None; - value.authorized_deployment_id = None; - value.authorized_by = None; - value.authorization_issued_at_unix_millis = None; - value.authorization_expires_at_unix_millis = None; - value.signature_algorithm = None; - value.signature = None; - value.validate().unwrap(); - assert_eq!( - classify_idunn_deployment_brake( - IdunnDeploymentBrakeObservation::Present(&value), - "yggdrasil", - "release-4", - "deployment-4", - 500 - ), - Err(IdunnDeploymentBrakeDenial::Engaged) - ); - } - - #[test] - fn evaluation_requires_the_dedicated_operator_signature() -> Result<()> { - let temp = tempfile::tempdir()?; - let signer = enroll_service_identity_at::( - &temp.path().join("operator.cc"), - )?; - let anchor = signer.trust_anchor()?; - let mut value = released(); - value.authorized_by = Some(anchor.identity_id.clone()); - value.signature = None; - let proof = signer.sign::(&rmp_serde::to_vec(&value)?); - value.signature = Some(proof.signature); - assert_eq!( - evaluate_idunn_deployment_brake( - IdunnDeploymentBrakeObservation::Present(&value), - &anchor, - "yggdrasil", - "release-4", - "deployment-4", - 500, - ), - Ok(()) - ); - value.reason = "tampered after authorization".into(); - assert_eq!( - evaluate_idunn_deployment_brake( - IdunnDeploymentBrakeObservation::Present(&value), - &anchor, - "yggdrasil", - "release-4", - "deployment-4", - 500, - ), - Err(IdunnDeploymentBrakeDenial::InvalidSignature) - ); - Ok(()) - } -} diff --git a/packages/cultnet-rs/src/idunn_lifecycle_brake.rs b/packages/cultnet-rs/src/idunn_lifecycle_brake.rs deleted file mode 100644 index 86eefa64..00000000 --- a/packages/cultnet-rs/src/idunn_lifecycle_brake.rs +++ /dev/null @@ -1,325 +0,0 @@ -use anyhow::{Result, bail}; -use cultcache_rs::DatabaseEntry; - -pub const IDUNN_LIFECYCLE_BRAKE_SCHEMA: &str = "idunn.lifecycle_brake.v1"; -pub const IDUNN_LIFECYCLE_BRAKE_TYPE: &str = "idunn.lifecycle_brake"; -pub const IDUNN_LIFECYCLE_BRAKE_AUTHORITY: &str = "idunn.root"; -pub const IDUNN_LIFECYCLE_BRAKE_SCOPE: &str = "continuity-restart"; - -/// Root-owned, per-target continuity brake. This document has no authority over -/// Idunn startup, deployment, route admission, or any target other than the one -/// named in `target`. -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "idunn.lifecycle_brake", schema = "idunn.lifecycle_brake.v1")] -pub struct IdunnLifecycleBrakeRecord { - #[cultcache(key = 0)] - pub schema_version: String, - #[cultcache(key = 1)] - pub authority: String, - #[cultcache(key = 2)] - pub runtime_id: String, - #[cultcache(key = 3)] - pub target: String, - #[cultcache(key = 4)] - pub scope: String, - #[cultcache(key = 5)] - pub status: String, - #[cultcache(key = 6)] - pub reason: String, - #[cultcache(key = 7)] - pub updated_at_unix_millis: u64, - #[cultcache(key = 8)] - pub released_until_unix_millis: Option, -} - -impl IdunnLifecycleBrakeRecord { - pub fn validate(&self) -> Result<()> { - if self.schema_version != IDUNN_LIFECYCLE_BRAKE_SCHEMA { - bail!("lifecycle brake schema is unsupported"); - } - identifier(&self.authority, "authority")?; - identifier(&self.runtime_id, "runtime id")?; - identifier(&self.target, "target")?; - identifier(&self.scope, "scope")?; - identifier(&self.reason, "reason")?; - if !matches!(self.status.as_str(), "engaged" | "released") - || self.updated_at_unix_millis == 0 - { - bail!("lifecycle brake status or update time is invalid"); - } - if self.status == "engaged" && self.released_until_unix_millis.is_some() { - bail!("engaged lifecycle brake cannot carry a release expiry"); - } - if self - .released_until_unix_millis - .is_some_and(|expires| expires <= self.updated_at_unix_millis) - { - bail!("lifecycle brake release expiry is not after its update"); - } - Ok(()) - } - - pub fn canonical_bytes(&self) -> Result> { - self.validate()?; - Ok(rmp_serde::to_vec(self)?) - } - - pub fn decode_canonical(payload: &[u8]) -> Result { - if messagepack_array_len(payload) != Some(9) { - bail!("lifecycle brake is not the 9-field positional contract"); - } - let value: Self = rmp_serde::from_slice(payload)?; - value.validate()?; - if rmp_serde::to_vec(&value)? != payload { - bail!("lifecycle brake encoding is not canonical"); - } - Ok(value) - } -} - -#[derive(Clone, Copy, Debug)] -pub enum IdunnLifecycleBrakeObservation<'a> { - Missing, - Corrupt, - Present(&'a IdunnLifecycleBrakeRecord), -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum IdunnLifecycleBrakeDenial { - Corrupt, - Foreign, - WrongRuntime, - WrongTarget, - Engaged, - Expired, -} - -/// Decide only whether Idunn may restart one already-admitted target to keep it -/// alive. Absence is the default allow state; a present record must bind the -/// exact Idunn runtime and target and must be explicitly released. -pub fn evaluate_idunn_continuity_restart( - observation: IdunnLifecycleBrakeObservation<'_>, - runtime_id: &str, - target: &str, - now_unix_millis: u64, -) -> std::result::Result<(), IdunnLifecycleBrakeDenial> { - let record = match observation { - IdunnLifecycleBrakeObservation::Missing => return Ok(()), - IdunnLifecycleBrakeObservation::Corrupt => { - return Err(IdunnLifecycleBrakeDenial::Corrupt); - } - IdunnLifecycleBrakeObservation::Present(record) => record, - }; - if record.validate().is_err() { - return Err(IdunnLifecycleBrakeDenial::Corrupt); - } - if record.authority != IDUNN_LIFECYCLE_BRAKE_AUTHORITY - || record.scope != IDUNN_LIFECYCLE_BRAKE_SCOPE - { - return Err(IdunnLifecycleBrakeDenial::Foreign); - } - if record.runtime_id != runtime_id { - return Err(IdunnLifecycleBrakeDenial::WrongRuntime); - } - if record.target != target { - return Err(IdunnLifecycleBrakeDenial::WrongTarget); - } - if record.status == "engaged" { - return Err(IdunnLifecycleBrakeDenial::Engaged); - } - if record - .released_until_unix_millis - .is_some_and(|expires| now_unix_millis >= expires) - { - return Err(IdunnLifecycleBrakeDenial::Expired); - } - Ok(()) -} - -fn identifier(value: &str, label: &str) -> Result<()> { - if value.trim().is_empty() || value.len() > 256 || value.chars().any(char::is_control) { - bail!("{label} is empty, oversized, or contains control characters"); - } - Ok(()) -} - -fn messagepack_array_len(payload: &[u8]) -> Option { - match *payload.first()? { - marker @ 0x90..=0x9f => Some((marker & 0x0f) as usize), - 0xdc if payload.len() >= 3 => Some(u16::from_be_bytes([payload[1], payload[2]]) as usize), - 0xdd if payload.len() >= 5 => { - Some(u32::from_be_bytes([payload[1], payload[2], payload[3], payload[4]]) as usize) - } - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn released() -> IdunnLifecycleBrakeRecord { - IdunnLifecycleBrakeRecord { - schema_version: IDUNN_LIFECYCLE_BRAKE_SCHEMA.into(), - authority: IDUNN_LIFECYCLE_BRAKE_AUTHORITY.into(), - runtime_id: "idunn-yggdrasil".into(), - target: "ghostlight".into(), - scope: IDUNN_LIFECYCLE_BRAKE_SCOPE.into(), - status: "released".into(), - reason: "continuity restart permitted".into(), - updated_at_unix_millis: 100, - released_until_unix_millis: None, - } - } - - fn evaluate( - observation: IdunnLifecycleBrakeObservation<'_>, - target: &str, - now: u64, - ) -> std::result::Result<(), IdunnLifecycleBrakeDenial> { - evaluate_idunn_continuity_restart(observation, "idunn-yggdrasil", target, now) - } - - #[test] - fn lifecycle_brake_round_trips_only_as_the_canonical_positional_contract() -> Result<()> { - let value = released(); - let encoded = value.canonical_bytes()?; - assert_eq!(encoded[0], 0x99); - assert_eq!( - IdunnLifecycleBrakeRecord::decode_canonical(&encoded)?, - value - ); - - let mut noncanonical = vec![0xdc, 0, 9]; - noncanonical.extend_from_slice(&encoded[1..]); - assert!(IdunnLifecycleBrakeRecord::decode_canonical(&noncanonical).is_err()); - - let mut extended = encoded; - extended[0] = 0x9a; - extended.push(0xc0); - assert!(IdunnLifecycleBrakeRecord::decode_canonical(&extended).is_err()); - Ok(()) - } - - #[test] - fn absence_and_explicit_release_allow_only_the_named_continuity_restart() { - assert_eq!( - evaluate(IdunnLifecycleBrakeObservation::Missing, "ghostlight", 500), - Ok(()) - ); - - let mut value = released(); - assert_eq!( - evaluate( - IdunnLifecycleBrakeObservation::Present(&value), - "ghostlight", - 500 - ), - Ok(()) - ); - value.released_until_unix_millis = Some(900); - assert_eq!( - evaluate( - IdunnLifecycleBrakeObservation::Present(&value), - "ghostlight", - 899 - ), - Ok(()) - ); - assert_eq!( - evaluate( - IdunnLifecycleBrakeObservation::Present(&value), - "ghostlight", - 900 - ), - Err(IdunnLifecycleBrakeDenial::Expired) - ); - assert_eq!( - evaluate(IdunnLifecycleBrakeObservation::Missing, "odin", 900), - Ok(()) - ); - assert_eq!( - evaluate(IdunnLifecycleBrakeObservation::Present(&value), "odin", 500), - Err(IdunnLifecycleBrakeDenial::WrongTarget) - ); - } - - #[test] - fn corrupt_foreign_wrong_runtime_and_wrong_target_records_deny() { - assert_eq!( - evaluate(IdunnLifecycleBrakeObservation::Corrupt, "ghostlight", 500), - Err(IdunnLifecycleBrakeDenial::Corrupt) - ); - - let mut value = released(); - value.status = "paused".into(); - assert_eq!( - evaluate( - IdunnLifecycleBrakeObservation::Present(&value), - "ghostlight", - 500 - ), - Err(IdunnLifecycleBrakeDenial::Corrupt) - ); - - let mut value = released(); - value.authority = "service-owned".into(); - assert_eq!( - evaluate( - IdunnLifecycleBrakeObservation::Present(&value), - "ghostlight", - 500 - ), - Err(IdunnLifecycleBrakeDenial::Foreign) - ); - - let mut value = released(); - value.scope = "deployment".into(); - assert_eq!( - evaluate( - IdunnLifecycleBrakeObservation::Present(&value), - "ghostlight", - 500 - ), - Err(IdunnLifecycleBrakeDenial::Foreign) - ); - - let value = released(); - assert_eq!( - evaluate_idunn_continuity_restart( - IdunnLifecycleBrakeObservation::Present(&value), - "idunn-nightwing", - "ghostlight", - 500 - ), - Err(IdunnLifecycleBrakeDenial::WrongRuntime) - ); - assert_eq!( - evaluate(IdunnLifecycleBrakeObservation::Present(&value), "odin", 500), - Err(IdunnLifecycleBrakeDenial::WrongTarget) - ); - } - - #[test] - fn engaged_always_denies_and_cannot_smuggle_a_release_expiry() { - let mut value = released(); - value.status = "engaged".into(); - assert_eq!( - evaluate( - IdunnLifecycleBrakeObservation::Present(&value), - "ghostlight", - 500 - ), - Err(IdunnLifecycleBrakeDenial::Engaged) - ); - value.released_until_unix_millis = Some(900); - assert_eq!( - evaluate( - IdunnLifecycleBrakeObservation::Present(&value), - "ghostlight", - 500 - ), - Err(IdunnLifecycleBrakeDenial::Corrupt) - ); - } -} diff --git a/packages/cultnet-rs/src/lib.rs b/packages/cultnet-rs/src/lib.rs index 074c2a29..0b9fbb55 100644 --- a/packages/cultnet-rs/src/lib.rs +++ b/packages/cultnet-rs/src/lib.rs @@ -1,16 +1,7 @@ mod contracts; -mod idunn_deployment_brake; -mod idunn_lifecycle_brake; -mod media_stream_contracts; -mod media_stream_wire; -mod provider_health_contracts; -mod runtime_authority_contracts; -mod service_identity; -mod snapshot_query; mod cultmesh; mod framing; mod operation_service; -mod provider_session; mod replication; mod rudp; mod schema_discovery; @@ -19,18 +10,9 @@ mod shard_catalog; mod transport; pub use contracts::*; -pub use idunn_deployment_brake::*; -pub use idunn_lifecycle_brake::*; -pub use media_stream_contracts::*; -pub use media_stream_wire::*; -pub use provider_health_contracts::*; -pub use runtime_authority_contracts::*; -pub use service_identity::*; -pub use snapshot_query::*; pub use cultmesh::*; pub use framing::*; pub use operation_service::*; -pub use provider_session::*; pub use replication::*; pub use rudp::*; pub use schema_discovery::*; diff --git a/packages/cultnet-rs/src/media_stream_contracts.rs b/packages/cultnet-rs/src/media_stream_contracts.rs deleted file mode 100644 index 5704912e..00000000 --- a/packages/cultnet-rs/src/media_stream_contracts.rs +++ /dev/null @@ -1,578 +0,0 @@ -//! CultMesh Media Stream: the typed contract for carrying live audio and video -//! over CultNet. -//! -//! These records describe a media stream and nothing about who produced it. A -//! capture broker, an editor runtime, a recorder, or a synthetic source may all -//! publish them; an OBS bridge, a compositor, a recorder, or a browser lowering -//! may all consume them. Producers advertise their streams through Odin and -//! consumers resolve them there, so neither end needs to name the other. -//! -//! They lived in `odin-core` as `muninn.media_*`, named after the one producer -//! that happened to emit them first. Every field here is producer-agnostic — the -//! name was the only coupling, and a second producer would have had to -//! impersonate Muninn or fork the shape. -//! -//! # Layout is wire contract -//! -//! CultCache encodes these positionally. Field order and `key` indices are the -//! wire format: append new fields with `default` at the next free index, never -//! reorder or renumber. `missing_video_chunk_keys` shows the pattern. -//! -//! Payloads carry `bytes` so they serialize as MessagePack `bin` rather than an -//! array of integers, which is what the C#, TypeScript and Python runtimes -//! expect and what keeps a 848-byte payload at 851 bytes instead of 1234. Note -//! that this is the derive's own attribute — `#[serde(with = "serde_bytes")]` -//! is inert here, because `DatabaseEntry` writes its own `Serialize`. -//! -//! # Timebase -//! -//! `pts_ticks`, `duration_ticks` and `deadline_ticks` are expressed in -//! `timebase_num / timebase_den` units, carried per record so a consumer never -//! has to assume a producer's clock. `deadline_ticks` is the presentation -//! deadline past which a late arrival is worth dropping rather than displaying. -//! -//! # Reliability -//! -//! Nothing here specifies a delivery guarantee. Whether a stream rides a -//! reliable or lossy CultNet channel, and whether it carries parity, is a -//! transport decision made per stream — not a property of the payload shape. -//! Video parity shards exist for producers that choose forward error correction; -//! audio currently has no parity record, and a producer emitting audio on a -//! lossy channel needs one. - -use cultcache_rs::DatabaseEntry; - -pub const GAMECULT_MEDIA_VIDEO_ACCESS_UNIT_SCHEMA: &str = "gamecult.media_video_access_unit.v1"; -pub const GAMECULT_MEDIA_VIDEO_PARITY_SHARD_SCHEMA: &str = "gamecult.media_video_parity_shard.v2"; -pub const GAMECULT_MEDIA_AUDIO_PACKET_SCHEMA: &str = "gamecult.media_audio_packet.v1"; -pub const GAMECULT_MEDIA_RECEIVER_FEEDBACK_SCHEMA: &str = "gamecult.media_receiver_feedback.v1"; -pub const GAMECULT_MEDIA_STREAM_ADVERTISEMENT_SCHEMA: &str = - "gamecult.media_stream_advertisement.v1"; -pub const GAMECULT_MEDIA_STREAM_REQUEST_SCHEMA: &str = "gamecult.media_stream_request.v1"; - -/// A media stream a producer can serve on request, as it appears in Odin. -/// -/// Odin admits these without any registration: it derives the document type -/// from the schema id (`prefix.vN` -> `prefix`) and refuses only a short list -/// of authority-owned types (`gamecult.runtime_presence_health`, -/// `odin.runtime_topology_correlation`, `idunn.expected_incarnation`, -/// `idunn.runtime_activation`, `idunn.process_write_lease`, -/// `gamecult.service_trust_anchor`). A new record whose type collided with one -/// of those would be rejected at the catalog with no mention of the collision. -/// -/// This is the record a consumer's picker is built from: which streams exist, -/// which video and audio sources each can capture, which codecs it can encode, -/// the defaults it would use, and where it serves the stream from. It is state, not media — it travels as a -/// CultMesh document through Odin, never on the media channel — and it names no -/// consumer. `producer_id` is the runtime that answers requests for it. -/// -/// Keyed by `stream_id`. -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "gamecult.media_stream_advertisement", - schema = "gamecult.media_stream_advertisement.v1" -)] -pub struct GameCultMediaStreamAdvertisementRecord { - #[cultcache(key = 0)] - pub stream_id: String, - #[cultcache(key = 1)] - pub producer_id: String, - #[cultcache(key = 2)] - pub label: String, - /// `available`, `streaming`, or `unavailable`. - #[cultcache(key = 3)] - pub state: String, - #[cultcache(key = 4)] - pub video_source_ids: Vec, - #[cultcache(key = 5)] - pub video_source_labels: Vec, - #[cultcache(key = 6)] - pub audio_source_ids: Vec, - #[cultcache(key = 7)] - pub audio_source_labels: Vec, - /// Codecs the producer can encode video as, e.g. `h264`. - #[cultcache(key = 8)] - pub video_codecs: Vec, - /// Codecs the producer can emit audio as, e.g. `pcm-f32le-interleaved`. - #[cultcache(key = 9)] - pub audio_codecs: Vec, - #[cultcache(key = 10)] - pub audio_sample_rate: u32, - #[cultcache(key = 11)] - pub audio_channels: u32, - #[cultcache(key = 12)] - pub default_video_bitrate_kbps: u32, - #[cultcache(key = 13)] - pub default_latency_budget_ms: u32, - #[cultcache(key = 14)] - pub media_packet_bytes: u32, - /// `host:port` the producer serves this stream from. A consumer dials it - /// with `media_connection_id` once its request is answered `running`; - /// the producer listens. The consumer's host admits nothing inbound, - /// which is what lets a viewer install a plugin without opening a port. - #[cultcache(key = 15)] - pub media_endpoint: String, - /// The CultNet RUDP connection id a consumer dials `media_endpoint` with. - #[cultcache(key = 16)] - pub media_connection_id: u32, - #[cultcache(key = 17)] - pub updated_at: String, -} - -/// A consumer asking a producer to start or stop serving a stream to it. -/// -/// The producer answers by rewriting `state` and `detail` on the same record -/// key, so a consumer watches one document for the outcome. A newer request for -/// the same `stream_id` and `receiver_id` supersedes an older one. -/// -/// It carries no consumer endpoint. The consumer dials the advertised -/// `media_endpoint` and identifies itself on the wire by `receiver_id`. -/// -/// Keyed by `media_stream_request_key(stream_id, receiver_id)`. -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "gamecult.media_stream_request", - schema = "gamecult.media_stream_request.v1" -)] -pub struct GameCultMediaStreamRequestRecord { - #[cultcache(key = 0)] - pub request_id: String, - #[cultcache(key = 1)] - pub stream_id: String, - #[cultcache(key = 2)] - pub producer_id: String, - #[cultcache(key = 3)] - pub receiver_id: String, - /// `start` or `stop`. - #[cultcache(key = 4)] - pub action: String, - /// `pending`, `running`, `stopped`, or `failed`; the producer owns it after - /// `pending`. - #[cultcache(key = 5)] - pub state: String, - /// Empty means no video. - #[cultcache(key = 6)] - pub video_source_id: String, - /// Empty means no audio. - #[cultcache(key = 7)] - pub audio_source_id: String, - #[cultcache(key = 8)] - pub video_codec: String, - #[cultcache(key = 9)] - pub audio_codec: String, - #[cultcache(key = 10)] - pub video_bitrate_kbps: u32, - #[cultcache(key = 11)] - pub latency_budget_ms: u32, - #[cultcache(key = 12)] - pub media_packet_bytes: u32, - #[cultcache(key = 13)] - pub detail: String, - #[cultcache(key = 14)] - pub updated_at: String, -} - -pub const MEDIA_STREAM_REQUEST_ACTIONS: [&str; 2] = ["start", "stop"]; -pub const MEDIA_STREAM_REQUEST_STATES: [&str; 4] = ["pending", "running", "stopped", "failed"]; -pub const MEDIA_STREAM_ADVERTISEMENT_STATES: [&str; 3] = ["available", "streaming", "unavailable"]; - -/// One live request per stream per receiver: a newer one replaces the older. -pub fn media_stream_request_key(stream_id: &str, receiver_id: &str) -> String { - format!("{stream_id}:{receiver_id}") -} - -pub fn validate_media_stream_advertisement( - record: &GameCultMediaStreamAdvertisementRecord, -) -> anyhow::Result<()> { - use anyhow::anyhow; - for (name, value) in [ - ("stream_id", &record.stream_id), - ("producer_id", &record.producer_id), - ("label", &record.label), - ("updated_at", &record.updated_at), - ] { - if value.is_empty() { - return Err(anyhow!("media stream advertisement {name} must be non-empty")); - } - } - if !MEDIA_STREAM_ADVERTISEMENT_STATES.contains(&record.state.as_str()) { - return Err(anyhow!( - "media stream advertisement state {:?} is not one of {:?}", - record.state, - MEDIA_STREAM_ADVERTISEMENT_STATES - )); - } - if record.video_source_ids.len() != record.video_source_labels.len() { - return Err(anyhow!( - "media stream advertisement has {} video source ids and {} labels", - record.video_source_ids.len(), - record.video_source_labels.len() - )); - } - if record.audio_source_ids.len() != record.audio_source_labels.len() { - return Err(anyhow!( - "media stream advertisement has {} audio source ids and {} labels", - record.audio_source_ids.len(), - record.audio_source_labels.len() - )); - } - if !record.audio_source_ids.is_empty() && (record.audio_sample_rate == 0 || record.audio_channels == 0) { - return Err(anyhow!( - "media stream advertisement offers audio without a sample rate and channel count" - )); - } - if record.media_connection_id == 0 { - return Err(anyhow!("media stream advertisement media_connection_id must be non-zero")); - } - if !is_host_port(&record.media_endpoint) { - return Err(anyhow!( - "media stream advertisement media_endpoint {:?} must be host:port", - record.media_endpoint - )); - } - Ok(()) -} - -pub fn validate_media_stream_request( - record: &GameCultMediaStreamRequestRecord, -) -> anyhow::Result<()> { - use anyhow::anyhow; - for (name, value) in [ - ("request_id", &record.request_id), - ("stream_id", &record.stream_id), - ("producer_id", &record.producer_id), - ("receiver_id", &record.receiver_id), - ("updated_at", &record.updated_at), - ] { - if value.is_empty() { - return Err(anyhow!("media stream request {name} must be non-empty")); - } - } - if !MEDIA_STREAM_REQUEST_ACTIONS.contains(&record.action.as_str()) { - return Err(anyhow!( - "media stream request action {:?} is not one of {:?}", - record.action, - MEDIA_STREAM_REQUEST_ACTIONS - )); - } - if !MEDIA_STREAM_REQUEST_STATES.contains(&record.state.as_str()) { - return Err(anyhow!( - "media stream request state {:?} is not one of {:?}", - record.state, - MEDIA_STREAM_REQUEST_STATES - )); - } - if record.action == "start" { - if record.video_source_id.is_empty() && record.audio_source_id.is_empty() { - return Err(anyhow!("media stream request asks for neither video nor audio")); - } - if !record.video_source_id.is_empty() && record.video_codec.is_empty() { - return Err(anyhow!("media stream request names a video source without a codec")); - } - if !record.audio_source_id.is_empty() && record.audio_codec.is_empty() { - return Err(anyhow!("media stream request names an audio source without a codec")); - } - } - Ok(()) -} - -/// A hostname or IP and a non-zero port. Not `SocketAddr`: a producer may -/// advertise a name the consumer resolves. -fn is_host_port(value: &str) -> bool { - let Some((host, port)) = value.rsplit_once(':') else { - return false; - }; - let host = host.trim_start_matches('[').trim_end_matches(']'); - !host.is_empty() && port.parse::().is_ok_and(|port| port != 0) -} - -#[cfg(test)] -mod media_stream_catalog_tests { - use super::*; - - fn advertisement() -> GameCultMediaStreamAdvertisementRecord { - GameCultMediaStreamAdvertisementRecord { - stream_id: "muninn.raven.av.rudp".into(), - producer_id: "raven".into(), - label: "Raven desktop".into(), - state: "available".into(), - video_source_ids: vec!["display:0".into()], - video_source_labels: vec!["Display 1".into()], - audio_source_ids: vec!["wasapi-loopback:Realtek".into()], - audio_source_labels: vec!["Realtek loopback".into()], - video_codecs: vec!["h264".into()], - audio_codecs: vec!["pcm-f32le-interleaved".into()], - audio_sample_rate: 48_000, - audio_channels: 2, - default_video_bitrate_kbps: 12_000, - default_latency_budget_ms: 250, - media_packet_bytes: 848, - media_endpoint: "192.168.178.20:5220".into(), - media_connection_id: 0x6d75_0001, - updated_at: "2026-09-10T00:00:00Z".into(), - } - } - - fn request() -> GameCultMediaStreamRequestRecord { - GameCultMediaStreamRequestRecord { - request_id: "req-1".into(), - stream_id: "muninn.raven.av.rudp".into(), - producer_id: "raven".into(), - receiver_id: "starfire.obs".into(), - action: "start".into(), - state: "pending".into(), - video_source_id: "display:0".into(), - audio_source_id: "wasapi-loopback:Realtek".into(), - video_codec: "h264".into(), - audio_codec: "pcm-f32le-interleaved".into(), - video_bitrate_kbps: 12_000, - latency_budget_ms: 250, - media_packet_bytes: 848, - detail: String::new(), - updated_at: "2026-09-10T00:00:00Z".into(), - } - } - - #[test] - fn a_complete_advertisement_and_request_validate() -> anyhow::Result<()> { - validate_media_stream_advertisement(&advertisement())?; - validate_media_stream_request(&request())?; - Ok(()) - } - - #[test] - fn source_ids_and_labels_must_pair_up() { - let mut record = advertisement(); - record.video_source_labels.clear(); - let error = validate_media_stream_advertisement(&record).unwrap_err(); - assert!(error.to_string().contains("video source ids"), "{error}"); - } - - #[test] - fn audio_on_offer_needs_a_sample_rate_and_channels() { - let mut record = advertisement(); - record.audio_channels = 0; - assert!(validate_media_stream_advertisement(&record).is_err()); - record.audio_source_ids.clear(); - record.audio_source_labels.clear(); - assert!(validate_media_stream_advertisement(&record).is_ok(), "no audio, no requirement"); - } - - /// The producer is the listener, so it must say where. - #[test] - fn an_advertisement_says_where_the_stream_is_served() { - for bad in ["", "raven", "raven:", "raven:0", ":5220"] { - let mut record = advertisement(); - record.media_endpoint = bad.into(); - assert!(validate_media_stream_advertisement(&record).unwrap_err().to_string().contains("host:port"), "{bad:?}"); - } - for good in ["raven:5220", "10.77.0.2:5220", "[::1]:5220"] { - let mut record = advertisement(); - record.media_endpoint = good.into(); - assert!(validate_media_stream_advertisement(&record).is_ok(), "{good:?}"); - } - } - - #[test] - fn a_start_needs_something_to_send() { - let mut record = request(); - record.video_source_id.clear(); - record.audio_source_id.clear(); - assert!(validate_media_stream_request(&record).unwrap_err().to_string().contains("neither")); - let mut record = request(); - record.video_codec.clear(); - assert!(validate_media_stream_request(&record).unwrap_err().to_string().contains("codec")); - } - - #[test] - fn a_stop_needs_no_sources() -> anyhow::Result<()> { - let mut record = request(); - record.action = "stop".into(); - record.video_source_id.clear(); - record.audio_source_id.clear(); - validate_media_stream_request(&record) - } - - #[test] - fn unknown_actions_and_states_are_refused() { - let mut record = request(); - record.action = "pause".into(); - assert!(validate_media_stream_request(&record).is_err()); - let mut record = request(); - record.state = "done".into(); - assert!(validate_media_stream_request(&record).is_err()); - } - - #[test] - fn one_request_per_stream_per_receiver() { - assert_eq!(media_stream_request_key("s", "starfire.obs"), "s:starfire.obs"); - } - - /// The records round-trip through the same positional encoding every - /// other runtime reads, so a Python or TypeScript consumer sees the same - /// slots. - #[test] - fn records_round_trip_positionally() -> anyhow::Result<()> { - let record = advertisement(); - let encoded = rmp_serde::to_vec(&record)?; - let decoded: GameCultMediaStreamAdvertisementRecord = rmp_serde::from_slice(&encoded)?; - assert_eq!(decoded, record); - let record = request(); - let encoded = rmp_serde::to_vec(&record)?; - let decoded: GameCultMediaStreamRequestRecord = rmp_serde::from_slice(&encoded)?; - assert_eq!(decoded, record); - Ok(()) - } -} - -/// One coded video access unit, possibly split across `chunk_count` records -/// sharing a `frame_id`. A consumer reassembles by `(stream_id, session_id, -/// frame_id)` and orders by `chunk_index`. -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "gamecult.media_video_access_unit", - schema = "gamecult.media_video_access_unit.v1" -)] -pub struct GameCultMediaVideoAccessUnitRecord { - #[cultcache(key = 0)] - pub stream_id: String, - #[cultcache(key = 1)] - pub session_id: String, - #[cultcache(key = 2)] - pub frame_id: u64, - #[cultcache(key = 3)] - pub codec: String, - #[cultcache(key = 4)] - pub pts_ticks: i64, - #[cultcache(key = 5)] - pub duration_ticks: u32, - #[cultcache(key = 6)] - pub timebase_num: u32, - #[cultcache(key = 7)] - pub timebase_den: u32, - #[cultcache(key = 8)] - pub keyframe: bool, - #[cultcache(key = 9)] - pub dependency_frame_id: Option, - #[cultcache(key = 10)] - pub deadline_ticks: i64, - #[cultcache(key = 11)] - pub chunk_index: u16, - #[cultcache(key = 12)] - pub chunk_count: u16, - #[cultcache(key = 13, bytes)] - pub payload: Vec, -} - -/// Forward-error-correction parity for one video access unit. Carries the -/// framing fields of the access unit it protects so a receiver can recover -/// without having seen the original, plus the shard geometry needed to run -/// recovery: `parity_index` within `parity_count`, and the chunk sizes the -/// parity was computed over. -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "gamecult.media_video_parity_shard", - schema = "gamecult.media_video_parity_shard.v2" -)] -pub struct GameCultMediaVideoParityShardRecord { - #[cultcache(key = 0)] - pub stream_id: String, - #[cultcache(key = 1)] - pub session_id: String, - #[cultcache(key = 2)] - pub frame_id: u64, - #[cultcache(key = 3)] - pub codec: String, - #[cultcache(key = 4)] - pub pts_ticks: i64, - #[cultcache(key = 5)] - pub duration_ticks: u32, - #[cultcache(key = 6)] - pub timebase_num: u32, - #[cultcache(key = 7)] - pub timebase_den: u32, - #[cultcache(key = 8)] - pub keyframe: bool, - #[cultcache(key = 9)] - pub dependency_frame_id: Option, - #[cultcache(key = 10)] - pub deadline_ticks: i64, - #[cultcache(key = 11)] - pub chunk_count: u16, - #[cultcache(key = 12)] - pub parity_index: u16, - #[cultcache(key = 13)] - pub parity_count: u16, - #[cultcache(key = 14)] - pub chunk_payload_bytes: u32, - #[cultcache(key = 15)] - pub last_chunk_payload_bytes: u32, - #[cultcache(key = 16, bytes)] - pub payload: Vec, -} - -/// One coded audio packet. Unlike video these are not chunked: a packet is -/// whole or it is absent, and `payload` carries whatever `codec` names. -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "gamecult.media_audio_packet", - schema = "gamecult.media_audio_packet.v1" -)] -pub struct GameCultMediaAudioPacketRecord { - #[cultcache(key = 0)] - pub stream_id: String, - #[cultcache(key = 1)] - pub session_id: String, - #[cultcache(key = 2)] - pub packet_id: u64, - #[cultcache(key = 3)] - pub codec: String, - #[cultcache(key = 4)] - pub pts_ticks: i64, - #[cultcache(key = 5)] - pub duration_ticks: u32, - #[cultcache(key = 6)] - pub timebase_num: u32, - #[cultcache(key = 7)] - pub timebase_den: u32, - #[cultcache(key = 8)] - pub deadline_ticks: i64, - #[cultcache(key = 9, bytes)] - pub payload: Vec, -} - -/// What a receiver observed, published back to the producer so it can adapt: -/// what decoded, what is missing, what arrived too late, and how much queue and -/// jitter the receiver is carrying. -/// -/// This is the only record here that flows consumer-to-producer. A producer is -/// free to ignore it; it reports observation, it does not issue commands. -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "gamecult.media_receiver_feedback", - schema = "gamecult.media_receiver_feedback.v1" -)] -pub struct GameCultMediaReceiverFeedbackRecord { - #[cultcache(key = 0)] - pub stream_id: String, - #[cultcache(key = 1)] - pub session_id: String, - #[cultcache(key = 2)] - pub receiver_id: String, - #[cultcache(key = 3)] - pub highest_decodable_frame_id: Option, - #[cultcache(key = 4)] - pub missing_frame_ids: Vec, - #[cultcache(key = 5)] - pub late_frame_ids: Vec, - #[cultcache(key = 6)] - pub requested_keyframe: bool, - #[cultcache(key = 7)] - pub jitter_us: i64, - #[cultcache(key = 8)] - pub decode_queue_us: i64, - #[cultcache(key = 9)] - pub observed_at: String, - #[cultcache(key = 10, default)] - pub missing_video_chunk_keys: Vec, -} diff --git a/packages/cultnet-rs/src/media_stream_wire.rs b/packages/cultnet-rs/src/media_stream_wire.rs deleted file mode 100644 index 9b7fbc1b..00000000 --- a/packages/cultnet-rs/src/media_stream_wire.rs +++ /dev/null @@ -1,581 +0,0 @@ -//! How a CultMesh Media Stream record travels: the envelope both ends must -//! agree on. -//! -//! The records in `media_stream_contracts` describe media. This describes how -//! one reaches a peer — wrapped in `cultnet.document_put_raw.v0` with a schema -//! id, a record key, and provenance. It lives here because a producer and a -//! consumer that never link each other still have to agree on it exactly, and -//! the last time they did not, one end was Rust and the other was 6,515 lines -//! of C++ that had drifted. -//! -//! # No producer's name belongs in a shared envelope -//! -//! The Muninn implementation this replaces hardcoded `"muninn-media:"` into -//! every message id and `"muninn.media"` into every tag, so a second producer -//! would have had to label its traffic as Muninn's. The producer namespace is a -//! parameter here: see [`MediaWireProvenance`]. -//! -//! # Record keys are addresses, not decoration -//! -//! A key identifies one piece of media within a session, so a consumer can tell -//! a retransmission from a new frame and a chunk from its siblings. The shapes -//! are fixed by this module rather than by each producer, because a consumer -//! parses them. - -use anyhow::{Result, anyhow}; - -use crate::contracts::{ - CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, CultNetWireContract, - decode_cultnet_message_from_slice, encode_cultnet_message_to_vec, -}; -use crate::media_stream_contracts::{ - GAMECULT_MEDIA_AUDIO_PACKET_SCHEMA, GAMECULT_MEDIA_RECEIVER_FEEDBACK_SCHEMA, - GAMECULT_MEDIA_VIDEO_ACCESS_UNIT_SCHEMA, GAMECULT_MEDIA_VIDEO_PARITY_SHARD_SCHEMA, - GameCultMediaAudioPacketRecord, GameCultMediaReceiverFeedbackRecord, - GameCultMediaVideoAccessUnitRecord, GameCultMediaVideoParityShardRecord, -}; - -/// The CultNet channel video and receiver feedback ride. -pub const GAMECULT_MEDIA_CHANNEL: &str = "media"; - -/// The CultNet channel audio rides. Audio has its own channel so one session -/// can carry it reliably with a short expiry while video stays lossy under -/// parity; the RUDP transport profile owns each channel's delivery. -pub const GAMECULT_MEDIA_AUDIO_CHANNEL: &str = "audio"; - -/// One media record on its way somewhere. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum GameCultMediaWireRecord { - Video(GameCultMediaVideoAccessUnitRecord), - VideoParity(GameCultMediaVideoParityShardRecord), - Audio(GameCultMediaAudioPacketRecord), - Feedback(GameCultMediaReceiverFeedbackRecord), -} - -/// Who is sending, and when. `producer` namespaces message ids and tags so a -/// stream can be attributed without the envelope knowing any producer by name. -#[derive(Clone, Copy, Debug)] -pub struct MediaWireProvenance<'a> { - pub stored_at: &'a str, - pub runtime_id: &'a str, - pub role: &'a str, - pub producer: &'a str, -} - -impl MediaWireProvenance<'_> { - fn validate(&self) -> Result<()> { - for (name, value) in [ - ("stored_at", self.stored_at), - ("runtime_id", self.runtime_id), - ("role", self.role), - ("producer", self.producer), - ] { - if value.is_empty() { - return Err(anyhow!("media wire provenance {name} must be non-empty")); - } - } - Ok(()) - } -} - -impl GameCultMediaWireRecord { - pub fn schema_id(&self) -> &'static str { - match self { - Self::Video(_) => GAMECULT_MEDIA_VIDEO_ACCESS_UNIT_SCHEMA, - Self::VideoParity(_) => GAMECULT_MEDIA_VIDEO_PARITY_SHARD_SCHEMA, - Self::Audio(_) => GAMECULT_MEDIA_AUDIO_PACKET_SCHEMA, - Self::Feedback(_) => GAMECULT_MEDIA_RECEIVER_FEEDBACK_SCHEMA, - } - } - - /// Addresses this record within its session. - pub fn record_key(&self) -> String { - match self { - Self::Video(record) => format!( - "{}:{}:video:{}:{}", - record.stream_id, record.session_id, record.frame_id, record.chunk_index - ), - Self::VideoParity(record) => format!( - "{}:{}:video-parity:{}:{}", - record.stream_id, record.session_id, record.frame_id, record.parity_index - ), - Self::Audio(record) => format!( - "{}:{}:audio:{}", - record.stream_id, record.session_id, record.packet_id - ), - Self::Feedback(record) => format!( - "{}:{}:feedback:{}", - record.stream_id, record.session_id, record.receiver_id - ), - } - } - - /// The record's own MessagePack form. - /// - /// Serializing the record directly is only correct because the payload - /// fields carry `#[cultcache(key = N, bytes)]`. Muninn previously mirrored - /// each record into a parallel tuple struct — every field restated, in - /// order, by hand — solely so `#[serde(with = "serde_bytes")]` could be - /// applied to the payload, because the `DatabaseEntry` derive does not read - /// serde attributes. Those shadow structs are gone; the derive emits `bin` - /// itself now. Do not reintroduce one. - fn payload_bytes(&self) -> Result> { - match self { - Self::Video(record) => rmp_serde::to_vec(record), - Self::VideoParity(record) => rmp_serde::to_vec(record), - Self::Audio(record) => rmp_serde::to_vec(record), - Self::Feedback(record) => rmp_serde::to_vec(record), - } - .map_err(Into::into) - } -} - -/// Wraps a media record for the wire. -pub fn encode_media_wire_record( - record: &GameCultMediaWireRecord, - provenance: MediaWireProvenance<'_>, -) -> Result> { - provenance.validate()?; - - let schema_id = record.schema_id(); - let record_key = record.record_key(); - let message = CultNetMessage::DocumentPutRaw { - message_id: format!( - "{}-media:{}:{}", - provenance.producer, - schema_id, - record_key.replace(':', "-") - ), - document: CultNetRawDocumentRecord { - schema_id: schema_id.to_string(), - record_key, - stored_at: provenance.stored_at.to_string(), - payload_encoding: CultNetRawPayloadEncoding::Messagepack, - payload: record.payload_bytes()?, - source_runtime_id: Some(provenance.runtime_id.to_string()), - source_agent_id: None, - source_role: Some(provenance.role.to_string()), - tags: Some(vec![format!("{}.media", provenance.producer)]), - }, - }; - - encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).map_err(Into::into) -} - -/// Unwraps a media record from the wire. -pub fn decode_media_wire_record(payload: &[u8]) -> Result { - let message = decode_cultnet_message_from_slice(payload, CultNetWireContract::CultNetSchemaV0)?; - let CultNetMessage::DocumentPutRaw { document, .. } = message else { - return Err(anyhow!("expected cultnet.document_put_raw.v0")); - }; - if document.payload_encoding != CultNetRawPayloadEncoding::Messagepack { - return Err(anyhow!("media document payload must be MessagePack")); - } - - let record = match document.schema_id.as_str() { - GAMECULT_MEDIA_VIDEO_ACCESS_UNIT_SCHEMA => { - let record: GameCultMediaVideoAccessUnitRecord = - rmp_serde::from_slice(&document.payload)?; - validate_video_record(&record)?; - GameCultMediaWireRecord::Video(record) - } - GAMECULT_MEDIA_VIDEO_PARITY_SHARD_SCHEMA => { - let record: GameCultMediaVideoParityShardRecord = - rmp_serde::from_slice(&document.payload)?; - validate_video_parity_record(&record)?; - GameCultMediaWireRecord::VideoParity(record) - } - GAMECULT_MEDIA_AUDIO_PACKET_SCHEMA => { - let record: GameCultMediaAudioPacketRecord = - rmp_serde::from_slice(&document.payload)?; - validate_audio_record(&record)?; - GameCultMediaWireRecord::Audio(record) - } - GAMECULT_MEDIA_RECEIVER_FEEDBACK_SCHEMA => { - let record: GameCultMediaReceiverFeedbackRecord = - rmp_serde::from_slice(&document.payload)?; - validate_feedback_record(&record)?; - GameCultMediaWireRecord::Feedback(record) - } - other => return Err(anyhow!("unsupported media schema {other}")), - }; - - // The key is derived from the record's own contents, so a mismatch means the - // envelope and its payload disagree about what this is. Admitting it would - // let a consumer address media by a key its producer never meant. - let expected = record.record_key(); - if document.record_key != expected { - return Err(anyhow!( - "record key mismatch: envelope says {}, record derives {expected}", - document.record_key - )); - } - - Ok(record) -} - -// --------------------------------------------------------------------------- -// Validation -// -// Moved here from Muninn with the envelope. A decoder that does not validate -// leaves each consumer to invent its own idea of a well-formed record, which is -// how the C++ receiver and the Rust sender came to disagree while both looked -// correct in isolation. Muninn's tests for these came too; they now cover the -// contract rather than one producer's copy of it. -// --------------------------------------------------------------------------- - -/// Addresses one chunk of a video frame. The string form appears in receiver -/// feedback and is parsed by consumers, so its shape is contract. -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub struct VideoChunkKey { - pub frame_id: u64, - pub chunk_index: u16, -} - -impl VideoChunkKey { - pub fn new(frame_id: u64, chunk_index: u16) -> Self { - Self { - frame_id, - chunk_index, - } - } - - pub fn parse(value: &str) -> Result { - if value.matches(':').count() != 1 { - return Err(anyhow!( - "video chunk key must contain exactly one ':' separator" - )); - } - let (frame_id, chunk_index) = value - .split_once(':') - .ok_or_else(|| anyhow!("video chunk key must be ':'"))?; - Ok(Self { - frame_id: frame_id - .parse() - .map_err(|_| anyhow!("video chunk key frame_id must be an integer"))?, - chunk_index: chunk_index - .parse() - .map_err(|_| anyhow!("video chunk key chunk_index must be an integer"))?, - }) - } - - pub fn as_feedback_key(&self) -> String { - format!("{}:{}", self.frame_id, self.chunk_index) - } -} - -pub fn video_chunk_feedback_key(frame_id: u64, chunk_index: u16) -> String { - VideoChunkKey::new(frame_id, chunk_index).as_feedback_key() -} - -pub fn normalize_video_chunk_feedback_keys(keys: Vec) -> Result> { - let mut parsed = keys - .iter() - .map(|key| VideoChunkKey::parse(key)) - .collect::>>()?; - parsed.sort_unstable(); - parsed.dedup(); - Ok(parsed - .into_iter() - .map(|key| key.as_feedback_key()) - .collect()) -} - -pub fn validate_video_record(record: &GameCultMediaVideoAccessUnitRecord) -> Result<()> { - if record.stream_id.is_empty() { - return Err(anyhow!("video media record stream_id must be non-empty")); - } - if record.session_id.is_empty() { - return Err(anyhow!("video media record session_id must be non-empty")); - } - if record.codec.is_empty() { - return Err(anyhow!("video media record codec must be non-empty")); - } - if record.timebase_num == 0 || record.timebase_den == 0 { - return Err(anyhow!("video media record timebase must be non-zero")); - } - if record.duration_ticks == 0 { - return Err(anyhow!( - "video media record duration_ticks must be greater than zero" - )); - } - if record.deadline_ticks < record.pts_ticks { - return Err(anyhow!( - "video media record deadline_ticks must not precede pts_ticks" - )); - } - if record.chunk_count == 0 { - return Err(anyhow!("video media record chunk_count must be non-zero")); - } - if record.chunk_index >= record.chunk_count { - return Err(anyhow!( - "video media record chunk_index {} is outside chunk_count {}", - record.chunk_index, - record.chunk_count - )); - } - if record.payload.is_empty() { - return Err(anyhow!("video media record payload must be non-empty")); - } - Ok(()) -} - -pub fn validate_video_parity_record(record: &GameCultMediaVideoParityShardRecord) -> Result<()> { - if record.stream_id.is_empty() { - return Err(anyhow!( - "video parity media record stream_id must be non-empty" - )); - } - if record.session_id.is_empty() { - return Err(anyhow!( - "video parity media record session_id must be non-empty" - )); - } - if record.codec.is_empty() { - return Err(anyhow!("video parity media record codec must be non-empty")); - } - if record.timebase_num == 0 || record.timebase_den == 0 { - return Err(anyhow!( - "video parity media record timebase must be non-zero" - )); - } - if record.duration_ticks == 0 { - return Err(anyhow!( - "video parity media record duration_ticks must be greater than zero" - )); - } - if record.deadline_ticks < record.pts_ticks { - return Err(anyhow!( - "video parity media record deadline_ticks must not precede pts_ticks" - )); - } - if record.chunk_count == 0 { - return Err(anyhow!( - "video parity media record chunk_count must be non-zero" - )); - } - if record.parity_count == 0 || record.parity_index >= record.parity_count { - return Err(anyhow!( - "video parity media record has invalid parity stripe metadata" - )); - } - if record.parity_count > record.chunk_count { - return Err(anyhow!( - "video parity media record parity_count exceeds chunk_count" - )); - } - if record.chunk_payload_bytes == 0 || record.last_chunk_payload_bytes == 0 { - return Err(anyhow!( - "video parity media record chunk lengths must be non-zero" - )); - } - if record.last_chunk_payload_bytes > record.chunk_payload_bytes { - return Err(anyhow!( - "video parity media record last chunk length exceeds regular chunk length" - )); - } - if record.payload.is_empty() { - return Err(anyhow!( - "video parity media record payload must be non-empty" - )); - } - if record.payload.len() > record.chunk_payload_bytes as usize { - return Err(anyhow!( - "video parity media record payload exceeds declared chunk length" - )); - } - Ok(()) -} - -pub fn validate_audio_record(record: &GameCultMediaAudioPacketRecord) -> Result<()> { - if record.stream_id.is_empty() { - return Err(anyhow!("audio media record stream_id must be non-empty")); - } - if record.session_id.is_empty() { - return Err(anyhow!("audio media record session_id must be non-empty")); - } - if record.codec.is_empty() { - return Err(anyhow!("audio media record codec must be non-empty")); - } - if record.timebase_num == 0 || record.timebase_den == 0 { - return Err(anyhow!("audio media record timebase must be non-zero")); - } - if record.duration_ticks == 0 { - return Err(anyhow!( - "audio media record duration_ticks must be greater than zero" - )); - } - if record.deadline_ticks < record.pts_ticks { - return Err(anyhow!( - "audio media record deadline_ticks must not precede pts_ticks" - )); - } - if record.payload.is_empty() { - return Err(anyhow!("audio media record payload must be non-empty")); - } - Ok(()) -} - -pub fn validate_feedback_record(record: &GameCultMediaReceiverFeedbackRecord) -> Result<()> { - if record.stream_id.is_empty() { - return Err(anyhow!( - "receiver feedback media record stream_id must be non-empty" - )); - } - if record.session_id.is_empty() { - return Err(anyhow!( - "receiver feedback media record session_id must be non-empty" - )); - } - if record.receiver_id.is_empty() { - return Err(anyhow!( - "receiver feedback media record receiver_id must be non-empty" - )); - } - if record.observed_at.is_empty() { - return Err(anyhow!( - "receiver feedback media record observed_at must be non-empty" - )); - } - if record.jitter_us < 0 { - return Err(anyhow!( - "receiver feedback media record jitter_us must be non-negative" - )); - } - if record.decode_queue_us < 0 { - return Err(anyhow!( - "receiver feedback media record decode_queue_us must be non-negative" - )); - } - normalize_video_chunk_feedback_keys(record.missing_video_chunk_keys.clone())?; - Ok(()) -} - -/// What a receiver has to say about a stream. The record it builds is the -/// contract's; this normalises it so both ends agree on its shape: damage -/// lists sorted and unique, chunk keys well-formed, and a keyframe requested -/// whenever a whole frame is missing, since a producer's repair cache holds -/// chunks, not references. Missing chunks alone are a repair request, which -/// exists precisely so that a keyframe is not needed. -#[derive(Clone, Debug)] -pub struct ReceiverFeedbackOptions<'a> { - pub stream_id: &'a str, - pub session_id: &'a str, - pub receiver_id: &'a str, - pub highest_decodable_frame_id: Option, - pub missing_frame_ids: Vec, - pub missing_video_chunk_keys: Vec, - pub late_frame_ids: Vec, - pub requested_keyframe: bool, - pub jitter_us: i64, - pub decode_queue_us: i64, - pub observed_at: &'a str, -} - -pub fn build_receiver_feedback( - options: ReceiverFeedbackOptions<'_>, -) -> Result { - let mut missing_frame_ids = options.missing_frame_ids; - missing_frame_ids.sort_unstable(); - missing_frame_ids.dedup(); - let missing_video_chunk_keys = - normalize_video_chunk_feedback_keys(options.missing_video_chunk_keys)?; - let mut late_frame_ids = options.late_frame_ids; - late_frame_ids.sort_unstable(); - late_frame_ids.dedup(); - let requested_keyframe = options.requested_keyframe || !missing_frame_ids.is_empty(); - - let record = GameCultMediaReceiverFeedbackRecord { - stream_id: options.stream_id.to_string(), - session_id: options.session_id.to_string(), - receiver_id: options.receiver_id.to_string(), - highest_decodable_frame_id: options.highest_decodable_frame_id, - missing_frame_ids, - late_frame_ids, - requested_keyframe, - jitter_us: options.jitter_us, - decode_queue_us: options.decode_queue_us, - observed_at: options.observed_at.to_string(), - missing_video_chunk_keys, - }; - validate_feedback_record(&record)?; - Ok(record) -} - -#[cfg(test)] -mod receiver_feedback_tests { - use super::*; - - fn options() -> ReceiverFeedbackOptions<'static> { - ReceiverFeedbackOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - receiver_id: "starfire.obs", - highest_decodable_frame_id: Some(40), - missing_frame_ids: Vec::new(), - missing_video_chunk_keys: Vec::new(), - late_frame_ids: Vec::new(), - requested_keyframe: false, - jitter_us: 0, - decode_queue_us: 2_000, - observed_at: "2026-06-18T00:00:00Z", - } - } - - #[test] - fn builds_receiver_feedback_with_sorted_unique_damage_lists() -> Result<()> { - let feedback = build_receiver_feedback(ReceiverFeedbackOptions { - missing_frame_ids: vec![43, 42, 43], - missing_video_chunk_keys: vec![ - video_chunk_feedback_key(43, 2), - video_chunk_feedback_key(42, 1), - video_chunk_feedback_key(42, 1), - ], - late_frame_ids: vec![39, 39, 38], - jitter_us: 700, - ..options() - })?; - - assert_eq!(feedback.missing_frame_ids, vec![42, 43]); - assert_eq!(feedback.missing_video_chunk_keys, vec!["42:1", "43:2"]); - assert_eq!(feedback.late_frame_ids, vec![38, 39]); - assert!(feedback.requested_keyframe, "a whole missing frame is a lost reference"); - Ok(()) - } - - /// A producer answers a keyframe request with an IDR. A repair request is - /// the cheaper alternative and must not carry one by accident. - #[test] - fn a_repair_request_does_not_ask_for_a_keyframe() -> Result<()> { - let feedback = build_receiver_feedback(ReceiverFeedbackOptions { - missing_video_chunk_keys: vec![video_chunk_feedback_key(42, 1)], - ..options() - })?; - assert!(!feedback.requested_keyframe); - Ok(()) - } - - #[test] - fn rejects_negative_receiver_feedback_timing() { - let error = build_receiver_feedback(ReceiverFeedbackOptions { jitter_us: -1, ..options() }) - .unwrap_err(); - assert!(error.to_string().contains("jitter_us")); - } - - #[test] - fn rejects_malformed_receiver_feedback_chunk_keys() { - let error = build_receiver_feedback(ReceiverFeedbackOptions { - missing_video_chunk_keys: vec!["frame:chunk".to_string()], - ..options() - }) - .unwrap_err(); - assert!(error.to_string().contains("frame_id"), "{error}"); - } - - #[test] - fn rejects_an_empty_receiver_id() { - let error = build_receiver_feedback(ReceiverFeedbackOptions { receiver_id: "", ..options() }) - .unwrap_err(); - assert!(error.to_string().contains("receiver_id")); - } -} diff --git a/packages/cultnet-rs/src/provider_health_contracts.rs b/packages/cultnet-rs/src/provider_health_contracts.rs deleted file mode 100644 index 632c74f2..00000000 --- a/packages/cultnet-rs/src/provider_health_contracts.rs +++ /dev/null @@ -1,716 +0,0 @@ -use anyhow::{Context, Result, anyhow, bail}; -use cultcache_rs::DatabaseEntry; -use serde::{Deserialize, Serialize}; - -use crate::runtime_authority_contracts::{ - GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA, GAMECULT_RUNTIME_PRESENCE_HEALTH_SIGNING_PURPOSE, - ODIN_RUNTIME_TOPOLOGY_CORRELATION_SCHEMA, ODIN_RUNTIME_TOPOLOGY_CORRELATION_SIGNING_PURPOSE, -}; -use crate::{ - GameCultProviderHealthIdentity, IdunnServiceIdentity, OdinTopologyIdentity, - derive_service_identity_id, -}; - -pub const GAMECULT_SERVICE_TRUST_ANCHOR_SCHEMA: &str = "gamecult.service_trust_anchor.v1"; -pub const IDUNN_AUTHENTICATED_PROVIDER_HEALTH_PROJECTION_SCHEMA: &str = - "idunn.authenticated_provider_health_projection.v1"; -pub const IDUNN_SIGNED_DAEMON_HEALTH_SCHEMA: &str = "idunn.signed_daemon_health.v1"; -pub const IDUNN_AUTHENTICATED_PROVIDER_HEALTH_PROJECTION_SIGNING_PURPOSE: &str = - "idunn.authenticated-provider-health-projection.v1"; -pub const IDUNN_PROVIDER_ACTIVE_REASON: &str = "authenticated_provider_active"; -pub const IDUNN_PROVIDER_WARMING_REASON: &str = "authenticated_provider_warming"; -pub const IDUNN_PROVIDER_DEGRADED_REASON: &str = "authenticated_provider_degraded"; -pub const IDUNN_PROVIDER_FAILED_REASON: &str = "authenticated_provider_failed"; -pub const IDUNN_DAEMON_HEALTH_TRUST_BINDING_SCHEMA: &str = "idunn.daemon_health_trust_binding.v1"; - -/// Provider-owned health statement. Ingress verifies `signature` over the -/// complete positional record with this field empty before admitting it. -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "idunn.signed_daemon_health", - schema = "idunn.signed_daemon_health.v1" -)] -pub struct IdunnSignedDaemonHealthRecord { - #[cultcache(key = 0)] - pub schema_version: String, - #[cultcache(key = 1)] - pub daemon_id: String, - #[cultcache(key = 2)] - pub health_contract: String, - #[cultcache(key = 3)] - pub source_runtime_id: String, - #[cultcache(key = 4)] - pub state: String, - #[cultcache(key = 5)] - pub detail: String, - #[cultcache(key = 6)] - pub signer_identity_id: String, - #[cultcache(key = 7)] - pub publisher_incarnation_id: String, - #[cultcache(key = 8)] - pub publisher_sequence: u64, - #[cultcache(key = 9)] - pub observed_at_unix_millis: u64, - #[cultcache(key = 10)] - pub release_id: Option, - #[cultcache(key = 11)] - pub release_witness_sha256: Option, - #[cultcache(key = 12)] - pub source_commit: Option, - #[cultcache(key = 13)] - pub deployment_id: Option, - #[cultcache(key = 14)] - pub signature_algorithm: String, - #[cultcache(key = 15, bytes)] - pub signature: Vec, - #[cultcache(key = 16)] - pub private_state_exposed: bool, - #[cultcache(key = 17, default)] - pub activation_witness_sha256: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct LegacyIdunnSignedDaemonHealthWire { - schema_version: String, - daemon_id: String, - health_contract: String, - source_runtime_id: String, - state: String, - detail: String, - signer_identity_id: String, - publisher_incarnation_id: String, - publisher_sequence: u64, - observed_at_unix_millis: u64, - release_id: Option, - release_witness_sha256: Option, - source_commit: Option, - deployment_id: Option, - signature_algorithm: String, - #[serde(with = "serde_bytes")] - signature: Vec, - private_state_exposed: bool, -} - -impl From for IdunnSignedDaemonHealthRecord { - fn from(value: LegacyIdunnSignedDaemonHealthWire) -> Self { - Self { - schema_version: value.schema_version, - daemon_id: value.daemon_id, - health_contract: value.health_contract, - source_runtime_id: value.source_runtime_id, - state: value.state, - detail: value.detail, - signer_identity_id: value.signer_identity_id, - publisher_incarnation_id: value.publisher_incarnation_id, - publisher_sequence: value.publisher_sequence, - observed_at_unix_millis: value.observed_at_unix_millis, - release_id: value.release_id, - release_witness_sha256: value.release_witness_sha256, - source_commit: value.source_commit, - deployment_id: value.deployment_id, - signature_algorithm: value.signature_algorithm, - signature: value.signature, - private_state_exposed: value.private_state_exposed, - activation_witness_sha256: None, - } - } -} - -impl IdunnSignedDaemonHealthRecord { - /// Decode the canonical current or pre-activation positional wire and - /// return the exact wire generation's unsigned signature payload. - pub fn decode_canonical_signed_payload(payload: &[u8]) -> Result<(Self, Vec)> { - match messagepack_array_len(payload) { - Some(18) => { - let statement: Self = rmp_serde::from_slice(payload) - .context("decoding generic signed daemon health")?; - if rmp_serde::to_vec(&statement)? != payload { - bail!("signed daemon health payload is not canonical positional MessagePack"); - } - let mut unsigned = statement.clone(); - unsigned.signature.clear(); - Ok((statement, rmp_serde::to_vec(&unsigned)?)) - } - Some(17) => { - let legacy: LegacyIdunnSignedDaemonHealthWire = rmp_serde::from_slice(payload) - .context("decoding legacy generic signed daemon health")?; - if rmp_serde::to_vec(&legacy)? != payload { - bail!("signed daemon health payload is not canonical positional MessagePack"); - } - let mut unsigned = legacy.clone(); - unsigned.signature.clear(); - let unsigned_payload = rmp_serde::to_vec(&unsigned)?; - Ok((legacy.into(), unsigned_payload)) - } - _ => bail!("signed daemon health payload is not canonical positional MessagePack"), - } - } - - pub fn validate(&self) -> Result<()> { - if self.schema_version != IDUNN_SIGNED_DAEMON_HEALTH_SCHEMA { - bail!("signed daemon health schema is unsupported"); - } - validate_identifier(&self.daemon_id, "daemon id")?; - validate_identifier(&self.health_contract, "health contract")?; - validate_identifier(&self.source_runtime_id, "source runtime id")?; - validate_identifier(&self.signer_identity_id, "signer identity id")?; - validate_identifier(&self.publisher_incarnation_id, "publisher incarnation id")?; - if !matches!( - self.state.as_str(), - "active" | "warming" | "degraded" | "failed" - ) || self.detail.len() > 512 - || self.signature_algorithm != "ed25519" - || self.signature.len() != 64 - || self.publisher_sequence == 0 - || self.observed_at_unix_millis == 0 - { - bail!("signed daemon health shape is invalid"); - } - validate_optional_release_binding( - &self.release_id, - &self.release_witness_sha256, - &self.source_commit, - )?; - validate_optional_identifier(&self.deployment_id, "deployment id")?; - validate_optional_sha256(&self.activation_witness_sha256, "activation witness sha256")?; - if self.private_state_exposed { - bail!("signed daemon health exposes private state"); - } - Ok(()) - } -} - -fn messagepack_array_len(payload: &[u8]) -> Option { - match *payload.first()? { - marker @ 0x90..=0x9f => Some(usize::from(marker & 0x0f)), - 0xdc => Some(usize::from(u16::from_be_bytes([ - *payload.get(1)?, - *payload.get(2)?, - ]))), - 0xdd => usize::try_from(u32::from_be_bytes([ - *payload.get(1)?, - *payload.get(2)?, - *payload.get(3)?, - *payload.get(4)?, - ])) - .ok(), - _ => None, - } -} - -/// Root-owned admission binding. Store ownership is the physical authority; -/// this document names the only signer accepted for one daemon/contract/runtime. -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "idunn.daemon_health_trust_binding", - schema = "idunn.daemon_health_trust_binding.v1" -)] -pub struct IdunnDaemonHealthTrustBindingRecord { - #[cultcache(key = 0)] - pub schema_version: String, - #[cultcache(key = 1)] - pub binding_id: String, - #[cultcache(key = 2)] - pub daemon_id: String, - #[cultcache(key = 3)] - pub health_contract: String, - #[cultcache(key = 4)] - pub source_runtime_id: String, - #[cultcache(key = 5)] - pub signer_identity_id: String, - #[cultcache(key = 6)] - pub signer_public_key: Vec, - #[cultcache(key = 7)] - pub binding_authority: String, - #[cultcache(key = 8)] - pub bound_at_unix_millis: u64, - #[cultcache(key = 9)] - pub release_binding_required: bool, - #[cultcache(key = 10)] - pub private_state_exposed: bool, -} - -impl IdunnDaemonHealthTrustBindingRecord { - pub fn validate(&self) -> Result<()> { - if self.schema_version != IDUNN_DAEMON_HEALTH_TRUST_BINDING_SCHEMA { - bail!("daemon health trust binding schema is unsupported"); - } - validate_identifier(&self.binding_id, "binding id")?; - validate_identifier(&self.daemon_id, "daemon id")?; - validate_identifier(&self.health_contract, "health contract")?; - validate_identifier(&self.source_runtime_id, "source runtime id")?; - validate_identifier(&self.signer_identity_id, "signer identity id")?; - if self.signer_public_key.len() != 32 - || self.binding_authority != "root" - || self.bound_at_unix_millis == 0 - { - bail!("daemon health trust binding authority or key is invalid"); - } - if self.private_state_exposed { - bail!("daemon health trust binding exposes private state"); - } - if self.signer_identity_id - != derive_service_identity_id::( - &self.signer_public_key, - )? - { - bail!("daemon health trust binding identity does not match its public key"); - } - Ok(()) - } -} - -/// Root-distributed public key binding for one service-owned signed contract. -/// Consumers pin this document; a self-declared key inside a signed projection -/// is never authority. -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "gamecult.service_trust_anchor", - schema = "gamecult.service_trust_anchor.v1" -)] -pub struct GameCultServiceTrustAnchorRecord { - #[cultcache(key = 0)] - pub schema_version: String, - #[cultcache(key = 1)] - pub trust_anchor_id: String, - #[cultcache(key = 2)] - pub service_id: String, - #[cultcache(key = 3)] - pub runtime_id: String, - #[cultcache(key = 4)] - pub signer_identity_id: String, - #[cultcache(key = 5)] - pub signer_public_key: Vec, - #[cultcache(key = 6)] - pub signature_algorithm: String, - #[cultcache(key = 7)] - pub signing_purpose: String, - #[cultcache(key = 8)] - pub signed_schema: String, - #[cultcache(key = 9)] - pub binding_authority: String, - #[cultcache(key = 10)] - pub bound_at_unix_millis: u64, - #[cultcache(key = 11)] - pub expires_at_unix_millis: Option, - #[cultcache(key = 12)] - pub private_state_exposed: bool, -} - -impl GameCultServiceTrustAnchorRecord { - pub fn validate(&self) -> Result<()> { - if self.schema_version != GAMECULT_SERVICE_TRUST_ANCHOR_SCHEMA { - bail!("service trust anchor schema is unsupported"); - } - validate_identifier(&self.trust_anchor_id, "trust anchor id")?; - validate_identifier(&self.service_id, "service id")?; - validate_identifier(&self.runtime_id, "runtime id")?; - validate_identifier(&self.signer_identity_id, "signer identity id")?; - validate_identifier(&self.signing_purpose, "signing purpose")?; - validate_identifier(&self.signed_schema, "signed schema")?; - if self.signer_public_key.len() != 32 - || self.signature_algorithm != "ed25519" - || self.binding_authority != "root" - || self.bound_at_unix_millis == 0 - || self - .expires_at_unix_millis - .is_some_and(|expires_at| expires_at <= self.bound_at_unix_millis) - || self.private_state_exposed - { - bail!("service trust anchor authority, key, lifetime, or privacy is invalid"); - } - if self.signed_schema == IDUNN_AUTHENTICATED_PROVIDER_HEALTH_PROJECTION_SCHEMA - && (self.service_id != "idunn" - || self.signing_purpose - != IDUNN_AUTHENTICATED_PROVIDER_HEALTH_PROJECTION_SIGNING_PURPOSE - || self.signer_identity_id - != derive_service_identity_id::(&self.signer_public_key)?) - { - bail!("Idunn provider-health trust anchor profile is invalid"); - } - let is_runtime_presence_profile = self - .signed_schema - .starts_with("gamecult.runtime_presence_health.") - || self - .signing_purpose - .starts_with("gamecult.runtime_presence_health."); - if is_runtime_presence_profile - && (self.signed_schema != GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA - || self.signing_purpose != GAMECULT_RUNTIME_PRESENCE_HEALTH_SIGNING_PURPOSE - || self.signer_identity_id - != derive_service_identity_id::( - &self.signer_public_key, - )?) - { - bail!("runtime presence trust anchor profile is invalid"); - } - if (self.signed_schema == ODIN_RUNTIME_TOPOLOGY_CORRELATION_SCHEMA - || self.signing_purpose == ODIN_RUNTIME_TOPOLOGY_CORRELATION_SIGNING_PURPOSE) - && (self.signed_schema != ODIN_RUNTIME_TOPOLOGY_CORRELATION_SCHEMA - || self.signing_purpose != ODIN_RUNTIME_TOPOLOGY_CORRELATION_SIGNING_PURPOSE - || self.service_id != "odin" - || self.signer_identity_id - != derive_service_identity_id::(&self.signer_public_key)?) - { - bail!("Odin topology correlation trust anchor profile is invalid"); - } - Ok(()) - } -} - -/// Idunn-owned public projection of one current authenticated generic provider -/// admission. Absence and dependency failure produce no record; they are not -/// provider states Idunn may manufacture. -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "idunn.authenticated_provider_health_projection", - schema = "idunn.authenticated_provider_health_projection.v1" -)] -pub struct IdunnAuthenticatedProviderHealthProjectionRecord { - #[cultcache(key = 0)] - pub schema_version: String, - #[cultcache(key = 1)] - pub projection_id: String, - #[cultcache(key = 2)] - pub daemon_id: String, - #[cultcache(key = 3)] - pub health_contract: String, - #[cultcache(key = 4)] - pub provider_state: String, - #[cultcache(key = 5)] - pub reason_code: String, - #[cultcache(key = 6)] - pub provider_observed_at_unix_millis: u64, - #[cultcache(key = 7)] - pub admitted_at_unix_millis: u64, - #[cultcache(key = 8)] - pub evaluated_at_unix_millis: u64, - #[cultcache(key = 9)] - pub trust_binding_id: String, - #[cultcache(key = 10)] - pub trust_binding_sha256: String, - #[cultcache(key = 11)] - pub signed_health_sha256: String, - #[cultcache(key = 12)] - pub authenticated_admission_sha256: String, - #[cultcache(key = 13)] - pub provider_signer_identity_id: String, - #[cultcache(key = 14)] - pub provider_incarnation_id: String, - #[cultcache(key = 15)] - pub provider_sequence: u64, - #[cultcache(key = 16)] - pub release_id: Option, - #[cultcache(key = 17)] - pub release_witness_sha256: Option, - #[cultcache(key = 18)] - pub source_commit: Option, - #[cultcache(key = 19)] - pub deployment_id: Option, - #[cultcache(key = 20)] - pub idunn_runtime_id: String, - #[cultcache(key = 21)] - pub idunn_signer_identity_id: String, - #[cultcache(key = 22)] - pub projection_incarnation_id: String, - #[cultcache(key = 23)] - pub projection_sequence: u64, - #[cultcache(key = 24)] - pub signature_algorithm: String, - #[cultcache(key = 25)] - pub signature: Vec, - #[cultcache(key = 26)] - pub private_state_exposed: bool, - #[cultcache(key = 27)] - pub expires_at_unix_millis: u64, -} - -/// Closed derivation for an authenticated provider state. `None` means no -/// provider-health projection may be published. -pub fn authenticated_provider_health_reason_code(state: &str) -> Option<&'static str> { - match state { - "active" => Some(IDUNN_PROVIDER_ACTIVE_REASON), - "warming" => Some(IDUNN_PROVIDER_WARMING_REASON), - "degraded" => Some(IDUNN_PROVIDER_DEGRADED_REASON), - "failed" => Some(IDUNN_PROVIDER_FAILED_REASON), - _ => None, - } -} - -impl IdunnAuthenticatedProviderHealthProjectionRecord { - pub fn validate(&self) -> Result<()> { - if self.schema_version != IDUNN_AUTHENTICATED_PROVIDER_HEALTH_PROJECTION_SCHEMA { - bail!("authenticated provider health projection schema is unsupported"); - } - validate_identifier(&self.projection_id, "projection id")?; - validate_identifier(&self.daemon_id, "daemon id")?; - validate_identifier(&self.health_contract, "health contract")?; - validate_identifier(&self.trust_binding_id, "trust binding id")?; - validate_identifier( - &self.provider_signer_identity_id, - "provider signer identity id", - )?; - validate_identifier(&self.provider_incarnation_id, "provider incarnation id")?; - validate_identifier(&self.idunn_runtime_id, "Idunn runtime id")?; - validate_identifier(&self.idunn_signer_identity_id, "Idunn signer identity id")?; - validate_identifier(&self.projection_incarnation_id, "projection incarnation id")?; - let reason_is_valid = authenticated_provider_health_reason_code(&self.provider_state) - .is_some_and(|reason| reason == self.reason_code); - if !reason_is_valid - || self.provider_observed_at_unix_millis == 0 - || self.admitted_at_unix_millis < self.provider_observed_at_unix_millis - || self.evaluated_at_unix_millis < self.admitted_at_unix_millis - || self.expires_at_unix_millis <= self.evaluated_at_unix_millis - || self.provider_sequence == 0 - || self.projection_sequence == 0 - || !is_sha256(&self.trust_binding_sha256) - || !is_sha256(&self.signed_health_sha256) - || !is_sha256(&self.authenticated_admission_sha256) - || self.signature_algorithm != "ed25519" - || self.signature.len() != 64 - || self.private_state_exposed - { - bail!( - "authenticated provider health projection shape, lineage, reason, signature, or privacy is invalid" - ); - } - validate_optional_release_binding( - &self.release_id, - &self.release_witness_sha256, - &self.source_commit, - )?; - validate_optional_identifier(&self.deployment_id, "deployment id")?; - if self.deployment_id.is_some() != self.release_id.is_some() { - bail!("authenticated provider health projection release lineage is partial"); - } - Ok(()) - } -} - -fn validate_identifier(value: &str, label: &str) -> Result<()> { - if value.trim().is_empty() || value.len() > 256 || value.chars().any(char::is_control) { - bail!("{label} is empty, oversized, or contains control characters"); - } - Ok(()) -} - -fn validate_optional_identifier(value: &Option, label: &str) -> Result<()> { - if let Some(value) = value { - validate_identifier(value, label)?; - } - Ok(()) -} - -fn validate_optional_sha256(value: &Option, label: &str) -> Result<()> { - if value.as_deref().is_some_and(|value| !is_sha256(value)) { - bail!("{label} is malformed"); - } - Ok(()) -} - -fn validate_optional_release_binding( - release_id: &Option, - witness: &Option, - source_commit: &Option, -) -> Result<()> { - if release_id.is_some() || witness.is_some() || source_commit.is_some() { - validate_optional_identifier(release_id, "release id")?; - let witness = witness - .as_deref() - .ok_or_else(|| anyhow!("release witness is absent"))?; - let commit = source_commit - .as_deref() - .ok_or_else(|| anyhow!("source commit is absent"))?; - if release_id.is_none() || !is_sha256(witness) || !is_lower_hex(commit, 40) { - bail!("release binding is incomplete or malformed"); - } - } - Ok(()) -} - -fn is_sha256(value: &str) -> bool { - value - .strip_prefix("sha256-") - .is_some_and(|digest| is_lower_hex(digest, 64)) -} - -fn is_lower_hex(value: &str, length: usize) -> bool { - value.len() == length - && value - .bytes() - .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn signed_health() -> IdunnSignedDaemonHealthRecord { - IdunnSignedDaemonHealthRecord { - schema_version: IDUNN_SIGNED_DAEMON_HEALTH_SCHEMA.into(), - daemon_id: "epiphany".into(), - health_contract: "epiphany.cultnet-rudp-runtime-health".into(), - source_runtime_id: "epiphany-yggdrasil".into(), - state: "active".into(), - detail: "managed-services-current".into(), - signer_identity_id: "provider-signing-key".into(), - publisher_incarnation_id: "9c2cba2e-c2de-4750-b222-b732f97d0435".into(), - publisher_sequence: 1, - observed_at_unix_millis: 100, - release_id: Some("release-1".into()), - release_witness_sha256: Some(format!("sha256-{}", "d".repeat(64))), - source_commit: Some("e".repeat(40)), - deployment_id: Some("deployment-1".into()), - signature_algorithm: "ed25519".into(), - signature: vec![6; 64], - private_state_exposed: false, - activation_witness_sha256: Some(format!("sha256-{}", "f".repeat(64))), - } - } - - fn anchor() -> GameCultServiceTrustAnchorRecord { - let public_key = vec![5; 32]; - GameCultServiceTrustAnchorRecord { - schema_version: GAMECULT_SERVICE_TRUST_ANCHOR_SCHEMA.into(), - trust_anchor_id: "root/idunn/provider-health".into(), - service_id: "idunn".into(), - runtime_id: "idunn-yggdrasil".into(), - signer_identity_id: derive_service_identity_id::(&public_key) - .unwrap(), - signer_public_key: public_key, - signature_algorithm: "ed25519".into(), - signing_purpose: IDUNN_AUTHENTICATED_PROVIDER_HEALTH_PROJECTION_SIGNING_PURPOSE.into(), - signed_schema: IDUNN_AUTHENTICATED_PROVIDER_HEALTH_PROJECTION_SCHEMA.into(), - binding_authority: "root".into(), - bound_at_unix_millis: 100, - expires_at_unix_millis: Some(200), - private_state_exposed: false, - } - } - - fn projection() -> IdunnAuthenticatedProviderHealthProjectionRecord { - IdunnAuthenticatedProviderHealthProjectionRecord { - schema_version: IDUNN_AUTHENTICATED_PROVIDER_HEALTH_PROJECTION_SCHEMA.into(), - projection_id: "authenticated-provider-health:epiphany".into(), - daemon_id: "epiphany".into(), - health_contract: "health.v1".into(), - provider_state: "active".into(), - reason_code: IDUNN_PROVIDER_ACTIVE_REASON.into(), - provider_observed_at_unix_millis: 100, - admitted_at_unix_millis: 101, - evaluated_at_unix_millis: 102, - trust_binding_id: "root/epiphany".into(), - trust_binding_sha256: format!("sha256-{}", "a".repeat(64)), - signed_health_sha256: format!("sha256-{}", "b".repeat(64)), - authenticated_admission_sha256: format!("sha256-{}", "c".repeat(64)), - provider_signer_identity_id: "provider-signing-key".into(), - provider_incarnation_id: "provider/boot/1".into(), - provider_sequence: 1, - release_id: Some("release-1".into()), - release_witness_sha256: Some(format!("sha256-{}", "d".repeat(64))), - source_commit: Some("e".repeat(40)), - deployment_id: Some("deployment-1".into()), - idunn_runtime_id: "idunn-yggdrasil".into(), - idunn_signer_identity_id: "idunn-signing-key".into(), - projection_incarnation_id: "idunn/boot/1".into(), - projection_sequence: 1, - signature_algorithm: "ed25519".into(), - signature: vec![6; 64], - private_state_exposed: false, - expires_at_unix_millis: 200, - } - } - - #[test] - fn contracts_accept_exact_root_and_lineage_shapes() -> Result<()> { - let anchor = anchor(); - let projection = projection(); - let health = signed_health(); - anchor.validate()?; - projection.validate()?; - health.validate()?; - let encoded = rmp_serde::to_vec(&health)?; - assert_eq!(encoded.first().copied(), Some(0xdc)); - assert_eq!(&encoded[1..3], &[0, 18]); - assert!( - encoded - .windows(4) - .any(|window| window == [0xc4, 0x40, 6, 6]), - "provider signature must use the portable MessagePack binary shape" - ); - assert_eq!( - rmp_serde::from_slice::(&encoded)?, - health - ); - assert_eq!( - rmp_serde::from_slice::(&rmp_serde::to_vec( - &anchor - )?)?, - anchor - ); - assert_eq!( - rmp_serde::from_slice::( - &rmp_serde::to_vec(&projection)? - )?, - projection - ); - assert_eq!(authenticated_provider_health_reason_code("missing"), None); - Ok(()) - } - - #[test] - fn activation_binding_is_additive_for_legacy_generic_health_records() -> Result<()> { - let mut health = signed_health(); - health.activation_witness_sha256 = None; - let mut legacy = rmp_serde::to_vec(&health)?; - assert_eq!(&legacy[..3], &[0xdc, 0, 18]); - assert_eq!(legacy.pop(), Some(0xc0)); - legacy[2] = 17; - let decoded: IdunnSignedDaemonHealthRecord = rmp_serde::from_slice(&legacy)?; - assert_eq!(decoded.activation_witness_sha256, None); - decoded.validate()?; - let (decoded, unsigned) = - IdunnSignedDaemonHealthRecord::decode_canonical_signed_payload(&legacy)?; - assert_eq!(decoded.activation_witness_sha256, None); - assert_eq!(&unsigned[..3], &[0xdc, 0, 17]); - Ok(()) - } - - #[test] - fn contracts_refuse_substitution_and_manufactured_states() { - let mut value = anchor(); - value.binding_authority = "idunn".into(); - assert!(value.validate().is_err()); - let mut value = anchor(); - value.signer_identity_id = "caller-selected".into(); - assert!(value.validate().is_err()); - let mut value = projection(); - value.reason_code = "release_drift".into(); - assert!(value.validate().is_err()); - let mut value = projection(); - value.provider_state = "missing".into(); - assert!(value.validate().is_err()); - let mut value = projection(); - value.deployment_id = None; - assert!(value.validate().is_err()); - let mut value = projection(); - value.signature.clear(); - assert!(value.validate().is_err()); - let mut value = projection(); - value.signed_health_sha256 = format!("sha256-{}", "A".repeat(64)); - assert!(value.validate().is_err()); - let mut value = signed_health(); - value.publisher_sequence = 0; - assert!(value.validate().is_err()); - let mut value = signed_health(); - value.activation_witness_sha256 = Some(format!("sha256-{}", "F".repeat(64))); - assert!(value.validate().is_err()); - let mut value = signed_health(); - value.release_witness_sha256 = None; - assert!(value.validate().is_err()); - let mut value = signed_health(); - value.private_state_exposed = true; - assert!(value.validate().is_err()); - } -} diff --git a/packages/cultnet-rs/src/provider_session.rs b/packages/cultnet-rs/src/provider_session.rs deleted file mode 100644 index 9965b0ef..00000000 --- a/packages/cultnet-rs/src/provider_session.rs +++ /dev/null @@ -1,625 +0,0 @@ -use anyhow::{Result, anyhow}; -use base64::Engine; -use base64::engine::general_purpose::STANDARD; -use chrono::DateTime; -use rmpv::Value; -use serde::de::DeserializeOwned; -use serde::{Deserialize, Serialize}; - -use crate::{CultNetMessage, CultNetRawDocumentRecord}; - -pub const CULTMESH_PROVIDER_SESSION_SERVICE_ID: &str = "gamecult.mesh.provider_session"; -pub const CULTMESH_PROVIDER_SESSION_PAYLOAD_ENCODING: &str = "messagepack-base64"; - -pub const CULTMESH_PROVIDER_REGISTER_OPERATION: &str = "provider.register"; -pub const CULTMESH_PROVIDER_RENEW_OPERATION: &str = "provider.renew"; -pub const CULTMESH_PROVIDER_PUBLICATION_PUT_OPERATION: &str = "provider.publication.put"; -pub const CULTMESH_PROVIDER_PUBLICATION_DELETE_OPERATION: &str = "provider.publication.delete"; -pub const CULTMESH_PROVIDER_RECEIPT_PUT_OPERATION: &str = "provider.receipt.put"; -pub const CULTMESH_PROVIDER_WITHDRAW_OPERATION: &str = "provider.withdraw"; - -pub const CULTMESH_PROVIDER_REGISTRATION_SCHEMA: &str = "gamecult.mesh.provider_registration.v1"; -pub const CULTMESH_PROVIDER_LEASE_SCHEMA: &str = "gamecult.mesh.provider_lease.v1"; -pub const CULTMESH_PROVIDER_LEASE_RENEWAL_SCHEMA: &str = "gamecult.mesh.provider_lease_renewal.v1"; -pub const CULTMESH_PROVIDER_PUBLICATION_PUT_SCHEMA: &str = - "gamecult.mesh.provider_publication_put.v1"; -pub const CULTMESH_PROVIDER_PUBLICATION_DELETE_SCHEMA: &str = - "gamecult.mesh.provider_publication_delete.v1"; -pub const CULTMESH_PROVIDER_COMMAND_SCHEMA: &str = "gamecult.mesh.provider_command.v1"; -pub const CULTMESH_PROVIDER_RECEIPT_PUT_SCHEMA: &str = "gamecult.mesh.provider_receipt_put.v1"; -pub const CULTMESH_PROVIDER_WITHDRAWAL_SCHEMA: &str = "gamecult.mesh.provider_withdrawal.v1"; -pub const CULTMESH_PROVIDER_MUTATION_ACCEPTANCE_SCHEMA: &str = - "gamecult.mesh.provider_mutation_acceptance.v1"; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CultMeshProviderOperationStatus { - Ok, - Conflict, - Expired, - Denied, - Invalid, -} - -impl CultMeshProviderOperationStatus { - pub fn as_str(self) -> &'static str { - match self { - Self::Ok => "ok", - Self::Conflict => "conflict", - Self::Expired => "expired", - Self::Denied => "denied", - Self::Invalid => "invalid", - } - } - - pub fn parse(value: &str) -> Result { - match value { - "ok" => Ok(Self::Ok), - "conflict" => Ok(Self::Conflict), - "expired" => Ok(Self::Expired), - "denied" => Ok(Self::Denied), - "invalid" => Ok(Self::Invalid), - _ => Err(anyhow!("Unsupported provider-session status {value:?}")), - } - } -} - -pub trait CultMeshProviderSessionPayload { - fn validate(&self) -> Result<()>; -} - -/// Provider-owned evidence carried verbatim in the RUDP Connect payload. -/// `client_session_id` distinguishes process/session generations; the optional -/// token remains opaque authorization evidence for the receiving service. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CultMeshProviderConnectEvidenceWire { - pub client_session_id: String, - #[serde(default)] - pub session_token: Option, -} - -impl CultMeshProviderSessionPayload for CultMeshProviderConnectEvidenceWire { - fn validate(&self) -> Result<()> { - require_text(&self.client_session_id, "clientSessionId")?; - require_optional_text(self.session_token.as_deref(), "sessionToken") - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CultMeshProviderRegistrationWire { - pub provider_id: String, - pub service_instance_id: String, - pub endpoint_id: String, - pub verse_id: String, - pub requested_lease_duration_ms: i32, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub authority_lease_id: Option, -} - -impl CultMeshProviderSessionPayload for CultMeshProviderRegistrationWire { - fn validate(&self) -> Result<()> { - require_identity( - &self.provider_id, - &self.service_instance_id, - &self.endpoint_id, - &self.verse_id, - )?; - require_positive(self.requested_lease_duration_ms, "requestedLeaseDurationMs")?; - require_optional_text(self.authority_lease_id.as_deref(), "authorityLeaseId") - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CultMeshProviderLeaseWire { - pub provider_id: String, - pub service_instance_id: String, - pub endpoint_id: String, - pub verse_id: String, - pub lease_id: String, - pub valid_from_utc: String, - pub expires_at_utc: String, -} - -impl CultMeshProviderSessionPayload for CultMeshProviderLeaseWire { - fn validate(&self) -> Result<()> { - require_identity( - &self.provider_id, - &self.service_instance_id, - &self.endpoint_id, - &self.verse_id, - )?; - require_text(&self.lease_id, "leaseId")?; - let valid_from = require_timestamp(&self.valid_from_utc, "validFromUtc")?; - let expires_at = require_timestamp(&self.expires_at_utc, "expiresAtUtc")?; - if expires_at <= valid_from { - return Err(anyhow!("Provider lease must expire after validFromUtc")); - } - Ok(()) - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CultMeshProviderLeaseRenewalWire { - pub lease_id: String, - pub requested_lease_duration_ms: i32, -} - -impl CultMeshProviderSessionPayload for CultMeshProviderLeaseRenewalWire { - fn validate(&self) -> Result<()> { - require_text(&self.lease_id, "leaseId")?; - require_positive(self.requested_lease_duration_ms, "requestedLeaseDurationMs") - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CultMeshProviderPublicationPutWire { - pub lease_id: String, - pub publication_id: String, - pub document: CultNetRawDocumentRecord, -} - -impl CultMeshProviderSessionPayload for CultMeshProviderPublicationPutWire { - fn validate(&self) -> Result<()> { - require_text(&self.lease_id, "leaseId")?; - require_text(&self.publication_id, "publicationId")?; - validate_raw_document(&self.document) - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CultMeshProviderPublicationDeleteWire { - pub lease_id: String, - pub publication_id: String, - pub schema_id: String, - pub record_key: String, -} - -impl CultMeshProviderSessionPayload for CultMeshProviderPublicationDeleteWire { - fn validate(&self) -> Result<()> { - require_text(&self.lease_id, "leaseId")?; - require_text(&self.publication_id, "publicationId")?; - require_text(&self.schema_id, "schemaId")?; - require_text(&self.record_key, "recordKey") - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CultMeshProviderCommandWire { - pub command_id: String, - pub command_kind: String, - pub provider_id: String, - pub service_instance_id: String, - pub payload: Value, -} - -impl CultMeshProviderSessionPayload for CultMeshProviderCommandWire { - fn validate(&self) -> Result<()> { - require_text(&self.command_id, "commandId")?; - require_text(&self.command_kind, "commandKind")?; - require_text(&self.provider_id, "providerId")?; - require_text(&self.service_instance_id, "serviceInstanceId") - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum CultMeshProviderReceiptStateWire { - Applied, - Rejected, - Failed, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CultMeshProviderCommandReceiptWire { - pub receipt_id: String, - pub command_id: String, - pub command_kind: String, - pub provider_id: String, - pub service_instance_id: String, - pub state: CultMeshProviderReceiptStateWire, - pub completed_at_utc: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub result: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -impl CultMeshProviderSessionPayload for CultMeshProviderCommandReceiptWire { - fn validate(&self) -> Result<()> { - require_text(&self.receipt_id, "receiptId")?; - require_text(&self.command_id, "commandId")?; - require_text(&self.command_kind, "commandKind")?; - require_text(&self.provider_id, "providerId")?; - require_text(&self.service_instance_id, "serviceInstanceId")?; - require_timestamp(&self.completed_at_utc, "completedAtUtc")?; - require_optional_text(self.error.as_deref(), "error") - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CultMeshProviderReceiptPutWire { - pub lease_id: String, - pub receipt: CultMeshProviderCommandReceiptWire, -} - -impl CultMeshProviderSessionPayload for CultMeshProviderReceiptPutWire { - fn validate(&self) -> Result<()> { - require_text(&self.lease_id, "leaseId")?; - self.receipt.validate() - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CultMeshProviderWithdrawalWire { - pub lease_id: String, -} - -impl CultMeshProviderSessionPayload for CultMeshProviderWithdrawalWire { - fn validate(&self) -> Result<()> { - require_text(&self.lease_id, "leaseId") - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CultMeshProviderMutationAcceptanceWire { - pub accepted_at_utc: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub lease_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub publication_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub command_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub receipt_id: Option, -} - -impl CultMeshProviderSessionPayload for CultMeshProviderMutationAcceptanceWire { - fn validate(&self) -> Result<()> { - require_timestamp(&self.accepted_at_utc, "acceptedAtUtc")?; - require_optional_text(self.lease_id.as_deref(), "leaseId")?; - require_optional_text(self.publication_id.as_deref(), "publicationId")?; - require_optional_text(self.command_id.as_deref(), "commandId")?; - require_optional_text(self.receipt_id.as_deref(), "receiptId") - } -} - -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct CultMeshProviderErrorWire {} - -impl CultMeshProviderSessionPayload for CultMeshProviderErrorWire { - fn validate(&self) -> Result<()> { - Ok(()) - } -} - -pub fn encode_provider_session_payload(value: &T) -> Result -where - T: CultMeshProviderSessionPayload + Serialize, -{ - value.validate()?; - Ok(STANDARD.encode(rmp_serde::to_vec_named(value)?)) -} - -pub fn encode_provider_connect_evidence( - evidence: &CultMeshProviderConnectEvidenceWire, -) -> Result> { - evidence.validate()?; - Ok(rmp_serde::to_vec_named(evidence)?) -} - -pub fn decode_provider_connect_evidence( - payload: &[u8], -) -> Result { - if payload.is_empty() { - return Err(anyhow!( - "Provider Connect evidence must be a non-empty MessagePack map" - )); - } - let evidence: CultMeshProviderConnectEvidenceWire = rmp_serde::from_slice(payload)?; - evidence.validate()?; - Ok(evidence) -} - -pub fn decode_provider_session_payload(payload: &str) -> Result -where - T: CultMeshProviderSessionPayload + DeserializeOwned, -{ - require_text(payload, "payload")?; - let bytes = STANDARD - .decode(payload) - .map_err(|error| anyhow!("Provider-session payload must be MessagePack base64: {error}"))?; - let value: T = rmp_serde::from_slice(&bytes)?; - value.validate()?; - Ok(value) -} - -pub fn decode_provider_command_document( - document: &CultNetRawDocumentRecord, -) -> Result { - if document.schema_id != CULTMESH_PROVIDER_COMMAND_SCHEMA { - return Err(anyhow!( - "Expected provider command schema {:?}, received {:?}", - CULTMESH_PROVIDER_COMMAND_SCHEMA, - document.schema_id - )); - } - let command: CultMeshProviderCommandWire = rmp_serde::from_slice(&document.payload)?; - command.validate()?; - Ok(command) -} - -pub fn create_provider_session_request( - message_id: impl Into, - operation: impl Into, - payload_schema: impl Into, - payload: &T, - source_runtime_id: Option, - target_runtime_id: Option, -) -> Result -where - T: CultMeshProviderSessionPayload + Serialize, -{ - let message_id = message_id.into(); - let operation = operation.into(); - let payload_schema = payload_schema.into(); - require_text(&message_id, "messageId")?; - validate_operation_schema(&operation, &payload_schema)?; - Ok(CultNetMessage::OperationRequest { - message_id, - service_id: CULTMESH_PROVIDER_SESSION_SERVICE_ID.to_string(), - operation, - payload_schema, - payload_encoding: CULTMESH_PROVIDER_SESSION_PAYLOAD_ENCODING.to_string(), - payload: encode_provider_session_payload(payload)?, - source_runtime_id, - target_runtime_id, - }) -} - -pub fn decode_provider_session_request( - request: &CultNetMessage, - expected_operation: &str, - expected_payload_schema: &str, -) -> Result -where - T: CultMeshProviderSessionPayload + DeserializeOwned, -{ - let CultNetMessage::OperationRequest { - service_id, - operation, - payload_schema, - payload_encoding, - payload, - .. - } = request - else { - return Err(anyhow!("Expected a CultNet operation request")); - }; - require_envelope( - service_id, - operation, - payload_schema, - payload_encoding, - expected_operation, - expected_payload_schema, - )?; - decode_provider_session_payload(payload) -} - -pub fn create_provider_session_response( - request: &CultNetMessage, - status: CultMeshProviderOperationStatus, - payload_schema: impl Into, - payload: &T, - source_runtime_id: Option, -) -> Result -where - T: CultMeshProviderSessionPayload + Serialize, -{ - let CultNetMessage::OperationRequest { - message_id, - operation, - .. - } = request - else { - return Err(anyhow!("Expected a CultNet operation request")); - }; - require_text(message_id, "messageId")?; - require_text(operation, "operation")?; - let payload_schema = payload_schema.into(); - require_text(&payload_schema, "payloadSchema")?; - Ok(CultNetMessage::OperationResponse { - message_id: message_id.clone(), - service_id: CULTMESH_PROVIDER_SESSION_SERVICE_ID.to_string(), - operation: operation.clone(), - status: status.as_str().to_string(), - payload_schema, - payload_encoding: CULTMESH_PROVIDER_SESSION_PAYLOAD_ENCODING.to_string(), - payload: encode_provider_session_payload(payload)?, - diagnostics: Vec::new(), - source_runtime_id, - }) -} - -pub fn create_provider_session_error_response( - request: &CultNetMessage, - status: CultMeshProviderOperationStatus, - diagnostics: Vec, - source_runtime_id: Option, -) -> Result { - if status == CultMeshProviderOperationStatus::Ok { - return Err(anyhow!( - "Provider-session error responses require a non-ok status" - )); - } - if diagnostics.is_empty() { - return Err(anyhow!( - "Provider-session error responses require diagnostics" - )); - } - for diagnostic in &diagnostics { - require_text(diagnostic, "diagnostics[]")?; - } - let mut response = create_provider_session_response( - request, - status, - CULTMESH_PROVIDER_MUTATION_ACCEPTANCE_SCHEMA, - &CultMeshProviderErrorWire {}, - source_runtime_id, - )?; - let CultNetMessage::OperationResponse { - diagnostics: response_diagnostics, - .. - } = &mut response - else { - unreachable!("provider-session response helper returned a request") - }; - *response_diagnostics = diagnostics; - Ok(response) -} - -pub fn decode_provider_session_response( - response: &CultNetMessage, - expected_operation: &str, - expected_payload_schema: &str, -) -> Result<(CultMeshProviderOperationStatus, T)> -where - T: CultMeshProviderSessionPayload + DeserializeOwned, -{ - let CultNetMessage::OperationResponse { - service_id, - operation, - status, - payload_schema, - payload_encoding, - payload, - .. - } = response - else { - return Err(anyhow!("Expected a CultNet operation response")); - }; - require_envelope( - service_id, - operation, - payload_schema, - payload_encoding, - expected_operation, - expected_payload_schema, - )?; - Ok(( - CultMeshProviderOperationStatus::parse(status)?, - decode_provider_session_payload(payload)?, - )) -} - -fn validate_operation_schema(operation: &str, payload_schema: &str) -> Result<()> { - let expected = match operation { - CULTMESH_PROVIDER_REGISTER_OPERATION => CULTMESH_PROVIDER_REGISTRATION_SCHEMA, - CULTMESH_PROVIDER_RENEW_OPERATION => CULTMESH_PROVIDER_LEASE_RENEWAL_SCHEMA, - CULTMESH_PROVIDER_PUBLICATION_PUT_OPERATION => CULTMESH_PROVIDER_PUBLICATION_PUT_SCHEMA, - CULTMESH_PROVIDER_PUBLICATION_DELETE_OPERATION => { - CULTMESH_PROVIDER_PUBLICATION_DELETE_SCHEMA - } - CULTMESH_PROVIDER_RECEIPT_PUT_OPERATION => CULTMESH_PROVIDER_RECEIPT_PUT_SCHEMA, - CULTMESH_PROVIDER_WITHDRAW_OPERATION => CULTMESH_PROVIDER_WITHDRAWAL_SCHEMA, - _ => { - return Err(anyhow!( - "Unsupported provider-session operation {operation:?}" - )); - } - }; - if payload_schema != expected { - return Err(anyhow!( - "Provider-session operation {operation:?} requires payload schema {expected:?}" - )); - } - Ok(()) -} - -fn require_envelope( - service_id: &str, - operation: &str, - payload_schema: &str, - payload_encoding: &str, - expected_operation: &str, - expected_payload_schema: &str, -) -> Result<()> { - if service_id != CULTMESH_PROVIDER_SESSION_SERVICE_ID { - return Err(anyhow!( - "Unexpected provider-session service id {service_id:?}" - )); - } - if operation != expected_operation { - return Err(anyhow!( - "Unexpected provider-session operation {operation:?}" - )); - } - if payload_schema != expected_payload_schema { - return Err(anyhow!( - "Unexpected provider-session payload schema {payload_schema:?}" - )); - } - if payload_encoding != CULTMESH_PROVIDER_SESSION_PAYLOAD_ENCODING { - return Err(anyhow!( - "Unexpected provider-session payload encoding {payload_encoding:?}" - )); - } - Ok(()) -} - -fn validate_raw_document(document: &CultNetRawDocumentRecord) -> Result<()> { - require_text(&document.schema_id, "document.schemaId")?; - require_text(&document.record_key, "document.recordKey")?; - Ok(()) -} - -fn require_identity( - provider_id: &str, - service_instance_id: &str, - endpoint_id: &str, - verse_id: &str, -) -> Result<()> { - require_text(provider_id, "providerId")?; - require_text(service_instance_id, "serviceInstanceId")?; - require_text(endpoint_id, "endpointId")?; - require_text(verse_id, "verseId") -} - -fn require_text(value: &str, field: &str) -> Result<()> { - if value.trim().is_empty() { - return Err(anyhow!("Provider-session {field} must not be empty")); - } - Ok(()) -} - -fn require_optional_text(value: Option<&str>, field: &str) -> Result<()> { - if let Some(value) = value { - require_text(value, field)?; - } - Ok(()) -} - -fn require_positive(value: i32, field: &str) -> Result<()> { - if value <= 0 { - return Err(anyhow!( - "Provider-session {field} must be greater than zero" - )); - } - Ok(()) -} - -fn require_timestamp(value: &str, field: &str) -> Result> { - require_text(value, field)?; - DateTime::parse_from_rfc3339(value) - .map_err(|error| anyhow!("Provider-session {field} must be RFC3339: {error}")) -} diff --git a/packages/cultnet-rs/src/replication.rs b/packages/cultnet-rs/src/replication.rs index 7073f1a9..d02a141f 100644 --- a/packages/cultnet-rs/src/replication.rs +++ b/packages/cultnet-rs/src/replication.rs @@ -540,7 +540,7 @@ impl CultNetDocumentRegistry { }) } - pub fn raw_document_record_from_envelope( + fn raw_document_record_from_envelope( &self, envelope: &CultCacheEnvelope, ) -> Result { diff --git a/packages/cultnet-rs/src/rudp.rs b/packages/cultnet-rs/src/rudp.rs index 174739be..8b47f9e7 100644 --- a/packages/cultnet-rs/src/rudp.rs +++ b/packages/cultnet-rs/src/rudp.rs @@ -6,9 +6,8 @@ use std::collections::VecDeque; use std::io::ErrorKind; use std::net::SocketAddr; use std::net::UdpSocket; -use std::thread; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use uuid::Uuid; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use crate::CultNetMessage; use crate::CultNetReconnectController; @@ -30,22 +29,6 @@ use crate::encode_cultnet_message_to_vec; const RUDP_MAGIC: [u8; 4] = [0x43, 0x4e, 0x52, 0x30]; const RUDP_VERSION: u8 = 0; const RUDP_FIXED_HEADER_BYTES: usize = 36; -pub const CULTNET_RUDP_DEFAULT_MAX_PAYLOAD_BYTES: usize = 16 * 1024 * 1024; -pub const CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS: usize = 32; -const RUDP_RECEIVED_SEQUENCE_WINDOW: usize = 4_096; -/// Live media tolerates loss but not lateness: a frame retransmitted after this -/// long describes a moment that has already passed. State channels never -/// expire, so this default applies only to the media channel. -const DEFAULT_MEDIA_RELIABLE_EXPIRE_AFTER_MS: u64 = 75; -/// A peer chooses its own channel ids and its own send order, so the per-channel -/// maps and the out-of-order hold buffer are both attacker-sized unless capped. -const RUDP_MAX_TRACKED_CHANNELS: usize = 64; -/// A peer cannot be trusted to pick the next sequence honestly. One packet far -/// ahead of the current highest would otherwise advance the receive state past -/// every legitimate sequence still in flight and starve the session. -const RUDP_RECEIVE_AHEAD_WINDOW: u32 = 1_024; -const RUDP_MAX_ORDERED_BUFFERED_FRAMES: usize = 1_024; -const RUDP_MAX_ORDERED_BUFFERED_BYTES: usize = 4 * 1024 * 1024; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CultNetRudpPacketType { @@ -85,7 +68,6 @@ pub struct CultNetRudpDeliveredFrame { #[derive(Clone, Debug, PartialEq, Eq)] pub struct CultNetRudpReceiveResult { pub delivered: Vec, - pub ready_to_send: Vec, pub reply: Option, pub pong: bool, pub pong_payload: Vec, @@ -118,40 +100,12 @@ pub struct CultNetRudpSendOptions { pub ordered: bool, pub sequenced: bool, pub now_ms: u64, - /// Drop a reliable packet instead of retransmitting it once this many - /// milliseconds have passed since the send call. `None` retransmits until - /// acknowledged, which is correct for state and wrong for live media, where - /// a late frame is worse than a missing one. - pub reliable_expire_after_ms: Option, -} - -/// Identifies every transport packet belonging to one non-expiring reliable -/// send. Receipts are local to the session that issued them and cannot survive -/// a peer reset. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CultNetRudpReliableSendReceipt { - session_scope: Uuid, - sequences: Vec, -} - -impl CultNetRudpReliableSendReceipt { - pub fn packet_sequences(&self) -> &[u32] { - &self.sequences - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CultNetRudpReliableSendStatus { - Pending, - Acknowledged, - Invalidated, } #[derive(Clone, Debug, PartialEq, Eq)] struct PendingReliablePacket { packet: CultNetRudpPacket, last_sent_at_ms: u64, - expires_at_ms: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -167,64 +121,42 @@ struct FragmentBuffer { fragment_count: u16, payloads: BTreeMap>, sequences: BTreeMap, - /// Session-local arrival ordinal of the newest fragment in this set. A - /// set that stops being touched is one that lost a fragment; it is the - /// first to go when the pending-set bound is reached. - last_touched: u64, } pub struct CultNetRudpSession { - session_scope: Uuid, connection_id: u32, resend_delay_ms: u64, max_pending_reliable_packets: Option, - max_payload_bytes: Option, - max_pending_fragment_sets: usize, initial_sequence: u32, next_sequence: u32, - next_sequenced_by_channel: BTreeMap, next_fragment_id: u16, connected: bool, last_received_at_ms: Option, highest_received_sequence: Option, received_sequences: BTreeSet, - latest_sequenced_by_channel: BTreeMap, pending_reliable: BTreeMap, - queued_reliable: VecDeque<(CultNetRudpPacket, Option)>, - reliable_packets_expired: u64, ordered_next_sequence_by_channel: BTreeMap, ordered_buffers: BTreeMap>, fragment_buffers: BTreeMap<(String, u16), FragmentBuffer>, - fragment_touches: u64, - fragment_sets_evicted: u64, } impl CultNetRudpSession { pub fn new(options: CultNetRudpSessionOptions) -> Self { Self { - session_scope: Uuid::new_v4(), connection_id: options.connection_id, resend_delay_ms: options.resend_delay_ms, max_pending_reliable_packets: options.max_pending_reliable_packets, - max_payload_bytes: None, - max_pending_fragment_sets: 64, initial_sequence: options.initial_sequence, next_sequence: options.initial_sequence, - next_sequenced_by_channel: BTreeMap::new(), next_fragment_id: 1, connected: false, last_received_at_ms: None, highest_received_sequence: None, received_sequences: BTreeSet::new(), - latest_sequenced_by_channel: BTreeMap::new(), pending_reliable: BTreeMap::new(), - queued_reliable: VecDeque::new(), - reliable_packets_expired: 0, ordered_next_sequence_by_channel: BTreeMap::new(), ordered_buffers: BTreeMap::new(), fragment_buffers: BTreeMap::new(), - fragment_touches: 0, - fragment_sets_evicted: 0, } } @@ -240,112 +172,22 @@ impl CultNetRudpSession { self.connected } - pub fn assume_connected(&mut self, now_ms: u64) { - self.connected = true; - self.last_received_at_ms = Some(now_ms); - } - pub fn pending_reliable_sequences(&self) -> Vec { self.pending_reliable.keys().copied().collect() } - /// Reliable packets dropped by their expiry deadline rather than - /// acknowledged. A rising count means the peer is not keeping up. - pub fn reliable_packets_expired(&self) -> u64 { - self.reliable_packets_expired - } - - /// Incomplete fragment sets discarded to admit a newer one. Each is a - /// payload that lost at least one fragment on the wire; a rising count is - /// the link's loss rate multiplied by the fragment count, and the reason - /// media payloads should fit in one packet. - pub fn fragment_sets_evicted(&self) -> u64 { - self.fragment_sets_evicted - } - - pub fn queued_reliable_packet_count(&self) -> usize { - self.queued_reliable.len() - } - - pub fn outstanding_reliable_packet_count(&self) -> usize { - self.pending_reliable.len() + self.queued_reliable.len() - } - - fn pending_accept_for_resend(&mut self, now_ms: u64) -> Option { - let pending = self - .pending_reliable - .values_mut() - .find(|pending| pending.packet.packet_type == CultNetRudpPacketType::Accept)?; - pending.last_sent_at_ms = now_ms; - Some(pending.packet.clone()) - } - - fn reliable_send_status( - &self, - receipt: &CultNetRudpReliableSendReceipt, - ) -> CultNetRudpReliableSendStatus { - if receipt.session_scope != self.session_scope { - return CultNetRudpReliableSendStatus::Invalidated; - } - if receipt - .sequences - .iter() - .any(|sequence| self.pending_reliable.contains_key(sequence)) - { - CultNetRudpReliableSendStatus::Pending - } else { - CultNetRudpReliableSendStatus::Acknowledged - } - } - - fn non_expiring_reliable_receipt( - &self, - packets: &[CultNetRudpPacket], - ) -> Result { - if packets.is_empty() || packets.iter().any(|packet| !packet.reliable) { - return Err(anyhow!( - "RUDP acknowledgement receipts require a non-empty reliable packet set" - )); - } - Ok(CultNetRudpReliableSendReceipt { - session_scope: self.session_scope, - sequences: packets.iter().map(|packet| packet.sequence).collect(), - }) - } - pub fn last_received_at_ms(&self) -> Option { self.last_received_at_ms } - pub fn set_max_payload_bytes(&mut self, max_payload_bytes: Option) { - self.max_payload_bytes = max_payload_bytes; - } - - pub fn set_max_pending_fragment_sets( - &mut self, - max_pending_fragment_sets: usize, - ) -> Result<()> { - if max_pending_fragment_sets == 0 { - return Err(anyhow!( - "RUDP max_pending_fragment_sets must be greater than zero" - )); - } - self.max_pending_fragment_sets = max_pending_fragment_sets; - Ok(()) - } - pub fn reset_peer_state(&mut self) { - self.session_scope = Uuid::new_v4(); self.next_sequence = self.initial_sequence; - self.next_sequenced_by_channel.clear(); self.next_fragment_id = 1; self.connected = false; self.last_received_at_ms = None; self.highest_received_sequence = None; self.received_sequences.clear(); - self.latest_sequenced_by_channel.clear(); self.pending_reliable.clear(); - self.queued_reliable.clear(); self.ordered_next_sequence_by_channel.clear(); self.ordered_buffers.clear(); self.fragment_buffers.clear(); @@ -361,7 +203,7 @@ impl CultNetRudpSession { true, false, ); - self.track_reliable(packet.clone(), now_ms, None); + self.track_reliable(packet.clone(), now_ms); Ok(packet) } @@ -381,7 +223,6 @@ impl CultNetRudpSession { self.ensure_reliable_capacity(1)?; self.remember_received(packet.sequence); - self.last_received_at_ms = Some(now_ms); self.connected = true; let response = self.create_packet( CultNetRudpPacketType::Accept, @@ -391,7 +232,7 @@ impl CultNetRudpSession { true, false, ); - self.track_reliable(response.clone(), now_ms, None); + self.track_reliable(response.clone(), now_ms); Ok(response) } @@ -401,13 +242,6 @@ impl CultNetRudpSession { payload: Vec, options: CultNetRudpSendOptions, ) -> Result { - if options.reliable - && self.pending_reliable.len() >= CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS - { - return Err(anyhow!( - "RUDP reliable send window is full; receive acknowledgements before sending" - )); - } self.send_many(channel_id, payload, options, None)? .into_iter() .next() @@ -426,16 +260,6 @@ impl CultNetRudpSession { "Cannot send RUDP data before the session is connected" )); } - if options.ordered && !options.reliable { - return Err(anyhow!("RUDP ordered delivery requires reliability")); - } - self.require_payload_size(payload.len())?; - // Reclaim deadline-passed sends before measuring window capacity, so an - // expiring stream cannot be refused for a backlog it no longer owns. - self.purge_expired_reliable(options.now_ms); - let expires_at_ms = options - .reliable_expire_after_ms - .map(|ttl| options.now_ms.saturating_add(ttl)); if let Some(max_fragment_bytes) = max_fragment_bytes { if max_fragment_bytes == 0 { @@ -463,13 +287,12 @@ impl CultNetRudpSession { index as u16, fragment_count as u16, ); + if packet.reliable { + self.track_reliable(packet.clone(), options.now_ms); + } packets.push(packet); } - return Ok(if options.reliable { - self.admit_reliable_packets(packets, options.now_ms, expires_at_ms) - } else { - packets - }); + return Ok(packets); } } @@ -483,7 +306,7 @@ impl CultNetRudpSession { options.sequenced, ); if packet.reliable { - return Ok(self.admit_reliable_packets(vec![packet], options.now_ms, expires_at_ms)); + self.track_reliable(packet.clone(), options.now_ms); } Ok(vec![packet]) } @@ -495,8 +318,6 @@ impl CultNetRudpSession { ) -> Result { self.require_connection(packet)?; self.apply_acknowledgements(packet); - self.purge_expired_reliable(now_ms); - let ready_to_send = self.promote_queued_reliable(now_ms); self.last_received_at_ms = Some(now_ms); let expected_sequence_if_uninitialized = self .highest_received_sequence @@ -508,7 +329,6 @@ impl CultNetRudpSession { self.connected = true; return Ok(CultNetRudpReceiveResult { delivered: Vec::new(), - ready_to_send, reply: None, pong: false, pong_payload: Vec::new(), @@ -518,9 +338,9 @@ impl CultNetRudpSession { } if packet.packet_type == CultNetRudpPacketType::Ping { + self.remember_received(packet.sequence); return Ok(CultNetRudpReceiveResult { delivered: Vec::new(), - ready_to_send, reply: Some(self.create_packet( CultNetRudpPacketType::Pong, "control", @@ -539,9 +359,11 @@ impl CultNetRudpSession { if packet.packet_type == CultNetRudpPacketType::Ack || packet.packet_type == CultNetRudpPacketType::Pong { + if packet.packet_type == CultNetRudpPacketType::Pong { + self.remember_received(packet.sequence); + } return Ok(CultNetRudpReceiveResult { delivered: Vec::new(), - ready_to_send, reply: None, pong: packet.packet_type == CultNetRudpPacketType::Pong, pong_payload: if packet.packet_type == CultNetRudpPacketType::Pong { @@ -555,10 +377,10 @@ impl CultNetRudpSession { } if packet.packet_type == CultNetRudpPacketType::Disconnect { + self.remember_received(packet.sequence); self.connected = false; return Ok(CultNetRudpReceiveResult { delivered: Vec::new(), - ready_to_send, reply: None, pong: false, pong_payload: Vec::new(), @@ -570,7 +392,6 @@ impl CultNetRudpSession { if packet.packet_type != CultNetRudpPacketType::Data { return Ok(CultNetRudpReceiveResult { delivered: Vec::new(), - ready_to_send, reply: None, pong: false, pong_payload: Vec::new(), @@ -579,38 +400,11 @@ impl CultNetRudpSession { }); } - let far_ahead = packet.reliable - && self.highest_received_sequence.is_some_and(|highest| { - packet.sequence > highest - && packet.sequence - highest > RUDP_RECEIVE_AHEAD_WINDOW - }); - if far_ahead { - // Refuse without remembering. Recording it would move the highest - // received sequence out of reach of everything still in flight. - return Ok(CultNetRudpReceiveResult { - delivered: Vec::new(), - ready_to_send, - reply: None, - pong: false, - pong_payload: Vec::new(), - disconnected: false, - disconnect_reason: Vec::new(), - }); - } - - let duplicate = packet.reliable - && (self.received_sequences.contains(&packet.sequence) - || self.highest_received_sequence.is_some_and(|highest| { - packet.sequence < highest - && highest - packet.sequence >= RUDP_RECEIVED_SEQUENCE_WINDOW as u32 - })); - if packet.reliable { - self.remember_received(packet.sequence); - } + let duplicate = self.received_sequences.contains(&packet.sequence); + self.remember_received(packet.sequence); if duplicate { return Ok(CultNetRudpReceiveResult { delivered: Vec::new(), - ready_to_send, reply: None, pong: false, pong_payload: Vec::new(), @@ -622,7 +416,6 @@ impl CultNetRudpSession { let Some((frame, ordered, next_sequence)) = self.reassemble(packet)? else { return Ok(CultNetRudpReceiveResult { delivered: Vec::new(), - ready_to_send, reply: None, pong: false, pong_payload: Vec::new(), @@ -631,33 +424,12 @@ impl CultNetRudpSession { }); }; let delivered = if ordered { - self.deliver_ordered(frame, next_sequence, expected_sequence_if_uninitialized)? - } else if packet.sequenced { - if !self - .latest_sequenced_by_channel - .contains_key(&frame.channel_id) - && self.latest_sequenced_by_channel.len() >= RUDP_MAX_TRACKED_CHANNELS - { - return Err(anyhow!("RUDP session has too many sequenced channels")); - } - let latest = self - .latest_sequenced_by_channel - .get(&frame.channel_id) - .copied(); - let newest_sequence = next_sequence.saturating_sub(1); - if latest.is_some_and(|latest| newest_sequence <= latest) { - Vec::new() - } else { - self.latest_sequenced_by_channel - .insert(frame.channel_id.clone(), newest_sequence); - vec![frame] - } + self.deliver_ordered(frame, next_sequence, expected_sequence_if_uninitialized) } else { vec![frame] }; Ok(CultNetRudpReceiveResult { delivered, - ready_to_send, reply: None, pong: false, pong_payload: Vec::new(), @@ -685,33 +457,6 @@ impl CultNetRudpSession { } } - pub fn create_ack_for(&self, sequence: u32) -> CultNetRudpPacket { - CultNetRudpPacket { - packet_type: CultNetRudpPacketType::Ack, - connection_id: self.connection_id, - sequence: 0, - ack: sequence, - ack_mask: 0, - channel_id: "control".to_string(), - reliable: false, - ordered: false, - sequenced: false, - fragment_id: 0, - fragment_index: 0, - fragment_count: 0, - payload: Vec::new(), - } - } - - pub fn create_ack_for_received(&mut self, sequence: u32) -> CultNetRudpPacket { - let (ack, _) = self.ack_state(); - if ack >= sequence && ack - sequence <= 32 { - self.create_ack() - } else { - self.create_ack_for(sequence) - } - } - pub fn create_ping(&mut self, payload: Vec) -> CultNetRudpPacket { self.create_packet( CultNetRudpPacketType::Ping, @@ -796,23 +541,11 @@ impl CultNetRudpSession { fragment_index: u16, fragment_count: u16, ) -> CultNetRudpPacket { - let sequence = if reliable { - let sequence = self.next_sequence; - self.next_sequence = self - .next_sequence - .saturating_add(1); - sequence - } else if sequenced { - let next = self - .next_sequenced_by_channel - .entry(channel_id.to_string()) - .or_insert(1); - let sequence = *next; - *next = next.saturating_add(1); - sequence - } else { - 0 - }; + let sequence = self.next_sequence; + self.next_sequence = self + .next_sequence + .checked_add(1) + .expect("sequence overflow"); let (ack, ack_mask) = self.ack_state(); CultNetRudpPacket { packet_type, @@ -831,88 +564,25 @@ impl CultNetRudpSession { } } - fn track_reliable( - &mut self, - packet: CultNetRudpPacket, - now_ms: u64, - expires_at_ms: Option, - ) { + fn track_reliable(&mut self, packet: CultNetRudpPacket, now_ms: u64) { self.pending_reliable.insert( packet.sequence, PendingReliablePacket { packet, last_sent_at_ms: now_ms, - expires_at_ms, }, ); } - /// Drop pending and queued reliable packets past their deadline. The - /// deadline is fixed when the send is admitted, so time spent waiting in - /// the backlog counts against it exactly as time spent awaiting an ACK does. - fn purge_expired_reliable(&mut self, now_ms: u64) { - let before = self.pending_reliable.len() + self.queued_reliable.len(); - self.pending_reliable - .retain(|_, pending| pending.expires_at_ms.is_none_or(|at| now_ms <= at)); - self.queued_reliable - .retain(|(_, expires_at_ms)| expires_at_ms.is_none_or(|at| now_ms <= at)); - let after = self.pending_reliable.len() + self.queued_reliable.len(); - self.reliable_packets_expired = self - .reliable_packets_expired - .saturating_add((before - after) as u64); - } - - fn admit_reliable_packets( - &mut self, - packets: Vec, - now_ms: u64, - expires_at_ms: Option, - ) -> Vec { - let available = - CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS.saturating_sub(self.pending_reliable.len()); - let mut ready = Vec::with_capacity(available.min(packets.len())); - for packet in packets { - if ready.len() < available { - self.track_reliable(packet.clone(), now_ms, expires_at_ms); - ready.push(packet); - } else { - self.queued_reliable.push_back((packet, expires_at_ms)); - } - } - ready - } - - fn promote_queued_reliable(&mut self, now_ms: u64) -> Vec { - let available = - CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS.saturating_sub(self.pending_reliable.len()); - let mut ready = Vec::with_capacity(available.min(self.queued_reliable.len())); - for _ in 0..available { - let Some((packet, expires_at_ms)) = self.queued_reliable.pop_front() else { - break; - }; - self.track_reliable(packet.clone(), now_ms, expires_at_ms); - ready.push(packet); - } - ready - } - fn ensure_reliable_capacity(&self, packet_count: usize) -> Result<()> { if packet_count == 0 { return Ok(()); } if let Some(limit) = self.max_pending_reliable_packets { - if self.outstanding_reliable_packet_count() + packet_count > limit { + if self.pending_reliable.len() + packet_count > limit { return Err(anyhow!("RUDP reliable send queue is full")); } } - // Refuse before allocation rather than at it. A session that runs out of - // reliable sequence space must report it, not abort the process. - let required = u32::try_from(packet_count) - .ok() - .filter(|required| self.next_sequence.checked_add(*required).is_some()); - if required.is_none() { - return Err(anyhow!("RUDP reliable sequence space is exhausted")); - } Ok(()) } @@ -933,13 +603,6 @@ impl CultNetRudpSession { { self.highest_received_sequence = Some(sequence); } - if self.received_sequences.len() > RUDP_RECEIVED_SEQUENCE_WINDOW { - let keep_from = self - .highest_received_sequence - .unwrap_or(sequence) - .saturating_sub(RUDP_RECEIVED_SEQUENCE_WINDOW as u32 - 1); - self.received_sequences = self.received_sequences.split_off(&keep_from); - } } fn ack_state(&self) -> (u32, u32) { @@ -958,7 +621,6 @@ impl CultNetRudpSession { packet: &CultNetRudpPacket, ) -> Result> { if packet.fragment_count == 0 { - self.require_payload_size(packet.payload.len())?; return Ok(Some(( CultNetRudpDeliveredFrame { channel_id: packet.channel_id.clone(), @@ -981,39 +643,6 @@ impl CultNetRudpSession { } let key = (packet.channel_id.clone(), packet.fragment_id); - if !self.fragment_buffers.contains_key(&key) - && self.fragment_buffers.len() >= self.max_pending_fragment_sets - { - // A set that is still incomplete when the bound is reached has - // almost certainly lost a fragment; its siblings were delivered - // long ago. Drop the one untouched longest so the session keeps - // receiving. Erroring here made any lossy fragmenting channel a - // countdown to a dead receiver. - let stalest = self - .fragment_buffers - .iter() - .min_by_key(|(_, buffer)| buffer.last_touched) - .map(|(key, _)| key.clone()) - .expect("bound reached implies a pending set exists"); - self.fragment_buffers.remove(&stalest); - self.fragment_sets_evicted += 1; - } - let buffered_bytes = self - .fragment_buffers - .get(&key) - .map(|buffer| { - buffer - .payloads - .iter() - .filter(|(index, _)| **index != packet.fragment_index) - .map(|(_, payload)| payload.len()) - .sum::() - }) - .unwrap_or(0); - if let Err(error) = self.require_payload_size(buffered_bytes + packet.payload.len()) { - self.fragment_buffers.remove(&key); - return Err(error); - } let buffer = self .fragment_buffers .entry(key.clone()) @@ -1023,15 +652,12 @@ impl CultNetRudpSession { fragment_count: packet.fragment_count, payloads: BTreeMap::new(), sequences: BTreeMap::new(), - last_touched: 0, }); if buffer.fragment_count != packet.fragment_count || buffer.ordered != packet.ordered { return Err(anyhow!( "RUDP fragment metadata changed within a fragment set" )); } - self.fragment_touches += 1; - buffer.last_touched = self.fragment_touches; buffer .payloads .insert(packet.fragment_index, packet.payload.clone()); @@ -1068,40 +694,13 @@ impl CultNetRudpSession { ))) } - fn require_payload_size(&self, payload_bytes: usize) -> Result<()> { - if self - .max_payload_bytes - .is_some_and(|max_payload_bytes| payload_bytes > max_payload_bytes) - { - return Err(anyhow!("RUDP payload exceeds max_payload_bytes")); - } - Ok(()) - } - - fn ordered_buffered_frames(&self) -> usize { - self.ordered_buffers.values().map(BTreeMap::len).sum() - } - - fn ordered_buffered_bytes(&self) -> usize { - self.ordered_buffers - .values() - .flat_map(BTreeMap::values) - .map(|pending| pending.frame.payload.len()) - .sum() - } - fn deliver_ordered( &mut self, frame: CultNetRudpDeliveredFrame, next_sequence_after_frame: u32, expected_sequence_if_uninitialized: u32, - ) -> Result> { + ) -> Vec { let channel_id = frame.channel_id.clone(); - if !self.ordered_next_sequence_by_channel.contains_key(&channel_id) - && self.ordered_next_sequence_by_channel.len() >= RUDP_MAX_TRACKED_CHANNELS - { - return Err(anyhow!("RUDP session has too many ordered channels")); - } let mut next = if let Some(next) = self .ordered_next_sequence_by_channel .get(&channel_id) @@ -1129,16 +728,10 @@ impl CultNetRudpSession { } if frame.sequence < next { - return Ok(Vec::new()); + return Vec::new(); } if frame.sequence > next { - if self.ordered_buffered_frames() >= RUDP_MAX_ORDERED_BUFFERED_FRAMES - || self.ordered_buffered_bytes() + frame.payload.len() - > RUDP_MAX_ORDERED_BUFFERED_BYTES - { - return Err(anyhow!("RUDP ordered hold buffer is full")); - } self.ordered_buffers.entry(channel_id).or_default().insert( frame.sequence, PendingOrderedFrame { @@ -1146,14 +739,14 @@ impl CultNetRudpSession { next_sequence: next_sequence_after_frame, }, ); - return Ok(Vec::new()); + return Vec::new(); } self.ordered_next_sequence_by_channel .insert(channel_id.clone(), next_sequence_after_frame); let mut delivered = vec![frame]; delivered.extend(self.drain_ordered(&channel_id)); - Ok(delivered) + delivered } fn drain_ordered(&mut self, channel_id: &str) -> Vec { @@ -1202,9 +795,7 @@ impl CultNetRudpSession { fn allocate_fragment_id(&mut self) -> u16 { let fragment_id = self.next_fragment_id; - // Wrap, never saturate: `saturating_add` parked every set after the - // 65,535th on one shared id, and the reassembler stitched them together. - self.next_fragment_id = self.next_fragment_id.wrapping_add(1); + self.next_fragment_id = self.next_fragment_id.saturating_add(1); if self.next_fragment_id == 0 { self.next_fragment_id = 1; } @@ -1242,9 +833,6 @@ pub struct CultNetRudpSocketTransportOptions { pub max_fragment_bytes: Option, pub max_pending_reliable_packets: Option, pub reconnect_policy: Option, - pub media_reliable_expire_after_ms: Option, - /// `None` means the historical default, `Reliable`. - pub media_delivery: Option, } impl CultNetRudpSocketTransportOptions { @@ -1255,8 +843,6 @@ impl CultNetRudpSocketTransportOptions { connection_id: u32, ) -> Self { Self { - media_reliable_expire_after_ms: Some(DEFAULT_MEDIA_RELIABLE_EXPIRE_AFTER_MS), - media_delivery: None, runtime_id: runtime_id.into(), socket, mode: CultNetRudpSocketMode::Client, @@ -1274,8 +860,6 @@ impl CultNetRudpSocketTransportOptions { pub fn server(runtime_id: impl Into, socket: UdpSocket, connection_id: u32) -> Self { Self { - media_reliable_expire_after_ms: Some(DEFAULT_MEDIA_RELIABLE_EXPIRE_AFTER_MS), - media_delivery: None, runtime_id: runtime_id.into(), socket, mode: CultNetRudpSocketMode::Server, @@ -1305,439 +889,16 @@ pub struct CultNetRudpSocketTransportConnection { pong_payloads: VecDeque>, } -pub struct CultNetRudpServerHubOptions { - pub runtime_id: String, - pub socket: UdpSocket, - pub connection_id: u32, - pub initial_sequence: u32, - pub resend_delay_ms: u64, - pub transport_id: Option, - pub max_payload_bytes: Option, - pub max_fragment_bytes: Option, - pub max_pending_reliable_packets: Option, - pub max_peers: usize, - pub media_reliable_expire_after_ms: Option, - /// `None` means the historical default, `Reliable`. - pub media_delivery: Option, -} - -impl CultNetRudpServerHubOptions { - pub fn new(runtime_id: impl Into, socket: UdpSocket, connection_id: u32) -> Self { - Self { - media_reliable_expire_after_ms: Some(DEFAULT_MEDIA_RELIABLE_EXPIRE_AFTER_MS), - media_delivery: None, - runtime_id: runtime_id.into(), - socket, - connection_id, - initial_sequence: 1, - resend_delay_ms: 250, - transport_id: None, - max_payload_bytes: None, - max_fragment_bytes: None, - max_pending_reliable_packets: None, - max_peers: 256, - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CultNetRudpServerSessionContext { - pub remote_addr: SocketAddr, - pub connection_id: u32, - /// Server-minted generation used to fence work from an older session at - /// the same UDP endpoint. - pub session_generation: u64, - /// Exact bytes supplied by the peer's most recently accepted Connect packet. - /// CultNet does not interpret these bytes; higher layers may use them as - /// transport evidence when making their own authorization decision. - pub connect_payload: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum CultNetRudpServerEvent { - Connected { - session: CultNetRudpServerSessionContext, - }, - Frame { - session: CultNetRudpServerSessionContext, - frame: CultNetTransportFrame, - }, - Pong { - session: CultNetRudpServerSessionContext, - payload: Vec, - }, - Disconnected { - session: CultNetRudpServerSessionContext, - reason: Vec, - }, -} - -struct CultNetRudpServerPeer { - session: CultNetRudpSession, - context: CultNetRudpServerSessionContext, -} - -/// One UDP listener with an independent reliable session for every remote peer. -/// -/// The hub owns packet/session mechanics only. Registration, leases, commands, -/// and all other application policy belong to the service using the hub. -pub struct CultNetRudpServerHub { - socket: UdpSocket, - connection_id: u32, - initial_sequence: u32, - resend_delay_ms: u64, - max_pending_reliable_packets: Option, - max_payload_bytes: usize, - max_fragment_bytes: Option, - max_peers: usize, - next_session_generation: u64, - peers: BTreeMap, - pending_events: VecDeque, - pub profile: CultNetTransportProfile, - stats: CultNetTransportStats, -} - -impl CultNetRudpServerHub { - pub fn new(options: CultNetRudpServerHubOptions) -> Result { - let local_addr = options.socket.local_addr()?; - if options.max_peers == 0 { - return Err(anyhow!( - "RUDP server hub max_peers must be greater than zero" - )); - } - let max_payload_bytes = options - .max_payload_bytes - .or(Some(CULTNET_RUDP_DEFAULT_MAX_PAYLOAD_BYTES as u32)); - let profile = create_rudp_transport_profile( - options.runtime_id, - RudpTransportProfileOptions { - media_reliable_expire_after_ms: options.media_reliable_expire_after_ms, - media_delivery: options.media_delivery, - transport_id: options.transport_id, - host: Some(local_addr.ip().to_string()), - port: Some(local_addr.port()), - max_payload_bytes, - max_fragment_bytes: options.max_fragment_bytes, - max_pending_reliable_packets: options.max_pending_reliable_packets, - reconnect_policy: None, - }, - ); - Ok(Self { - socket: options.socket, - connection_id: options.connection_id, - initial_sequence: options.initial_sequence, - resend_delay_ms: options.resend_delay_ms, - max_pending_reliable_packets: options - .max_pending_reliable_packets - .map(|value| value as usize), - max_payload_bytes: max_payload_bytes.expect("RUDP hub has an effective payload limit") - as usize, - max_fragment_bytes: options.max_fragment_bytes.map(|value| value as usize), - max_peers: options.max_peers, - next_session_generation: 1, - peers: BTreeMap::new(), - pending_events: VecDeque::new(), - profile, - stats: CultNetTransportStats::default(), - }) - } - - pub fn local_addr(&self) -> Result { - Ok(self.socket.local_addr()?) - } - - pub fn stats(&self) -> CultNetTransportStats { - let mut stats = self.stats.clone(); - stats.reliable_packets_expired = self - .peers - .values() - .map(|peer| peer.session.reliable_packets_expired()) - .fold(0_u64, |total, expired| total.saturating_add(expired)); - stats.fragment_sets_evicted = self - .peers - .values() - .map(|peer| peer.session.fragment_sets_evicted()) - .fold(0_u64, |total, evicted| total.saturating_add(evicted)); - stats - } - - pub fn sessions(&self) -> Vec { - self.peers - .values() - .map(|peer| peer.context.clone()) - .collect() - } - - pub fn session(&self, remote_addr: SocketAddr) -> Option<&CultNetRudpServerSessionContext> { - self.peers.get(&remote_addr).map(|peer| &peer.context) - } - - pub fn send( - &mut self, - session: &CultNetRudpServerSessionContext, - channel_id: &str, - payload: Vec, - ) -> Result<()> { - let remote_addr = session.remote_addr; - let packets = { - let peer = self - .peers - .get_mut(&remote_addr) - .ok_or_else(|| anyhow!("Unknown RUDP server peer {remote_addr}"))?; - if peer.context.session_generation != session.session_generation { - return Err(anyhow!( - "RUDP server session generation {} is no longer active", - session.session_generation - )); - } - peer.session.send_many( - channel_id, - payload, - channel_send_options( - channel_id, - now_ms(), - channel_reliable_expire_after_ms(&self.profile, channel_id), - channel_delivery(&self.profile, channel_id), - ), - self.max_fragment_bytes, - )? - }; - for packet in packets { - self.send_packet(remote_addr, &packet)?; - } - self.stats.frames_sent += 1; - Ok(()) - } - - pub fn send_schema_message( - &mut self, - session: &CultNetRudpServerSessionContext, - message: &CultNetMessage, - ) -> Result<()> { - let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?; - self.send(session, "schema", payload) - } - - pub fn disconnect( - &mut self, - session: &CultNetRudpServerSessionContext, - reason: Vec, - ) -> Result { - let Some(peer) = self.peers.get(&session.remote_addr) else { - return Ok(false); - }; - if peer.context.session_generation != session.session_generation { - return Ok(false); - } - let mut peer = self - .peers - .remove(&session.remote_addr) - .expect("validated RUDP peer exists"); - let packet = peer.session.create_disconnect(reason); - self.send_packet(session.remote_addr, &packet)?; - Ok(true) - } - - /// The next event, or `None` only when nothing is waiting on the socket. - /// - /// Packets that carry no event for the caller — acknowledgements, pings, - /// a repeated Connect, a fragment that completes nothing yet — are - /// consumed here and reading continues. Returning `None` after one of - /// those made a caller that polls once per send fall behind its own - /// peers' acknowledgements, and a reliable channel then resent everything. - pub fn receive_event_once(&mut self) -> Result> { - loop { - if let Some(event) = self.pending_events.pop_front() { - return Ok(Some(event)); - } - if !self.receive_packet_once()? { - return Ok(None); - } - } - } - - /// Reads one datagram and applies it. `false` when the socket is empty. - fn receive_packet_once(&mut self) -> Result { - let mut wire = vec![0_u8; 65_535]; - let (received, remote_addr) = match self.socket.recv_from(&mut wire) { - Ok(value) => value, - Err(error) - if error.kind() == ErrorKind::WouldBlock - || error.kind() == ErrorKind::TimedOut - || error.kind() == ErrorKind::ConnectionReset => - { - return Ok(false); - } - Err(error) => return Err(error.into()), - }; - wire.truncate(received); - self.stats.bytes_received += received as u64; - let packet = decode_rudp_packet(&wire)?; - if packet.connection_id != self.connection_id { - return Ok(true); - } - - if packet.packet_type == CultNetRudpPacketType::Connect { - if let Some(peer) = self.peers.get_mut(&remote_addr) - && peer.context.connect_payload == packet.payload - { - let _ = peer.session.receive(&packet, now_ms())?; - let reply = peer - .session - .pending_accept_for_resend(now_ms()) - .unwrap_or_else(|| peer.session.create_ack()); - self.send_packet(remote_addr, &reply)?; - return Ok(true); - } - if !self.peers.contains_key(&remote_addr) && self.peers.len() >= self.max_peers { - return Err(anyhow!("RUDP server hub peer limit reached")); - } - let mut session = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: self.connection_id, - initial_sequence: self.initial_sequence, - resend_delay_ms: self.resend_delay_ms, - max_pending_reliable_packets: self.max_pending_reliable_packets, - }); - session.set_max_payload_bytes(Some(self.max_payload_bytes)); - let generation = self.next_session_generation; - self.next_session_generation = self - .next_session_generation - .checked_add(1) - .ok_or_else(|| anyhow!("RUDP server session generation exhausted"))?; - let context = CultNetRudpServerSessionContext { - remote_addr, - connection_id: packet.connection_id, - session_generation: generation, - connect_payload: packet.payload.clone(), - }; - let accept = session.accept_connect(&packet, now_ms(), Vec::new())?; - self.send_packet(remote_addr, &accept)?; - let replaced = self.peers.insert( - remote_addr, - CultNetRudpServerPeer { - session, - context: context.clone(), - }, - ); - if let Some(replaced) = replaced { - self.pending_events - .push_back(CultNetRudpServerEvent::Disconnected { - session: replaced.context, - reason: b"replaced by a new Connect generation".to_vec(), - }); - } - self.pending_events - .push_back(CultNetRudpServerEvent::Connected { session: context }); - return Ok(true); - } - - let Some(peer) = self.peers.get_mut(&remote_addr) else { - return Ok(true); - }; - let result = peer.session.receive(&packet, now_ms())?; - let context = peer.context.clone(); - let ack = if packet.reliable { - Some(peer.session.create_ack_for_received(packet.sequence)) - } else { - None - }; - if let Some(reply) = result.reply { - self.send_packet(remote_addr, &reply)?; - } - for ready in result.ready_to_send { - self.send_packet(remote_addr, &ready)?; - } - if let Some(ack) = ack { - self.send_packet(remote_addr, &ack)?; - } - if result.pong { - self.pending_events.push_back(CultNetRudpServerEvent::Pong { - session: context.clone(), - payload: result.pong_payload, - }); - } - for delivered in result.delivered { - self.stats.frames_received += 1; - self.pending_events - .push_back(CultNetRudpServerEvent::Frame { - session: context.clone(), - frame: CultNetTransportFrame { - channel_id: delivered.channel_id, - payload: delivered.payload, - }, - }); - } - if result.disconnected { - self.pending_events - .push_back(CultNetRudpServerEvent::Disconnected { - session: context, - reason: result.disconnect_reason, - }); - self.peers.remove(&remote_addr); - } - Ok(true) - } - - pub fn poll_resends(&mut self) -> Result<()> { - let now = now_ms(); - let mut packets = Vec::new(); - for (remote_addr, peer) in &mut self.peers { - packets.extend( - peer.session - .due_resends(now) - .into_iter() - .map(|packet| (*remote_addr, packet)), - ); - } - for (remote_addr, packet) in packets { - self.send_packet(remote_addr, &packet)?; - } - Ok(()) - } - - pub fn remove_timed_out_sessions( - &mut self, - timeout_ms: u64, - ) -> Vec { - let now = now_ms(); - let timed_out = self - .peers - .iter_mut() - .filter_map(|(remote_addr, peer)| { - peer.session - .check_timeout(now, timeout_ms) - .then_some((*remote_addr, peer.context.clone())) - }) - .collect::>(); - for (remote_addr, _) in &timed_out { - self.peers.remove(remote_addr); - } - timed_out.into_iter().map(|(_, context)| context).collect() - } - - fn send_packet(&mut self, remote_addr: SocketAddr, packet: &CultNetRudpPacket) -> Result<()> { - let wire = encode_rudp_packet(packet)?; - let sent = self.socket.send_to(&wire, remote_addr)?; - self.stats.bytes_sent += sent as u64; - Ok(()) - } -} - impl CultNetRudpSocketTransportConnection { pub fn new(options: CultNetRudpSocketTransportOptions) -> Result { let local_addr = options.socket.local_addr()?; - let max_payload_bytes = options - .max_payload_bytes - .or(Some(CULTNET_RUDP_DEFAULT_MAX_PAYLOAD_BYTES as u32)); let profile = create_rudp_transport_profile( options.runtime_id, RudpTransportProfileOptions { - media_reliable_expire_after_ms: options.media_reliable_expire_after_ms, - media_delivery: options.media_delivery, transport_id: options.transport_id, host: Some(local_addr.ip().to_string()), port: Some(local_addr.port()), - max_payload_bytes, + max_payload_bytes: options.max_payload_bytes, max_fragment_bytes: options.max_fragment_bytes, max_pending_reliable_packets: options.max_pending_reliable_packets, reconnect_policy: options.reconnect_policy, @@ -1746,16 +907,14 @@ impl CultNetRudpSocketTransportConnection { let max_pending_reliable_packets = options .max_pending_reliable_packets .map(|value| value as usize); - let mut session = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: options.connection_id, - initial_sequence: options.initial_sequence, - resend_delay_ms: options.resend_delay_ms, - max_pending_reliable_packets, - }); - session.set_max_payload_bytes(max_payload_bytes.map(|value| value as usize)); Ok(Self { socket: options.socket, - session, + session: CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: options.connection_id, + initial_sequence: options.initial_sequence, + resend_delay_ms: options.resend_delay_ms, + max_pending_reliable_packets, + }), mode: options.mode, remote_addr: options.remote_addr, profile, @@ -1772,10 +931,7 @@ impl CultNetRudpSocketTransportConnection { } pub fn stats(&self) -> CultNetTransportStats { - let mut stats = self.stats.clone(); - stats.reliable_packets_expired = self.session.reliable_packets_expired(); - stats.fragment_sets_evicted = self.session.fragment_sets_evicted(); - stats + self.stats.clone() } pub fn disconnect_reason(&self) -> Option<&[u8]> { @@ -1797,66 +953,14 @@ impl CultNetRudpSocketTransportConnection { } pub fn send(&mut self, channel_id: &str, payload: Vec) -> Result<()> { - let options = - channel_send_options( - channel_id, - now_ms(), - channel_reliable_expire_after_ms(&self.profile, channel_id), - channel_delivery(&self.profile, channel_id), - ); + let options = channel_send_options(channel_id, now_ms()); let packets = self.session .send_many(channel_id, payload, options, self.max_fragment_bytes)?; - self.send_data_packets(&packets)?; - self.stats.frames_sent += 1; - Ok(()) - } - - /// Sends one frame on a reliable channel whose packets cannot expire and - /// returns the transport receipt used to observe its exact acknowledgement. - pub fn send_reliable( - &mut self, - channel_id: &str, - payload: Vec, - ) -> Result { - let options = - channel_send_options( - channel_id, - now_ms(), - channel_reliable_expire_after_ms(&self.profile, channel_id), - channel_delivery(&self.profile, channel_id), - ); - // This lineage has no reliable-expiry concept, so every reliable channel is - // already non-expiring. Restore the expiry half of this guard if one is added. - if !options.reliable { - return Err(anyhow!( - "RUDP acknowledgement receipts require a non-expiring reliable channel" - )); + for packet in packets { + self.send_packet(&packet)?; } - let packets = - self.session - .send_many(channel_id, payload, options, self.max_fragment_bytes)?; - self.send_data_packets(&packets)?; - let receipt = self.session.non_expiring_reliable_receipt(&packets)?; self.stats.frames_sent += 1; - Ok(receipt) - } - - pub fn reliable_send_status( - &self, - receipt: &CultNetRudpReliableSendReceipt, - ) -> CultNetRudpReliableSendStatus { - self.session.reliable_send_status(receipt) - } - - fn send_data_packets(&mut self, packets: &[CultNetRudpPacket]) -> Result<()> { - let fragmented = packets.len() > 1; - for (index, packet) in packets.iter().enumerate() { - self.send_packet(packet)?; - if fragmented && (index + 1) % 16 == 0 { - thread::sleep(Duration::from_millis(1)); - } - } Ok(()) } @@ -1895,75 +999,18 @@ impl CultNetRudpSocketTransportConnection { self.session.check_timeout(now_ms(), timeout_ms) } - pub fn pending_reliable_packet_count(&self) -> usize { - self.session.pending_reliable_sequences().len() - } - - pub fn outstanding_reliable_packet_count(&self) -> usize { - self.session.outstanding_reliable_packet_count() - } - - pub fn flush_reliable(&mut self, timeout: Duration) -> Result<()> { - let original_timeout = self.socket.read_timeout()?; - let poll_timeout = original_timeout - .map(|configured| configured.min(Duration::from_millis(10))) - .unwrap_or(Duration::from_millis(10)); - self.socket.set_read_timeout(Some(poll_timeout))?; - - let deadline = Instant::now() + timeout; - let mut preserved_frames = VecDeque::new(); - let result = (|| { - while self.outstanding_reliable_packet_count() > 0 { - if Instant::now() >= deadline { - return Err(anyhow!( - "RUDP reliable flush timed out with {} packets outstanding", - self.outstanding_reliable_packet_count() - )); - } - if let Some(frame) = self.receive_once()? { - preserved_frames.push_back(frame); - } - self.poll_resends()?; - } - Ok(()) - })(); - - preserved_frames.append(&mut self.delivered_frames); - self.delivered_frames = preserved_frames; - let restore = self.socket.set_read_timeout(original_timeout); - match (result, restore) { - (Err(error), _) => Err(error), - (Ok(()), Err(error)) => Err(error.into()), - (Ok(()), Ok(())) => Ok(()), - } - } - - /// The next delivered frame, or `None` only when nothing is waiting on - /// the socket. Acknowledgements, pongs and incomplete fragments are - /// consumed and reading continues; see the hub's `receive_event_once`. pub fn receive_once(&mut self) -> Result> { - loop { - if let Some(frame) = self.delivered_frames.pop_front() { - return Ok(Some(frame)); - } - if !self.receive_packet_once()? { - return Ok(None); - } + if let Some(frame) = self.delivered_frames.pop_front() { + return Ok(Some(frame)); } - } - /// Reads one datagram and applies it. `false` when the socket is empty - /// or the session has been disconnected. - fn receive_packet_once(&mut self) -> Result { let mut wire = vec![0_u8; 65_535]; let (received, remote_addr) = match self.socket.recv_from(&mut wire) { Ok(value) => value, Err(error) - if error.kind() == ErrorKind::WouldBlock - || error.kind() == ErrorKind::TimedOut - || error.kind() == ErrorKind::ConnectionReset => + if error.kind() == ErrorKind::WouldBlock || error.kind() == ErrorKind::TimedOut => { - return Ok(false); + return Ok(None); } Err(error) => return Err(error.into()), }; @@ -1978,14 +1025,14 @@ impl CultNetRudpSocketTransportConnection { { self.remote_addr = Some(remote_addr); } else { - return Ok(true); + return Ok(None); } } } else { if self.mode == CultNetRudpSocketMode::Server && packet.packet_type != CultNetRudpPacketType::Connect { - return Ok(true); + return Ok(None); } self.remote_addr = Some(remote_addr); } @@ -1995,22 +1042,19 @@ impl CultNetRudpSocketTransportConnection { self.session.reset_peer_state(); let accept = self.session.accept_connect(&packet, now_ms(), Vec::new())?; self.send_packet(&accept)?; - return Ok(true); + return Ok(None); } let result = self.session.receive(&packet, now_ms())?; if let Some(reply) = result.reply { self.send_packet(&reply)?; } - for ready in result.ready_to_send { - self.send_packet(&ready)?; - } if result.pong { self.pong_payloads.push_back(result.pong_payload); } if result.disconnected { self.disconnect_reason = Some(result.disconnect_reason); - return Ok(false); + return Ok(None); } for frame in result.delivered { @@ -2020,14 +1064,12 @@ impl CultNetRudpSocketTransportConnection { }); self.stats.frames_received += 1; } - // Only what the sender is waiting on is acknowledged. An unreliable - // frame carries no sequence, so an ACK for it tells the sender - // nothing and costs it one datagram to read for every one it sent. - if packet.reliable || packet.packet_type == CultNetRudpPacketType::Accept { - let ack = self.session.create_ack_for_received(packet.sequence); + let frame = self.delivered_frames.pop_front(); + if packet.packet_type == CultNetRudpPacketType::Accept || frame.is_some() { + let ack = self.session.create_ack(); self.send_packet(&ack)?; } - Ok(true) + Ok(frame) } pub fn poll_resends(&mut self) -> Result<()> { @@ -2151,9 +1193,6 @@ pub struct RudpTransportProfileOptions { pub max_fragment_bytes: Option, pub max_pending_reliable_packets: Option, pub reconnect_policy: Option, - pub media_reliable_expire_after_ms: Option, - /// `None` means the historical default, `Reliable`. - pub media_delivery: Option, } pub fn create_rudp_transport_profile( @@ -2184,7 +1223,6 @@ pub fn create_rudp_transport_profile( max_payload_bytes: options.max_payload_bytes, max_fragment_bytes: options.max_fragment_bytes, max_pending_reliable_packets: options.max_pending_reliable_packets, - reliable_expire_after_ms: None, }, CultNetTransportChannel { channel_id: "latest".to_string(), @@ -2193,7 +1231,6 @@ pub fn create_rudp_transport_profile( max_payload_bytes: options.max_payload_bytes, max_fragment_bytes: options.max_fragment_bytes, max_pending_reliable_packets: options.max_pending_reliable_packets, - reliable_expire_after_ms: None, }, CultNetTransportChannel { channel_id: "realtime".to_string(), @@ -2202,29 +1239,14 @@ pub fn create_rudp_transport_profile( max_payload_bytes: options.max_payload_bytes, max_fragment_bytes: options.max_fragment_bytes, max_pending_reliable_packets: options.max_pending_reliable_packets, - reliable_expire_after_ms: None, }, CultNetTransportChannel { channel_id: "media".to_string(), - delivery: options - .media_delivery - .unwrap_or(CultNetTransportDelivery::Reliable), - ordering: CultNetTransportOrdering::Unordered, - max_payload_bytes: options.max_payload_bytes, - max_fragment_bytes: options.max_fragment_bytes, - max_pending_reliable_packets: options.max_pending_reliable_packets, - reliable_expire_after_ms: options.media_reliable_expire_after_ms, - }, - // Audio beside lossy video on one session: reliable, but a packet - // older than the media expiry is not worth a retransmit. - CultNetTransportChannel { - channel_id: "audio".to_string(), delivery: CultNetTransportDelivery::Reliable, ordering: CultNetTransportOrdering::Unordered, max_payload_bytes: options.max_payload_bytes, max_fragment_bytes: options.max_fragment_bytes, max_pending_reliable_packets: options.max_pending_reliable_packets, - reliable_expire_after_ms: options.media_reliable_expire_after_ms, }, ], }], @@ -2333,77 +1355,31 @@ fn packet_type_to_code(packet_type: CultNetRudpPacketType) -> u8 { } } -/// The advertised profile owns each channel's expiry. Resolving it here keeps -/// one answer for what a channel does, rather than a runtime field that can -/// disagree with the profile the peer was shown. -fn channel_reliable_expire_after_ms( - profile: &CultNetTransportProfile, - channel_id: &str, -) -> Option { - profile - .transports - .iter() - .flat_map(|transport| transport.channels.iter()) - .find(|channel| channel.channel_id == channel_id) - .and_then(|channel| channel.reliable_expire_after_ms) -} - -/// The advertised profile owns each channel's delivery, for the same reason it -/// owns expiry: a send path that hardcodes reliability can disagree with the -/// profile the peer was shown, and then no one can say what the channel does. -/// -/// Falls back to `Reliable` for a channel the profile does not describe, which -/// is what every channel got before delivery was configurable. -fn channel_delivery( - profile: &CultNetTransportProfile, - channel_id: &str, -) -> CultNetTransportDelivery { - profile - .transports - .iter() - .flat_map(|transport| transport.channels.iter()) - .find(|channel| channel.channel_id == channel_id) - .map(|channel| channel.delivery) - .unwrap_or(CultNetTransportDelivery::Reliable) -} - -fn channel_send_options( - channel_id: &str, - now_ms: u64, - reliable_expire_after_ms: Option, - delivery: CultNetTransportDelivery, -) -> CultNetRudpSendOptions { +fn channel_send_options(channel_id: &str, now_ms: u64) -> CultNetRudpSendOptions { match channel_id { "schema" => CultNetRudpSendOptions { reliable: true, ordered: true, sequenced: false, now_ms, - reliable_expire_after_ms: None, }, "latest" => CultNetRudpSendOptions { reliable: false, ordered: false, sequenced: true, now_ms, - reliable_expire_after_ms: None, }, - // The one channel whose delivery a caller chooses. Media that has gone - // stale is worth dropping, not retransmitting: a reliable media channel - // under loss adds load exactly when the link has least to give. - "media" | "audio" => CultNetRudpSendOptions { - reliable: matches!(delivery, CultNetTransportDelivery::Reliable), + "media" => CultNetRudpSendOptions { + reliable: true, ordered: false, sequenced: false, now_ms, - reliable_expire_after_ms, }, _ => CultNetRudpSendOptions { reliable: false, ordered: false, sequenced: false, now_ms, - reliable_expire_after_ms: None, }, } } diff --git a/packages/cultnet-rs/src/runtime_authority_contracts.rs b/packages/cultnet-rs/src/runtime_authority_contracts.rs deleted file mode 100644 index 426be1fb..00000000 --- a/packages/cultnet-rs/src/runtime_authority_contracts.rs +++ /dev/null @@ -1,3309 +0,0 @@ -use anyhow::{Context, Result, bail, ensure}; -use cultcache_rs::DatabaseEntry; -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; -use rand::RngCore; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::io::{Read, Write}; -use zeroize::Zeroizing; - -pub const GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA: &str = "gamecult.runtime_presence_health.v2"; -pub const GAMECULT_RUNTIME_PRESENCE_HEALTH_SIGNING_PURPOSE: &str = - "gamecult.runtime_presence_health.v2"; -pub const GAMECULT_RUNTIME_ACTIVATION_PROOF_SIGNING_PURPOSE: &str = - "gamecult.runtime_presence.activation-proof.v1"; -pub const IDUNN_RUNTIME_ACTIVATION_SCHEMA: &str = "idunn.runtime_activation.v2"; -pub const IDUNN_RUNTIME_ACTIVATION_SIGNING_PURPOSE: &str = "idunn.runtime_activation.v2"; -pub const IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME: &str = "gamecult-idunn-runtime-activation-key"; -pub const IDUNN_PROCESS_WRITE_LEASE_SCHEMA: &str = "idunn.process_write_lease.v1"; -pub const IDUNN_EXPECTED_INCARNATION_SCHEMA: &str = "idunn.expected_incarnation.v2"; -pub const ODIN_RUNTIME_TOPOLOGY_CORRELATION_SCHEMA: &str = "odin.runtime_topology_correlation.v2"; -pub const ODIN_RUNTIME_TOPOLOGY_CORRELATION_SIGNING_PURPOSE: &str = - "odin.runtime_topology_correlation.v2"; - -const MAX_RUNTIME_CAPABILITIES: usize = 256; -const MAX_RUNTIME_DEPENDENCIES: usize = 256; -const MAX_TOPOLOGY_DISAGREEMENTS: usize = 256; -const IDUNN_RUNTIME_ACTIVATION_ID_DOMAIN: &[u8] = b"idunn.runtime-activation.id.v1\0"; -const IDUNN_RUNTIME_ACTIVATION_SIGNATURE_DOMAIN: &[u8] = b"idunn.runtime-activation.signature.v1\0"; - -/// One provider-owned capability claim inside a runtime-presence statement. -/// The enclosing vector is canonical only when these records are strictly -/// increasing by `(capability, schema, compatibility)`. Capacity describes -/// that one claim and does not create a second identity for the same claim. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct GameCultRuntimeCapability { - pub capability: String, - pub schema: String, - pub compatibility: String, - pub capacity: u32, -} - -impl GameCultRuntimeCapability { - fn identity(&self) -> (&str, &str, &str) { - (&self.capability, &self.schema, &self.compatibility) - } -} - -/// Idunn's admitted requirement for one provider-owned runtime capability. -/// The provider reports actual capacity in `GameCultRuntimeCapability`; this -/// record states only the minimum needed for this incarnation to be Ready. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct IdunnExpectedCapability { - pub capability: String, - pub schema: String, - pub compatibility: String, - pub minimum_capacity: u32, -} - -impl IdunnExpectedCapability { - fn identity(&self) -> (&str, &str, &str) { - (&self.capability, &self.schema, &self.compatibility) - } -} - -/// Dual-proved claim from one exact runtime launch. The stable provider key and -/// Idunn's activation-scoped ephemeral key both sign `canonical_proof_payload`: -/// the canonical positional encoding of this complete record with both proof -/// byte fields empty. Present authority requires a matching Idunn-published -/// current activation; neither proof can establish admission by itself. -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "gamecult.runtime_presence_health", - schema = "gamecult.runtime_presence_health.v2" -)] -pub struct GameCultRuntimePresenceHealthRecord { - #[cultcache(key = 0)] - pub schema_version: String, - #[cultcache(key = 1)] - pub target: String, - #[cultcache(key = 2)] - pub expected_projection_sha256: String, - #[cultcache(key = 3)] - pub plan_id: String, - #[cultcache(key = 4)] - pub incarnation_id: String, - #[cultcache(key = 5)] - pub sealed_release_id: String, - #[cultcache(key = 6)] - pub activation_witness_sha256: String, - #[cultcache(key = 7)] - pub state_schema_generation: Option, - #[cultcache(key = 8)] - pub state_contract_sha256: Option, - #[cultcache(key = 9)] - pub runtime_id: String, - #[cultcache(key = 10)] - pub runtime_instance_id: String, - #[cultcache(key = 11)] - pub bound_endpoint: Option, - #[cultcache(key = 12)] - pub capabilities: Vec, - #[cultcache(key = 13)] - pub health_contract: String, - #[cultcache(key = 14)] - pub state: String, - #[cultcache(key = 15)] - pub detail: String, - #[cultcache(key = 16)] - pub write_lease_sha256: Option, - #[cultcache(key = 17)] - pub signer_identity_id: String, - #[cultcache(key = 18)] - pub publisher_sequence: u64, - #[cultcache(key = 19)] - pub observed_at_unix_millis: u64, - #[cultcache(key = 20)] - pub signature_algorithm: String, - #[cultcache(key = 21, bytes)] - pub signature: Vec, - #[cultcache(key = 22)] - pub activation_signer_identity_id: String, - #[cultcache(key = 23, bytes)] - pub activation_signature: Vec, -} - -impl GameCultRuntimePresenceHealthRecord { - /// Return the one canonical payload covered by both the stable provider - /// signature and the activation-scoped proof. Both signature fields are - /// empty in these bytes, avoiding circular signatures while binding every - /// authority-bearing statement field and both signer identities. - pub fn canonical_proof_payload(&self) -> Result> { - self.validate_shape(true)?; - let mut unsigned = self.clone(); - unsigned.signature.clear(); - unsigned.activation_signature.clear(); - Ok(rmp_serde::to_vec(&unsigned)?) - } - - /// Digest the complete signed presence record for an Odin correlation - /// receipt. An unsigned construction value is deliberately not digestible. - pub fn canonical_sha256(&self) -> Result { - self.validate()?; - Ok(prefixed_sha256(&rmp_serde::to_vec(self)?)) - } - - pub fn validate(&self) -> Result<()> { - self.validate_shape(false) - } - - fn validate_shape(&self, allow_unsigned: bool) -> Result<()> { - if self.schema_version != GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA { - bail!("runtime presence schema is unsupported"); - } - validate_authority_identifier(&self.target, "target")?; - validate_required_sha256( - &self.expected_projection_sha256, - "expected projection sha256", - )?; - validate_required_sha256(&self.plan_id, "plan id")?; - validate_authority_identifier(&self.incarnation_id, "incarnation id")?; - validate_required_sha256(&self.sealed_release_id, "sealed release id")?; - validate_required_sha256(&self.activation_witness_sha256, "activation witness sha256")?; - match (&self.state_schema_generation, &self.state_contract_sha256) { - (Some(generation), Some(contract_sha256)) => { - validate_authority_identifier(generation, "state schema generation")?; - validate_required_sha256(contract_sha256, "state contract sha256")?; - } - (None, None) => {} - _ => bail!("runtime presence state lineage is partial"), - } - validate_authority_identifier(&self.runtime_id, "runtime id")?; - validate_required_sha256(&self.runtime_instance_id, "runtime instance id")?; - validate_optional_endpoint(&self.bound_endpoint)?; - validate_runtime_capabilities(&self.capabilities)?; - validate_authority_identifier(&self.health_contract, "health contract")?; - validate_authority_identifier(&self.signer_identity_id, "signer identity id")?; - validate_authority_identifier( - &self.activation_signer_identity_id, - "activation signer identity id", - )?; - validate_optional_sha256(&self.write_lease_sha256, "write lease sha256")?; - - let signature_is_valid = - self.signature.len() == 64 || (allow_unsigned && self.signature.is_empty()); - let activation_signature_is_valid = self.activation_signature.len() == 64 - || (allow_unsigned && self.activation_signature.is_empty()); - if self.publisher_sequence == 0 - || self.observed_at_unix_millis == 0 - || !matches!( - self.state.as_str(), - "warming" | "active" | "degraded" | "failed" - ) - || self.detail.len() > 512 - || self.detail.chars().any(char::is_control) - || self.signature_algorithm != "ed25519" - || !signature_is_valid - || !activation_signature_is_valid - { - bail!("runtime presence state, sequence, detail, or signature is invalid"); - } - if self.state == "warming" && self.write_lease_sha256.is_some() { - bail!("warming runtime presence cannot claim a process write lease"); - } - if self.state_schema_generation.is_none() && self.write_lease_sha256.is_some() { - bail!("stateless runtime presence cannot claim a process write lease"); - } - Ok(()) - } -} - -/// Public route facts admitted for one incarnation. These are service-facing -/// endpoints only; actuator paths, unit files, container sockets, and runner -/// credentials do not belong in the Expected projection. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct IdunnExpectedRoute { - pub route_id: String, - pub transport: String, - pub stable_endpoint: String, - pub candidate_endpoint: String, -} - -impl IdunnExpectedRoute { - fn validate(&self) -> Result<()> { - validate_authority_identifier(&self.route_id, "route id")?; - validate_endpoint(&self.stable_endpoint, "stable route endpoint")?; - validate_endpoint(&self.candidate_endpoint, "candidate route endpoint")?; - if self.stable_endpoint == self.candidate_endpoint { - bail!("stable and candidate route endpoints are identical"); - } - match self.transport.as_str() { - "http" => { - if !(self.stable_endpoint.starts_with("http://") - || self.stable_endpoint.starts_with("https://")) - || !self.candidate_endpoint.starts_with("http://") - { - bail!("HTTP route endpoints use an unsupported scheme"); - } - } - "tcp" => { - if !self.stable_endpoint.starts_with("tcp://") - || !self.candidate_endpoint.starts_with("tcp://") - { - bail!("TCP route endpoints use an unsupported scheme"); - } - } - "rudp" => { - if !self.stable_endpoint.starts_with("rudp://") - || !self.candidate_endpoint.starts_with("rudp://") - { - bail!("RUDP route endpoints use an unsupported scheme"); - } - } - _ => bail!("route transport is unsupported"), - } - Ok(()) - } -} - -/// One capability requirement and Idunn's exact admitted provider selection. -/// Provider fields are all absent while unresolved. Managed providers bind an -/// Expected digest; external operator bindings instead bind an endpoint. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct IdunnExpectedDependency { - pub kind: String, - pub capability: String, - pub schema: String, - pub compatibility: String, - pub minimum_capacity: u32, - pub startup: String, - pub provider_id: Option, - pub provider_authority: Option, - pub provider_expected_projection_sha256: Option, - pub provider_endpoint: Option, -} - -impl IdunnExpectedDependency { - fn identity(&self) -> (&str, &str, &str) { - (&self.capability, &self.schema, &self.compatibility) - } - - fn validate(&self) -> Result<()> { - if !matches!( - self.kind.as_str(), - "bootstrap" - | "required" - | "optional" - | "shared-infrastructure" - | "private" - | "external-operator-binding" - ) { - bail!("dependency kind is unsupported"); - } - validate_authority_identifier(&self.capability, "dependency capability")?; - validate_authority_identifier(&self.schema, "dependency schema")?; - validate_authority_identifier(&self.compatibility, "dependency compatibility")?; - if self.minimum_capacity == 0 - || !matches!(self.startup.as_str(), "before-start" | "before-promotion") - { - bail!("dependency capacity or startup condition is invalid"); - } - if self.kind == "bootstrap" && self.startup != "before-start" { - bail!("bootstrap dependency must be satisfied before start"); - } - validate_optional_identifier(&self.provider_id, "dependency provider id")?; - validate_optional_identifier(&self.provider_authority, "dependency provider authority")?; - validate_optional_sha256( - &self.provider_expected_projection_sha256, - "dependency provider expected projection sha256", - )?; - validate_optional_endpoint_labeled( - &self.provider_endpoint, - "dependency provider endpoint", - )?; - - match ( - &self.provider_id, - &self.provider_authority, - &self.provider_expected_projection_sha256, - &self.provider_endpoint, - ) { - (None, None, None, None) => {} - (Some(_), Some(authority), Some(_), _) if authority == "managed-incarnation" => { - if self.kind == "external-operator-binding" { - bail!("external operator dependency cannot select a managed incarnation"); - } - } - (Some(_), Some(authority), None, Some(_)) - if authority == "external-operator-binding" - && self.kind == "external-operator-binding" => {} - _ => bail!("dependency provider binding is partial or authority-incoherent"), - } - Ok(()) - } -} - -/// Idunn-owned desired deployment identity published into CultMesh before -/// promotion. It describes the admitted incarnation and its public contracts, -/// never the private details needed to execute it on a particular host. -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "idunn.expected_incarnation", - schema = "idunn.expected_incarnation.v2" -)] -pub struct IdunnExpectedIncarnationRecord { - #[cultcache(key = 0)] - pub schema_version: String, - #[cultcache(key = 1)] - pub target: String, - #[cultcache(key = 2)] - pub plan_id: String, - #[cultcache(key = 3)] - pub incarnation_id: String, - #[cultcache(key = 4)] - pub sealed_release_id: String, - #[cultcache(key = 5)] - pub source_repository: String, - #[cultcache(key = 6)] - pub source_revision: String, - #[cultcache(key = 7)] - pub recipe_sha256: String, - #[cultcache(key = 8)] - pub runtime_id: String, - #[cultcache(key = 9)] - pub expected_signer_identity_id: String, - #[cultcache(key = 10)] - pub health_contract: String, - #[cultcache(key = 11)] - pub artifact_sha256: String, - #[cultcache(key = 12)] - pub state_schema_generation: Option, - #[cultcache(key = 13)] - pub state_contract_sha256: Option, - #[cultcache(key = 14)] - pub write_lease_required: bool, - #[cultcache(key = 15)] - pub route: Option, - #[cultcache(key = 16)] - pub capabilities: Vec, - #[cultcache(key = 17)] - pub dependencies: Vec, -} - -impl IdunnExpectedIncarnationRecord { - pub fn decode_canonical(payload: &[u8]) -> Result { - if messagepack_array_len(payload) != Some(18) { - bail!("expected incarnation is not the 18-field positional contract"); - } - let expected: Self = - rmp_serde::from_slice(payload).context("decoding expected incarnation")?; - if rmp_serde::to_vec(&expected)? != payload { - bail!("expected incarnation is not canonical positional MessagePack"); - } - expected.validate()?; - Ok(expected) - } - - pub fn canonical_bytes(&self) -> Result> { - self.validate()?; - Ok(rmp_serde::to_vec(self)?) - } - - pub fn canonical_sha256(&self) -> Result { - Ok(prefixed_sha256(&self.canonical_bytes()?)) - } - - pub fn validate(&self) -> Result<()> { - if self.schema_version != IDUNN_EXPECTED_INCARNATION_SCHEMA { - bail!("expected incarnation schema is unsupported"); - } - validate_authority_identifier(&self.target, "target")?; - validate_required_sha256(&self.plan_id, "plan id")?; - validate_authority_identifier(&self.incarnation_id, "incarnation id")?; - validate_required_sha256(&self.sealed_release_id, "sealed release id")?; - validate_source_repository(&self.source_repository)?; - if !is_lower_hex(&self.source_revision, 40) { - bail!("source revision is not a lowercase Git object id"); - } - validate_required_sha256(&self.recipe_sha256, "recipe sha256")?; - validate_authority_identifier(&self.runtime_id, "runtime id")?; - validate_authority_identifier( - &self.expected_signer_identity_id, - "expected signer identity id", - )?; - validate_authority_identifier(&self.health_contract, "health contract")?; - validate_required_sha256(&self.artifact_sha256, "artifact sha256")?; - match (&self.state_schema_generation, &self.state_contract_sha256) { - (Some(generation), Some(contract_sha256)) => { - validate_authority_identifier(generation, "state schema generation")?; - validate_required_sha256(contract_sha256, "state contract sha256")?; - } - (None, None) => {} - _ => bail!("expected incarnation state lineage is partial"), - } - if self.write_lease_required && self.state_schema_generation.is_none() { - bail!("stateless incarnation cannot require a process write lease"); - } - if let Some(route) = &self.route { - route.validate()?; - } - validate_expected_capabilities(&self.capabilities)?; - validate_expected_dependencies(&self.dependencies)?; - Ok(()) - } -} - -/// Odin's observation of one dependency selected in the bound Expected -/// projection. Evidence digests identify the signed/provider-owned facts Odin -/// used; they do not copy those facts into Odin authority. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct OdinRuntimeDependencyEvidence { - pub kind: String, - pub capability: String, - pub schema: String, - pub compatibility: String, - pub provider_id: Option, - pub provider_authority: Option, - pub provider_expected_projection_sha256: Option, - pub provider_endpoint: Option, - pub observed_capacity: Option, - pub provider_evidence_sha256: Option, - pub ready: bool, -} - -impl OdinRuntimeDependencyEvidence { - fn identity(&self) -> (&str, &str, &str) { - (&self.capability, &self.schema, &self.compatibility) - } - - fn validate(&self) -> Result<()> { - if !matches!( - self.kind.as_str(), - "bootstrap" - | "required" - | "optional" - | "shared-infrastructure" - | "private" - | "external-operator-binding" - ) { - bail!("dependency evidence kind is unsupported"); - } - validate_authority_identifier(&self.kind, "dependency evidence kind")?; - validate_authority_identifier(&self.capability, "dependency evidence capability")?; - validate_authority_identifier(&self.schema, "dependency evidence schema")?; - validate_authority_identifier(&self.compatibility, "dependency evidence compatibility")?; - validate_optional_identifier(&self.provider_id, "dependency evidence provider id")?; - validate_optional_identifier( - &self.provider_authority, - "dependency evidence provider authority", - )?; - validate_optional_sha256( - &self.provider_expected_projection_sha256, - "dependency evidence provider expected projection sha256", - )?; - validate_optional_endpoint_labeled( - &self.provider_endpoint, - "dependency evidence provider endpoint", - )?; - validate_optional_sha256( - &self.provider_evidence_sha256, - "dependency provider evidence sha256", - )?; - if self.observed_capacity == Some(0) { - bail!("dependency evidence capacity is zero"); - } - if self.provider_id.is_none() - && (self.provider_authority.is_some() - || self.provider_expected_projection_sha256.is_some() - || self.provider_endpoint.is_some() - || self.observed_capacity.is_some() - || self.provider_evidence_sha256.is_some() - || self.ready) - { - bail!("dependency evidence has facts without a provider"); - } - if self.provider_id.is_some() && self.provider_authority.is_none() { - bail!("dependency evidence provider authority is missing"); - } - match ( - &self.provider_id, - &self.provider_authority, - &self.provider_expected_projection_sha256, - &self.provider_endpoint, - ) { - (None, None, None, None) => {} - (Some(_), Some(authority), Some(_), _) if authority == "managed-incarnation" => { - if self.kind == "external-operator-binding" { - bail!("external dependency evidence names a managed incarnation"); - } - } - (Some(_), Some(authority), None, Some(_)) - if authority == "external-operator-binding" - && self.kind == "external-operator-binding" => {} - _ => bail!("dependency evidence provider binding is authority-incoherent"), - } - if self.ready - && (self.observed_capacity.is_none() || self.provider_evidence_sha256.is_none()) - { - bail!("ready dependency lacks capacity or signed evidence"); - } - Ok(()) - } -} - -/// One explicit Expected/observed disagreement. Codes are unique and sorted so -/// the signed receipt has one canonical explanation for each disagreement. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct OdinTopologyDisagreement { - pub code: String, - pub expected: Option, - pub observed: Option, -} - -impl OdinTopologyDisagreement { - fn validate(&self) -> Result<()> { - validate_authority_identifier(&self.code, "topology disagreement code")?; - validate_optional_detail(&self.expected, "topology disagreement expected value")?; - validate_optional_detail(&self.observed, "topology disagreement observed value")?; - if self.expected.is_none() && self.observed.is_none() { - bail!("topology disagreement carries no expected or observed value"); - } - Ok(()) - } -} - -/// Odin-owned signed correlation of Idunn's exact Expected projection with -/// current Idunn activation and provider-signed presence. Presence state and -/// write-lease observation remain explicit so an opaque Ready bit cannot cross -/// Idunn's warming/fencing boundary. The signature covers the canonical -/// positional encoding with `signature` empty. -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "odin.runtime_topology_correlation", - schema = "odin.runtime_topology_correlation.v2" -)] -pub struct OdinRuntimeTopologyCorrelationRecord { - #[cultcache(key = 0)] - pub schema_version: String, - #[cultcache(key = 1)] - pub target: String, - #[cultcache(key = 2)] - pub expected_projection_sha256: String, - #[cultcache(key = 3)] - pub expected: bool, - #[cultcache(key = 4)] - pub current_activation_sha256: Option, - #[cultcache(key = 5)] - pub signed_presence_sha256: Option, - #[cultcache(key = 6)] - pub observed_presence_state: Option, - #[cultcache(key = 7)] - pub observed_presence_publisher_sequence: Option, - #[cultcache(key = 8)] - pub observed_write_lease_sha256: Option, - #[cultcache(key = 9)] - pub observed_capabilities: Vec, - #[cultcache(key = 10)] - pub runtime_id: String, - #[cultcache(key = 11)] - pub runtime_instance_id: Option, - #[cultcache(key = 12)] - pub present: bool, - #[cultcache(key = 13)] - pub ready: bool, - #[cultcache(key = 14)] - pub dependencies: Vec, - #[cultcache(key = 15)] - pub disagreements: Vec, - #[cultcache(key = 16)] - pub signer_identity_id: String, - #[cultcache(key = 17)] - pub publisher_sequence: u64, - #[cultcache(key = 18)] - pub observed_at_unix_millis: u64, - #[cultcache(key = 19)] - pub signature_algorithm: String, - #[cultcache(key = 20, bytes)] - pub signature: Vec, -} - -impl OdinRuntimeTopologyCorrelationRecord { - pub fn decode_canonical_signed_payload(payload: &[u8]) -> Result<(Self, Vec)> { - if messagepack_array_len(payload) != Some(21) { - bail!("topology correlation is not the 21-field positional contract"); - } - let receipt: Self = - rmp_serde::from_slice(payload).context("decoding runtime topology correlation")?; - if rmp_serde::to_vec(&receipt)? != payload { - bail!("topology correlation is not canonical positional MessagePack"); - } - receipt.validate()?; - let unsigned = receipt.unsigned_signature_payload()?; - Ok((receipt, unsigned)) - } - - pub fn unsigned_signature_payload(&self) -> Result> { - self.validate_shape(true)?; - let mut unsigned = self.clone(); - unsigned.signature.clear(); - Ok(rmp_serde::to_vec(&unsigned)?) - } - - pub fn canonical_bytes(&self) -> Result> { - self.validate()?; - Ok(rmp_serde::to_vec(self)?) - } - - pub fn canonical_sha256(&self) -> Result { - Ok(prefixed_sha256(&self.canonical_bytes()?)) - } - - pub fn validate(&self) -> Result<()> { - self.validate_shape(false) - } - - pub fn validate_against_expected( - &self, - expected: &IdunnExpectedIncarnationRecord, - current_write_lease_sha256: Option<&str>, - ) -> Result<()> { - self.validate()?; - expected.validate()?; - if self.expected_projection_sha256 != expected.canonical_sha256()? - || self.target != expected.target - || self.runtime_id != expected.runtime_id - || self.dependencies.len() != expected.dependencies.len() - { - bail!("topology correlation substitutes or omits Expected authority"); - } - for (evidence, requirement) in self.dependencies.iter().zip(&expected.dependencies) { - if evidence.identity() != requirement.identity() - || evidence.kind != requirement.kind - || evidence.provider_id != requirement.provider_id - || evidence.provider_authority != requirement.provider_authority - || evidence.provider_expected_projection_sha256 - != requirement.provider_expected_projection_sha256 - || evidence.provider_endpoint != requirement.provider_endpoint - { - bail!("topology dependency evidence does not bind the Expected dependency"); - } - if evidence.ready - && evidence - .observed_capacity - .is_none_or(|capacity| capacity < requirement.minimum_capacity) - { - bail!("ready dependency does not meet Expected capacity"); - } - } - let mut capability_disagreements = Vec::new(); - correlate_capabilities( - &mut capability_disagreements, - &expected.capabilities, - &self.observed_capabilities, - ); - for disagreement in &capability_disagreements { - if !self.disagreements.contains(disagreement) { - bail!("topology correlation omits an observed capability disagreement"); - } - } - if let Some(current_write_lease_sha256) = current_write_lease_sha256 { - validate_required_sha256(current_write_lease_sha256, "current write lease sha256")?; - } - if expected.write_lease_required { - if self - .observed_write_lease_sha256 - .as_deref() - .is_some_and(|observed| Some(observed) != current_write_lease_sha256) - { - bail!("observed write lease is not the current Idunn grant"); - } - if self.ready - && (self.observed_write_lease_sha256.is_none() - || current_write_lease_sha256.is_none()) - { - bail!("Ready stateful topology lacks the exact current write lease"); - } - } else if self.observed_write_lease_sha256.is_some() || current_write_lease_sha256.is_some() - { - bail!("stateless Expected topology cannot carry a process write lease"); - } - Ok(()) - } - - fn validate_shape(&self, allow_unsigned: bool) -> Result<()> { - if self.schema_version != ODIN_RUNTIME_TOPOLOGY_CORRELATION_SCHEMA { - bail!("topology correlation schema is unsupported"); - } - validate_authority_identifier(&self.target, "target")?; - validate_required_sha256( - &self.expected_projection_sha256, - "correlation expected projection sha256", - )?; - if !self.expected { - bail!("topology correlation cannot deny the Expected record it binds"); - } - validate_optional_sha256(&self.current_activation_sha256, "current activation sha256")?; - validate_optional_sha256(&self.signed_presence_sha256, "signed presence sha256")?; - validate_optional_identifier(&self.observed_presence_state, "observed presence state")?; - if self.observed_presence_publisher_sequence == Some(0) { - bail!("observed presence publisher sequence is zero"); - } - validate_optional_sha256( - &self.observed_write_lease_sha256, - "observed write lease sha256", - )?; - validate_runtime_capabilities(&self.observed_capabilities)?; - if self.signed_presence_sha256.is_none() && !self.observed_capabilities.is_empty() { - bail!("topology correlation has capabilities without signed presence"); - } - match ( - &self.signed_presence_sha256, - &self.observed_presence_state, - self.observed_presence_publisher_sequence, - &self.observed_write_lease_sha256, - ) { - (None, None, None, None) => {} - (Some(_), Some(state), Some(_), write_lease) - if matches!(state.as_str(), "warming" | "active" | "degraded" | "failed") => - { - if state == "warming" && write_lease.is_some() { - bail!("warming topology presence cannot carry a process write lease"); - } - } - _ => bail!("signed presence state, sequence, or write-lease observation is partial"), - } - validate_authority_identifier(&self.runtime_id, "runtime id")?; - validate_optional_sha256(&self.runtime_instance_id, "runtime instance id")?; - let has_runtime_evidence = - self.current_activation_sha256.is_some() || self.signed_presence_sha256.is_some(); - if self.runtime_instance_id.is_some() != has_runtime_evidence { - bail!("runtime instance identity and observation evidence are partial"); - } - if self.present - && (self.current_activation_sha256.is_none() - || self.signed_presence_sha256.is_none() - || self.observed_presence_publisher_sequence.is_none() - || self.runtime_instance_id.is_none()) - { - bail!("Present topology state lacks an authenticated runtime session"); - } - validate_dependency_evidence(&self.dependencies)?; - validate_topology_disagreements(&self.disagreements)?; - let has_activation = self.current_activation_sha256.is_some(); - let has_presence = self.signed_presence_sha256.is_some(); - if has_activation && has_presence && !self.present { - bail!("authenticated runtime session cannot be projected as not Present"); - } - if has_activation != has_presence && self.disagreements.is_empty() { - bail!("partial or rejected runtime evidence lacks an explicit disagreement"); - } - if self.ready - && (!self.present - || self.observed_presence_state.as_deref() != Some("active") - || !self.disagreements.is_empty() - || self - .dependencies - .iter() - .any(|dependency| dependency.kind != "optional" && !dependency.ready)) - { - bail!("Ready topology state is not supported by correlated evidence"); - } - validate_authority_identifier(&self.signer_identity_id, "signer identity id")?; - let signature_is_valid = - self.signature.len() == 64 || (allow_unsigned && self.signature.is_empty()); - if self.publisher_sequence == 0 - || self.observed_at_unix_millis == 0 - || self.signature_algorithm != "ed25519" - || !signature_is_valid - { - bail!("topology correlation sequence, time, or signature is invalid"); - } - Ok(()) - } -} - -/// An in-memory activation-scoped signer reconstructed by the launched service -/// from its protected systemd credential. The stable service identity never -/// receives this key, and the public activation record never contains it. -pub struct IdunnRuntimeActivationSigner { - signing_key: SigningKey, -} - -impl IdunnRuntimeActivationSigner { - fn generate() -> Self { - let mut seed = Zeroizing::new([0_u8; 32]); - rand::rng().fill_bytes(seed.as_mut()); - Self { - signing_key: SigningKey::from_bytes(&*seed), - } - } - - /// Open the exact raw 32-byte Ed25519 seed supplied through Idunn's systemd - /// credential without retaining an ordinary heap copy. No persistent key - /// schema exists: this authority lives for one activation and is replaced - /// on every launch. - pub fn from_credential_reader(mut credential: impl Read) -> Result { - let mut seed = Zeroizing::new([0_u8; 32]); - credential - .read_exact(seed.as_mut()) - .context("reading runtime activation credential")?; - let mut extra = [0_u8; 1]; - if credential.read(&mut extra)? != 0 { - bail!("runtime activation credential is longer than 32 bytes"); - } - Ok(Self { - signing_key: SigningKey::from_bytes(&*seed), - }) - } - - fn write_credential(&self, mut destination: impl Write) -> Result<()> { - let seed = Zeroizing::new(self.signing_key.to_bytes()); - destination - .write_all(seed.as_ref()) - .context("writing runtime activation credential") - } - - pub fn public_key(&self) -> Vec { - self.signing_key.verifying_key().to_bytes().to_vec() - } - - pub fn identity_id(&self) -> String { - derive_idunn_runtime_activation_identity_id(&self.public_key()) - .expect("an Ed25519 activation signer always has a 32-byte public key") - } - - pub fn sign_presence_proof( - &self, - presence: &GameCultRuntimePresenceHealthRecord, - ) -> Result> { - if presence.activation_signer_identity_id != self.identity_id() { - bail!("runtime presence names a different activation signer"); - } - let payload = presence.canonical_proof_payload()?; - Ok(self - .signing_key - .sign(&runtime_activation_signing_message(&payload)) - .to_bytes() - .to_vec()) - } -} - -/// Idunn's one-shot launch material. Issuance always creates a fresh key, and -/// writing the protected credential consumes the only Idunn-side signer. -pub struct IdunnRuntimeActivationLaunch { - activation: IdunnRuntimeActivationRecord, - signer: IdunnRuntimeActivationSigner, -} - -impl IdunnRuntimeActivationLaunch { - pub fn issue( - expected: &IdunnExpectedIncarnationRecord, - runtime_instance_id: String, - issued_at_unix_millis: u64, - idunn_signer: &crate::ServiceIdentitySigner, - ) -> Result { - let signer = IdunnRuntimeActivationSigner::generate(); - let activation = IdunnRuntimeActivationRecord::issue_with_signer( - expected, - runtime_instance_id, - &signer, - issued_at_unix_millis, - idunn_signer, - )?; - Ok(Self { activation, signer }) - } - - pub fn activation(&self) -> &IdunnRuntimeActivationRecord { - &self.activation - } - - pub fn write_credential(self, destination: impl Write) -> Result { - self.signer.write_credential(destination)?; - Ok(self.activation) - } -} - -/// Derive the activation-scoped identity from the Idunn-issued public key. The -/// domain is dedicated to runtime activation and cannot be substituted for a -/// stable provider, Idunn, or Odin service identity. -pub fn derive_idunn_runtime_activation_identity_id(public_key: &[u8]) -> Result { - if public_key.len() != 32 { - bail!("runtime activation public key has invalid length"); - } - Ok(format!( - "{:x}", - Sha256::digest([IDUNN_RUNTIME_ACTIVATION_ID_DOMAIN, public_key].concat()) - )) -} - -/// Current Idunn authority for one launch. Idunn's stable signature binds the -/// complete Expected digest and activation key; Expected in turn pins the -/// provider signer identity. The provider public key is lookup material, not a -/// second authority: its profile-derived identity must equal Idunn's signed -/// selection exactly. -#[derive(Clone, Debug)] -pub struct VerifiedRuntimeAuthority { - expected: IdunnExpectedIncarnationRecord, - activation: IdunnRuntimeActivationRecord, - provider_signer_public_key: Vec, - expected_sha256: String, - activation_sha256: String, -} - -impl VerifiedRuntimeAuthority { - pub fn expected(&self) -> &IdunnExpectedIncarnationRecord { - &self.expected - } - - pub fn activation(&self) -> &IdunnRuntimeActivationRecord { - &self.activation - } - - pub fn expected_sha256(&self) -> &str { - &self.expected_sha256 - } - - pub fn activation_sha256(&self) -> &str { - &self.activation_sha256 - } -} - -pub fn verify_runtime_authority( - expected: &IdunnExpectedIncarnationRecord, - activation: &IdunnRuntimeActivationRecord, - idunn_anchor: &crate::ServiceIdentityTrustAnchor, - provider_signer_public_key: &[u8], -) -> Result { - expected.validate()?; - activation.verify_for_expected(expected, idunn_anchor)?; - if crate::derive_service_identity_id::( - provider_signer_public_key, - )? != expected.expected_signer_identity_id - { - bail!("provider public key is not Idunn's Expected signer selection"); - } - Ok(VerifiedRuntimeAuthority { - expected: expected.clone(), - activation: activation.clone(), - provider_signer_public_key: provider_signer_public_key.to_vec(), - expected_sha256: expected.canonical_sha256()?, - activation_sha256: activation.canonical_sha256()?, - }) -} - -#[derive(Clone, Copy, Debug)] -pub struct RuntimePresenceAuthenticationContext { - pub trusted_received_at_unix_millis: u64, - pub maximum_age_millis: u64, - pub maximum_future_skew_millis: u64, -} - -/// Canonical runtime bytes authenticated by both the Expected provider key and -/// the current activation key. No semantic claim has yet been accepted as -/// Present; Odin must correlate this observation against current authority. -#[derive(Clone, Debug)] -pub struct AuthenticatedRuntimePresenceClaim { - record: GameCultRuntimePresenceHealthRecord, - canonical_bytes: Vec, - signed_presence_sha256: String, - received_at_unix_millis: u64, -} - -impl AuthenticatedRuntimePresenceClaim { - pub fn record(&self) -> &GameCultRuntimePresenceHealthRecord { - &self.record - } - - pub fn canonical_bytes(&self) -> &[u8] { - &self.canonical_bytes - } - - pub fn signed_presence_sha256(&self) -> &str { - &self.signed_presence_sha256 - } - - pub fn received_at_unix_millis(&self) -> u64 { - self.received_at_unix_millis - } -} - -/// A signed Odin receipt authenticated against the exact current Expected, -/// activation, write lease, Idunn-admitted topology key, and trusted receipt -/// time. This is evidence, not a replay cursor or route-promotion grant. The -/// caller must source the key from Idunn's admitted Odin binding. Idunn's -/// durable admission owner must atomically check and advance Odin's publisher -/// sequence before treating the receipt as current. -#[derive(Clone, Debug)] -pub struct AuthenticatedOdinRuntimeTopologyCorrelation { - record: OdinRuntimeTopologyCorrelationRecord, - canonical_bytes: Vec, -} - -impl AuthenticatedOdinRuntimeTopologyCorrelation { - pub fn record(&self) -> &OdinRuntimeTopologyCorrelationRecord { - &self.record - } - - pub fn canonical_bytes(&self) -> &[u8] { - &self.canonical_bytes - } -} - -#[derive(Clone, Copy, Debug)] -pub struct OdinTopologyAuthenticationContext { - pub trusted_received_at_unix_millis: u64, - pub maximum_age_millis: u64, - pub maximum_future_skew_millis: u64, -} - -pub fn authenticate_odin_runtime_topology_correlation( - canonical_correlation: &[u8], - authority: &VerifiedRuntimeAuthority, - current_write_lease_sha256: Option<&str>, - admitted_odin_signer_public_key: &[u8], - context: OdinTopologyAuthenticationContext, -) -> Result { - ensure!( - context.trusted_received_at_unix_millis > 0 && context.maximum_age_millis > 0, - "Odin topology trusted time and maximum age must be positive" - ); - let (record, unsigned) = OdinRuntimeTopologyCorrelationRecord::decode_canonical_signed_payload( - canonical_correlation, - )?; - record.validate_against_expected(authority.expected(), current_write_lease_sha256)?; - if record.current_activation_sha256.as_deref() != Some(authority.activation_sha256()) - || record.runtime_instance_id.as_deref() - != Some(authority.activation.runtime_instance_id.as_str()) - { - bail!("topology correlation does not bind the current activation"); - } - let latest_trusted_timestamp = context - .trusted_received_at_unix_millis - .saturating_add(context.maximum_future_skew_millis); - if record.observed_at_unix_millis > latest_trusted_timestamp - || context - .trusted_received_at_unix_millis - .saturating_sub(record.observed_at_unix_millis) - > context.maximum_age_millis - { - bail!("Odin topology correlation is outside the trusted observation window"); - } - crate::verify_service_identity_signature_with_public_key::< - crate::OdinTopologyIdentity, - crate::OdinRuntimeTopologyCorrelationPurpose, - >( - admitted_odin_signer_public_key, - &unsigned, - &crate::ServiceIdentitySignature { - identity_id: record.signer_identity_id.clone(), - signature: record.signature.clone(), - }, - )?; - Ok(AuthenticatedOdinRuntimeTopologyCorrelation { - record, - canonical_bytes: canonical_correlation.to_vec(), - }) -} - -/// Odin's deterministic interpretation of an authenticated provider claim. -/// Authentication of the stable provider and current activation establishes -/// Present. Correlation disagreements remain explicit and deny Ready; they do -/// not erase the observed session that produced them. -#[derive(Clone, Debug)] -pub struct RuntimePresenceCorrelation { - presence: VerifiedRuntimePresence, - disagreements: Vec, -} - -impl RuntimePresenceCorrelation { - pub fn claim(&self) -> &AuthenticatedRuntimePresenceClaim { - &self.presence.claim - } - - pub fn disagreements(&self) -> &[OdinTopologyDisagreement] { - &self.disagreements - } - - pub fn into_present(self) -> VerifiedRuntimePresence { - self.presence - } - - pub fn into_undisputed_present(self) -> Result { - if !self.disagreements.is_empty() { - bail!("runtime presence disagrees with current Expected projection"); - } - Ok(self.presence) - } -} - -/// A dual-authenticated claim that matches current authority. This is the only -/// value that represents semantic Present. Replay admission deliberately does -/// not live here: Odin's durable store must atomically check and advance every -/// authenticated provider sequence, including disagreement-bearing claims, -/// before it signs a topology correlation. -#[derive(Clone, Debug)] -pub struct VerifiedRuntimePresence { - claim: AuthenticatedRuntimePresenceClaim, -} - -impl VerifiedRuntimePresence { - pub fn record(&self) -> &GameCultRuntimePresenceHealthRecord { - self.claim.record() - } - - pub fn canonical_bytes(&self) -> &[u8] { - self.claim.canonical_bytes() - } - - pub fn signed_presence_sha256(&self) -> &str { - self.claim.signed_presence_sha256() - } - - pub fn accepted_at_unix_millis(&self) -> u64 { - self.claim.received_at_unix_millis() - } -} - -pub fn authenticate_runtime_presence_claim( - canonical_presence: &[u8], - authority: &VerifiedRuntimeAuthority, - context: RuntimePresenceAuthenticationContext, -) -> Result { - ensure!( - context.trusted_received_at_unix_millis > 0 && context.maximum_age_millis > 0, - "runtime presence trusted time and maximum age must be positive" - ); - if messagepack_array_len(canonical_presence) != Some(24) { - bail!("runtime presence payload is not the 24-field positional contract"); - } - let presence: GameCultRuntimePresenceHealthRecord = - rmp_serde::from_slice(canonical_presence).context("decoding runtime presence health")?; - if rmp_serde::to_vec(&presence)? != canonical_presence { - bail!("runtime presence payload is not canonical positional MessagePack"); - } - presence.validate()?; - let latest_trusted_timestamp = context - .trusted_received_at_unix_millis - .saturating_add(context.maximum_future_skew_millis); - if presence.observed_at_unix_millis > latest_trusted_timestamp - || authority.activation.issued_at_unix_millis > latest_trusted_timestamp - || context - .trusted_received_at_unix_millis - .saturating_sub(presence.observed_at_unix_millis) - > context.maximum_age_millis - { - bail!("runtime presence is outside the trusted observation window"); - } - if presence.signer_identity_id != authority.expected.expected_signer_identity_id { - bail!("runtime presence signer is not Idunn's Expected signer selection"); - } - - let proof_payload = presence.canonical_proof_payload()?; - crate::verify_service_identity_signature_with_public_key::< - crate::GameCultProviderHealthIdentity, - crate::GameCultRuntimePresenceHealthPurpose, - >( - &authority.provider_signer_public_key, - &proof_payload, - &crate::ServiceIdentitySignature { - identity_id: presence.signer_identity_id.clone(), - signature: presence.signature.clone(), - }, - )?; - let activation_public_key: [u8; 32] = authority - .activation - .activation_signer_public_key - .as_slice() - .try_into() - .map_err(|_| anyhow::anyhow!("runtime activation public key has invalid length"))?; - let activation_signature = Signature::from_slice(&presence.activation_signature) - .map_err(|_| anyhow::anyhow!("runtime activation signature has invalid length"))?; - VerifyingKey::from_bytes(&activation_public_key)? - .verify( - &runtime_activation_signing_message(&proof_payload), - &activation_signature, - ) - .map_err(|_| anyhow::anyhow!("runtime activation proof verification failed"))?; - - Ok(AuthenticatedRuntimePresenceClaim { - record: presence, - canonical_bytes: canonical_presence.to_vec(), - signed_presence_sha256: prefixed_sha256(canonical_presence), - received_at_unix_millis: context.trusted_received_at_unix_millis, - }) -} - -pub fn correlate_runtime_presence_claim( - claim: AuthenticatedRuntimePresenceClaim, - authority: &VerifiedRuntimeAuthority, -) -> Result { - let expected = authority.expected(); - let activation = authority.activation(); - let presence = claim.record(); - let expected_endpoint = expected - .route - .as_ref() - .map(|route| route.candidate_endpoint.clone()); - let mut disagreements = Vec::new(); - push_disagreement( - &mut disagreements, - "activation-signer-identity", - Some(&activation.activation_signer_identity_id), - Some(&presence.activation_signer_identity_id), - ); - push_disagreement( - &mut disagreements, - "activation-witness", - Some(authority.activation_sha256()), - Some(&presence.activation_witness_sha256), - ); - push_disagreement( - &mut disagreements, - "bound-endpoint", - expected_endpoint.as_deref(), - presence.bound_endpoint.as_deref(), - ); - push_disagreement( - &mut disagreements, - "expected-projection", - Some(authority.expected_sha256()), - Some(&presence.expected_projection_sha256), - ); - push_disagreement( - &mut disagreements, - "health-contract", - Some(&expected.health_contract), - Some(&presence.health_contract), - ); - push_disagreement( - &mut disagreements, - "incarnation-id", - Some(&expected.incarnation_id), - Some(&presence.incarnation_id), - ); - push_disagreement( - &mut disagreements, - "plan-id", - Some(&expected.plan_id), - Some(&presence.plan_id), - ); - push_disagreement( - &mut disagreements, - "runtime-id", - Some(&expected.runtime_id), - Some(&presence.runtime_id), - ); - push_disagreement( - &mut disagreements, - "runtime-instance-id", - Some(&activation.runtime_instance_id), - Some(&presence.runtime_instance_id), - ); - push_disagreement( - &mut disagreements, - "sealed-release-id", - Some(&expected.sealed_release_id), - Some(&presence.sealed_release_id), - ); - push_disagreement( - &mut disagreements, - "signer-identity", - Some(&expected.expected_signer_identity_id), - Some(&presence.signer_identity_id), - ); - push_disagreement( - &mut disagreements, - "state-contract", - expected.state_contract_sha256.as_deref(), - presence.state_contract_sha256.as_deref(), - ); - push_disagreement( - &mut disagreements, - "state-schema-generation", - expected.state_schema_generation.as_deref(), - presence.state_schema_generation.as_deref(), - ); - push_disagreement( - &mut disagreements, - "target", - Some(&expected.target), - Some(&presence.target), - ); - if activation.issued_at_unix_millis > presence.observed_at_unix_millis { - disagreements.push(OdinTopologyDisagreement { - code: "activation-issued-after-presence".into(), - expected: Some(format!("at-most:{}", presence.observed_at_unix_millis)), - observed: Some(activation.issued_at_unix_millis.to_string()), - }); - } - correlate_capabilities( - &mut disagreements, - &expected.capabilities, - &presence.capabilities, - ); - disagreements.sort_by(|left, right| left.code.cmp(&right.code)); - validate_topology_disagreements(&disagreements)?; - Ok(RuntimePresenceCorrelation { - presence: VerifiedRuntimePresence { claim }, - disagreements, - }) -} - -fn push_disagreement( - disagreements: &mut Vec, - code: &str, - expected: Option<&str>, - observed: Option<&str>, -) { - if expected != observed { - disagreements.push(OdinTopologyDisagreement { - code: code.into(), - expected: expected.map(str::to_string), - observed: observed.map(str::to_string), - }); - } -} - -fn correlate_capabilities( - disagreements: &mut Vec, - expected: &[IdunnExpectedCapability], - observed: &[GameCultRuntimeCapability], -) { - for (index, required) in expected.iter().enumerate() { - let actual = observed - .iter() - .find(|actual| actual.identity() == required.identity()); - let requirement = format!( - "{}/{}/{} capacity>={}", - required.capability, required.schema, required.compatibility, required.minimum_capacity - ); - match actual { - None => disagreements.push(OdinTopologyDisagreement { - code: format!("expected-capability-{index:03}-missing"), - expected: Some(requirement), - observed: None, - }), - Some(actual) if actual.capacity < required.minimum_capacity => { - disagreements.push(OdinTopologyDisagreement { - code: format!("expected-capability-{index:03}-capacity"), - expected: Some(requirement), - observed: Some(format!("capacity={}", actual.capacity)), - }); - } - Some(_) => {} - } - } -} - -fn runtime_activation_signing_message(payload: &[u8]) -> Vec { - let purpose = GAMECULT_RUNTIME_ACTIVATION_PROOF_SIGNING_PURPOSE.as_bytes(); - let mut out = Vec::with_capacity( - IDUNN_RUNTIME_ACTIVATION_SIGNATURE_DOMAIN.len() + purpose.len() + payload.len() + 16, - ); - out.extend_from_slice(IDUNN_RUNTIME_ACTIVATION_SIGNATURE_DOMAIN); - out.extend_from_slice(&(purpose.len() as u64).to_be_bytes()); - out.extend_from_slice(purpose); - out.extend_from_slice(&(payload.len() as u64).to_be_bytes()); - out.extend_from_slice(payload); - out -} - -/// Idunn-issued launch identity. Idunn's stable service identity signs the -/// complete activation, including the ephemeral public key, before the private -/// credential is passed to one workload alongside Expected. Odin still treats -/// it as current only after Idunn publishes an observed native process. -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "idunn.runtime_activation", - schema = "idunn.runtime_activation.v2" -)] -pub struct IdunnRuntimeActivationRecord { - #[cultcache(key = 0)] - pub schema_version: String, - #[cultcache(key = 1)] - pub expected_projection_sha256: String, - #[cultcache(key = 2)] - pub runtime_id: String, - #[cultcache(key = 3)] - pub runtime_instance_id: String, - #[cultcache(key = 4)] - pub activation_signer_identity_id: String, - #[cultcache(key = 5, bytes)] - pub activation_signer_public_key: Vec, - #[cultcache(key = 6)] - pub issued_at_unix_millis: u64, - #[cultcache(key = 7)] - pub idunn_signer_identity_id: String, - #[cultcache(key = 8)] - pub signature_algorithm: String, - #[cultcache(key = 9, bytes)] - pub signature: Vec, -} - -impl IdunnRuntimeActivationRecord { - fn issue_with_signer( - expected: &IdunnExpectedIncarnationRecord, - runtime_instance_id: String, - activation_signer: &IdunnRuntimeActivationSigner, - issued_at_unix_millis: u64, - idunn_signer: &crate::ServiceIdentitySigner, - ) -> Result { - expected.validate()?; - let mut activation = Self { - schema_version: IDUNN_RUNTIME_ACTIVATION_SCHEMA.into(), - expected_projection_sha256: expected.canonical_sha256()?, - runtime_id: expected.runtime_id.clone(), - runtime_instance_id, - activation_signer_identity_id: activation_signer.identity_id(), - activation_signer_public_key: activation_signer.public_key(), - issued_at_unix_millis, - idunn_signer_identity_id: idunn_signer.entry().identity_id.clone(), - signature_algorithm: "ed25519".into(), - signature: Vec::new(), - }; - let proof = idunn_signer.sign::( - &activation.unsigned_signature_payload()?, - ); - activation.signature = proof.signature; - activation.validate()?; - Ok(activation) - } - - pub fn decode_canonical(payload: &[u8]) -> Result { - if messagepack_array_len(payload) != Some(10) { - bail!("runtime activation is not the 10-field positional contract"); - } - let activation: Self = - rmp_serde::from_slice(payload).context("decoding runtime activation")?; - if rmp_serde::to_vec(&activation)? != payload { - bail!("runtime activation is not canonical positional MessagePack"); - } - activation.validate()?; - Ok(activation) - } - - pub fn canonical_bytes(&self) -> Result> { - self.validate()?; - Ok(rmp_serde::to_vec(self)?) - } - - pub fn canonical_sha256(&self) -> Result { - Ok(prefixed_sha256(&self.canonical_bytes()?)) - } - - pub fn unsigned_signature_payload(&self) -> Result> { - self.validate_shape(true)?; - let mut unsigned = self.clone(); - unsigned.signature.clear(); - Ok(rmp_serde::to_vec(&unsigned)?) - } - - pub fn verify_for_expected( - &self, - expected: &IdunnExpectedIncarnationRecord, - idunn_anchor: &crate::ServiceIdentityTrustAnchor, - ) -> Result<()> { - self.validate()?; - expected.validate()?; - if self.expected_projection_sha256 != expected.canonical_sha256()? - || self.runtime_id != expected.runtime_id - { - bail!("runtime activation does not bind the current Expected incarnation"); - } - crate::verify_service_identity_signature::< - crate::IdunnServiceIdentity, - crate::IdunnRuntimeActivationPurpose, - >( - idunn_anchor, - &self.unsigned_signature_payload()?, - &crate::ServiceIdentitySignature { - identity_id: self.idunn_signer_identity_id.clone(), - signature: self.signature.clone(), - }, - ) - .context("verifying Idunn runtime activation signature") - } - - pub fn validate(&self) -> Result<()> { - self.validate_shape(false) - } - - fn validate_shape(&self, allow_unsigned: bool) -> Result<()> { - if self.schema_version != IDUNN_RUNTIME_ACTIVATION_SCHEMA { - bail!("runtime activation schema is unsupported"); - } - validate_required_sha256( - &self.expected_projection_sha256, - "activation expected projection sha256", - )?; - validate_authority_identifier(&self.runtime_id, "activation runtime id")?; - validate_required_sha256(&self.runtime_instance_id, "runtime instance id")?; - validate_authority_identifier( - &self.activation_signer_identity_id, - "activation signer identity id", - )?; - if self.activation_signer_identity_id - != derive_idunn_runtime_activation_identity_id(&self.activation_signer_public_key)? - { - bail!("runtime activation signer identity does not match its public key"); - } - validate_authority_identifier( - &self.idunn_signer_identity_id, - "Idunn activation signer identity id", - )?; - let signature_is_valid = - self.signature.len() == 64 || (allow_unsigned && self.signature.is_empty()); - if self.issued_at_unix_millis == 0 - || self.signature_algorithm != "ed25519" - || !signature_is_valid - { - bail!("runtime activation issue time or signature is invalid"); - } - Ok(()) - } -} - -/// Unsigned Idunn-owned local file that grants one exact process incarnation -/// state-write authority. It is authoritative only when read through the -/// root-owned admitted path and lock protocol; its bytes carry no route or -/// traffic authority. -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache( - type = "idunn.process_write_lease", - schema = "idunn.process_write_lease.v1" -)] -pub struct IdunnProcessWriteLeaseRecord { - #[cultcache(key = 0)] - pub schema_version: String, - #[cultcache(key = 1)] - pub target: String, - #[cultcache(key = 2)] - pub expected_projection_sha256: String, - #[cultcache(key = 3)] - pub plan_id: String, - #[cultcache(key = 4)] - pub incarnation_id: String, - #[cultcache(key = 5)] - pub sealed_release_id: String, - #[cultcache(key = 6)] - pub activation_witness_sha256: String, - #[cultcache(key = 7)] - pub state_schema_generation: String, - #[cultcache(key = 8)] - pub state_contract_sha256: String, - #[cultcache(key = 9)] - pub runtime_id: String, - #[cultcache(key = 10)] - pub runtime_instance_id: String, - #[cultcache(key = 11)] - pub warming_presence_sha256: String, - #[cultcache(key = 12)] - pub lease_epoch: u64, - #[cultcache(key = 13)] - pub issued_at_unix_millis: u64, -} - -impl IdunnProcessWriteLeaseRecord { - pub fn decode_canonical(payload: &[u8]) -> Result { - if messagepack_array_len(payload) != Some(14) { - bail!("process write lease is not the 14-field positional contract"); - } - let lease: Self = rmp_serde::from_slice(payload).context("decoding process write lease")?; - if rmp_serde::to_vec(&lease)? != payload { - bail!("process write lease is not canonical positional MessagePack"); - } - lease.validate()?; - Ok(lease) - } - - pub fn canonical_bytes(&self) -> Result> { - self.validate()?; - Ok(rmp_serde::to_vec(self)?) - } - - pub fn canonical_sha256(&self) -> Result { - Ok(prefixed_sha256(&self.canonical_bytes()?)) - } - - pub fn validate(&self) -> Result<()> { - if self.schema_version != IDUNN_PROCESS_WRITE_LEASE_SCHEMA { - bail!("process write lease schema is unsupported"); - } - validate_authority_identifier(&self.target, "target")?; - validate_required_sha256( - &self.expected_projection_sha256, - "lease expected projection sha256", - )?; - validate_required_sha256(&self.plan_id, "plan id")?; - validate_authority_identifier(&self.incarnation_id, "incarnation id")?; - validate_required_sha256(&self.sealed_release_id, "sealed release id")?; - validate_required_sha256(&self.activation_witness_sha256, "activation witness sha256")?; - validate_authority_identifier(&self.state_schema_generation, "state schema generation")?; - validate_required_sha256(&self.state_contract_sha256, "state contract sha256")?; - validate_authority_identifier(&self.runtime_id, "runtime id")?; - validate_required_sha256(&self.runtime_instance_id, "runtime instance id")?; - validate_required_sha256(&self.warming_presence_sha256, "warming presence sha256")?; - if self.lease_epoch == 0 || self.issued_at_unix_millis == 0 { - bail!("process write lease epoch or issue time is invalid"); - } - Ok(()) - } -} - -fn messagepack_array_len(payload: &[u8]) -> Option { - match *payload.first()? { - marker @ 0x90..=0x9f => Some(usize::from(marker & 0x0f)), - 0xdc => Some(usize::from(u16::from_be_bytes([ - *payload.get(1)?, - *payload.get(2)?, - ]))), - 0xdd => usize::try_from(u32::from_be_bytes([ - *payload.get(1)?, - *payload.get(2)?, - *payload.get(3)?, - *payload.get(4)?, - ])) - .ok(), - _ => None, - } -} - -fn validate_authority_identifier(value: &str, label: &str) -> Result<()> { - if value.is_empty() - || value.len() > 256 - || value.trim() != value - || value.chars().any(char::is_control) - { - bail!("{label} is empty, oversized, padded, or contains control characters"); - } - Ok(()) -} - -fn validate_optional_identifier(value: &Option, label: &str) -> Result<()> { - if let Some(value) = value { - validate_authority_identifier(value, label)?; - } - Ok(()) -} - -fn validate_source_repository(value: &str) -> Result<()> { - validate_authority_identifier(value, "source repository")?; - let segments: Vec<&str> = value.split('/').collect(); - if segments.len() < 3 - || !segments[0].contains('.') - || segments.iter().any(|segment| { - segment.is_empty() - || matches!(*segment, "." | "..") - || !segment - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) - }) - { - bail!("source repository is not a canonical host/owner/repository identity"); - } - Ok(()) -} - -fn validate_endpoint(value: &str, label: &str) -> Result<()> { - if value.is_empty() - || value.len() > 2048 - || value.trim() != value - || value.chars().any(char::is_control) - { - bail!("{label} is empty, oversized, padded, or contains control characters"); - } - Ok(()) -} - -fn validate_optional_endpoint_labeled(value: &Option, label: &str) -> Result<()> { - if let Some(endpoint) = value { - validate_endpoint(endpoint, label)?; - } - Ok(()) -} - -fn validate_optional_endpoint(value: &Option) -> Result<()> { - validate_optional_endpoint_labeled(value, "bound endpoint") -} - -fn validate_runtime_capabilities(capabilities: &[GameCultRuntimeCapability]) -> Result<()> { - if capabilities.len() > MAX_RUNTIME_CAPABILITIES { - bail!("runtime presence capability count exceeds its contract bound"); - } - let mut previous: Option<(&str, &str, &str)> = None; - for capability in capabilities { - validate_authority_identifier(&capability.capability, "capability")?; - validate_authority_identifier(&capability.schema, "capability schema")?; - validate_authority_identifier(&capability.compatibility, "capability compatibility")?; - if capability.capacity == 0 { - bail!("runtime capability capacity is zero"); - } - let identity = ( - capability.capability.as_str(), - capability.schema.as_str(), - capability.compatibility.as_str(), - ); - if previous.is_some_and(|previous| previous >= identity) { - bail!("runtime capabilities are not strictly sorted and unique"); - } - previous = Some(identity); - } - Ok(()) -} - -fn validate_expected_capabilities(capabilities: &[IdunnExpectedCapability]) -> Result<()> { - if capabilities.len() > MAX_RUNTIME_CAPABILITIES { - bail!("Expected capability count exceeds its contract bound"); - } - let mut previous: Option<(&str, &str, &str)> = None; - for capability in capabilities { - validate_authority_identifier(&capability.capability, "Expected capability")?; - validate_authority_identifier(&capability.schema, "Expected capability schema")?; - validate_authority_identifier( - &capability.compatibility, - "Expected capability compatibility", - )?; - if capability.minimum_capacity == 0 { - bail!("Expected capability minimum capacity is zero"); - } - let identity = capability.identity(); - if previous.is_some_and(|previous| previous >= identity) { - bail!("Expected capabilities are not strictly sorted and unique"); - } - previous = Some(identity); - } - Ok(()) -} - -fn validate_expected_dependencies(dependencies: &[IdunnExpectedDependency]) -> Result<()> { - if dependencies.len() > MAX_RUNTIME_DEPENDENCIES { - bail!("Expected dependency count exceeds its contract bound"); - } - let mut previous: Option<(&str, &str, &str)> = None; - for dependency in dependencies { - dependency.validate()?; - let identity = dependency.identity(); - if previous.is_some_and(|previous| previous >= identity) { - bail!("Expected dependencies are not strictly sorted and unique"); - } - previous = Some(identity); - } - Ok(()) -} - -fn validate_dependency_evidence(dependencies: &[OdinRuntimeDependencyEvidence]) -> Result<()> { - if dependencies.len() > MAX_RUNTIME_DEPENDENCIES { - bail!("dependency evidence count exceeds its contract bound"); - } - let mut previous: Option<(&str, &str, &str)> = None; - for dependency in dependencies { - dependency.validate()?; - let identity = dependency.identity(); - if previous.is_some_and(|previous| previous >= identity) { - bail!("dependency evidence is not strictly sorted and unique"); - } - previous = Some(identity); - } - Ok(()) -} - -fn validate_topology_disagreements(disagreements: &[OdinTopologyDisagreement]) -> Result<()> { - if disagreements.len() > MAX_TOPOLOGY_DISAGREEMENTS { - bail!("topology disagreement count exceeds its contract bound"); - } - let mut previous: Option<&str> = None; - for disagreement in disagreements { - disagreement.validate()?; - if previous.is_some_and(|previous| previous >= disagreement.code.as_str()) { - bail!("topology disagreements are not strictly sorted and unique by code"); - } - previous = Some(&disagreement.code); - } - Ok(()) -} - -fn validate_optional_detail(value: &Option, label: &str) -> Result<()> { - if value.as_deref().is_some_and(|value| { - value.is_empty() - || value.len() > 1024 - || value.trim() != value - || value.chars().any(char::is_control) - }) { - bail!("{label} is empty, oversized, padded, or contains control characters"); - } - Ok(()) -} - -fn validate_required_sha256(value: &str, label: &str) -> Result<()> { - if !is_sha256(value) { - bail!("{label} is malformed"); - } - Ok(()) -} - -fn validate_optional_sha256(value: &Option, label: &str) -> Result<()> { - if value.as_deref().is_some_and(|value| !is_sha256(value)) { - bail!("{label} is malformed"); - } - Ok(()) -} - -fn prefixed_sha256(bytes: &[u8]) -> String { - format!("sha256-{:x}", Sha256::digest(bytes)) -} - -fn is_sha256(value: &str) -> bool { - value - .strip_prefix("sha256-") - .is_some_and(|digest| is_lower_hex(digest, 64)) -} - -fn is_lower_hex(value: &str, length: usize) -> bool { - value.len() == length - && value - .bytes() - .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{ - GAMECULT_SERVICE_TRUST_ANCHOR_SCHEMA, GameCultProviderHealthIdentity, - GameCultRuntimePresenceHealthPurpose, GameCultServiceTrustAnchorRecord, - IDUNN_SIGNED_DAEMON_HEALTH_SCHEMA, IdunnServiceIdentity, - OdinRuntimeTopologyCorrelationPurpose, OdinTopologyIdentity, ServiceIdentitySignature, - ServiceIdentitySigner, ServiceSignaturePurpose, derive_service_identity_id, - enroll_service_identity_at, verify_service_identity_signature, - }; - - struct WrongOdinTopologyPurpose; - - impl ServiceSignaturePurpose for WrongOdinTopologyPurpose { - const PURPOSE: &'static [u8] = b"odin.not-runtime-topology-correlation.v1"; - } - - fn digest(byte: char) -> String { - format!("sha256-{}", byte.to_string().repeat(64)) - } - - fn runtime_capability(name: &str) -> GameCultRuntimeCapability { - GameCultRuntimeCapability { - capability: name.into(), - schema: format!("{name}.v1"), - compatibility: "v1".into(), - capacity: 1, - } - } - - fn expected_capability(name: &str) -> IdunnExpectedCapability { - IdunnExpectedCapability { - capability: name.into(), - schema: format!("{name}.v1"), - compatibility: "v1".into(), - minimum_capacity: 1, - } - } - - fn runtime_presence() -> GameCultRuntimePresenceHealthRecord { - GameCultRuntimePresenceHealthRecord { - schema_version: GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into(), - target: "ghostlight".into(), - expected_projection_sha256: digest('9'), - plan_id: digest('e'), - incarnation_id: "ghostlight/2026-09-02/1".into(), - sealed_release_id: digest('f'), - activation_witness_sha256: digest('a'), - state_schema_generation: Some("world-v2".into()), - state_contract_sha256: Some(digest('b')), - runtime_id: "ghostlight-yggdrasil-1".into(), - runtime_instance_id: digest('8'), - bound_endpoint: Some("http://127.0.0.1:14103".into()), - capabilities: vec![ - runtime_capability("conversation"), - runtime_capability("state"), - ], - health_contract: "ghostlight.runtime-health.v1".into(), - state: "active".into(), - detail: "ready".into(), - write_lease_sha256: Some(digest('c')), - signer_identity_id: "provider-signing-key".into(), - publisher_sequence: 1, - observed_at_unix_millis: 100, - signature_algorithm: "ed25519".into(), - signature: vec![6; 64], - activation_signer_identity_id: "activation-signing-key".into(), - activation_signature: vec![7; 64], - } - } - - fn runtime_activation() -> IdunnRuntimeActivationRecord { - let signer = IdunnRuntimeActivationSigner::from_credential_reader(&[8; 32][..]).unwrap(); - IdunnRuntimeActivationRecord { - schema_version: IDUNN_RUNTIME_ACTIVATION_SCHEMA.into(), - expected_projection_sha256: digest('9'), - runtime_id: "ghostlight-yggdrasil-1".into(), - runtime_instance_id: digest('8'), - activation_signer_identity_id: signer.identity_id(), - activation_signer_public_key: signer.public_key(), - issued_at_unix_millis: 99, - idunn_signer_identity_id: "idunn-signing-key".into(), - signature_algorithm: "ed25519".into(), - signature: vec![5; 64], - } - } - - fn process_write_lease() -> IdunnProcessWriteLeaseRecord { - IdunnProcessWriteLeaseRecord { - schema_version: IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into(), - target: "ghostlight".into(), - expected_projection_sha256: digest('9'), - plan_id: digest('e'), - incarnation_id: "ghostlight/2026-09-02/1".into(), - sealed_release_id: digest('f'), - activation_witness_sha256: digest('a'), - state_schema_generation: "world-v2".into(), - state_contract_sha256: digest('b'), - runtime_id: "ghostlight-yggdrasil-1".into(), - runtime_instance_id: digest('8'), - warming_presence_sha256: digest('d'), - lease_epoch: 1, - issued_at_unix_millis: 101, - } - } - - fn expected_dependency( - kind: &str, - capability: &str, - provider_id: Option<&str>, - ) -> IdunnExpectedDependency { - IdunnExpectedDependency { - kind: kind.into(), - capability: capability.into(), - schema: format!("{capability}.v1"), - compatibility: "v1".into(), - minimum_capacity: 1, - startup: if kind == "bootstrap" { - "before-start".into() - } else { - "before-promotion".into() - }, - provider_id: provider_id.map(str::to_string), - provider_authority: provider_id.map(|_| "managed-incarnation".into()), - provider_expected_projection_sha256: provider_id.map(|_| digest('1')), - provider_endpoint: provider_id.map(|_| "tcp://127.0.0.1:14100".into()), - } - } - - fn expected_incarnation() -> IdunnExpectedIncarnationRecord { - IdunnExpectedIncarnationRecord { - schema_version: IDUNN_EXPECTED_INCARNATION_SCHEMA.into(), - target: "ghostlight".into(), - plan_id: digest('2'), - incarnation_id: "ghostlight/2026-09-02/1".into(), - sealed_release_id: digest('3'), - source_repository: "github.com/GameCult/Ghostlight".into(), - source_revision: "4".repeat(40), - recipe_sha256: digest('5'), - runtime_id: "ghostlight-yggdrasil-1".into(), - expected_signer_identity_id: "ghostlight-provider-signing-key".into(), - health_contract: "ghostlight.runtime-health.v1".into(), - artifact_sha256: digest('6'), - state_schema_generation: Some("world-v2".into()), - state_contract_sha256: Some(digest('7')), - write_lease_required: true, - route: Some(IdunnExpectedRoute { - route_id: "ghostlight-public".into(), - transport: "http".into(), - stable_endpoint: "https://ghostlight.gamecult.net".into(), - candidate_endpoint: "http://127.0.0.1:14103".into(), - }), - capabilities: vec![ - expected_capability("conversation"), - expected_capability("state"), - ], - dependencies: vec![ - expected_dependency("shared-infrastructure", "rendezvous", Some("odin-ygg-1")), - expected_dependency("optional", "telemetry", None), - ], - } - } - - fn dependency_evidence(requirement: &IdunnExpectedDependency) -> OdinRuntimeDependencyEvidence { - let selected = requirement.provider_id.is_some(); - OdinRuntimeDependencyEvidence { - kind: requirement.kind.clone(), - capability: requirement.capability.clone(), - schema: requirement.schema.clone(), - compatibility: requirement.compatibility.clone(), - provider_id: requirement.provider_id.clone(), - provider_authority: requirement.provider_authority.clone(), - provider_expected_projection_sha256: requirement - .provider_expected_projection_sha256 - .clone(), - provider_endpoint: requirement.provider_endpoint.clone(), - observed_capacity: selected.then_some(1), - provider_evidence_sha256: selected.then(|| digest('8')), - ready: selected, - } - } - - fn topology_correlation( - expected: &IdunnExpectedIncarnationRecord, - ) -> OdinRuntimeTopologyCorrelationRecord { - OdinRuntimeTopologyCorrelationRecord { - schema_version: ODIN_RUNTIME_TOPOLOGY_CORRELATION_SCHEMA.into(), - target: expected.target.clone(), - expected_projection_sha256: expected.canonical_sha256().unwrap(), - expected: true, - current_activation_sha256: Some(digest('9')), - signed_presence_sha256: Some(digest('a')), - observed_presence_state: Some("active".into()), - observed_presence_publisher_sequence: Some(1), - observed_write_lease_sha256: Some(digest('c')), - observed_capabilities: expected - .capabilities - .iter() - .map(|capability| GameCultRuntimeCapability { - capability: capability.capability.clone(), - schema: capability.schema.clone(), - compatibility: capability.compatibility.clone(), - capacity: capability.minimum_capacity, - }) - .collect(), - runtime_id: expected.runtime_id.clone(), - runtime_instance_id: Some(digest('b')), - present: true, - ready: true, - dependencies: expected - .dependencies - .iter() - .map(dependency_evidence) - .collect(), - disagreements: vec![], - signer_identity_id: "odin-topology-signing-key".into(), - publisher_sequence: 1, - observed_at_unix_millis: 102, - signature_algorithm: "ed25519".into(), - signature: vec![7; 64], - } - } - - fn validate_against_current_lease( - receipt: &OdinRuntimeTopologyCorrelationRecord, - expected: &IdunnExpectedIncarnationRecord, - ) -> Result<()> { - let current_lease = digest('c'); - receipt.validate_against_expected(expected, Some(¤t_lease)) - } - - fn topology_anchor() -> GameCultServiceTrustAnchorRecord { - let public_key = vec![9; 32]; - GameCultServiceTrustAnchorRecord { - schema_version: GAMECULT_SERVICE_TRUST_ANCHOR_SCHEMA.into(), - trust_anchor_id: "root/odin/runtime-topology".into(), - service_id: "odin".into(), - runtime_id: "odin-yggdrasil-1".into(), - signer_identity_id: derive_service_identity_id::(&public_key) - .unwrap(), - signer_public_key: public_key, - signature_algorithm: "ed25519".into(), - signing_purpose: ODIN_RUNTIME_TOPOLOGY_CORRELATION_SIGNING_PURPOSE.into(), - signed_schema: ODIN_RUNTIME_TOPOLOGY_CORRELATION_SCHEMA.into(), - binding_authority: "root".into(), - bound_at_unix_millis: 100, - expires_at_unix_millis: Some(200), - private_state_exposed: false, - } - } - - fn runtime_presence_anchor() -> GameCultServiceTrustAnchorRecord { - let public_key = vec![7; 32]; - GameCultServiceTrustAnchorRecord { - schema_version: GAMECULT_SERVICE_TRUST_ANCHOR_SCHEMA.into(), - trust_anchor_id: "root/ghostlight/runtime-presence".into(), - service_id: "ghostlight".into(), - runtime_id: "ghostlight-yggdrasil-1".into(), - signer_identity_id: derive_service_identity_id::( - &public_key, - ) - .unwrap(), - signer_public_key: public_key, - signature_algorithm: "ed25519".into(), - signing_purpose: GAMECULT_RUNTIME_PRESENCE_HEALTH_SIGNING_PURPOSE.into(), - signed_schema: GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into(), - binding_authority: "root".into(), - bound_at_unix_millis: 100, - expires_at_unix_millis: Some(200), - private_state_exposed: false, - } - } - - struct DualProofFixture { - stable_signer: ServiceIdentitySigner, - activation_signer: IdunnRuntimeActivationSigner, - odin_signer: ServiceIdentitySigner, - idunn_anchor: crate::ServiceIdentityTrustAnchor, - stable_public_key: Vec, - odin_anchor: GameCultServiceTrustAnchorRecord, - expected: IdunnExpectedIncarnationRecord, - activation: IdunnRuntimeActivationRecord, - presence: GameCultRuntimePresenceHealthRecord, - } - - fn dual_proof_fixture(identity_path: &std::path::Path) -> Result { - let stable_signer = - enroll_service_identity_at::(identity_path)?; - let stable_public = stable_signer.trust_anchor()?; - let idunn_signer = enroll_service_identity_at::( - &identity_path.with_extension("idunn.cc"), - )?; - let idunn_anchor = idunn_signer.trust_anchor()?; - let odin_signer = enroll_service_identity_at::( - &identity_path.with_extension("odin.cc"), - )?; - let odin_public = odin_signer.trust_anchor()?; - let mut expected = expected_incarnation(); - expected.expected_signer_identity_id = stable_public.identity_id.clone(); - expected.capabilities[0].minimum_capacity = 2; - let launch = - IdunnRuntimeActivationLaunch::issue(&expected, digest('8'), 99, &idunn_signer)?; - let activation = launch.activation().clone(); - let mut activation_credential = Vec::new(); - assert_eq!( - launch.write_credential(&mut activation_credential)?, - activation - ); - let activation_signer = - IdunnRuntimeActivationSigner::from_credential_reader(&activation_credential[..])?; - - let stable_public_key = stable_public.public_key.clone(); - let odin_anchor = GameCultServiceTrustAnchorRecord { - schema_version: GAMECULT_SERVICE_TRUST_ANCHOR_SCHEMA.into(), - trust_anchor_id: "root/odin/runtime-topology".into(), - service_id: "odin".into(), - runtime_id: "odin-yggdrasil-1".into(), - signer_identity_id: odin_public.identity_id, - signer_public_key: odin_public.public_key, - signature_algorithm: "ed25519".into(), - signing_purpose: ODIN_RUNTIME_TOPOLOGY_CORRELATION_SIGNING_PURPOSE.into(), - signed_schema: ODIN_RUNTIME_TOPOLOGY_CORRELATION_SCHEMA.into(), - binding_authority: "root".into(), - bound_at_unix_millis: 1, - expires_at_unix_millis: Some(1_000), - private_state_exposed: false, - }; - - let mut presence = runtime_presence(); - presence.target = expected.target.clone(); - presence.plan_id = expected.plan_id.clone(); - presence.incarnation_id = expected.incarnation_id.clone(); - presence.sealed_release_id = expected.sealed_release_id.clone(); - presence.state_schema_generation = expected.state_schema_generation.clone(); - presence.state_contract_sha256 = expected.state_contract_sha256.clone(); - presence.bound_endpoint = expected - .route - .as_ref() - .map(|route| route.candidate_endpoint.clone()); - presence.capabilities = expected - .capabilities - .iter() - .map(|capability| GameCultRuntimeCapability { - capability: capability.capability.clone(), - schema: capability.schema.clone(), - compatibility: capability.compatibility.clone(), - capacity: capability.minimum_capacity, - }) - .collect(); - presence.health_contract = expected.health_contract.clone(); - presence.runtime_id = activation.runtime_id.clone(); - presence.runtime_instance_id = activation.runtime_instance_id.clone(); - presence.expected_projection_sha256 = activation.expected_projection_sha256.clone(); - presence.activation_witness_sha256 = activation.canonical_sha256()?; - presence.signer_identity_id = stable_public.identity_id; - presence.activation_signer_identity_id = activation_signer.identity_id(); - presence.observed_at_unix_millis = 110; - presence.signature.clear(); - presence.activation_signature.clear(); - let proof_payload = presence.canonical_proof_payload()?; - presence.signature = stable_signer - .sign::(&proof_payload) - .signature; - presence.activation_signature = activation_signer.sign_presence_proof(&presence)?; - - Ok(DualProofFixture { - stable_signer, - activation_signer, - odin_signer, - idunn_anchor, - stable_public_key, - odin_anchor, - expected, - activation, - presence, - }) - } - - fn resign_presence(fixture: &mut DualProofFixture) -> Result> { - fixture.presence.signature.clear(); - fixture.presence.activation_signature.clear(); - let proof_payload = fixture.presence.canonical_proof_payload()?; - fixture.presence.signature = fixture - .stable_signer - .sign::(&proof_payload) - .signature; - fixture.presence.activation_signature = fixture - .activation_signer - .sign_presence_proof(&fixture.presence)?; - Ok(rmp_serde::to_vec(&fixture.presence)?) - } - - fn fixture_authority(fixture: &DualProofFixture) -> Result { - verify_runtime_authority( - &fixture.expected, - &fixture.activation, - &fixture.idunn_anchor, - &fixture.stable_public_key, - ) - } - - fn authenticate_fixture( - fixture: &DualProofFixture, - ) -> Result { - authenticate_runtime_presence_claim( - &rmp_serde::to_vec(&fixture.presence)?, - &fixture_authority(fixture)?, - RuntimePresenceAuthenticationContext { - trusted_received_at_unix_millis: 120, - maximum_age_millis: 30, - maximum_future_skew_millis: 5, - }, - ) - } - - fn correlate_fixture(fixture: &DualProofFixture) -> Result { - correlate_runtime_presence_claim( - authenticate_fixture(fixture)?, - &fixture_authority(fixture)?, - ) - } - - fn verify_fixture(fixture: &DualProofFixture) -> Result { - correlate_fixture(fixture)?.into_undisputed_present() - } - - fn authenticated_topology_receipt( - fixture: &DualProofFixture, - presence: &VerifiedRuntimePresence, - present: bool, - disagreements: Vec, - ) -> Result { - let authority = fixture_authority(fixture)?; - let mut receipt = topology_correlation(&fixture.expected); - receipt.current_activation_sha256 = Some(authority.activation_sha256().into()); - receipt.signed_presence_sha256 = Some(presence.signed_presence_sha256().into()); - receipt.observed_presence_state = Some(presence.record().state.clone()); - receipt.observed_presence_publisher_sequence = Some(presence.record().publisher_sequence); - receipt.observed_write_lease_sha256 = presence.record().write_lease_sha256.clone(); - receipt.observed_capabilities = presence.record().capabilities.clone(); - receipt.runtime_instance_id = Some(fixture.activation.runtime_instance_id.clone()); - receipt.present = present; - receipt.ready = present && presence.record().state == "active"; - receipt.disagreements = disagreements; - receipt.signer_identity_id = fixture.odin_anchor.signer_identity_id.clone(); - receipt.signature.clear(); - receipt.signature = fixture - .odin_signer - .sign::(&receipt.unsigned_signature_payload()?) - .signature; - let encoded = receipt.canonical_bytes()?; - authenticate_odin_runtime_topology_correlation( - &encoded, - &authority, - presence.record().write_lease_sha256.as_deref(), - &fixture.odin_anchor.signer_public_key, - OdinTopologyAuthenticationContext { - trusted_received_at_unix_millis: 120, - maximum_age_millis: 30, - maximum_future_skew_millis: 5, - }, - ) - } - - #[test] - fn runtime_authority_contracts_round_trip_as_canonical_positional_records() -> Result<()> { - let presence = runtime_presence(); - presence.validate()?; - let encoded_presence = rmp_serde::to_vec(&presence)?; - assert_eq!(messagepack_array_len(&encoded_presence), Some(24)); - let decoded_presence: GameCultRuntimePresenceHealthRecord = - rmp_serde::from_slice(&encoded_presence)?; - assert_eq!(decoded_presence, presence); - let unsigned = presence.canonical_proof_payload()?; - let unsigned_presence: GameCultRuntimePresenceHealthRecord = - rmp_serde::from_slice(&unsigned)?; - assert!(unsigned_presence.signature.is_empty()); - assert!(unsigned_presence.activation_signature.is_empty()); - - let activation = runtime_activation(); - let encoded_activation = activation.canonical_bytes()?; - assert_eq!(messagepack_array_len(&encoded_activation), Some(10)); - assert_eq!( - IdunnRuntimeActivationRecord::decode_canonical(&encoded_activation)?, - activation - ); - assert_eq!( - activation.canonical_sha256()?, - prefixed_sha256(&encoded_activation) - ); - - let lease = process_write_lease(); - lease.validate()?; - let encoded_lease = lease.canonical_bytes()?; - assert_eq!(messagepack_array_len(&encoded_lease), Some(14)); - assert_eq!( - IdunnProcessWriteLeaseRecord::decode_canonical(&encoded_lease)?, - lease - ); - assert_eq!(lease.canonical_sha256()?, prefixed_sha256(&encoded_lease)); - Ok(()) - } - - #[test] - fn exact_stable_and_activation_proofs_admit_runtime_presence() -> Result<()> { - let temp = tempfile::tempdir()?; - let fixture = dual_proof_fixture(&temp.path().join("runtime-presence.cc"))?; - assert_eq!(fixture.activation.activation_signer_public_key.len(), 32); - let verified = verify_fixture(&fixture)?; - assert_eq!(verified.record(), &fixture.presence); - Ok(()) - } - - #[test] - fn authenticated_expected_disagreements_remain_typed_present_and_not_ready() -> Result<()> { - type Mutation = fn(&mut GameCultRuntimePresenceHealthRecord); - let cases: &[(&str, &str, Mutation)] = &[ - ("target", "target", |value| value.target = "epiphany".into()), - ("projection", "expected-projection", |value| { - value.expected_projection_sha256 = digest('0') - }), - ("plan", "plan-id", |value| value.plan_id = digest('0')), - ("incarnation", "incarnation-id", |value| { - value.incarnation_id = "ghostlight/other/1".into() - }), - ("release", "sealed-release-id", |value| { - value.sealed_release_id = digest('0') - }), - ("runtime", "runtime-id", |value| { - value.runtime_id = "ghostlight-other-runtime".into() - }), - ("process", "runtime-instance-id", |value| { - value.runtime_instance_id = digest('0') - }), - ("endpoint", "bound-endpoint", |value| { - value.bound_endpoint = Some("http://127.0.0.1:14104".into()) - }), - ("health", "health-contract", |value| { - value.health_contract = "ghostlight.other-health.v1".into() - }), - ("state-generation", "state-schema-generation", |value| { - value.state_schema_generation = Some("world-v3".into()) - }), - ("state-contract", "state-contract", |value| { - value.state_contract_sha256 = Some(digest('0')) - }), - ("activation", "activation-witness", |value| { - value.activation_witness_sha256 = digest('0') - }), - ]; - - let temp = tempfile::tempdir()?; - for (name, expected_code, mutate) in cases { - let mut fixture = dual_proof_fixture(&temp.path().join(format!("{name}.cc")))?; - mutate(&mut fixture.presence); - resign_presence(&mut fixture)?; - let correlation = correlate_fixture(&fixture)?; - assert!( - correlation - .disagreements() - .iter() - .any(|disagreement| disagreement.code == *expected_code), - "missing {expected_code} disagreement for {name}" - ); - assert_eq!( - correlation.claim().record().runtime_instance_id, - fixture.presence.runtime_instance_id - ); - assert!(correlation.into_undisputed_present().is_err()); - } - Ok(()) - } - - #[test] - fn provider_owns_observed_capacity_while_expected_owns_only_the_minimum() -> Result<()> { - let temp = tempfile::tempdir()?; - let mut fixture = dual_proof_fixture(&temp.path().join("runtime-presence.cc"))?; - fixture.presence.capabilities[0].capacity = 50; - fixture - .presence - .capabilities - .push(runtime_capability("telemetry")); - resign_presence(&mut fixture)?; - verify_fixture(&fixture)?; - - fixture.presence.capabilities[0].capacity = 1; - fixture - .presence - .capabilities - .retain(|capability| capability.capability != "state"); - resign_presence(&mut fixture)?; - let correlation = correlate_fixture(&fixture)?; - let codes = correlation - .disagreements() - .iter() - .map(|disagreement| disagreement.code.as_str()) - .collect::>(); - assert!(codes.contains(&"expected-capability-000-capacity")); - assert!(codes.contains(&"expected-capability-001-missing")); - assert!(!codes.iter().any(|code| code.contains("unexpected"))); - Ok(()) - } - - #[test] - fn stable_proof_and_provider_key_substitution_fail_closed() -> Result<()> { - let temp = tempfile::tempdir()?; - let mut fixture = dual_proof_fixture(&temp.path().join("runtime-presence.cc"))?; - fixture.presence.detail = "tampered after signing".into(); - assert!(authenticate_fixture(&fixture).is_err()); - - let other_idunn = enroll_service_identity_at::( - &temp.path().join("other-idunn.cc"), - )?; - assert!( - verify_runtime_authority( - &fixture.expected, - &fixture.activation, - &other_idunn.trust_anchor()?, - &fixture.stable_public_key, - ) - .is_err() - ); - - fixture.stable_public_key = vec![9; 32]; - assert!(fixture_authority(&fixture).is_err()); - Ok(()) - } - - #[test] - fn incumbent_stable_key_without_activation_key_cannot_claim_presence() -> Result<()> { - let temp = tempfile::tempdir()?; - let mut fixture = dual_proof_fixture(&temp.path().join("runtime-presence.cc"))?; - fixture.presence.activation_signature = vec![0; 64]; - let proof_payload = fixture.presence.canonical_proof_payload()?; - fixture.presence.signature = fixture - .stable_signer - .sign::(&proof_payload) - .signature; - - let error = verify_fixture(&fixture).unwrap_err(); - assert!(error.to_string().contains("activation proof verification")); - Ok(()) - } - - #[test] - fn activation_cannot_substitute_another_expected_runtime_id() -> Result<()> { - let temp = tempfile::tempdir()?; - let mut fixture = dual_proof_fixture(&temp.path().join("runtime-presence.cc"))?; - fixture.activation.runtime_id = "ghostlight-yggdrasil-2".into(); - fixture.presence.runtime_id = fixture.activation.runtime_id.clone(); - fixture.presence.activation_witness_sha256 = fixture.activation.canonical_sha256()?; - - // The stable service key can re-sign the altered canonical statement, - // but the activation proof still came from the other runtime id. - let proof_payload = fixture.presence.canonical_proof_payload()?; - fixture.presence.signature = fixture - .stable_signer - .sign::(&proof_payload) - .signature; - let error = fixture_authority(&fixture).unwrap_err(); - assert!(error.to_string().contains("current Expected")); - Ok(()) - } - - #[test] - fn idunn_signature_prevents_provider_minted_activation() -> Result<()> { - let temp = tempfile::tempdir()?; - let mut fixture = dual_proof_fixture(&temp.path().join("runtime-presence.cc"))?; - fixture.activation.issued_at_unix_millis += 1; - fixture.presence.activation_witness_sha256 = fixture.activation.canonical_sha256()?; - resign_presence(&mut fixture)?; - let error = fixture_authority(&fixture).unwrap_err(); - assert!( - error - .to_string() - .contains("Idunn runtime activation signature") - ); - Ok(()) - } - - #[test] - fn authenticated_odin_receipt_is_fresh_evidence_not_replay_authority() -> Result<()> { - let temp = tempfile::tempdir()?; - let fixture = dual_proof_fixture(&temp.path().join("runtime-presence.cc"))?; - let authority = fixture_authority(&fixture)?; - let presence = verify_fixture(&fixture)?; - let accepted = authenticated_topology_receipt(&fixture, &presence, true, Vec::new())?; - assert_eq!( - accepted.record().observed_presence_publisher_sequence, - Some(fixture.presence.publisher_sequence) - ); - assert_eq!( - accepted.record().signed_presence_sha256.as_deref(), - Some(presence.signed_presence_sha256()) - ); - - let mut tampered = accepted.record().clone(); - tampered.signature = vec![0; 64]; - assert!( - authenticate_odin_runtime_topology_correlation( - &tampered.canonical_bytes()?, - &authority, - presence.record().write_lease_sha256.as_deref(), - &fixture.odin_anchor.signer_public_key, - OdinTopologyAuthenticationContext { - trusted_received_at_unix_millis: 120, - maximum_age_millis: 30, - maximum_future_skew_millis: 5, - }, - ) - .is_err() - ); - assert!( - authenticate_odin_runtime_topology_correlation( - accepted.canonical_bytes(), - &authority, - presence.record().write_lease_sha256.as_deref(), - &fixture.odin_anchor.signer_public_key, - OdinTopologyAuthenticationContext { - trusted_received_at_unix_millis: 200, - maximum_age_millis: 30, - maximum_future_skew_millis: 5, - }, - ) - .is_err() - ); - Ok(()) - } - - #[test] - fn separate_launches_receive_distinct_activation_identities() -> Result<()> { - let temp = tempfile::tempdir()?; - let idunn = - enroll_service_identity_at::(&temp.path().join("idunn.cc"))?; - let expected = expected_incarnation(); - let first = IdunnRuntimeActivationLaunch::issue(&expected, digest('8'), 99, &idunn)?; - let second = IdunnRuntimeActivationLaunch::issue(&expected, digest('9'), 100, &idunn)?; - assert_ne!( - first.activation().activation_signer_identity_id, - second.activation().activation_signer_identity_id - ); - Ok(()) - } - - #[test] - fn trusted_time_rejects_stale_and_future_presence() -> Result<()> { - let temp = tempfile::tempdir()?; - let mut fixture = dual_proof_fixture(&temp.path().join("runtime-presence.cc"))?; - let authority = fixture_authority(&fixture)?; - let canonical = rmp_serde::to_vec(&fixture.presence)?; - assert!( - authenticate_runtime_presence_claim( - &canonical, - &authority, - RuntimePresenceAuthenticationContext { - trusted_received_at_unix_millis: 200, - maximum_age_millis: 10, - maximum_future_skew_millis: 5, - }, - ) - .is_err() - ); - - fixture.presence.observed_at_unix_millis = 126; - resign_presence(&mut fixture)?; - assert!( - authenticate_runtime_presence_claim( - &rmp_serde::to_vec(&fixture.presence)?, - &authority, - RuntimePresenceAuthenticationContext { - trusted_received_at_unix_millis: 120, - maximum_age_millis: 30, - maximum_future_skew_millis: 5, - }, - ) - .is_err() - ); - - Ok(()) - } - - #[test] - fn runtime_authority_decoders_reject_malformed_and_noncanonical_bytes() -> Result<()> { - let temp = tempfile::tempdir()?; - let fixture = dual_proof_fixture(&temp.path().join("runtime-presence.cc"))?; - assert!( - authenticate_runtime_presence_claim( - &[0x91, 0xc0], - &fixture_authority(&fixture)?, - RuntimePresenceAuthenticationContext { - trusted_received_at_unix_millis: 120, - maximum_age_millis: 30, - maximum_future_skew_millis: 5, - }, - ) - .is_err() - ); - assert!(IdunnRuntimeActivationRecord::decode_canonical(&[0x91, 0xc0]).is_err()); - assert!(IdunnProcessWriteLeaseRecord::decode_canonical(&[0x91, 0xc0]).is_err()); - - let mut legacy_presence = fixture.presence.clone(); - legacy_presence.schema_version = "gamecult.runtime_presence_health.v1".into(); - assert!( - authenticate_runtime_presence_claim( - &rmp_serde::to_vec(&legacy_presence)?, - &fixture_authority(&fixture)?, - RuntimePresenceAuthenticationContext { - trusted_received_at_unix_millis: 120, - maximum_age_millis: 30, - maximum_future_skew_millis: 5, - }, - ) - .is_err() - ); - let mut legacy_activation = fixture.activation.clone(); - legacy_activation.schema_version = "idunn.runtime_activation.v1".into(); - assert!( - IdunnRuntimeActivationRecord::decode_canonical(&rmp_serde::to_vec(&legacy_activation)?) - .is_err() - ); - let mut legacy_expected = fixture.expected.clone(); - legacy_expected.schema_version = "idunn.expected_incarnation.v1".into(); - assert!( - IdunnExpectedIncarnationRecord::decode_canonical(&rmp_serde::to_vec(&legacy_expected)?) - .is_err() - ); - let mut legacy_topology = topology_correlation(&fixture.expected); - legacy_topology.schema_version = "odin.runtime_topology_correlation.v1".into(); - assert!( - OdinRuntimeTopologyCorrelationRecord::decode_canonical_signed_payload( - &rmp_serde::to_vec(&legacy_topology)?, - ) - .is_err() - ); - - let presence = rmp_serde::to_vec(&runtime_presence())?; - assert_eq!(&presence[..3], &[0xdc, 0, 24]); - let mut noncanonical_presence = vec![0xdd, 0, 0, 0, 24]; - noncanonical_presence.extend_from_slice(&presence[3..]); - assert!( - authenticate_runtime_presence_claim( - &noncanonical_presence, - &fixture_authority(&fixture)?, - RuntimePresenceAuthenticationContext { - trusted_received_at_unix_millis: 120, - maximum_age_millis: 30, - maximum_future_skew_millis: 5, - }, - ) - .is_err() - ); - - let activation = runtime_activation().canonical_bytes()?; - assert_eq!(activation[0], 0x9a); - let mut noncanonical_activation = vec![0xdc, 0, 10]; - noncanonical_activation.extend_from_slice(&activation[1..]); - assert!(IdunnRuntimeActivationRecord::decode_canonical(&noncanonical_activation).is_err()); - - let lease = process_write_lease().canonical_bytes()?; - assert_eq!(lease[0], 0x9e); - let mut noncanonical_lease = vec![0xdc, 0, 14]; - noncanonical_lease.extend_from_slice(&lease[1..]); - assert!(IdunnProcessWriteLeaseRecord::decode_canonical(&noncanonical_lease).is_err()); - Ok(()) - } - - #[test] - fn runtime_presence_refuses_capability_and_warming_lease_ambiguity() { - let mut value = runtime_presence(); - value.capabilities.reverse(); - assert!(value.validate().is_err()); - - let mut value = runtime_presence(); - let mut duplicate = runtime_capability("state"); - duplicate.capacity = 2; - value.capabilities.push(duplicate); - assert!(value.validate().is_err()); - - let mut value = runtime_presence(); - value.capabilities[0].capacity = 0; - assert!(value.validate().is_err()); - - let mut value = runtime_presence(); - value.state = "warming".into(); - assert!(value.validate().is_err()); - value.write_lease_sha256 = None; - assert!(value.validate().is_ok()); - - let mut value = runtime_presence(); - value.state_schema_generation = None; - assert!(value.validate().is_err()); - value.state_contract_sha256 = None; - assert!(value.validate().is_err()); - value.write_lease_sha256 = None; - assert!(value.validate().is_ok()); - } - - #[test] - fn runtime_authority_refuses_malformed_digest_process_and_epoch_bindings() { - let mut value = runtime_presence(); - value.activation_witness_sha256 = digest('A'); - assert!(value.validate().is_err()); - - let mut value = runtime_presence(); - value.plan_id = "plan-1".into(); - assert!(value.validate().is_err()); - - let mut value = runtime_presence(); - value.sealed_release_id = "release-1".into(); - assert!(value.validate().is_err()); - - let mut value = runtime_presence(); - value.runtime_instance_id = "systemd-invocation-1".into(); - assert!(value.validate().is_err()); - - let mut value = runtime_presence(); - value.expected_projection_sha256 = "expected-1".into(); - assert!(value.validate().is_err()); - - let mut value = runtime_presence(); - value.write_lease_sha256 = Some("lease-1".into()); - assert!(value.validate().is_err()); - - let mut value = process_write_lease(); - value.warming_presence_sha256 = digest('D'); - assert!(value.validate().is_err()); - - let mut value = process_write_lease(); - value.plan_id = "plan-1".into(); - assert!(value.validate().is_err()); - - let mut value = process_write_lease(); - value.runtime_instance_id = "pid-4242".into(); - assert!(value.validate().is_err()); - - let mut value = process_write_lease(); - value.lease_epoch = 0; - assert!(value.validate().is_err()); - - let mut value = runtime_activation(); - value.runtime_instance_id = digest('A'); - assert!(value.validate().is_err()); - - let mut value = runtime_activation(); - value.expected_projection_sha256 = "expected-1".into(); - assert!(value.validate().is_err()); - } - - #[test] - fn runtime_presence_trust_anchor_is_provider_profile_and_purpose_bound() -> Result<()> { - let anchor = runtime_presence_anchor(); - anchor.validate()?; - - let mut value = anchor.clone(); - value.signing_purpose = "idunn.signed_daemon_health.v1".into(); - assert!(value.validate().is_err()); - - let mut value = anchor.clone(); - value.signed_schema = IDUNN_SIGNED_DAEMON_HEALTH_SCHEMA.into(); - assert!(value.validate().is_err()); - - let mut value = anchor.clone(); - value.signing_purpose = "gamecult.runtime_presence_health.v1".into(); - value.signed_schema = "gamecult.runtime_presence_health.v1".into(); - assert!(value.validate().is_err()); - - let mut value = anchor; - value.signer_identity_id = - derive_service_identity_id::(&value.signer_public_key)?; - assert!(value.validate().is_err()); - Ok(()) - } - - #[test] - fn process_write_lease_cannot_encode_route_or_traffic_authority() -> Result<()> { - let canonical = process_write_lease().canonical_bytes()?; - assert_eq!(messagepack_array_len(&canonical), Some(14)); - - for forbidden in ["route", "traffic"] { - let mut with_extra_authority = vec![0xdc, 0, 15]; - with_extra_authority.extend_from_slice(&canonical[1..]); - with_extra_authority.extend_from_slice(&rmp_serde::to_vec(&forbidden)?); - assert!(IdunnProcessWriteLeaseRecord::decode_canonical(&with_extra_authority).is_err()); - } - Ok(()) - } - - #[test] - fn expected_and_topology_correlation_round_trip_canonically() -> Result<()> { - let expected = expected_incarnation(); - let expected_bytes = expected.canonical_bytes()?; - assert_eq!(&expected_bytes[..3], &[0xdc, 0, 18]); - assert_eq!( - IdunnExpectedIncarnationRecord::decode_canonical(&expected_bytes)?, - expected - ); - assert_eq!( - expected.canonical_sha256()?, - prefixed_sha256(&expected_bytes) - ); - - let receipt = topology_correlation(&expected); - validate_against_current_lease(&receipt, &expected)?; - let receipt_bytes = receipt.canonical_bytes()?; - assert_eq!(&receipt_bytes[..3], &[0xdc, 0, 21]); - let (decoded, unsigned) = - OdinRuntimeTopologyCorrelationRecord::decode_canonical_signed_payload(&receipt_bytes)?; - assert_eq!(decoded, receipt); - let unsigned_receipt: OdinRuntimeTopologyCorrelationRecord = - rmp_serde::from_slice(&unsigned)?; - assert!(unsigned_receipt.signature.is_empty()); - assert_eq!(unsigned, receipt.unsigned_signature_payload()?); - assert_eq!(receipt.canonical_sha256()?, prefixed_sha256(&receipt_bytes)); - Ok(()) - } - - #[test] - fn expected_and_topology_decoders_reject_noncanonical_or_extended_records() -> Result<()> { - assert!(IdunnExpectedIncarnationRecord::decode_canonical(&[0x91, 0xc0]).is_err()); - assert!( - OdinRuntimeTopologyCorrelationRecord::decode_canonical_signed_payload(&[0x91, 0xc0]) - .is_err() - ); - - let expected = expected_incarnation().canonical_bytes()?; - let mut noncanonical_expected = vec![0xdd, 0, 0, 0, 18]; - noncanonical_expected.extend_from_slice(&expected[3..]); - assert!(IdunnExpectedIncarnationRecord::decode_canonical(&noncanonical_expected).is_err()); - - let receipt = topology_correlation(&expected_incarnation()).canonical_bytes()?; - let mut noncanonical_receipt = vec![0xdd, 0, 0, 0, 21]; - noncanonical_receipt.extend_from_slice(&receipt[3..]); - assert!( - OdinRuntimeTopologyCorrelationRecord::decode_canonical_signed_payload( - &noncanonical_receipt, - ) - .is_err() - ); - - for forbidden in ["private-host-path", "runner-secret"] { - let mut extended = vec![0xdc, 0, 19]; - extended.extend_from_slice(&expected[3..]); - extended.extend_from_slice(&rmp_serde::to_vec(&forbidden)?); - assert!(IdunnExpectedIncarnationRecord::decode_canonical(&extended).is_err()); - } - Ok(()) - } - - #[test] - fn expected_refuses_partial_state_and_noncanonical_claim_sets() { - let mut value = expected_incarnation(); - value.state_contract_sha256 = None; - assert!(value.validate().is_err()); - - let mut value = expected_incarnation(); - value.state_schema_generation = None; - assert!(value.validate().is_err()); - - let mut value = expected_incarnation(); - value.state_schema_generation = None; - value.state_contract_sha256 = None; - assert!(value.validate().is_err()); - value.write_lease_required = false; - assert!(value.validate().is_ok()); - - let mut value = expected_incarnation(); - value.capabilities.reverse(); - assert!(value.validate().is_err()); - - let mut value = expected_incarnation(); - value.capabilities.push(expected_capability("state")); - assert!(value.validate().is_err()); - - let mut value = expected_incarnation(); - value.dependencies.reverse(); - assert!(value.validate().is_err()); - - let mut value = expected_incarnation(); - value.dependencies.push(value.dependencies[1].clone()); - assert!(value.validate().is_err()); - - let mut value = expected_incarnation(); - let mut duplicate = value.dependencies[1].clone(); - duplicate.kind = "required".into(); - value.dependencies.push(duplicate); - assert!(value.validate().is_err()); - - let mut value = expected_incarnation(); - value.dependencies[0].provider_authority = None; - assert!(value.validate().is_err()); - - let mut value = expected_incarnation(); - value.source_revision = "A".repeat(40); - assert!(value.validate().is_err()); - - for private_source in [ - "F:\\Projects\\Ghostlight", - "/srv/build/Ghostlight", - "https://token@github.com/GameCult/Ghostlight", - ] { - let mut value = expected_incarnation(); - value.source_repository = private_source.into(); - assert!(value.validate().is_err()); - } - - let mut value = expected_incarnation(); - let stable_endpoint = value.route.as_ref().unwrap().stable_endpoint.clone(); - value.route.as_mut().unwrap().candidate_endpoint = stable_endpoint; - assert!(value.validate().is_err()); - } - - #[test] - fn expected_route_keeps_rudp_distinct_and_scheme_exact() { - let mut value = expected_incarnation(); - value.route = Some(IdunnExpectedRoute { - route_id: "odin-rendezvous".into(), - transport: "rudp".into(), - stable_endpoint: "rudp://10.77.0.1:17871".into(), - candidate_endpoint: "rudp://127.0.0.1:24171".into(), - }); - assert!(value.validate().is_ok()); - - value.route.as_mut().unwrap().candidate_endpoint = "tcp://127.0.0.1:24171".into(); - assert!(value.validate().is_err()); - - value.route.as_mut().unwrap().candidate_endpoint = "udp://127.0.0.1:24171".into(); - assert!(value.validate().is_err()); - } - - #[test] - fn topology_correlation_refuses_expected_substitution_and_dependency_omission() { - let expected = expected_incarnation(); - - let mut value = topology_correlation(&expected); - value.target = "epiphany".into(); - assert!(value.validate().is_ok()); - assert!(validate_against_current_lease(&value, &expected).is_err()); - - let mut value = topology_correlation(&expected); - value.runtime_id = "ghostlight-other-runtime".into(); - assert!(validate_against_current_lease(&value, &expected).is_err()); - - let mut value = topology_correlation(&expected); - value.expected_projection_sha256 = digest('c'); - assert!(validate_against_current_lease(&value, &expected).is_err()); - - let mut value = topology_correlation(&expected); - value.dependencies.pop(); - assert!(value.validate().is_ok()); - assert!(validate_against_current_lease(&value, &expected).is_err()); - - let mut value = topology_correlation(&expected); - value.dependencies[0].provider_id = Some("odin-substitute".into()); - assert!(value.validate().is_ok()); - assert!(validate_against_current_lease(&value, &expected).is_err()); - - let mut value = topology_correlation(&expected); - value.dependencies[0].kind = "required".into(); - assert!(value.validate().is_ok()); - assert!(validate_against_current_lease(&value, &expected).is_err()); - - let mut value = topology_correlation(&expected); - value.dependencies[0].observed_capacity = Some(0); - assert!(value.validate().is_err()); - - let mut value = topology_correlation(&expected); - value.dependencies[0].observed_capacity = Some(1); - let mut stricter = expected.clone(); - stricter.dependencies[0].minimum_capacity = 2; - value.expected_projection_sha256 = stricter.canonical_sha256().unwrap(); - assert!(validate_against_current_lease(&value, &stricter).is_err()); - } - - #[test] - fn topology_correlation_requires_explicit_coherent_partial_states() { - let expected = expected_incarnation(); - let mut value = topology_correlation(&expected); - value.signed_presence_sha256 = None; - value.observed_presence_state = None; - value.observed_presence_publisher_sequence = None; - value.observed_write_lease_sha256 = None; - value.observed_capabilities.clear(); - value.present = false; - value.ready = false; - assert!(value.validate().is_err()); - - value.disagreements.push(OdinTopologyDisagreement { - code: "presence-missing".into(), - expected: Some("signed-runtime-presence".into()), - observed: None, - }); - assert!(value.validate().is_ok()); - - let mut value = topology_correlation(&expected); - value.runtime_instance_id = None; - assert!(value.validate().is_err()); - - let mut value = topology_correlation(&expected); - value.current_activation_sha256 = None; - value.present = true; - value.ready = false; - value.disagreements.push(OdinTopologyDisagreement { - code: "activation-missing".into(), - expected: Some("current-activation".into()), - observed: None, - }); - assert!(value.validate().is_err()); - - let mut value = topology_correlation(&expected); - value.expected = false; - value.present = false; - value.ready = false; - assert!(value.validate().is_err()); - } - - #[test] - fn topology_correlation_refuses_unsorted_or_duplicate_claims() { - let expected = expected_incarnation(); - let mut value = topology_correlation(&expected); - value.dependencies.reverse(); - assert!(value.validate().is_err()); - - let mut value = topology_correlation(&expected); - value.dependencies.push(value.dependencies[1].clone()); - assert!(value.validate().is_err()); - - let mut value = topology_correlation(&expected); - value.ready = false; - value.disagreements = vec![ - OdinTopologyDisagreement { - code: "schema-mismatch".into(), - expected: Some("state.v2".into()), - observed: Some("state.v1".into()), - }, - OdinTopologyDisagreement { - code: "endpoint-mismatch".into(), - expected: Some("route-a".into()), - observed: Some("route-b".into()), - }, - ]; - assert!(value.validate().is_err()); - - value.disagreements[1].code = "schema-mismatch".into(); - assert!(value.validate().is_err()); - } - - #[test] - fn topology_correlation_preserves_warming_and_exact_write_lease_authority() { - let expected = expected_incarnation(); - let value = topology_correlation(&expected); - validate_against_current_lease(&value, &expected).unwrap(); - assert!( - value - .validate_against_expected(&expected, Some(&digest('d'))) - .is_err() - ); - assert!(value.validate_against_expected(&expected, None).is_err()); - - let mut digest_only_ready = topology_correlation(&expected); - digest_only_ready.observed_write_lease_sha256 = None; - digest_only_ready.validate().unwrap(); - assert!( - digest_only_ready - .validate_against_expected(&expected, Some(&digest('c'))) - .is_err() - ); - - let mut warming = topology_correlation(&expected); - warming.observed_presence_state = Some("warming".into()); - warming.ready = false; - assert!(warming.validate().is_err()); - warming.observed_write_lease_sha256 = None; - warming.validate().unwrap(); - warming.validate_against_expected(&expected, None).unwrap(); - warming.ready = true; - assert!(warming.validate().is_err()); - - let mut partial = topology_correlation(&expected); - partial.observed_presence_state = None; - assert!(partial.validate().is_err()); - let mut partial = topology_correlation(&expected); - partial.signed_presence_sha256 = None; - assert!(partial.validate().is_err()); - - let mut stateless = expected.clone(); - stateless.state_schema_generation = None; - stateless.state_contract_sha256 = None; - stateless.write_lease_required = false; - let mut stateless_receipt = topology_correlation(&stateless); - stateless_receipt.observed_write_lease_sha256 = None; - stateless_receipt - .validate_against_expected(&stateless, None) - .unwrap(); - stateless_receipt.observed_write_lease_sha256 = Some(digest('c')); - assert!( - stateless_receipt - .validate_against_expected(&stateless, Some(&digest('c'))) - .is_err() - ); - } - - #[test] - fn topology_correlation_cannot_manufacture_ready() { - let expected = expected_incarnation(); - let mut value = topology_correlation(&expected); - value.dependencies[0].ready = false; - assert!(value.validate().is_err()); - - let mut value = topology_correlation(&expected); - value.disagreements.push(OdinTopologyDisagreement { - code: "capability-mismatch".into(), - expected: Some("conversation.v1".into()), - observed: Some("conversation.v0".into()), - }); - assert!(value.validate().is_err()); - - let mut value = topology_correlation(&expected); - value.present = false; - value.disagreements.push(OdinTopologyDisagreement { - code: "identity-mismatch".into(), - expected: Some("expected-runtime".into()), - observed: Some("other-runtime".into()), - }); - assert!(value.validate().is_err()); - - let mut value = topology_correlation(&expected); - value.dependencies[0].provider_evidence_sha256 = None; - assert!(value.validate().is_err()); - - let mut missing_capability = topology_correlation(&expected); - missing_capability.observed_capabilities.pop(); - assert!(missing_capability.validate().is_ok()); - assert!( - missing_capability - .validate_against_expected(&expected, Some(&digest('c'))) - .is_err() - ); - missing_capability.present = true; - missing_capability.ready = false; - missing_capability.disagreements = vec![OdinTopologyDisagreement { - code: "generic-mismatch".into(), - expected: Some("expected".into()), - observed: Some("observed".into()), - }]; - assert!( - missing_capability - .validate_against_expected(&expected, Some(&digest('c'))) - .is_err() - ); - correlate_capabilities( - &mut missing_capability.disagreements, - &expected.capabilities, - &missing_capability.observed_capabilities, - ); - missing_capability - .disagreements - .sort_by(|left, right| left.code.cmp(&right.code)); - missing_capability - .validate_against_expected(&expected, Some(&digest('c'))) - .unwrap(); - - let mut additional_capability = topology_correlation(&expected); - additional_capability - .observed_capabilities - .push(runtime_capability("telemetry")); - additional_capability - .validate_against_expected(&expected, Some(&digest('c'))) - .unwrap(); - } - - #[test] - fn topology_signature_is_exact_and_purpose_bound() -> Result<()> { - let temp = tempfile::tempdir()?; - let signer = enroll_service_identity_at::( - &temp.path().join("odin-topology.cc"), - )?; - let anchor = signer.trust_anchor()?; - let expected = expected_incarnation(); - let mut receipt = topology_correlation(&expected); - receipt.signer_identity_id = anchor.identity_id.clone(); - receipt.signature.clear(); - let unsigned = receipt.unsigned_signature_payload()?; - let proof = signer.sign::(&unsigned); - receipt.signature = proof.signature.clone(); - - let encoded = receipt.canonical_bytes()?; - let (decoded, decoded_unsigned) = - OdinRuntimeTopologyCorrelationRecord::decode_canonical_signed_payload(&encoded)?; - verify_service_identity_signature::< - OdinTopologyIdentity, - OdinRuntimeTopologyCorrelationPurpose, - >( - &anchor, - &decoded_unsigned, - &ServiceIdentitySignature { - identity_id: decoded.signer_identity_id, - signature: decoded.signature, - }, - )?; - - let mut substituted = decoded_unsigned.clone(); - substituted.push(0); - assert!( - verify_service_identity_signature::< - OdinTopologyIdentity, - OdinRuntimeTopologyCorrelationPurpose, - >(&anchor, &substituted, &proof) - .is_err() - ); - - let wrong_purpose = signer.sign::(&decoded_unsigned); - assert!( - verify_service_identity_signature::< - OdinTopologyIdentity, - OdinRuntimeTopologyCorrelationPurpose, - >(&anchor, &decoded_unsigned, &wrong_purpose) - .is_err() - ); - Ok(()) - } - - #[test] - fn topology_trust_anchor_is_odin_profile_and_purpose_bound() -> Result<()> { - let anchor = topology_anchor(); - anchor.validate()?; - - let mut value = anchor.clone(); - value.service_id = "idunn".into(); - assert!(value.validate().is_err()); - - let mut value = anchor.clone(); - value.signing_purpose = GAMECULT_RUNTIME_PRESENCE_HEALTH_SIGNING_PURPOSE.into(); - assert!(value.validate().is_err()); - - let mut value = anchor.clone(); - value.signed_schema = GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into(); - assert!(value.validate().is_err()); - - let mut value = anchor; - value.signer_identity_id = - derive_service_identity_id::(&value.signer_public_key)?; - assert!(value.validate().is_err()); - Ok(()) - } -} diff --git a/packages/cultnet-rs/src/service_identity.rs b/packages/cultnet-rs/src/service_identity.rs deleted file mode 100644 index 6f3e58d8..00000000 --- a/packages/cultnet-rs/src/service_identity.rs +++ /dev/null @@ -1,959 +0,0 @@ -use anyhow::{Context, Result, anyhow, bail}; -use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, SingleFileMessagePackBackingStore}; -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; -use rand::RngCore; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::io::{Read, Write}; -use std::marker::PhantomData; -use std::path::Path; -#[cfg(windows)] -use zeroize::Zeroize; -use zeroize::Zeroizing; - -pub const WINDOWS_SERVICE_IDENTITY_ASSURANCE: &str = "os_user_installation_bound_best_effort"; -pub const LINUX_SERVICE_IDENTITY_ASSURANCE: &str = "os_installation_file_bound_cloneable_baseline"; - -/// Fixes every authority-bearing name at the call site. A service cannot open -/// another service's identity by supplying a convenient runtime string. -pub trait ServiceIdentityProfile: Send + Sync + 'static { - const PRIVATE_TYPE: &'static str; - const PRIVATE_SCHEMA: &'static str; - const PRIVATE_KEY: &'static str; - const TRUST_ANCHOR_TYPE: &'static str; - const TRUST_ANCHOR_SCHEMA: &'static str; - const TRUST_ANCHOR_KEY: &'static str; - const ID_DOMAIN: &'static [u8]; - const SIGNATURE_DOMAIN: &'static [u8]; - const PROTECTOR_CONTEXT: &'static str; -} - -/// A purpose is a type, not caller-provided prose. Protocol implementations -/// define one zero-sized type per signed statement kind. -pub trait ServiceSignaturePurpose: Send + Sync + 'static { - const PURPOSE: &'static [u8]; -} - -pub enum IdunnServiceIdentity {} - -/// Odin-owned identity dedicated to signed Expected/Present/Ready topology -/// correlation. It is not a provider-health key and cannot issue Idunn -/// admission or lifecycle authority. -pub enum OdinTopologyIdentity {} - -pub struct OdinRuntimeTopologyCorrelationPurpose; - -impl ServiceSignaturePurpose for OdinRuntimeTopologyCorrelationPurpose { - const PURPOSE: &'static [u8] = b"odin.runtime_topology_correlation.v2"; -} - -impl ServiceIdentityProfile for OdinTopologyIdentity { - const PRIVATE_TYPE: &'static str = "odin.topology_identity.private.v1"; - const PRIVATE_SCHEMA: &'static str = "odin.topology_identity.private.v1"; - const PRIVATE_KEY: &'static str = "odin-topology-identity"; - const TRUST_ANCHOR_TYPE: &'static str = "odin.topology_identity.trust_anchor.v1"; - const TRUST_ANCHOR_SCHEMA: &'static str = "odin.topology_identity.trust_anchor.v1"; - const TRUST_ANCHOR_KEY: &'static str = "odin-topology-identity-public"; - const ID_DOMAIN: &'static [u8] = b"odin.topology-identity.id.v1\0"; - const SIGNATURE_DOMAIN: &'static [u8] = b"odin.topology-identity.signature.v1\0"; - const PROTECTOR_CONTEXT: &'static str = "odin-topology-identity-v1"; -} - -/// Root-operated identity dedicated to deployment-brake release grants. The -/// Idunn daemon receives only this profile's public trust anchor; opening the -/// private store is a provisioning operation, never daemon startup work. -pub enum IdunnDeploymentBrakeOperatorIdentity {} - -pub struct IdunnDeploymentBrakeReleasePurpose; - -impl ServiceSignaturePurpose - for IdunnDeploymentBrakeReleasePurpose -{ - const PURPOSE: &'static [u8] = b"idunn.deployment-release.v1"; -} - -impl ServiceIdentityProfile for IdunnDeploymentBrakeOperatorIdentity { - const PRIVATE_TYPE: &'static str = "idunn.deployment_brake_operator.private.v1"; - const PRIVATE_SCHEMA: &'static str = "idunn.deployment_brake_operator.private.v1"; - const PRIVATE_KEY: &'static str = "idunn-deployment-brake-operator"; - const TRUST_ANCHOR_TYPE: &'static str = "idunn.deployment_brake_operator.trust_anchor.v1"; - const TRUST_ANCHOR_SCHEMA: &'static str = "idunn.deployment_brake_operator.trust_anchor.v1"; - const TRUST_ANCHOR_KEY: &'static str = "idunn-deployment-brake-operator-public"; - const ID_DOMAIN: &'static [u8] = b"idunn.deployment-brake-operator.id.v1\0"; - const SIGNATURE_DOMAIN: &'static [u8] = b"idunn.deployment-brake-operator.signature.v1\0"; - const PROTECTOR_CONTEXT: &'static str = "idunn-deployment-brake-operator-v1"; -} - -/// Dedicated provider-owned identity for generic daemon-health statements. -/// It is intentionally independent of any one provider runtime or repository. -pub enum GameCultProviderHealthIdentity {} - -/// The only generic health statement purpose accepted from provider-health -/// identities by Idunn admission. -pub struct IdunnSignedDaemonHealthPurpose; - -impl ServiceSignaturePurpose for IdunnSignedDaemonHealthPurpose { - const PURPOSE: &'static [u8] = b"idunn.signed_daemon_health.v1"; -} - -/// Provider-owned runtime presence and health. This is deliberately distinct -/// from the legacy daemon-health purpose so an old signature cannot acquire -/// deployment-incarnation authority by substitution. -pub struct GameCultRuntimePresenceHealthPurpose; - -impl ServiceSignaturePurpose - for GameCultRuntimePresenceHealthPurpose -{ - const PURPOSE: &'static [u8] = b"gamecult.runtime_presence_health.v2"; -} - -impl ServiceIdentityProfile for GameCultProviderHealthIdentity { - const PRIVATE_TYPE: &'static str = "gamecult.provider_health_identity.private.v1"; - const PRIVATE_SCHEMA: &'static str = "gamecult.provider_health_identity.private.v1"; - const PRIVATE_KEY: &'static str = "gamecult-provider-health-identity"; - const TRUST_ANCHOR_TYPE: &'static str = "gamecult.provider_health_identity.trust_anchor.v1"; - const TRUST_ANCHOR_SCHEMA: &'static str = "gamecult.provider_health_identity.trust_anchor.v1"; - const TRUST_ANCHOR_KEY: &'static str = "gamecult-provider-health-identity-public"; - const ID_DOMAIN: &'static [u8] = b"gamecult.provider-health.identity.v1\0"; - const SIGNATURE_DOMAIN: &'static [u8] = b"gamecult.provider-health.signature.v1\0"; - const PROTECTOR_CONTEXT: &'static str = "gamecult-provider-health-identity-v1"; -} - -/// The only signing purpose accepted for Idunn's public projection of a -/// provider-authenticated health admission. Keeping this profile here makes -/// purpose selection a compile-time protocol choice rather than caller text. -pub struct IdunnAuthenticatedProviderHealthProjectionPurpose; - -impl ServiceSignaturePurpose - for IdunnAuthenticatedProviderHealthProjectionPurpose -{ - const PURPOSE: &'static [u8] = b"idunn.authenticated-provider-health-projection.v1"; -} - -/// Idunn's stable identity signs every activation before its ephemeral key is -/// handed to a workload. This makes the launch witness portable without -/// allowing a configuration file or provider to mint its own activation. -pub struct IdunnRuntimeActivationPurpose; - -impl ServiceSignaturePurpose for IdunnRuntimeActivationPurpose { - const PURPOSE: &'static [u8] = b"idunn.runtime_activation.v2"; -} - -impl ServiceIdentityProfile for IdunnServiceIdentity { - const PRIVATE_TYPE: &'static str = "idunn.service_identity.private.v1"; - const PRIVATE_SCHEMA: &'static str = "idunn.service_identity.private.v1"; - const PRIVATE_KEY: &'static str = "idunn-service-identity"; - const TRUST_ANCHOR_TYPE: &'static str = "idunn.service_identity.trust_anchor.v1"; - const TRUST_ANCHOR_SCHEMA: &'static str = "idunn.service_identity.trust_anchor.v1"; - const TRUST_ANCHOR_KEY: &'static str = "idunn-service-identity-public"; - const ID_DOMAIN: &'static [u8] = b"idunn.service-identity.id.v1\0"; - const SIGNATURE_DOMAIN: &'static [u8] = b"idunn.service-identity.signature.v1\0"; - const PROTECTOR_CONTEXT: &'static str = "idunn-service-identity-v1"; -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct ServiceIdentityPrivateEntry { - pub schema_version: String, - pub identity_id: String, - pub public_key: Vec, - pub protected_private_seed: Vec, - pub protector_kind: String, - pub protector_binding: String, - pub protector_version: String, - pub assurance: String, - pub created_at: String, - pub enrollment_nonce: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct ServiceIdentityTrustAnchor { - pub schema_version: String, - pub identity_id: String, - pub public_key: Vec, - pub assurance: String, - pub identity_created_at: String, - pub source_identity_record_sha256: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ServiceIdentitySignature { - pub identity_id: String, - pub signature: Vec, -} - -/// The only value holding unprotected key material. It exposes signing, never -/// seed export or access to the underlying signing key. -pub struct ServiceIdentitySigner { - entry: ServiceIdentityPrivateEntry, - signing_key: SigningKey, - profile: PhantomData

, -} - -impl ServiceIdentitySigner

{ - pub fn entry(&self) -> &ServiceIdentityPrivateEntry { - &self.entry - } - - pub fn trust_anchor(&self) -> Result { - Ok(ServiceIdentityTrustAnchor { - schema_version: P::TRUST_ANCHOR_SCHEMA.into(), - identity_id: self.entry.identity_id.clone(), - public_key: self.entry.public_key.clone(), - assurance: self.entry.assurance.clone(), - identity_created_at: self.entry.created_at.clone(), - source_identity_record_sha256: format!( - "sha256-{}", - hex(&Sha256::digest(rmp_serde::to_vec(&self.entry)?)) - ), - }) - } - - pub fn sign>(&self, payload: &[u8]) -> ServiceIdentitySignature { - ServiceIdentitySignature { - identity_id: self.entry.identity_id.clone(), - signature: self - .signing_key - .sign(&signing_message::(payload)) - .to_bytes() - .to_vec(), - } - } -} - -pub fn enroll_service_identity_at( - path: &Path, -) -> Result> { - if path.exists() { - bail!( - "service identity store {} already exists; enrollment is immutable", - path.display() - ); - } - prepare_private_parent(path)?; - let mut seed = Zeroizing::new([0u8; 32]); - rand::rng().fill_bytes(seed.as_mut()); - let signing_key = SigningKey::from_bytes(&*seed); - let mut nonce = [0u8; 32]; - rand::rng().fill_bytes(&mut nonce); - let public_key = signing_key.verifying_key().to_bytes(); - let binding = platform_binding::

()?; - let entry = ServiceIdentityPrivateEntry { - schema_version: P::PRIVATE_SCHEMA.into(), - identity_id: identity_id::

(&public_key), - public_key: public_key.to_vec(), - protected_private_seed: protect_seed::

(&*seed, &binding)?, - protector_kind: platform_protector_kind().into(), - protector_binding: binding, - protector_version: "v1".into(), - assurance: platform_assurance().into(), - created_at: chrono::Utc::now().to_rfc3339(), - enrollment_nonce: nonce.to_vec(), - }; - validate_private::

(&entry)?; - let envelope = private_envelope::

(&entry)?; - atomic_create_private_store(path, &envelope)?; - harden_store_permissions(path)?; - open_service_identity_at::

(path) -} - -pub fn open_service_identity_at( - path: &Path, -) -> Result> { - if !path.is_file() { - bail!("service identity store {} does not exist", path.display()); - } - let entries = SingleFileMessagePackBackingStore::new(path).pull_all()?; - open_service_identity_envelopes::

(&entries) -} - -/// Opens an immutable service-identity credential from a read-only stream. -/// -/// Unlike [`open_service_identity_at`], this path has no sibling-lock or -/// writable-filesystem requirement. It is intended for immutable credential -/// copies such as systemd `LoadCredential` files. -pub fn open_service_identity_credential_reader( - mut reader: impl Read, -) -> Result> { - let mut bytes = Zeroizing::new(Vec::new()); - reader - .read_to_end(&mut bytes) - .context("failed to read immutable service identity credential")?; - open_service_identity_credential_bytes::

(&bytes) -} - -/// Opens an immutable service-identity credential from its exact bytes. -pub fn open_service_identity_credential_bytes( - bytes: &[u8], -) -> Result> { - let mut decoder = rmp_serde::Deserializer::new(std::io::Cursor::new(bytes)); - let entries = Vec::::deserialize(&mut decoder) - .context("immutable service identity credential is malformed MessagePack")?; - if decoder.position() != bytes.len() as u64 { - bail!("immutable service identity credential contains trailing data"); - } - open_service_identity_envelopes::

(&entries) -} - -fn open_service_identity_envelopes( - entries: &[CultCacheEnvelope], -) -> Result> { - let [envelope] = entries else { - bail!("service identity store must contain exactly one immutable envelope"); - }; - if envelope.r#type != P::PRIVATE_TYPE - || envelope.key != P::PRIVATE_KEY - || envelope.schema_id.as_deref() != Some(P::PRIVATE_SCHEMA) - { - bail!("service identity store belongs to a different profile or schema"); - } - let entry: ServiceIdentityPrivateEntry = rmp_serde::from_slice(&envelope.payload) - .context("service identity private payload is malformed MessagePack")?; - validate_private::

(&entry)?; - if entry.protector_kind != platform_protector_kind() || entry.assurance != platform_assurance() - { - bail!("service identity protector does not belong to this platform implementation"); - } - let binding = platform_binding::

()?; - if entry.protector_binding != binding { - bail!("service identity protector binding does not match this OS installation or profile"); - } - let seed = unprotect_seed::

(&entry.protected_private_seed, &binding)?; - let seed: &[u8; 32] = seed - .as_slice() - .try_into() - .map_err(|_| anyhow!("unprotected service identity seed has invalid length"))?; - let signing_key = SigningKey::from_bytes(seed); - if signing_key.verifying_key().to_bytes().as_slice() != entry.public_key.as_slice() { - bail!("service identity private seed does not match enrolled public key"); - } - Ok(ServiceIdentitySigner { - entry, - signing_key, - profile: PhantomData, - }) -} - -pub fn export_service_identity_trust_anchor( - signer: &ServiceIdentitySigner

, - path: &Path, -) -> Result { - let anchor = signer.trust_anchor()?; - ensure_parent(path)?; - let envelope = CultCacheEnvelope { - key: P::TRUST_ANCHOR_KEY.into(), - r#type: P::TRUST_ANCHOR_TYPE.into(), - payload: rmp_serde::to_vec(&anchor)?, - stored_at: anchor.identity_created_at.clone(), - schema_id: Some(P::TRUST_ANCHOR_SCHEMA.into()), - }; - let mut backing = SingleFileMessagePackBackingStore::new(path); - match backing.pull_all()?.as_slice() { - [] => backing.push(&envelope)?, - [current] if current == &envelope => {} - _ => bail!("service identity trust anchor output already contains different state"), - } - Ok(anchor) -} - -pub fn verify_service_identity_signature( - anchor: &ServiceIdentityTrustAnchor, - payload: &[u8], - proof: &ServiceIdentitySignature, -) -> Result<()> -where - P: ServiceIdentityProfile, - S: ServiceSignaturePurpose

, -{ - validate_anchor::

(anchor)?; - if proof.identity_id != anchor.identity_id { - bail!("service identity signature names a different identity"); - } - let key: [u8; 32] = anchor - .public_key - .as_slice() - .try_into() - .map_err(|_| anyhow!("service identity public key has invalid length"))?; - let signature = Signature::from_slice(&proof.signature) - .map_err(|_| anyhow!("service identity signature has invalid length"))?; - VerifyingKey::from_bytes(&key)? - .verify(&signing_message::(payload), &signature) - .map_err(|_| anyhow!("service identity signature verification failed")) -} - -/// Verify against a public key already admitted by an owning trust store. -/// The profile derives the identity, so callers cannot substitute a convenient -/// identity string while retaining the same signature bytes. -pub fn verify_service_identity_signature_with_public_key( - public_key: &[u8], - payload: &[u8], - proof: &ServiceIdentitySignature, -) -> Result<()> -where - P: ServiceIdentityProfile, - S: ServiceSignaturePurpose

, -{ - if proof.identity_id != derive_service_identity_id::

(public_key)? { - bail!("service identity signature names a different identity"); - } - let key: [u8; 32] = public_key - .try_into() - .map_err(|_| anyhow!("service identity public key has invalid length"))?; - let signature = Signature::from_slice(&proof.signature) - .map_err(|_| anyhow!("service identity signature has invalid length"))?; - VerifyingKey::from_bytes(&key)? - .verify(&signing_message::(payload), &signature) - .map_err(|_| anyhow!("service identity signature verification failed")) -} - -fn validate_private(entry: &ServiceIdentityPrivateEntry) -> Result<()> { - if entry.schema_version != P::PRIVATE_SCHEMA - || entry.public_key.len() != 32 - || entry.enrollment_nonce.len() != 32 - || entry.protected_private_seed.is_empty() - || entry.protector_version != "v1" - { - bail!("service identity private entry violates its profile schema"); - } - chrono::DateTime::parse_from_rfc3339(&entry.created_at) - .map_err(|_| anyhow!("service identity created_at is not RFC3339"))?; - if identity_id::

(&entry.public_key) != entry.identity_id { - bail!("service identity id does not match public key"); - } - Ok(()) -} - -fn validate_anchor(anchor: &ServiceIdentityTrustAnchor) -> Result<()> { - if anchor.schema_version != P::TRUST_ANCHOR_SCHEMA - || anchor.public_key.len() != 32 - || derive_service_identity_id::

(&anchor.public_key)? != anchor.identity_id - { - bail!("service identity trust anchor violates its profile schema"); - } - Ok(()) -} - -/// Derives the profile-bound identity id from an exact Ed25519 public key. -/// Consumers use this instead of accepting a caller-supplied identity string. -pub fn derive_service_identity_id(public_key: &[u8]) -> Result { - if public_key.len() != 32 { - bail!("service identity public key has invalid length"); - } - Ok(hex(&Sha256::digest([P::ID_DOMAIN, public_key].concat()))) -} - -fn identity_id(key: &[u8]) -> String { - derive_service_identity_id::

(key).expect("internal service identity key is Ed25519") -} - -fn signing_message>( - payload: &[u8], -) -> Vec { - let mut out = - Vec::with_capacity(P::SIGNATURE_DOMAIN.len() + S::PURPOSE.len() + payload.len() + 16); - out.extend_from_slice(P::SIGNATURE_DOMAIN); - out.extend_from_slice(&(S::PURPOSE.len() as u64).to_be_bytes()); - out.extend_from_slice(S::PURPOSE); - out.extend_from_slice(&(payload.len() as u64).to_be_bytes()); - out.extend_from_slice(payload); - out -} - -fn private_envelope( - entry: &ServiceIdentityPrivateEntry, -) -> Result { - Ok(CultCacheEnvelope { - key: P::PRIVATE_KEY.into(), - r#type: P::PRIVATE_TYPE.into(), - payload: rmp_serde::to_vec(entry)?, - stored_at: entry.created_at.clone(), - schema_id: Some(P::PRIVATE_SCHEMA.into()), - }) -} - -fn ensure_parent(path: &Path) -> Result<()> { - let parent = path - .parent() - .ok_or_else(|| anyhow!("service identity path has no parent"))?; - std::fs::create_dir_all(parent)?; - Ok(()) -} - -/// Like [`ensure_parent`], but also locks the directory down to owner-only -/// access. Reserved for paths that hold private key material: a public trust -/// anchor is routinely exported into a directory another service already -/// owns and reads from, and forcing that directory to `0700` would lock the -/// legitimate reader out rather than protect anything. -fn prepare_private_parent(path: &Path) -> Result<()> { - ensure_parent(path)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let parent = path - .parent() - .ok_or_else(|| anyhow!("service identity path has no parent"))?; - std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?; - } - Ok(()) -} - -/// Enrollment is a one-shot authority transition. `create_new` makes two -/// simultaneous enrollments unable to overwrite one another, while the encoded -/// envelope vector remains a supported CultCache single-file representation. -fn atomic_create_private_store(path: &Path, envelope: &CultCacheEnvelope) -> Result<()> { - let bytes = rmp_serde::to_vec(&vec![envelope.clone()]) - .context("failed to encode immutable service identity store")?; - let mut file = std::fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(path) - .with_context(|| { - format!( - "service identity store {} already exists or cannot be created; enrollment is immutable", - path.display() - ) - })?; - if let Err(error) = file.write_all(&bytes).and_then(|()| file.sync_all()) { - drop(file); - let _ = std::fs::remove_file(path); - return Err(error).context("failed to persist immutable service identity store"); - } - Ok(()) -} -fn harden_store_permissions(_path: &Path) -> Result<()> { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o600))?; - } - Ok(()) -} - -#[cfg(windows)] -fn platform_protector_kind() -> &'static str { - "windows_dpapi_current_user" -} -#[cfg(target_os = "linux")] -fn platform_protector_kind() -> &'static str { - "linux_file_mode_machine_id_binding" -} -#[cfg(windows)] -fn platform_assurance() -> &'static str { - WINDOWS_SERVICE_IDENTITY_ASSURANCE -} -#[cfg(target_os = "linux")] -fn platform_assurance() -> &'static str { - LINUX_SERVICE_IDENTITY_ASSURANCE -} - -#[cfg(windows)] -fn platform_binding() -> Result { - Ok(format!("dpapi-current-user:{}", P::PROTECTOR_CONTEXT)) -} -#[cfg(target_os = "linux")] -fn platform_binding() -> Result { - let raw = std::fs::read_to_string("/etc/machine-id") - .or_else(|_| std::fs::read_to_string("/var/lib/dbus/machine-id")) - .context("Linux machine-id is unavailable")?; - let id = raw.trim(); - if id.is_empty() { - bail!("Linux machine-id is empty"); - } - Ok(format!( - "{}:machine-id-sha256:{}", - P::PROTECTOR_CONTEXT, - hex(&Sha256::digest(id.as_bytes())) - )) -} - -#[cfg(windows)] -fn protect_seed(seed: &[u8; 32], binding: &str) -> Result> { - Ok(dpapi::

(seed, binding, true)?.to_vec()) -} -#[cfg(windows)] -fn unprotect_seed( - seed: &[u8], - binding: &str, -) -> Result>> { - dpapi::

(seed, binding, false) -} -#[cfg(windows)] -fn dpapi( - input: &[u8], - binding: &str, - protect: bool, -) -> Result>> { - use windows_sys::Win32::Foundation::LocalFree; - use windows_sys::Win32::Security::Cryptography::{ - CRYPT_INTEGER_BLOB, CRYPTPROTECT_UI_FORBIDDEN, CryptProtectData, CryptUnprotectData, - }; - let mut input = Zeroizing::new(input.to_vec()); - let mut entropy = Zeroizing::new( - Sha256::digest( - [ - b"gamecult-service-identity-dpapi-v1\0".as_slice(), - P::PROTECTOR_CONTEXT.as_bytes(), - binding.as_bytes(), - ] - .concat(), - ) - .to_vec(), - ); - let ib = CRYPT_INTEGER_BLOB { - cbData: input.len() as u32, - pbData: input.as_mut_ptr(), - }; - let eb = CRYPT_INTEGER_BLOB { - cbData: entropy.len() as u32, - pbData: entropy.as_mut_ptr(), - }; - let mut output = CRYPT_INTEGER_BLOB { - cbData: 0, - pbData: std::ptr::null_mut(), - }; - let ok = unsafe { - if protect { - CryptProtectData( - &ib, - std::ptr::null(), - &eb, - std::ptr::null(), - std::ptr::null(), - CRYPTPROTECT_UI_FORBIDDEN, - &mut output, - ) - } else { - CryptUnprotectData( - &ib, - std::ptr::null_mut(), - &eb, - std::ptr::null(), - std::ptr::null(), - CRYPTPROTECT_UI_FORBIDDEN, - &mut output, - ) - } - }; - if ok == 0 { - return Err(std::io::Error::last_os_error()) - .context("DPAPI service identity operation failed"); - } - if output.pbData.is_null() { - bail!("DPAPI service identity operation returned a null output"); - } - let output = unsafe { std::slice::from_raw_parts_mut(output.pbData, output.cbData as usize) }; - let bytes = Zeroizing::new(output.to_vec()); - output.zeroize(); - unsafe { - LocalFree(output.as_mut_ptr().cast()); - } - Ok(bytes) -} - -#[cfg(target_os = "linux")] -fn protect_seed(seed: &[u8; 32], binding: &str) -> Result> { - xor_linux::

(seed, binding) -} -#[cfg(target_os = "linux")] -fn unprotect_seed( - seed: &[u8], - binding: &str, -) -> Result>> { - if seed.len() != 32 { - bail!("protected Linux service seed has invalid length"); - } - let mask = Sha256::digest( - [ - b"gamecult-linux-service-seed-v1\0".as_slice(), - P::PROTECTOR_CONTEXT.as_bytes(), - binding.as_bytes(), - ] - .concat(), - ); - let mut clear_seed = Zeroizing::new(Vec::with_capacity(seed.len())); - clear_seed.extend(seed.iter().zip(mask).map(|(a, b)| a ^ b)); - Ok(clear_seed) -} -#[cfg(target_os = "linux")] -fn xor_linux(seed: &[u8], binding: &str) -> Result> { - let mask = Sha256::digest( - [ - b"gamecult-linux-service-seed-v1\0".as_slice(), - P::PROTECTOR_CONTEXT.as_bytes(), - binding.as_bytes(), - ] - .concat(), - ); - Ok(seed.iter().zip(mask).map(|(a, b)| a ^ b).collect()) -} - -fn hex(bytes: &[u8]) -> String { - const D: &[u8; 16] = b"0123456789abcdef"; - let mut s = String::with_capacity(bytes.len() * 2); - for b in bytes { - s.push(D[(b >> 4) as usize] as char); - s.push(D[(b & 15) as usize] as char); - } - s -} - -#[cfg(test)] -mod tests { - use super::*; - enum OtherPurpose {} - impl ServiceSignaturePurpose for OtherPurpose { - const PURPOSE: &'static [u8] = b"idunn.other.v1"; - } - - #[test] - fn immutable_enrollment_reopens_and_is_purpose_and_payload_bound() -> Result<()> { - let temp = tempfile::tempdir()?; - let path = temp.path().join("idunn.ccmp"); - let signer = enroll_service_identity_at::(&path)?; - let proof = signer.sign::(b"healthy"); - let anchor = signer.trust_anchor()?; - verify_service_identity_signature::< - IdunnServiceIdentity, - IdunnAuthenticatedProviderHealthProjectionPurpose, - >(&anchor, b"healthy", &proof)?; - assert!( - verify_service_identity_signature::< - IdunnServiceIdentity, - IdunnAuthenticatedProviderHealthProjectionPurpose, - >(&anchor, b"unhealthy", &proof) - .is_err() - ); - assert!( - verify_service_identity_signature::( - &anchor, b"healthy", &proof - ) - .is_err() - ); - assert!(enroll_service_identity_at::(&path).is_err()); - assert_eq!( - derive_service_identity_id::(&anchor.public_key)?, - anchor.identity_id - ); - assert!(derive_service_identity_id::(&[0; 31]).is_err()); - assert_eq!( - open_service_identity_at::(&path)?.entry(), - signer.entry() - ); - Ok(()) - } - - #[test] - fn immutable_credential_reader_opens_without_a_path_and_retains_signing_authority() -> Result<()> - { - let temp = tempfile::tempdir()?; - let path = temp.path().join("provider-health.cc"); - let enrolled = enroll_service_identity_at::(&path)?; - let bytes = std::fs::read(path)?; - - let from_reader = open_service_identity_credential_reader::( - std::io::Cursor::new(bytes.clone()), - )?; - let from_bytes = - open_service_identity_credential_bytes::(&bytes)?; - assert_eq!(from_reader.entry(), enrolled.entry()); - assert_eq!(from_bytes.entry(), enrolled.entry()); - - let payload = b"runtime-presence"; - let proof = from_reader.sign::(payload); - verify_service_identity_signature::< - GameCultProviderHealthIdentity, - GameCultRuntimePresenceHealthPurpose, - >(&enrolled.trust_anchor()?, payload, &proof)?; - Ok(()) - } - - #[test] - fn immutable_credential_reader_rejects_ambiguous_profile_or_machine_state() -> Result<()> { - let temp = tempfile::tempdir()?; - let path = temp.path().join("idunn.ccmp"); - enroll_service_identity_at::(&path)?; - let bytes = std::fs::read(path)?; - - assert!( - open_service_identity_credential_bytes::(&bytes) - .is_err() - ); - - let mut envelopes: Vec = rmp_serde::from_slice(&bytes)?; - let mut duplicated = envelopes.clone(); - duplicated.push(envelopes[0].clone()); - assert!( - open_service_identity_credential_bytes::(&rmp_serde::to_vec( - &duplicated - )?) - .is_err() - ); - - let mut trailing = bytes.clone(); - trailing.push(0xc0); - assert!(open_service_identity_credential_bytes::(&trailing).is_err()); - - let mut wrong_seed_envelopes = envelopes.clone(); - let mut wrong_seed: ServiceIdentityPrivateEntry = - rmp_serde::from_slice(&wrong_seed_envelopes[0].payload)?; - wrong_seed.protected_private_seed[0] ^= 1; - wrong_seed_envelopes[0].payload = rmp_serde::to_vec(&wrong_seed)?; - assert!( - open_service_identity_credential_bytes::(&rmp_serde::to_vec( - &wrong_seed_envelopes - )?) - .is_err() - ); - - let mut entry: ServiceIdentityPrivateEntry = rmp_serde::from_slice(&envelopes[0].payload)?; - entry.protector_binding.push_str(":another-machine"); - envelopes[0].payload = rmp_serde::to_vec(&entry)?; - assert!( - open_service_identity_credential_bytes::(&rmp_serde::to_vec( - &envelopes - )?) - .is_err() - ); - Ok(()) - } - - #[test] - fn malformed_or_profile_substituted_state_fails_closed_without_replacement() -> Result<()> { - enum Alien {} - impl ServiceIdentityProfile for Alien { - const PRIVATE_TYPE: &'static str = "alien.private.v1"; - const PRIVATE_SCHEMA: &'static str = "alien.private.v1"; - const PRIVATE_KEY: &'static str = "alien"; - const TRUST_ANCHOR_TYPE: &'static str = "alien.anchor.v1"; - const TRUST_ANCHOR_SCHEMA: &'static str = "alien.anchor.v1"; - const TRUST_ANCHOR_KEY: &'static str = "alien-public"; - const ID_DOMAIN: &'static [u8] = b"alien.id\0"; - const SIGNATURE_DOMAIN: &'static [u8] = b"alien.sig\0"; - const PROTECTOR_CONTEXT: &'static str = "alien-v1"; - } - let temp = tempfile::tempdir()?; - let malformed = temp.path().join("bad.ccmp"); - std::fs::write(&malformed, b"rot")?; - let before = std::fs::read(&malformed)?; - assert!(open_service_identity_at::(&malformed).is_err()); - assert!(enroll_service_identity_at::(&malformed).is_err()); - assert_eq!(before, std::fs::read(&malformed)?); - let good = temp.path().join("good.ccmp"); - enroll_service_identity_at::(&good)?; - assert!(open_service_identity_at::(&good).is_err()); - Ok(()) - } - - #[test] - fn exporting_a_public_trust_anchor_does_not_narrow_its_parent_permissions() -> Result<()> { - let temp = tempfile::tempdir()?; - let signer = - enroll_service_identity_at::(&temp.path().join("private.ccmp"))?; - let public_parent = temp.path().join("public"); - std::fs::create_dir(&public_parent)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&public_parent, std::fs::Permissions::from_mode(0o750))?; - } - let output = public_parent.join("public.ccmp"); - export_service_identity_trust_anchor(&signer, &output)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - assert_eq!( - std::fs::metadata(&public_parent)?.permissions().mode() & 0o777, - 0o750, - "exporting a public trust anchor must not reassign its parent directory's permissions" - ); - } - Ok(()) - } - - #[test] - fn exported_anchor_is_public_only_immutable_and_rejects_identity_substitution() -> Result<()> { - let temp = tempfile::tempdir()?; - let signer = - enroll_service_identity_at::(&temp.path().join("private.ccmp"))?; - let output = temp.path().join("public.ccmp"); - let anchor = export_service_identity_trust_anchor(&signer, &output)?; - let bytes = std::fs::read(&output)?; - assert!( - !bytes - .windows(signer.entry().protected_private_seed.len()) - .any(|w| w == signer.entry().protected_private_seed) - ); - export_service_identity_trust_anchor(&signer, &output)?; - assert_eq!(bytes, std::fs::read(&output)?); - let other = - enroll_service_identity_at::(&temp.path().join("other.ccmp"))?; - let proof = other.sign::(b"healthy"); - assert!( - verify_service_identity_signature::< - IdunnServiceIdentity, - IdunnAuthenticatedProviderHealthProjectionPurpose, - >(&anchor, b"healthy", &proof) - .is_err() - ); - Ok(()) - } - - #[test] - fn provider_health_profile_is_domain_separated_and_purpose_bound() -> Result<()> { - let temp = tempfile::tempdir()?; - let signer = enroll_service_identity_at::( - &temp.path().join("provider-health.cc"), - )?; - let proof = signer.sign::(b"signed-health-record"); - let anchor = signer.trust_anchor()?; - verify_service_identity_signature::< - GameCultProviderHealthIdentity, - IdunnSignedDaemonHealthPurpose, - >(&anchor, b"signed-health-record", &proof)?; - verify_service_identity_signature_with_public_key::< - GameCultProviderHealthIdentity, - IdunnSignedDaemonHealthPurpose, - >(&anchor.public_key, b"signed-health-record", &proof)?; - assert_eq!( - anchor.schema_version, - "gamecult.provider_health_identity.trust_anchor.v1" - ); - assert_ne!( - derive_service_identity_id::(&anchor.public_key)?, - derive_service_identity_id::(&anchor.public_key)? - ); - Ok(()) - } - - #[test] - fn runtime_presence_and_legacy_health_purposes_cannot_be_substituted() -> Result<()> { - let temp = tempfile::tempdir()?; - let signer = enroll_service_identity_at::( - &temp.path().join("provider-runtime-presence.cc"), - )?; - let anchor = signer.trust_anchor()?; - let payload = b"runtime-presence"; - - let presence = signer.sign::(payload); - verify_service_identity_signature::< - GameCultProviderHealthIdentity, - GameCultRuntimePresenceHealthPurpose, - >(&anchor, payload, &presence)?; - assert!( - verify_service_identity_signature::< - GameCultProviderHealthIdentity, - IdunnSignedDaemonHealthPurpose, - >(&anchor, payload, &presence) - .is_err() - ); - - let legacy = signer.sign::(payload); - assert!( - verify_service_identity_signature::< - GameCultProviderHealthIdentity, - GameCultRuntimePresenceHealthPurpose, - >(&anchor, payload, &legacy) - .is_err() - ); - Ok(()) - } -} diff --git a/packages/cultnet-rs/src/snapshot_query.rs b/packages/cultnet-rs/src/snapshot_query.rs deleted file mode 100644 index f6150d1d..00000000 --- a/packages/cultnet-rs/src/snapshot_query.rs +++ /dev/null @@ -1,339 +0,0 @@ -use anyhow::{Result, anyhow}; -use std::collections::{BTreeMap, BTreeSet}; - -use crate::{CultNetDocumentRegistry, CultNetMessage, CultNetRawDocumentRecord}; - -/// Read-only backing surface for a CultNet snapshot server. -/// -/// Records returned here are already the stored wire records. The snapshot -/// server selects and clones them; it never decodes payloads, rewrites source -/// metadata, or applies them to another cache. -pub trait CultNetRawSnapshotSource { - fn raw_snapshot(&self) -> Result>; -} - -impl CultNetRawSnapshotSource for Vec { - fn raw_snapshot(&self) -> Result> { - Ok(self.clone()) - } -} - -impl CultNetRawSnapshotSource for F -where - F: Fn() -> Result>, -{ - fn raw_snapshot(&self) -> Result> { - self() - } -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct CultNetReadOnlySnapshotPolicy { - allowed_records: BTreeSet<(String, String)>, -} - -impl CultNetReadOnlySnapshotPolicy { - pub fn new() -> Self { - Self::default() - } - - pub fn allow( - &mut self, - schema_id: impl Into, - record_key: impl Into, - ) -> Result<&mut Self> { - let schema_id = schema_id.into(); - let record_key = record_key.into(); - require_non_empty(&schema_id, "schema_id")?; - require_non_empty(&record_key, "record_key")?; - self.allowed_records.insert((schema_id, record_key)); - Ok(self) - } - - fn allows(&self, schema_id: &str, record_key: &str) -> bool { - self.allowed_records - .contains(&(schema_id.to_string(), record_key.to_string())) - } -} - -/// Serve one raw snapshot request from an explicitly bounded, read-only view. -/// -/// The document registry proves that every exposed schema belongs to the -/// runtime. The policy grants individual `(schema_id, record_key)` pairs. The -/// source owns the bytes and metadata; this function only selects records. -pub fn serve_read_only_raw_snapshot( - document_registry: &CultNetDocumentRegistry, - policy: &CultNetReadOnlySnapshotPolicy, - source: &S, - request: &CultNetMessage, -) -> Result { - let CultNetMessage::SnapshotRequest { - message_id, - schema_ids, - record_keys, - } = request - else { - return Err(anyhow!("expected cultnet.snapshot_request.v0")); - }; - require_non_empty(message_id, "message_id")?; - reject_duplicates(schema_ids.as_deref(), "requested schema id")?; - reject_duplicates(record_keys.as_deref(), "requested record key")?; - - let requested_schemas = schema_ids - .as_ref() - .map(|values| values.iter().map(String::as_str).collect::>()); - let requested_keys = record_keys - .as_ref() - .map(|values| values.iter().map(String::as_str).collect::>()); - let mut selected = Vec::new(); - let mut seen = BTreeSet::new(); - - for document in source.raw_snapshot()? { - let identity = (document.schema_id.clone(), document.record_key.clone()); - if !seen.insert(identity.clone()) { - return Err(anyhow!( - "snapshot source contains duplicate record {:?}/{:?}", - identity.0, - identity.1 - )); - } - if document_registry - .binding_by_schema_id(&document.schema_id) - .is_none() - { - return Err(anyhow!( - "snapshot source record {:?}/{:?} uses an unregistered schema", - document.schema_id, - document.record_key - )); - } - if !policy.allows(&document.schema_id, &document.record_key) { - continue; - } - if requested_schemas - .as_ref() - .is_some_and(|schemas| !schemas.contains(document.schema_id.as_str())) - { - continue; - } - if requested_keys - .as_ref() - .is_some_and(|keys| !keys.contains(document.record_key.as_str())) - { - continue; - } - selected.push(document); - } - - Ok(CultNetMessage::SnapshotResponseRaw { - message_id: message_id.clone(), - documents: selected, - }) -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct CultNetSnapshotSourceExpectation { - pub runtime_id: Option, - pub agent_id: Option, - pub role: Option, - pub tags: Option>, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CultNetSnapshotRecordExpectation { - pub schema_id: String, - pub record_key: String, - pub source: CultNetSnapshotSourceExpectation, -} - -impl CultNetSnapshotRecordExpectation { - pub fn new(schema_id: impl Into, record_key: impl Into) -> Self { - Self { - schema_id: schema_id.into(), - record_key: record_key.into(), - source: CultNetSnapshotSourceExpectation::default(), - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CultNetRawSnapshotQuery { - message_id: String, - expectations: BTreeMap<(String, String), CultNetSnapshotSourceExpectation>, -} - -impl CultNetRawSnapshotQuery { - pub fn new( - message_id: impl Into, - records: Vec, - ) -> Result { - let message_id = message_id.into(); - require_non_empty(&message_id, "message_id")?; - if records.is_empty() { - return Err(anyhow!("snapshot query must request at least one record")); - } - let mut expectations = BTreeMap::new(); - for record in records { - require_non_empty(&record.schema_id, "schema_id")?; - require_non_empty(&record.record_key, "record_key")?; - let identity = (record.schema_id, record.record_key); - if expectations - .insert(identity.clone(), record.source) - .is_some() - { - return Err(anyhow!( - "snapshot query contains duplicate requested record {:?}/{:?}", - identity.0, - identity.1 - )); - } - } - Ok(Self { - message_id, - expectations, - }) - } - - pub fn request(&self) -> CultNetMessage { - CultNetMessage::SnapshotRequest { - message_id: self.message_id.clone(), - schema_ids: Some( - self.expectations - .keys() - .map(|(schema_id, _)| schema_id.clone()) - .collect::>() - .into_iter() - .collect(), - ), - record_keys: Some( - self.expectations - .keys() - .map(|(_, record_key)| record_key.clone()) - .collect::>() - .into_iter() - .collect(), - ), - } - } - - /// Validate a response and return records without mutating any local cache. - pub fn accept_response( - &self, - response: CultNetMessage, - ) -> Result> { - let CultNetMessage::SnapshotResponseRaw { - message_id, - documents, - } = response - else { - return Err(anyhow!("expected cultnet.snapshot_response_raw.v0")); - }; - if message_id != self.message_id { - return Err(anyhow!( - "snapshot response message id {:?} does not match request {:?}", - message_id, - self.message_id - )); - } - - let mut seen = BTreeSet::new(); - for document in &documents { - let identity = (document.schema_id.clone(), document.record_key.clone()); - if !seen.insert(identity.clone()) { - return Err(anyhow!( - "snapshot response contains duplicate record {:?}/{:?}", - identity.0, - identity.1 - )); - } - let expected = self.expectations.get(&identity).ok_or_else(|| { - anyhow!( - "snapshot response contains unexpected record {:?}/{:?}", - identity.0, - identity.1 - ) - })?; - require_expected_metadata( - "source_runtime_id", - expected.runtime_id.as_deref(), - document.source_runtime_id.as_deref(), - &identity, - )?; - require_expected_metadata( - "source_agent_id", - expected.agent_id.as_deref(), - document.source_agent_id.as_deref(), - &identity, - )?; - require_expected_metadata( - "source_role", - expected.role.as_deref(), - document.source_role.as_deref(), - &identity, - )?; - if let Some(expected_tags) = expected.tags.as_ref() - && document.tags.as_ref() != Some(expected_tags) - { - return Err(anyhow!( - "snapshot response record {:?}/{:?} has unexpected tags: expected {:?}, got {:?}", - identity.0, - identity.1, - expected_tags, - document.tags - )); - } - } - Ok(documents) - } -} - -pub fn query_read_only_raw_snapshot( - query: &CultNetRawSnapshotQuery, - exchange: F, -) -> Result> -where - F: FnOnce(CultNetMessage) -> Result, -{ - query.accept_response(exchange(query.request())?) -} - -fn require_expected_metadata( - field: &str, - expected: Option<&str>, - actual: Option<&str>, - identity: &(String, String), -) -> Result<()> { - if let Some(expected) = expected - && actual != Some(expected) - { - return Err(anyhow!( - "snapshot response record {:?}/{:?} has unexpected {field}: expected {:?}, got {:?}", - identity.0, - identity.1, - expected, - actual - )); - } - Ok(()) -} - -fn reject_duplicates(values: Option<&[String]>, label: &str) -> Result<()> { - let Some(values) = values else { - return Ok(()); - }; - let mut seen = BTreeSet::new(); - for value in values { - require_non_empty(value, label)?; - if !seen.insert(value) { - return Err(anyhow!("duplicate {label} {value:?}")); - } - } - Ok(()) -} - -fn require_non_empty(value: &str, field: &str) -> Result<()> { - if value.trim().is_empty() { - return Err(anyhow!("{field} must be non-empty")); - } - Ok(()) -} diff --git a/packages/cultnet-rs/src/transport.rs b/packages/cultnet-rs/src/transport.rs index d097aa3c..d7aad261 100644 --- a/packages/cultnet-rs/src/transport.rs +++ b/packages/cultnet-rs/src/transport.rs @@ -19,13 +19,6 @@ pub struct CultNetTransportStats { pub bytes_sent: u64, pub frames_received: u64, pub frames_sent: u64, - /// Reliable packets dropped by a send-side expiry deadline instead of being - /// retransmitted. Always zero for transports without reliable expiry. - pub reliable_packets_expired: u64, - /// Incomplete fragment sets discarded to admit newer ones. Each is one - /// payload that lost a fragment on the wire. Zero for non-fragmenting - /// transports. - pub fragment_sets_evicted: u64, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -208,7 +201,6 @@ pub fn create_tcp_framed_transport_profile( max_payload_bytes: options.max_payload_bytes, max_fragment_bytes: options.max_fragment_bytes, max_pending_reliable_packets: None, - reliable_expire_after_ms: None, }], }], } @@ -266,27 +258,6 @@ where let mut header = [0_u8; FRAME_HEADER_BYTES]; self.stream.read_exact(&mut header)?; let payload_len = u32::from_be_bytes(header) as usize; - // Refuse before allocating. The length is the peer's claim, so honouring - // it without checking the advertised maximum lets one header reserve as - // much memory as the peer cares to name. - if let Some(max_payload_bytes) = self - .profile - .transports - .iter() - .find(|transport| transport.protocol == CultNetTransportProtocol::TcpFramed) - .and_then(|transport| { - transport - .channels - .iter() - .find(|channel| channel.channel_id == "schema") - }) - .and_then(|channel| channel.max_payload_bytes) - && payload_len > max_payload_bytes as usize - { - return Err(anyhow!( - "tcp_framed payload length {payload_len} exceeds advertised maximum {max_payload_bytes}" - )); - } let mut payload = vec![0_u8; payload_len]; self.stream.read_exact(&mut payload)?; self.stats.bytes_received += (FRAME_HEADER_BYTES + payload_len) as u64; diff --git a/packages/cultnet-rs/tests/byte_slot_migration.rs b/packages/cultnet-rs/tests/byte_slot_migration.rs deleted file mode 100644 index 0acdcd4d..00000000 --- a/packages/cultnet-rs/tests/byte_slot_migration.rs +++ /dev/null @@ -1,113 +0,0 @@ -//! Does adding `#[cultcache(key = N, bytes)]` to a field break reading the -//! records already written without it? -//! -//! The attribute changes a slot from a MessagePack array-of-integers (serde's -//! default for `Vec`) to a real `bin`. Six of cultnet-rs's byte fields -//! already carry it and four do not, so converting the remaining four is a -//! stored-data question before it is a signature question: every record -//! written before the flip holds an array in that slot. -//! -//! Both directions are tested, because a migration has to survive a rollback -//! as well as a roll-forward. - -use cultcache_rs::DatabaseEntry; -use serde::Serialize; - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "test.byte_slot", schema = "test.byte_slot.v1")] -struct ArrayEncoded { - #[cultcache(key = 0)] - schema_version: String, - #[cultcache(key = 1)] - payload: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "test.byte_slot", schema = "test.byte_slot.v1")] -struct BinEncoded { - #[cultcache(key = 0)] - schema_version: String, - #[cultcache(key = 1, bytes)] - payload: Vec, -} - -fn sample() -> Vec { - (0u8..=255).collect() -} - -#[test] -fn the_attribute_is_what_switches_the_wire_shape() { - let array = rmp_serde::to_vec(&ArrayEncoded { - schema_version: "test.byte_slot.v1".into(), - payload: sample(), - }) - .expect("array encodes"); - let bin = rmp_serde::to_vec(&BinEncoded { - schema_version: "test.byte_slot.v1".into(), - payload: sample(), - }) - .expect("bin encodes"); - - // 256 bytes as `bin` is a 3-byte header plus the payload. As an array it - // is one or two bytes per element, because every value above 0x7f needs a - // u8 marker of its own. - println!("array-encoded {} bytes, bin-encoded {} bytes", array.len(), bin.len()); - assert!( - array.len() > bin.len(), - "array {} should exceed bin {}", - array.len(), - bin.len() - ); -} - -#[test] -fn a_bytes_reader_still_reads_records_written_before_the_attribute() { - let written_before = rmp_serde::to_vec(&ArrayEncoded { - schema_version: "test.byte_slot.v1".into(), - payload: sample(), - }) - .expect("array encodes"); - - // serde_bytes::ByteBuf accepts an array marker as well as bin, so the - // flip is NOT a hard read break. This was worth measuring rather than - // assuming: the opposite would have forced a dual-read path or a rewrite - // migration onto every field with persisted records. - let read_after = - rmp_serde::from_slice::(&written_before).expect("bytes reader reads an array"); - assert_eq!(read_after.payload, sample()); -} - -#[test] -fn a_pre_attribute_reader_still_reads_records_written_after_it() { - let written_after = rmp_serde::to_vec(&BinEncoded { - schema_version: "test.byte_slot.v1".into(), - payload: sample(), - }) - .expect("bin encodes"); - - // The rollback direction, so reverting the binary is survivable. - let read_by_old = - rmp_serde::from_slice::(&written_after).expect("plain reader reads bin"); - assert_eq!(read_by_old.payload, sample()); -} - -#[test] -fn re_encoding_an_old_record_under_the_attribute_does_not_reproduce_its_bytes() { - // Where the cost actually lands. Reading is compatible; reproducing is - // not. Idunn byte-exact-checks its control store by re-encoding what it - // decoded, and canonical_sha256 hashes the same serialization -- so a - // record written before the flip can be read, but can never re-encode to - // the bytes it was stored as, and its canonical digest changes. - let written_before = rmp_serde::to_vec(&ArrayEncoded { - schema_version: "test.byte_slot.v1".into(), - payload: sample(), - }) - .expect("array encodes"); - let decoded = rmp_serde::from_slice::(&written_before).expect("reads"); - let re_encoded = rmp_serde::to_vec(&decoded).expect("re-encodes"); - - assert_ne!( - written_before, re_encoded, - "identical bytes would mean the attribute changed nothing" - ); -} diff --git a/packages/cultnet-rs/tests/cultnet.rs b/packages/cultnet-rs/tests/cultnet.rs index 0b76990f..6511fdbd 100644 --- a/packages/cultnet-rs/tests/cultnet.rs +++ b/packages/cultnet-rs/tests/cultnet.rs @@ -4,7 +4,6 @@ use chrono::Utc; use cultcache_rs::CultCache; use cultcache_rs::DatabaseEntry; use cultcache_rs::SingleFileMessagePackBackingStore; -use cultnet_rs::CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS; use cultnet_rs::CultMesh; use cultnet_rs::CultMeshAuthorityLease; use cultnet_rs::CultMeshPeerCard; @@ -61,14 +60,12 @@ use cultnet_rs::encode_frame; use cultnet_rs::encode_rudp_packet; use pretty_assertions::assert_eq; use std::cell::RefCell; -use std::collections::VecDeque; use std::net::UdpSocket; use std::rc::Rc; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::Duration as StdDuration; -use std::time::Instant as StdInstant; const TS_HELLO_FRAME: &[u8] = include_bytes!("fixtures/cultnet-ts-hello.frame"); const TS_LEGACY_LOGIN_FRAME: &[u8] = include_bytes!("fixtures/cultnet-ts-legacy-login.frame"); @@ -258,7 +255,6 @@ fn cultnet_schema_messages_round_trip_through_messagepack_frames() -> Result<()> max_payload_bytes: None, max_fragment_bytes: None, max_pending_reliable_packets: None, - reliable_expire_after_ms: None, }], }], }]), @@ -359,7 +355,6 @@ fn rudp_transport_profile_advertises_state_and_realtime_channels() { let profile = create_rudp_transport_profile( "rust-rudp", cultnet_rs::RudpTransportProfileOptions { - media_delivery: None, transport_id: Some("public-rudp".to_string()), host: Some("127.0.0.1".to_string()), port: Some(7777), @@ -367,7 +362,6 @@ fn rudp_transport_profile_advertises_state_and_realtime_channels() { max_fragment_bytes: Some(1000), max_pending_reliable_packets: Some(64), reconnect_policy: None, - media_reliable_expire_after_ms: None, }, ); @@ -428,12 +422,6 @@ fn rudp_transport_profile_advertises_state_and_realtime_channels() { CultNetTransportOrdering::Unordered, Some(64) ), - ( - "audio", - CultNetTransportDelivery::Reliable, - CultNetTransportOrdering::Unordered, - Some(64) - ), ] ); } @@ -766,133 +754,6 @@ fn rudp_session_pings_and_detects_receive_timeout() -> Result<()> { Ok(()) } -#[test] -fn rudp_lossy_packets_cannot_create_reliable_ordered_gaps() -> Result<()> { - let mut client = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: 198, - initial_sequence: 1, - ..CultNetRudpSessionOptions::default() - }); - let mut server = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: 198, - initial_sequence: 100, - ..CultNetRudpSessionOptions::default() - }); - let connect = client.create_connect(0, Vec::new())?; - let accept = server.accept_connect(&connect, 1, Vec::new())?; - client.receive(&accept, 2)?; - - let ping = client.create_ping(b"pulse".to_vec()); - let pong = server - .receive(&ping, 3)? - .reply - .expect("ping should produce pong"); - let realtime = server.send( - "realtime", - b"discarded realtime".to_vec(), - CultNetRudpSendOptions::default(), - )?; - let latest = server.send( - "latest", - b"discarded latest state".to_vec(), - CultNetRudpSendOptions { - sequenced: true, - ..CultNetRudpSendOptions::default() - }, - )?; - let schema = server.send( - "schema", - b"committed response".to_vec(), - CultNetRudpSendOptions { - reliable: true, - ordered: true, - ..CultNetRudpSendOptions::default() - }, - )?; - - assert_eq!(ping.sequence, 0); - assert_eq!(pong.sequence, 0); - assert_eq!(realtime.sequence, 0); - assert_eq!(latest.sequence, 1); - assert_eq!(schema.sequence, accept.sequence + 1); - assert_eq!( - client.receive(&schema, 4)?.delivered[0].payload, - b"committed response" - ); - Ok(()) -} - -#[test] -fn rudp_unreliable_sequenced_delivery_is_scoped_to_its_channel() -> Result<()> { - let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: 197, - initial_sequence: 50, - ..CultNetRudpSessionOptions::default() - }); - let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: 197, - initial_sequence: 100, - ..CultNetRudpSessionOptions::default() - }); - let connect = sender.create_connect(0, Vec::new())?; - let accept = receiver.accept_connect(&connect, 1, Vec::new())?; - sender.receive(&accept, 2)?; - let options = CultNetRudpSendOptions { - sequenced: true, - ..CultNetRudpSendOptions::default() - }; - let older = sender.send("latest", b"older".to_vec(), options.clone())?; - let newer = sender.send("latest", b"newer".to_vec(), options)?; - - assert_eq!(older.sequence, 1); - assert_eq!(newer.sequence, 2); - assert_eq!(receiver.receive(&newer, 3)?.delivered[0].payload, b"newer"); - assert!(receiver.receive(&older, 4)?.delivered.is_empty()); - Ok(()) -} - -#[test] -fn rudp_rejects_unreliable_ordered_delivery() -> Result<()> { - let mut session = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: 196, - ..CultNetRudpSessionOptions::default() - }); - session.receive( - &CultNetRudpPacket { - packet_type: CultNetRudpPacketType::Accept, - connection_id: 196, - sequence: 50, - ack: 0, - ack_mask: 0, - channel_id: "control".to_string(), - reliable: false, - ordered: false, - sequenced: false, - fragment_id: 0, - fragment_index: 0, - fragment_count: 0, - payload: Vec::new(), - }, - 0, - )?; - let error = session - .send( - "schema", - b"cannot order what will not retransmit".to_vec(), - CultNetRudpSendOptions { - ordered: true, - ..CultNetRudpSendOptions::default() - }, - ) - .unwrap_err(); - assert!( - error - .to_string() - .contains("ordered delivery requires reliability") - ); - Ok(()) -} - #[test] fn rudp_session_bounds_pending_reliable_packets_before_enqueue() -> Result<()> { let mut session = CultNetRudpSession::new(CultNetRudpSessionOptions { @@ -1255,7 +1116,6 @@ fn rudp_socket_transport_handshakes_and_carries_reliable_ordered_schema_frames() let connection_id = 0x1020_3040; let mut server = CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { - media_delivery: None, runtime_id: "rust-rudp-server".to_string(), socket: server_socket, mode: CultNetRudpSocketMode::Server, @@ -1268,11 +1128,9 @@ fn rudp_socket_transport_handshakes_and_carries_reliable_ordered_schema_frames() max_fragment_bytes: None, max_pending_reliable_packets: None, reconnect_policy: None, - media_reliable_expire_after_ms: None, })?; let mut client = CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { - media_delivery: None, runtime_id: "rust-rudp-client".to_string(), socket: client_socket, mode: CultNetRudpSocketMode::Client, @@ -1285,7 +1143,6 @@ fn rudp_socket_transport_handshakes_and_carries_reliable_ordered_schema_frames() max_fragment_bytes: None, max_pending_reliable_packets: None, reconnect_policy: None, - media_reliable_expire_after_ms: None, })?; client.connect(b"join".to_vec())?; @@ -1349,7 +1206,6 @@ fn rudp_socket_transport_carries_fragmented_reliable_ordered_schema_frames() -> let connection_id = 0x1020_3041; let mut server = CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { - media_delivery: None, runtime_id: "rust-rudp-fragment-server".to_string(), socket: server_socket, mode: CultNetRudpSocketMode::Server, @@ -1362,11 +1218,9 @@ fn rudp_socket_transport_carries_fragmented_reliable_ordered_schema_frames() -> max_fragment_bytes: Some(8), max_pending_reliable_packets: None, reconnect_policy: None, - media_reliable_expire_after_ms: None, })?; let mut client = CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { - media_delivery: None, runtime_id: "rust-rudp-fragment-client".to_string(), socket: client_socket, mode: CultNetRudpSocketMode::Client, @@ -1379,7 +1233,6 @@ fn rudp_socket_transport_carries_fragmented_reliable_ordered_schema_frames() -> max_fragment_bytes: Some(8), max_pending_reliable_packets: None, reconnect_policy: None, - media_reliable_expire_after_ms: None, })?; client.connect(b"join".to_vec())?; @@ -1395,164 +1248,6 @@ fn rudp_socket_transport_carries_fragmented_reliable_ordered_schema_frames() -> Ok(()) } -#[test] -fn rudp_session_advances_large_fragment_sets_through_a_bounded_reliable_window() -> Result<()> { - let connection_id = 0x1020_3042; - let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id, - initial_sequence: 1, - resend_delay_ms: 25, - max_pending_reliable_packets: None, - }); - let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id, - initial_sequence: 100, - resend_delay_ms: 25, - max_pending_reliable_packets: None, - }); - for session in [&mut sender, &mut receiver] { - session.receive( - &CultNetRudpPacket { - packet_type: CultNetRudpPacketType::Accept, - connection_id, - sequence: 0, - ack: 0, - ack_mask: 0, - channel_id: "control".into(), - reliable: false, - ordered: false, - sequenced: false, - fragment_id: 0, - fragment_index: 0, - fragment_count: 0, - payload: Vec::new(), - }, - 0, - )?; - } - - let fragment_count = CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS + 17; - let payload = (0..fragment_count * 8) - .map(|index| (index % 251) as u8) - .collect::>(); - let initial = sender.send_many( - "schema", - payload.clone(), - CultNetRudpSendOptions { - reliable: true, - ordered: true, - now_ms: 1, - ..CultNetRudpSendOptions::default() - }, - Some(8), - )?; - assert_eq!(initial.len(), CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS); - let oldest_sequence = initial[0].sequence; - assert_eq!(sender.pending_reliable_sequences().len(), initial.len()); - assert_eq!(sender.queued_reliable_packet_count(), 17); - - let mut wire = VecDeque::from(initial); - let mut delivered = Vec::new(); - while !wire.is_empty() { - let admitted = wire.len(); - for _ in 0..admitted { - let packet = wire.pop_front().expect("admitted packet remains queued"); - let received = receiver.receive(&packet, 2)?; - delivered.extend(received.delivered); - } - let acknowledged = sender.receive(&receiver.create_ack(), 3)?; - wire.extend(acknowledged.ready_to_send); - } - - assert_eq!(sender.outstanding_reliable_packet_count(), 0); - assert_eq!(delivered.len(), 1); - assert_eq!(delivered[0].payload, payload); - let old_ack = receiver.create_ack_for_received(oldest_sequence); - assert_eq!(old_ack.ack, oldest_sequence); - assert_eq!(old_ack.ack_mask, 0); - Ok(()) -} - -#[test] -fn rudp_socket_flush_waits_for_large_fragment_delivery() -> Result<()> { - let server_socket = bind_udp_socket()?; - let client_socket = bind_udp_socket()?; - let server_addr = server_socket.local_addr()?; - let connection_id = 0x1020_3043; - let mut server = - CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { - media_delivery: None, - runtime_id: "rust-rudp-window-server".into(), - socket: server_socket, - mode: CultNetRudpSocketMode::Server, - remote_addr: None, - connection_id, - initial_sequence: 100, - resend_delay_ms: 25, - transport_id: None, - max_payload_bytes: None, - max_fragment_bytes: Some(8), - max_pending_reliable_packets: None, - reconnect_policy: None, - media_reliable_expire_after_ms: None, - })?; - let mut client = - CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { - media_delivery: None, - runtime_id: "rust-rudp-window-client".into(), - socket: client_socket, - mode: CultNetRudpSocketMode::Client, - remote_addr: Some(server_addr), - connection_id, - initial_sequence: 1, - resend_delay_ms: 25, - transport_id: None, - max_payload_bytes: None, - max_fragment_bytes: Some(8), - max_pending_reliable_packets: None, - reconnect_policy: None, - media_reliable_expire_after_ms: None, - })?; - client.connect(b"join".to_vec())?; - pump_rudp_handshake(&mut client, &mut server)?; - - let payload = vec![0x5a; (CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS + 17) * 8]; - client.send("schema", payload.clone())?; - assert_eq!( - client.pending_reliable_packet_count(), - CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS - ); - assert_eq!( - client.outstanding_reliable_packet_count(), - CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS + 17 - ); - - let server_worker = thread::spawn(move || -> Result { - let delivery_deadline = StdInstant::now() + StdDuration::from_secs(5); - let delivered = loop { - if let Some(frame) = server.receive_once()? { - break frame; - } - if StdInstant::now() >= delivery_deadline { - anyhow::bail!("RUDP large socket frame was not delivered"); - } - }; - let linger_deadline = StdInstant::now() + StdDuration::from_millis(250); - while StdInstant::now() < linger_deadline { - let _ = server.receive_once()?; - server.poll_resends()?; - } - Ok(delivered) - }); - client.flush_reliable(StdDuration::from_secs(5))?; - let delivered = server_worker - .join() - .map_err(|_| anyhow::anyhow!("RUDP server worker panicked"))??; - assert_eq!(delivered.payload, payload); - assert_eq!(client.outstanding_reliable_packet_count(), 0); - Ok(()) -} - #[test] fn cultmesh_facade_creates_rudp_client_from_peer_endpoint() -> Result<()> { let connection_id = 0x2030_4050; @@ -1950,7 +1645,7 @@ fn document_registry_replicates_typed_cultcache_state() -> Result<()> { let mut origin = CultCache::new(); origin.register_entry_type::()?; - origin.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&origin_store))?; + origin.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&origin_store)); origin.pull_all_backing_stores()?; origin.put("epiphany.persona", &payload)?; @@ -1958,7 +1653,7 @@ fn document_registry_replicates_typed_cultcache_state() -> Result<()> { let mut target = CultCache::new(); target.register_entry_type::()?; - target.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store))?; + target.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store)); target.pull_all_backing_stores()?; let applied = registry.apply_snapshot_response::(&mut target, &snapshot)?; @@ -2007,7 +1702,7 @@ fn raw_snapshot_replication_preserves_messagepack_payload_bytes() -> Result<()> let mut origin = CultCache::new(); origin.register_entry_type::()?; - origin.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&origin_store))?; + origin.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&origin_store)); origin.pull_all_backing_stores()?; origin.put("epiphany.persona", &payload)?; @@ -2018,7 +1713,7 @@ fn raw_snapshot_replication_preserves_messagepack_payload_bytes() -> Result<()> let mut target = CultCache::new(); target.register_entry_type::()?; - target.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store))?; + target.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store)); target.pull_all_backing_stores()?; let applied = registry .apply_raw_snapshot_response::(&mut target, &raw_snapshot)?; @@ -2054,7 +1749,7 @@ fn raw_snapshot_replication_hydrates_same_schema_rust_aliases() -> Result<()> { )); let mut origin = CultCache::new(); origin.register_entry_type::()?; - origin.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&origin_store))?; + origin.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&origin_store)); origin.pull_all_backing_stores()?; origin.put("epiphany.persona", &canonical)?; let raw_snapshot = origin_registry.create_raw_snapshot_response( @@ -2073,7 +1768,7 @@ fn raw_snapshot_replication_hydrates_same_schema_rust_aliases() -> Result<()> { )); let mut target = CultCache::new(); target.register_entry_type::()?; - target.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store))?; + target.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store)); target.pull_all_backing_stores()?; let applied = alias_registry @@ -2113,7 +1808,7 @@ fn reactive_document_coalesces_direct_same_schema_alias_member_writes() -> Resul { let mut cache = cache.lock().expect("cache mutex"); cache.register_entry_type::()?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store))?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store)); cache.pull_all_backing_stores()?; cache.put( "note:reactive", @@ -2178,7 +1873,7 @@ fn reactive_document_tracks_canonical_reconciliation_delta() -> Result<()> { { let mut cache = cache.lock().expect("cache mutex"); cache.register_entry_type::()?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store))?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store)); cache.pull_all_backing_stores()?; cache.put( "note:reconcile", @@ -2438,514 +2133,3 @@ fn schema_discovery_round_trips_over_legacy_gamecult_contract_when_inline_schema ); Ok(()) } - -#[test] -fn rudp_sequenced_channel_tracking_is_bounded() -> Result<()> { - let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: 401, - initial_sequence: 1, - ..CultNetRudpSessionOptions::default() - }); - let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: 401, - initial_sequence: 100, - ..CultNetRudpSessionOptions::default() - }); - let connect = sender.create_connect(0, Vec::new())?; - let accept = receiver.accept_connect(&connect, 1, Vec::new())?; - sender.receive(&accept, 2)?; - - // A peer picks its own channel ids, so the sequenced map is attacker-sized. - let mut error = None; - for channel in 0..256u32 { - let packet = sender.send( - &format!("channel-{channel}"), - b"state".to_vec(), - CultNetRudpSendOptions { - sequenced: true, - ..CultNetRudpSendOptions::default() - }, - )?; - if let Err(refused) = receiver.receive(&packet, 3) { - error = Some(refused); - break; - } - } - let error = error.expect("unbounded sequenced channel tracking"); - assert!( - error.to_string().contains("too many sequenced channels"), - "unexpected refusal: {error}" - ); - Ok(()) -} - -#[test] -fn rudp_ordered_channel_tracking_is_bounded() -> Result<()> { - let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: 402, - initial_sequence: 1, - ..CultNetRudpSessionOptions::default() - }); - let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: 402, - initial_sequence: 100, - ..CultNetRudpSessionOptions::default() - }); - let connect = sender.create_connect(0, Vec::new())?; - let accept = receiver.accept_connect(&connect, 1, Vec::new())?; - sender.receive(&accept, 2)?; - - let mut error = None; - for channel in 0..256u32 { - let packet = sender.send( - &format!("ordered-{channel}"), - b"state".to_vec(), - CultNetRudpSendOptions { - reliable: true, - ordered: true, - ..CultNetRudpSendOptions::default() - }, - )?; - match receiver.receive(&packet, 3) { - Ok(_) => { - sender.receive(&receiver.create_ack_for(packet.sequence), 3)?; - } - Err(refused) => { - error = Some(refused); - break; - } - } - } - let error = error.expect("unbounded ordered channel tracking"); - assert!( - error.to_string().contains("too many ordered channels"), - "unexpected refusal: {error}" - ); - Ok(()) -} - -#[test] -fn rudp_ordered_hold_buffer_is_bounded() -> Result<()> { - let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: 403, - initial_sequence: 1, - ..CultNetRudpSessionOptions::default() - }); - let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: 403, - initial_sequence: 100, - ..CultNetRudpSessionOptions::default() - }); - let connect = sender.create_connect(0, Vec::new())?; - let accept = receiver.accept_connect(&connect, 1, Vec::new())?; - sender.receive(&accept, 2)?; - - let options = CultNetRudpSendOptions { - reliable: true, - ordered: true, - ..CultNetRudpSendOptions::default() - }; - // Withhold the first frame so every later one has to be held. - let withheld = sender.send("schema", b"first".to_vec(), options.clone())?; - sender.receive(&receiver.create_ack_for(withheld.sequence), 3)?; - - let mut error = None; - for step in 0..4_096u32 { - let packet = sender.send("schema", vec![(step % 251) as u8], options.clone())?; - match receiver.receive(&packet, 4) { - Ok(result) => { - assert!(result.delivered.is_empty(), "gap should hold every frame"); - sender.receive(&receiver.create_ack_for(packet.sequence), 4)?; - } - Err(refused) => { - error = Some(refused); - break; - } - } - } - let error = error.expect("unbounded ordered hold buffer"); - assert!( - error.to_string().contains("ordered hold buffer is full"), - "unexpected refusal: {error}" - ); - Ok(()) -} - -#[test] -fn rudp_far_ahead_sequence_cannot_starve_the_session() -> Result<()> { - let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: 404, - initial_sequence: 1, - ..CultNetRudpSessionOptions::default() - }); - let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: 404, - initial_sequence: 100, - ..CultNetRudpSessionOptions::default() - }); - let connect = sender.create_connect(0, Vec::new())?; - let accept = receiver.accept_connect(&connect, 1, Vec::new())?; - sender.receive(&accept, 2)?; - - let options = CultNetRudpSendOptions { - reliable: true, - ..CultNetRudpSendOptions::default() - }; - let first = sender.send("schema", b"before".to_vec(), options.clone())?; - assert_eq!(receiver.receive(&first, 3)?.delivered.len(), 1); - sender.receive(&receiver.create_ack_for(first.sequence), 3)?; - - // A forged packet far past the current highest. Recording it would push the - // receive state beyond every sequence still in flight. - let forged = CultNetRudpPacket { - packet_type: CultNetRudpPacketType::Data, - connection_id: 404, - sequence: first.sequence.saturating_add(500_000), - ack: 0, - ack_mask: 0, - channel_id: "schema".to_string(), - reliable: true, - ordered: false, - sequenced: false, - fragment_id: 0, - fragment_index: 0, - fragment_count: 0, - payload: b"hostile".to_vec(), - }; - assert!(receiver.receive(&forged, 4)?.delivered.is_empty()); - - // The session must still accept the next legitimate packet. - let second = sender.send("schema", b"after".to_vec(), options)?; - assert_eq!( - receiver.receive(&second, 5)?.delivered[0].payload, - b"after".to_vec() - ); - Ok(()) -} - -#[test] -fn rudp_reliable_sequence_exhaustion_is_reported_not_fatal() -> Result<()> { - let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: 405, - // One sequence left: the handshake takes it, so the next send is the - // first allocation with nowhere to go. - initial_sequence: u32::MAX - 1, - ..CultNetRudpSessionOptions::default() - }); - let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: 405, - initial_sequence: 100, - ..CultNetRudpSessionOptions::default() - }); - let connect = sender.create_connect(0, Vec::new())?; - let accept = receiver.accept_connect(&connect, 1, Vec::new())?; - sender.receive(&accept, 2)?; - - let error = sender - .send( - "schema", - b"one past the end".to_vec(), - CultNetRudpSendOptions { - reliable: true, - ..CultNetRudpSendOptions::default() - }, - ) - .unwrap_err(); - assert!( - error.to_string().contains("sequence space is exhausted"), - "unexpected error: {error}" - ); - Ok(()) -} - -#[test] -fn tcp_framed_receive_refuses_a_length_beyond_the_advertised_maximum() -> Result<()> { - let profile = create_tcp_framed_transport_profile( - "rust-transport", - TcpFramedTransportProfileOptions { - transport_id: Some("bounded-tcp".to_string()), - max_payload_bytes: Some(64), - ..TcpFramedTransportProfileOptions::default() - }, - ); - let advertised = profile - .transports - .iter() - .find(|transport| transport.protocol == CultNetTransportProtocol::TcpFramed) - .and_then(|transport| { - transport - .channels - .iter() - .find(|channel| channel.channel_id == "schema") - }) - .and_then(|channel| channel.max_payload_bytes) - .expect("tcp_framed schema channel advertises a maximum payload"); - - // Only a header, declaring more than the profile allows. Honouring it would - // reserve the peer's number before a single payload byte arrives. - let mut header = ((advertised as u64 + 1) as u32).to_be_bytes().to_vec(); - header.extend_from_slice(b"short"); - let mut receiver = TcpFramedTransportConnection::new(std::io::Cursor::new(header), profile); - let error = receiver.receive().unwrap_err(); - assert!( - error.to_string().contains("exceeds advertised maximum"), - "unexpected error: {error}" - ); - Ok(()) -} - -#[test] -fn rudp_reliable_expiry_drops_late_packets_and_leaves_durable_sends_alone() -> Result<()> { - let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: 7, - initial_sequence: 10, - resend_delay_ms: 100, - ..CultNetRudpSessionOptions::default() - }); - accept_rudp_session(&mut sender, 7)?; - - let expiring = sender.send( - "media", - b"late".to_vec(), - CultNetRudpSendOptions { - reliable: true, - now_ms: 0, - reliable_expire_after_ms: Some(50), - ..CultNetRudpSendOptions::default() - }, - )?; - let durable = sender.send( - "schema", - b"state".to_vec(), - CultNetRudpSendOptions { - reliable: true, - now_ms: 0, - ..CultNetRudpSendOptions::default() - }, - )?; - assert_eq!(sender.pending_reliable_sequences().len(), 2); - assert_eq!(sender.reliable_packets_expired(), 0); - - // One millisecond before the deadline nothing is dropped: the boundary is - // inclusive, so a packet is never discarded early. - sender.send( - "schema", - b"tick".to_vec(), - CultNetRudpSendOptions { - reliable: false, - now_ms: 50, - ..CultNetRudpSendOptions::default() - }, - )?; - assert_eq!(sender.reliable_packets_expired(), 0); - - sender.send( - "schema", - b"tick".to_vec(), - CultNetRudpSendOptions { - reliable: false, - now_ms: 51, - ..CultNetRudpSendOptions::default() - }, - )?; - assert_eq!(sender.reliable_packets_expired(), 1); - assert_eq!( - sender.pending_reliable_sequences(), - vec![durable.sequence], - "a send without an expiry deadline is never dropped" - ); - assert_ne!(expiring.sequence, durable.sequence); - Ok(()) -} - -#[test] -fn rudp_reliable_expiry_also_reclaims_the_send_backlog() -> Result<()> { - let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: 9, - initial_sequence: 10, - resend_delay_ms: 100, - ..CultNetRudpSessionOptions::default() - }); - accept_rudp_session(&mut sender, 9)?; - - let fragment_count = CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS + 5; - let payload = (0..fragment_count * 8) - .map(|index| (index % 251) as u8) - .collect::>(); - sender.send_many( - "media", - payload, - CultNetRudpSendOptions { - reliable: true, - now_ms: 0, - reliable_expire_after_ms: Some(30), - ..CultNetRudpSendOptions::default() - }, - Some(8), - )?; - assert_eq!( - sender.pending_reliable_sequences().len(), - CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS - ); - assert_eq!(sender.queued_reliable_packet_count(), 5); - - // The deadline is fixed at admission, so waiting in the backlog spends it - // exactly as waiting for an acknowledgement does. A queued packet promoted - // after its moment has passed would be as useless as a retransmitted one. - sender.send( - "schema", - b"tick".to_vec(), - CultNetRudpSendOptions { - reliable: false, - now_ms: 31, - ..CultNetRudpSendOptions::default() - }, - )?; - assert_eq!(sender.pending_reliable_sequences().len(), 0); - assert_eq!(sender.queued_reliable_packet_count(), 0); - assert_eq!( - sender.reliable_packets_expired(), - fragment_count as u64, - "both the in-flight window and the backlog are counted" - ); - Ok(()) -} - -fn accept_rudp_session(session: &mut CultNetRudpSession, connection_id: u32) -> Result<()> { - session.receive( - &CultNetRudpPacket { - packet_type: CultNetRudpPacketType::Accept, - connection_id, - sequence: 0, - ack: 0, - ack_mask: 0, - channel_id: "control".into(), - reliable: false, - ordered: false, - sequenced: false, - fragment_id: 0, - fragment_index: 0, - fragment_count: 0, - payload: Vec::new(), - }, - 0, - )?; - Ok(()) -} - -#[test] -fn rudp_profile_expires_only_the_media_channels() -> Result<()> { - let socket = std::net::UdpSocket::bind("127.0.0.1:0")?; - let peer = socket.local_addr()?; - let options = CultNetRudpSocketTransportOptions::client("expiry-profile", socket, peer, 11); - let connection = CultNetRudpSocketTransportConnection::new(options)?; - - let channels = connection - .profile - .transports - .iter() - .flat_map(|transport| transport.channels.iter()) - .map(|channel| (channel.channel_id.as_str(), channel.reliable_expire_after_ms)) - .collect::>(); - - for media_channel in ["media", "audio"] { - assert!( - channels.contains(&(media_channel, Some(75))), - "the {media_channel} channel advertises its expiry so a peer can see it: {channels:?}" - ); - } - for (channel_id, expiry) in &channels { - if !matches!(*channel_id, "media" | "audio") { - assert_eq!( - *expiry, None, - "state channel {channel_id} must never expire a reliable send" - ); - } - } - Ok(()) -} - -fn connected_rudp_pair() -> Result<(CultNetRudpSession, CultNetRudpSession)> { - let mut client = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: 311, - initial_sequence: 1, - ..CultNetRudpSessionOptions::default() - }); - let mut server = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: 311, - initial_sequence: 100, - ..CultNetRudpSessionOptions::default() - }); - let connect = client.create_connect(0, Vec::new())?; - let accept = server.accept_connect(&connect, 1, Vec::new())?; - client.receive(&accept, 2)?; - Ok((client, server)) -} - -#[test] -fn rudp_evicts_stalest_incomplete_fragment_set_instead_of_dying() -> Result<()> { - let (mut client, mut server) = connected_rudp_pair()?; - client.set_max_pending_fragment_sets(4)?; - let fragmented = |server: &mut CultNetRudpSession, fill: u8| { - server.send_many( - "media", - vec![fill; 2500], - CultNetRudpSendOptions::default(), - Some(1000), - ) - }; - - // Eight payloads each lose their last fragment on the wire. Before, the - // fifth new set errored the receiver out and every later fragmented - // payload with it. - let mut stranded = Vec::new(); - for fill in 1..=8u8 { - let packets = fragmented(&mut server, fill)?; - assert_eq!(packets.len(), 3); - for packet in &packets[..2] { - assert!(client.receive(packet, 10)?.delivered.is_empty()); - } - stranded.push(packets[2].clone()); - } - assert_eq!(client.fragment_sets_evicted(), 4); - - // A complete payload still lands. - let complete = fragmented(&mut server, 9)?; - let mut delivered = Vec::new(); - for packet in &complete { - delivered.extend(client.receive(packet, 11)?.delivered); - } - assert_eq!(delivered.len(), 1); - assert_eq!(delivered[0].payload, vec![9u8; 2500]); - assert_eq!(client.fragment_sets_evicted(), 5); - - // The eviction order is stalest-first: set 8 is still pending and completes - // when its late fragment shows up; set 1 was evicted long ago, so its late - // fragment opens a fresh set and delivers nothing. - assert_eq!( - client.receive(&stranded[7], 12)?.delivered[0].payload, - vec![8u8; 2500] - ); - assert!(client.receive(&stranded[0], 13)?.delivered.is_empty()); - Ok(()) -} - -#[test] -fn rudp_fragment_ids_wrap_instead_of_saturating() -> Result<()> { - let (_client, mut server) = connected_rudp_pair()?; - let mut ids = Vec::new(); - for _ in 0..(u16::MAX as usize + 1) { - let packets = server.send_many( - "media", - vec![0u8; 1500], - CultNetRudpSendOptions::default(), - Some(1000), - )?; - ids.push(packets[0].fragment_id); - } - assert_eq!(ids[0], 1); - assert_eq!(ids[u16::MAX as usize - 1], u16::MAX); - // The 65,536th set must not share an id with the 65,535th. - assert_eq!(ids[u16::MAX as usize], 1); - Ok(()) -} diff --git a/packages/cultnet-rs/tests/media_channel_delivery.rs b/packages/cultnet-rs/tests/media_channel_delivery.rs deleted file mode 100644 index 499be0d3..00000000 --- a/packages/cultnet-rs/tests/media_channel_delivery.rs +++ /dev/null @@ -1,92 +0,0 @@ -//! Media delivery is the caller's choice, and the advertised profile is where -//! that choice lives. -//! -//! Before this, `media` was hardcoded `Reliable` in two places — the profile -//! builder and the per-channel send options — so no caller could say otherwise. -//! A producer asking for an unreliable media lane got a reliable one that -//! retransmits under loss, which adds load exactly when the link has least to -//! give. Muninn asked for that lane by passing no expiry, meaning "unreliable, -//! so expiry is moot", and got reliable-with-no-expiry instead: retransmit -//! forever. -//! -//! The default stays `Reliable` so every existing consumer is untouched. - -use std::net::UdpSocket; - -use cultnet_rs::{ - CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions, - CultNetTransportDelivery, -}; - -fn media_channel_delivery( - media_delivery: Option, -) -> (CultNetTransportDelivery, Option) { - let socket = UdpSocket::bind("127.0.0.1:0").expect("binds"); - let endpoint = "127.0.0.1:5204".parse().expect("parses"); - let mut options = CultNetRudpSocketTransportOptions::client("test-media", socket, endpoint, 7); - options.media_delivery = media_delivery; - options.media_reliable_expire_after_ms = None; - - let transport = CultNetRudpSocketTransportConnection::new(options).expect("opens"); - let channel = transport - .profile - .transports - .first() - .expect("a transport") - .channels - .iter() - .find(|channel| channel.channel_id == "media") - .expect("a media channel"); - (channel.delivery, channel.reliable_expire_after_ms) -} - -#[test] -fn media_defaults_to_reliable_so_existing_consumers_do_not_move() { - let (delivery, _) = media_channel_delivery(None); - assert_eq!(delivery, CultNetTransportDelivery::Reliable); -} - -#[test] -fn a_caller_can_ask_for_an_unreliable_media_lane() { - let (delivery, expiry) = media_channel_delivery(Some(CultNetTransportDelivery::Unreliable)); - assert_eq!(delivery, CultNetTransportDelivery::Unreliable); - assert_eq!( - expiry, None, - "an unreliable channel has nothing to expire; the pairing should stay honest" - ); -} - -#[test] -fn asking_for_reliable_explicitly_matches_the_default() { - let (explicit, _) = media_channel_delivery(Some(CultNetTransportDelivery::Reliable)); - let (defaulted, _) = media_channel_delivery(None); - assert_eq!(explicit, defaulted); -} - -/// The channels whose semantics are fixed by design must not drift with this. -#[test] -fn the_other_channels_keep_their_fixed_delivery() { - let socket = UdpSocket::bind("127.0.0.1:0").expect("binds"); - let endpoint = "127.0.0.1:5204".parse().expect("parses"); - let mut options = CultNetRudpSocketTransportOptions::client("test-media", socket, endpoint, 8); - options.media_delivery = Some(CultNetTransportDelivery::Unreliable); - - let transport = CultNetRudpSocketTransportConnection::new(options).expect("opens"); - let channels = &transport - .profile - .transports - .first() - .expect("a transport") - .channels; - - let delivery_of = |id: &str| { - channels - .iter() - .find(|channel| channel.channel_id == id) - .unwrap_or_else(|| panic!("channel {id}")) - .delivery - }; - - assert_eq!(delivery_of("schema"), CultNetTransportDelivery::Reliable); - assert_eq!(delivery_of("realtime"), CultNetTransportDelivery::Unreliable); -} diff --git a/packages/cultnet-rs/tests/media_stream_contracts.rs b/packages/cultnet-rs/tests/media_stream_contracts.rs deleted file mode 100644 index 963d78a2..00000000 --- a/packages/cultnet-rs/tests/media_stream_contracts.rs +++ /dev/null @@ -1,217 +0,0 @@ -//! The CultMesh Media Stream records are a wire contract shared by producers and -//! consumers that never link each other. These tests pin the properties a -//! consumer is entitled to rely on: positional layout, declared field order, and -//! tolerance of records written before an appended field existed. - -use cultcache_rs::DatabaseEntry; -use cultnet_rs::{ - GAMECULT_MEDIA_AUDIO_PACKET_SCHEMA, GAMECULT_MEDIA_RECEIVER_FEEDBACK_SCHEMA, - GAMECULT_MEDIA_VIDEO_ACCESS_UNIT_SCHEMA, GAMECULT_MEDIA_VIDEO_PARITY_SHARD_SCHEMA, - GameCultMediaAudioPacketRecord, GameCultMediaReceiverFeedbackRecord, - GameCultMediaVideoAccessUnitRecord, GameCultMediaVideoParityShardRecord, -}; -use rmpv::Value; - -fn access_unit() -> GameCultMediaVideoAccessUnitRecord { - GameCultMediaVideoAccessUnitRecord { - stream_id: "raven-primary-av".to_string(), - session_id: "session-1".to_string(), - frame_id: 4242, - codec: "h264".to_string(), - pts_ticks: 90_000, - duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - keyframe: true, - dependency_frame_id: None, - deadline_ticks: 108_000, - chunk_index: 2, - chunk_count: 5, - payload: vec![0xDE, 0xAD, 0xBE, 0xEF], - } -} - -fn audio_packet() -> GameCultMediaAudioPacketRecord { - GameCultMediaAudioPacketRecord { - stream_id: "raven-primary-av".to_string(), - session_id: "session-1".to_string(), - packet_id: 77, - codec: "opus".to_string(), - pts_ticks: 48_000, - duration_ticks: 960, - timebase_num: 1, - timebase_den: 48_000, - deadline_ticks: 96_000, - payload: vec![1, 2, 3], - } -} - -fn decode_as_value(bytes: &[u8]) -> Value { - rmp_serde::from_slice(bytes).expect("records decode as generic MessagePack") -} - -/// The declared `key` indices are the wire layout, not documentation. A consumer -/// in another language reads position 2 for `frame_id` and position 13 for -/// `payload`; reordering the struct would silently break it. -#[test] -fn video_access_unit_encodes_as_a_positional_array() { - let encoded = rmp_serde::to_vec(&access_unit()).expect("encodes"); - let Value::Array(slots) = decode_as_value(&encoded) else { - panic!("access units must encode as a positional array"); - }; - - assert_eq!(slots.len(), 14, "field count is wire contract"); - assert_eq!(slots[0].as_str(), Some("raven-primary-av")); - assert_eq!(slots[2].as_u64(), Some(4242), "slot 2 is frame_id"); - assert_eq!(slots[3].as_str(), Some("h264")); - assert_eq!(slots[8].as_bool(), Some(true), "slot 8 is keyframe"); - assert!(slots[9].is_nil(), "an absent dependency stays nil, not 0"); - assert_eq!(slots[12].as_u64(), Some(5), "slot 12 is chunk_count"); - assert_eq!( - slots[13].as_slice(), - Some([0xDE, 0xAD, 0xBE, 0xEF].as_slice()), - "slot 13 is the payload, carried as bin" - ); -} - -/// Payloads must reach the wire as MessagePack `bin`, not as an array of -/// integers. The C#, TypeScript and Python runtimes all read byte fields as -/// `bin`, so a payload serialized as a sequence is unreadable by the reference -/// implementation, and costs 1.45x besides: 848 bytes encode to 1234 as an -/// integer array against 851 as `bin`. -/// -/// This is what the derive's `bytes` attribute buys. `#[serde(with = -/// "serde_bytes")]` will not do it — `DatabaseEntry` emits its own `Serialize` -/// and reads only its own `cultcache` attribute namespace. -#[test] -fn payload_should_encode_as_messagepack_bin() { - let encoded = rmp_serde::to_vec(&access_unit()).expect("encodes"); - let Value::Array(slots) = decode_as_value(&encoded) else { - panic!("access units must encode as a positional array"); - }; - - assert_eq!( - slots[13].as_slice(), - Some([0xDE, 0xAD, 0xBE, 0xEF].as_slice()), - "payloads belong on the wire as bin, not as an array of integers" - ); -} - -#[test] -fn audio_packets_are_whole_and_carry_their_own_timebase() { - let encoded = rmp_serde::to_vec(&audio_packet()).expect("encodes"); - let Value::Array(slots) = decode_as_value(&encoded) else { - panic!("audio packets must encode as a positional array"); - }; - - assert_eq!(slots.len(), 10, "field count is wire contract"); - assert_eq!(slots[2].as_u64(), Some(77), "slot 2 is packet_id"); - assert_eq!(slots[6].as_u64(), Some(1), "slot 6 is timebase_num"); - assert_eq!(slots[7].as_u64(), Some(48_000), "slot 7 is timebase_den"); - - let decoded: GameCultMediaAudioPacketRecord = - rmp_serde::from_slice(&encoded).expect("round-trips"); - assert_eq!(decoded, audio_packet()); -} - -#[test] -fn parity_shards_carry_the_geometry_recovery_needs() { - let shard = GameCultMediaVideoParityShardRecord { - stream_id: "raven-primary-av".to_string(), - session_id: "session-1".to_string(), - frame_id: 4242, - codec: "h264".to_string(), - pts_ticks: 90_000, - duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - keyframe: false, - dependency_frame_id: Some(4241), - deadline_ticks: 108_000, - chunk_count: 5, - parity_index: 1, - parity_count: 2, - chunk_payload_bytes: 1024, - last_chunk_payload_bytes: 512, - payload: vec![9, 9, 9], - }; - - let encoded = rmp_serde::to_vec(&shard).expect("encodes"); - let Value::Array(slots) = decode_as_value(&encoded) else { - panic!("parity shards must encode as a positional array"); - }; - - assert_eq!(slots.len(), 17, "field count is wire contract"); - assert_eq!(slots[12].as_u64(), Some(1), "slot 12 is parity_index"); - assert_eq!(slots[13].as_u64(), Some(2), "slot 13 is parity_count"); - assert_eq!( - slots[15].as_u64(), - Some(512), - "slot 15 is last_chunk_payload_bytes, which recovery needs to size the tail" - ); - - let decoded: GameCultMediaVideoParityShardRecord = - rmp_serde::from_slice(&encoded).expect("round-trips"); - assert_eq!(decoded, shard); -} - -/// `missing_video_chunk_keys` was appended after producers were already writing -/// feedback. A record written without it must still decode, or the append was a -/// breaking change wearing a `default` attribute. -#[test] -fn receiver_feedback_decodes_records_written_before_its_last_field() { - let without_appended_field = Value::Array(vec![ - Value::from("raven-primary-av"), - Value::from("session-1"), - Value::from("starfire-obs"), - Value::from(4200u64), - Value::Array(vec![Value::from(4198u64)]), - Value::Array(vec![Value::from(4199u64)]), - Value::from(true), - Value::from(1_500i64), - Value::from(9_000i64), - Value::from("2026-09-09T00:00:00Z"), - ]); - - let mut bytes = Vec::new(); - rmpv::encode::write_value(&mut bytes, &without_appended_field).expect("writes legacy record"); - - let decoded: GameCultMediaReceiverFeedbackRecord = - rmp_serde::from_slice(&bytes).expect("a pre-append record must still decode"); - - assert_eq!(decoded.receiver_id, "starfire-obs"); - assert_eq!(decoded.highest_decodable_frame_id, Some(4200)); - assert!(decoded.requested_keyframe); - assert!( - decoded.missing_video_chunk_keys.is_empty(), - "the appended field defaults rather than failing the decode" - ); -} - -/// The published constants and the derived type identity must not drift apart; -/// a consumer resolving by schema id and a producer registering by type would -/// then disagree without either failing to compile. -#[test] -fn published_schema_constants_match_the_derived_identity() { - assert_eq!( - GameCultMediaVideoAccessUnitRecord::SCHEMA_NAME, - GAMECULT_MEDIA_VIDEO_ACCESS_UNIT_SCHEMA - ); - assert_eq!( - GameCultMediaVideoParityShardRecord::SCHEMA_NAME, - GAMECULT_MEDIA_VIDEO_PARITY_SHARD_SCHEMA - ); - assert_eq!( - GameCultMediaAudioPacketRecord::SCHEMA_NAME, - GAMECULT_MEDIA_AUDIO_PACKET_SCHEMA - ); - assert_eq!( - GameCultMediaReceiverFeedbackRecord::SCHEMA_NAME, - GAMECULT_MEDIA_RECEIVER_FEEDBACK_SCHEMA - ); - - assert_eq!( - GameCultMediaAudioPacketRecord::TYPE, - "gamecult.media_audio_packet" - ); -} diff --git a/packages/cultnet-rs/tests/media_stream_wire.rs b/packages/cultnet-rs/tests/media_stream_wire.rs deleted file mode 100644 index cee31a04..00000000 --- a/packages/cultnet-rs/tests/media_stream_wire.rs +++ /dev/null @@ -1,217 +0,0 @@ -//! The envelope, and one proof that removing Muninn's shadow structs changed -//! nothing on the wire. - -use cultnet_rs::{ - GAMECULT_MEDIA_CHANNEL, GameCultMediaAudioPacketRecord, GameCultMediaReceiverFeedbackRecord, - GameCultMediaVideoAccessUnitRecord, GameCultMediaVideoParityShardRecord, - GameCultMediaWireRecord, MediaWireProvenance, decode_media_wire_record, - encode_media_wire_record, -}; -use serde::Serialize; - -fn provenance() -> MediaWireProvenance<'static> { - MediaWireProvenance { - stored_at: "unix:1000", - runtime_id: "raven", - role: "muninn.media", - producer: "muninn", - } -} - -fn access_unit() -> GameCultMediaVideoAccessUnitRecord { - GameCultMediaVideoAccessUnitRecord { - stream_id: "raven-primary-av".to_string(), - session_id: "session-1".to_string(), - frame_id: 4242, - codec: "h264".to_string(), - pts_ticks: 90_000, - duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - keyframe: true, - dependency_frame_id: None, - deadline_ticks: 108_000, - chunk_index: 2, - chunk_count: 5, - payload: (0u8..=255).collect(), - } -} - -/// Muninn's `VideoAccessUnitWirePayload`, reproduced exactly as it was. -/// -/// It existed only so `#[serde(with = "serde_bytes")]` could reach the payload, -/// because the `DatabaseEntry` derive ignores serde attributes. Every other -/// field was restated by hand, in order, and had to stay in step with the record -/// by discipline alone. -#[derive(Serialize)] -struct LegacyVideoAccessUnitWirePayload<'a>( - &'a str, - &'a str, - u64, - &'a str, - i64, - u32, - u32, - u32, - bool, - Option, - i64, - u16, - u16, - #[serde(with = "serde_bytes")] &'a [u8], -); - -/// If these differ, deleting the shadow structs was a wire break rather than a -/// simplification, and every existing consumer would need to move in step. -#[test] -fn the_derive_reproduces_the_hand_written_wire_payload_byte_for_byte() { - let record = access_unit(); - let legacy = LegacyVideoAccessUnitWirePayload( - &record.stream_id, - &record.session_id, - record.frame_id, - &record.codec, - record.pts_ticks, - record.duration_ticks, - record.timebase_num, - record.timebase_den, - record.keyframe, - record.dependency_frame_id, - record.deadline_ticks, - record.chunk_index, - record.chunk_count, - &record.payload, - ); - - let from_derive = rmp_serde::to_vec(&record).expect("record encodes"); - let from_shadow = rmp_serde::to_vec(&legacy).expect("shadow struct encodes"); - - assert_eq!( - from_derive, from_shadow, - "the `bytes` attribute must reproduce what serde_bytes produced by hand" - ); -} - -#[test] -fn a_video_record_round_trips_through_the_envelope() { - let record = GameCultMediaWireRecord::Video(access_unit()); - let wire = encode_media_wire_record(&record, provenance()).expect("encodes"); - assert_eq!(decode_media_wire_record(&wire).expect("decodes"), record); -} - -#[test] -fn every_variant_round_trips() { - let variants = vec![ - GameCultMediaWireRecord::Video(access_unit()), - GameCultMediaWireRecord::VideoParity(GameCultMediaVideoParityShardRecord { - stream_id: "s".to_string(), - session_id: "x".to_string(), - frame_id: 1, - codec: "h264".to_string(), - pts_ticks: 0, - duration_ticks: 1, - timebase_num: 1, - timebase_den: 90_000, - keyframe: false, - dependency_frame_id: Some(0), - deadline_ticks: 1, - chunk_count: 4, - parity_index: 1, - parity_count: 2, - chunk_payload_bytes: 848, - last_chunk_payload_bytes: 400, - payload: vec![1, 2, 3], - }), - GameCultMediaWireRecord::Audio(GameCultMediaAudioPacketRecord { - stream_id: "s".to_string(), - session_id: "x".to_string(), - packet_id: 9, - codec: "opus".to_string(), - pts_ticks: 0, - duration_ticks: 960, - timebase_num: 1, - timebase_den: 48_000, - deadline_ticks: 1, - payload: vec![4, 5, 6], - }), - GameCultMediaWireRecord::Feedback(GameCultMediaReceiverFeedbackRecord { - stream_id: "s".to_string(), - session_id: "x".to_string(), - receiver_id: "starfire-obs".to_string(), - highest_decodable_frame_id: Some(41), - missing_frame_ids: vec![42], - late_frame_ids: vec![43], - requested_keyframe: true, - jitter_us: 500, - decode_queue_us: 2_000, - observed_at: "unix:1000".to_string(), - missing_video_chunk_keys: vec!["42:1".to_string()], - }), - ]; - - for record in variants { - let wire = encode_media_wire_record(&record, provenance()).expect("encodes"); - assert_eq!( - decode_media_wire_record(&wire).expect("decodes"), - record, - "variant {} did not survive the envelope", - record.schema_id() - ); - } -} - -/// Record keys address one piece of media within a session. A consumer parses -/// them, so their shape is contract. -#[test] -fn record_keys_distinguish_chunks_parity_and_senders() { - let mut second_chunk = access_unit(); - second_chunk.chunk_index = 3; - - let first = GameCultMediaWireRecord::Video(access_unit()).record_key(); - let second = GameCultMediaWireRecord::Video(second_chunk).record_key(); - assert_ne!(first, second, "chunks of one frame must not share a key"); - assert!(first.contains(":video:"), "got {first}"); -} - -/// A shared envelope must not label another producer's traffic as Muninn's. -#[test] -fn the_producer_namespace_is_the_callers_not_the_envelopes() { - let record = GameCultMediaWireRecord::Video(access_unit()); - let theirs = MediaWireProvenance { - stored_at: "unix:1000", - runtime_id: "nightwing", - role: "brokkr.media", - producer: "brokkr", - }; - - let wire = encode_media_wire_record(&record, theirs).expect("encodes"); - let text = String::from_utf8_lossy(&wire); - assert!(text.contains("brokkr-media:"), "message id names the producer"); - assert!(text.contains("brokkr.media"), "tag names the producer"); - assert!( - !text.contains("muninn"), - "the envelope must not name a producer the caller did not supply" - ); -} - -#[test] -fn provenance_must_be_complete() { - let record = GameCultMediaWireRecord::Video(access_unit()); - for blank in ["stored_at", "runtime_id", "role", "producer"] { - let mut incomplete = provenance(); - match blank { - "stored_at" => incomplete.stored_at = "", - "runtime_id" => incomplete.runtime_id = "", - "role" => incomplete.role = "", - _ => incomplete.producer = "", - } - let error = encode_media_wire_record(&record, incomplete) - .expect_err("empty provenance must be refused"); - assert!(error.to_string().contains(blank), "got {error}"); - } -} - -#[test] -fn the_media_channel_is_named_once() { - assert_eq!(GAMECULT_MEDIA_CHANNEL, "media"); -} diff --git a/packages/cultnet-rs/tests/provider_session.rs b/packages/cultnet-rs/tests/provider_session.rs deleted file mode 100644 index 4ad7c23a..00000000 --- a/packages/cultnet-rs/tests/provider_session.rs +++ /dev/null @@ -1,365 +0,0 @@ -use anyhow::Result; -use cultnet_rs::*; - -const REGISTRATION_GOLDEN: &str = "hqpwcm92aWRlcklkqGFldGhlcmlhsXNlcnZpY2VJbnN0YW5jZUlkq2FldGhlcmlhLTQyqmVuZHBvaW50SWSvYWV0aGVyaWEtcHVibGljp3ZlcnNlSWSmcHVibGljuHJlcXVlc3RlZExlYXNlRHVyYXRpb25Nc811MLBhdXRob3JpdHlMZWFzZUlkq2F1dGhvcml0eS03"; -const CONNECT_EVIDENCE_GOLDEN: &str = - "gq9jbGllbnRTZXNzaW9uSWSyYWV0aGVyaWEtY2xpZW50LTQyrHNlc3Npb25Ub2tlbrJvZGluLXNlc3Npb24tdG9rZW4="; -const TOKENLESS_CONNECT_EVIDENCE_GOLDEN: &str = - "gq9jbGllbnRTZXNzaW9uSWSwYW5vbnltb3VzLWNsaWVudKxzZXNzaW9uVG9rZW7A"; - -fn registration() -> CultMeshProviderRegistrationWire { - CultMeshProviderRegistrationWire { - provider_id: "aetheria".to_string(), - service_instance_id: "aetheria-42".to_string(), - endpoint_id: "aetheria-public".to_string(), - verse_id: "public".to_string(), - requested_lease_duration_ms: 30_000, - authority_lease_id: Some("authority-7".to_string()), - } -} - -#[test] -fn registration_matches_csharp_and_typescript_golden_payload() -> Result<()> { - let encoded = encode_provider_session_payload(®istration())?; - assert_eq!(encoded, REGISTRATION_GOLDEN); - assert_eq!( - decode_provider_session_payload::(&encoded)?, - registration() - ); - Ok(()) -} - -#[test] -fn connect_evidence_is_a_validated_named_messagepack_map() -> Result<()> { - let evidence = CultMeshProviderConnectEvidenceWire { - client_session_id: "aetheria-client-42".to_string(), - session_token: Some("odin-session-token".to_string()), - }; - let encoded = encode_provider_connect_evidence(&evidence)?; - assert_eq!( - base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &encoded), - CONNECT_EVIDENCE_GOLDEN - ); - assert_eq!(decode_provider_connect_evidence(&encoded)?, evidence); - let value: rmpv::Value = rmp_serde::from_slice(&encoded)?; - let map = value.as_map().expect("Connect evidence is a named map"); - assert_eq!( - map.iter() - .find_map(|(key, value)| (key.as_str() == Some("clientSessionId")).then_some(value)) - .and_then(rmpv::Value::as_str), - Some("aetheria-client-42") - ); - assert_eq!( - map.iter() - .find_map(|(key, value)| (key.as_str() == Some("sessionToken")).then_some(value)) - .and_then(rmpv::Value::as_str), - Some("odin-session-token") - ); - - assert!( - encode_provider_connect_evidence(&CultMeshProviderConnectEvidenceWire { - client_session_id: String::new(), - session_token: None, - }) - .is_err() - ); - assert!( - encode_provider_connect_evidence(&CultMeshProviderConnectEvidenceWire { - client_session_id: "client".to_string(), - session_token: Some(" ".to_string()), - }) - .is_err() - ); - assert!(decode_provider_connect_evidence(&[]).is_err()); - let tokenless = CultMeshProviderConnectEvidenceWire { - client_session_id: "anonymous-client".to_string(), - session_token: None, - }; - let tokenless_encoded = encode_provider_connect_evidence(&tokenless)?; - assert_eq!( - base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - &tokenless_encoded - ), - TOKENLESS_CONNECT_EVIDENCE_GOLDEN - ); - assert_eq!( - decode_provider_connect_evidence(&tokenless_encoded)?, - tokenless - ); - Ok(()) -} - -#[test] -fn registration_round_trips_inside_canonical_operation_envelope() -> Result<()> { - let request = create_provider_session_request( - "register-1", - CULTMESH_PROVIDER_REGISTER_OPERATION, - CULTMESH_PROVIDER_REGISTRATION_SCHEMA, - ®istration(), - Some("aetheria-42".to_string()), - Some("odin".to_string()), - )?; - let bytes = encode_cultnet_message_to_vec(&request, CultNetWireContract::CultNetSchemaV0)?; - let decoded_request = - decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0)?; - let decoded: CultMeshProviderRegistrationWire = decode_provider_session_request( - &decoded_request, - CULTMESH_PROVIDER_REGISTER_OPERATION, - CULTMESH_PROVIDER_REGISTRATION_SCHEMA, - )?; - assert_eq!(decoded, registration()); - - let lease = CultMeshProviderLeaseWire { - provider_id: "aetheria".to_string(), - service_instance_id: "aetheria-42".to_string(), - endpoint_id: "aetheria-public".to_string(), - verse_id: "public".to_string(), - lease_id: "lease-2".to_string(), - valid_from_utc: "2026-07-14T12:00:00Z".to_string(), - expires_at_utc: "2026-07-14T12:00:10Z".to_string(), - }; - let response = create_provider_session_response( - &decoded_request, - CultMeshProviderOperationStatus::Ok, - CULTMESH_PROVIDER_LEASE_SCHEMA, - &lease, - Some("odin".to_string()), - )?; - let (status, decoded_lease) = decode_provider_session_response::( - &response, - CULTMESH_PROVIDER_REGISTER_OPERATION, - CULTMESH_PROVIDER_LEASE_SCHEMA, - )?; - assert_eq!(status, CultMeshProviderOperationStatus::Ok); - assert_eq!(decoded_lease, lease); - Ok(()) -} - -#[test] -fn publication_and_command_preserve_typed_messagepack_values() -> Result<()> { - let publication = CultMeshProviderPublicationPutWire { - lease_id: "lease-1".to_string(), - publication_id: "surface:pilot".to_string(), - document: CultNetRawDocumentRecord { - schema_id: "gamecult.eve.surface.v1".to_string(), - record_key: "eve:surface:pilot".to_string(), - stored_at: String::new(), - payload_encoding: CultNetRawPayloadEncoding::Messagepack, - payload: vec![0x81, 0xa2, 0x69, 0x64, 0x01], - source_runtime_id: Some("aetheria-42".to_string()), - source_agent_id: None, - source_role: None, - tags: None, - }, - }; - let publication_encoded = encode_provider_session_payload(&publication)?; - assert_eq!( - decode_provider_session_payload::( - &publication_encoded - )?, - publication - ); - - let command = CultMeshProviderCommandWire { - command_id: "cmd-7".to_string(), - command_kind: "pilot.thrust".to_string(), - provider_id: "aetheria".to_string(), - service_instance_id: "aetheria-42".to_string(), - payload: rmpv::Value::Map(vec![(rmpv::Value::from("axis"), rmpv::Value::F64(0.75))]), - }; - let command_bytes = base64::Engine::decode( - &base64::engine::general_purpose::STANDARD, - encode_provider_session_payload(&command)?, - )?; - let decoded_command = decode_provider_command_document(&CultNetRawDocumentRecord { - schema_id: CULTMESH_PROVIDER_COMMAND_SCHEMA.to_string(), - record_key: "provider-command:aetheria:aetheria-42:cmd-7".to_string(), - stored_at: String::new(), - payload_encoding: CultNetRawPayloadEncoding::Messagepack, - payload: command_bytes, - source_runtime_id: Some("odin".to_string()), - source_agent_id: None, - source_role: Some("provider-session-broker".to_string()), - tags: None, - })?; - assert_eq!(decoded_command, command); - Ok(()) -} - -#[test] -fn lifecycle_validation_rejects_split_or_malformed_authority() { - let mismatched = create_provider_session_request( - "bad-schema", - CULTMESH_PROVIDER_REGISTER_OPERATION, - CULTMESH_PROVIDER_PUBLICATION_PUT_SCHEMA, - ®istration(), - None, - None, - ); - assert!(mismatched.is_err()); - - let malformed_lease = CultMeshProviderLeaseWire { - provider_id: "provider".to_string(), - service_instance_id: "instance".to_string(), - endpoint_id: "endpoint".to_string(), - verse_id: "verse".to_string(), - lease_id: "lease".to_string(), - valid_from_utc: "2026-07-14T12:00:10Z".to_string(), - expires_at_utc: "2026-07-14T12:00:00Z".to_string(), - }; - assert!(encode_provider_session_payload(&malformed_lease).is_err()); - - let malformed_request = CultNetMessage::OperationRequest { - message_id: String::new(), - service_id: CULTMESH_PROVIDER_SESSION_SERVICE_ID.to_string(), - operation: String::new(), - payload_schema: CULTMESH_PROVIDER_REGISTRATION_SCHEMA.to_string(), - payload_encoding: CULTMESH_PROVIDER_SESSION_PAYLOAD_ENCODING.to_string(), - payload: REGISTRATION_GOLDEN.to_string(), - source_runtime_id: None, - target_runtime_id: None, - }; - assert!( - create_provider_session_response( - &malformed_request, - CultMeshProviderOperationStatus::Invalid, - CULTMESH_PROVIDER_MUTATION_ACCEPTANCE_SCHEMA, - &CultMeshProviderMutationAcceptanceWire { - accepted_at_utc: "2026-07-14T12:00:00Z".to_string(), - lease_id: None, - publication_id: None, - command_id: None, - receipt_id: None, - }, - None, - ) - .is_err() - ); -} - -#[test] -fn remaining_lifecycle_payloads_round_trip_as_named_maps() -> Result<()> { - let renewal = CultMeshProviderLeaseRenewalWire { - lease_id: "lease-1".to_string(), - requested_lease_duration_ms: 30_000, - }; - assert_eq!( - decode_provider_session_payload::( - &encode_provider_session_payload(&renewal)? - )?, - renewal - ); - - let deletion = CultMeshProviderPublicationDeleteWire { - lease_id: "lease-1".to_string(), - publication_id: "surface:pilot".to_string(), - schema_id: "gamecult.eve.surface.v1".to_string(), - record_key: "eve:surface:pilot".to_string(), - }; - assert_eq!( - decode_provider_session_payload::( - &encode_provider_session_payload(&deletion)? - )?, - deletion - ); - - let receipt = CultMeshProviderReceiptPutWire { - lease_id: "lease-1".to_string(), - receipt: CultMeshProviderCommandReceiptWire { - receipt_id: "receipt-7".to_string(), - command_id: "cmd-7".to_string(), - command_kind: "pilot.thrust".to_string(), - provider_id: "aetheria".to_string(), - service_instance_id: "aetheria-42".to_string(), - state: CultMeshProviderReceiptStateWire::Applied, - completed_at_utc: "2026-07-14T12:01:02.345Z".to_string(), - result: Some(rmpv::Value::Map(vec![( - rmpv::Value::from("accepted"), - rmpv::Value::Boolean(true), - )])), - error: None, - }, - }; - assert_eq!( - decode_provider_session_payload::( - &encode_provider_session_payload(&receipt)? - )?, - receipt - ); - - let withdrawal = CultMeshProviderWithdrawalWire { - lease_id: "lease-1".to_string(), - }; - assert_eq!( - decode_provider_session_payload::( - &encode_provider_session_payload(&withdrawal)? - )?, - withdrawal - ); - - let acceptance = CultMeshProviderMutationAcceptanceWire { - accepted_at_utc: "2026-07-14T12:01:03Z".to_string(), - lease_id: Some("lease-1".to_string()), - publication_id: Some("surface:pilot".to_string()), - command_id: None, - receipt_id: None, - }; - assert_eq!( - decode_provider_session_payload::( - &encode_provider_session_payload(&acceptance)? - )?, - acceptance - ); - Ok(()) -} - -#[test] -fn non_ok_response_uses_correlated_empty_map_and_diagnostics() -> Result<()> { - let request = create_provider_session_request( - "register-denied", - CULTMESH_PROVIDER_REGISTER_OPERATION, - CULTMESH_PROVIDER_REGISTRATION_SCHEMA, - ®istration(), - Some("aetheria-42".to_string()), - Some("odin".to_string()), - )?; - let response = create_provider_session_error_response( - &request, - CultMeshProviderOperationStatus::Denied, - vec!["registration authority rejected the session".to_string()], - Some("odin".to_string()), - )?; - let CultNetMessage::OperationResponse { - message_id, - operation, - status, - payload_schema, - payload, - diagnostics, - .. - } = response - else { - panic!("expected operation response"); - }; - assert_eq!(message_id, "register-denied"); - assert_eq!(operation, CULTMESH_PROVIDER_REGISTER_OPERATION); - assert_eq!(status, "denied"); - assert_eq!(payload_schema, CULTMESH_PROVIDER_MUTATION_ACCEPTANCE_SCHEMA); - assert_eq!(diagnostics.len(), 1); - let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, payload)?; - assert_eq!( - rmp_serde::from_slice::(&bytes)?, - rmpv::Value::Map(Vec::new()) - ); - assert!( - create_provider_session_error_response( - &request, - CultMeshProviderOperationStatus::Ok, - vec!["not an error".to_string()], - None, - ) - .is_err() - ); - Ok(()) -} diff --git a/packages/cultnet-rs/tests/rudp_server_hub.rs b/packages/cultnet-rs/tests/rudp_server_hub.rs deleted file mode 100644 index 201cf3b4..00000000 --- a/packages/cultnet-rs/tests/rudp_server_hub.rs +++ /dev/null @@ -1,574 +0,0 @@ -use std::collections::VecDeque; -use std::net::UdpSocket; -use std::time::Duration; - -use anyhow::{Result, anyhow}; -use cultnet_rs::*; - -const CONNECTION_ID: u32 = 0x4355_4c54; - -fn socket() -> Result { - let socket = UdpSocket::bind("127.0.0.1:0")?; - socket.set_read_timeout(Some(Duration::from_millis(20)))?; - Ok(socket) -} - -fn client(remote: std::net::SocketAddr) -> Result { - let mut options = - CultNetRudpSocketTransportOptions::client("provider", socket()?, remote, CONNECTION_ID); - options.max_fragment_bytes = Some(2_048); - CultNetRudpSocketTransportConnection::new(options) -} - -fn connect( - hub: &mut CultNetRudpServerHub, - client: &mut CultNetRudpSocketTransportConnection, - evidence: &[u8], -) -> Result { - client.connect(evidence.to_vec())?; - let mut connected = None; - for _ in 0..20 { - if let Some(CultNetRudpServerEvent::Connected { session }) = hub.receive_event_once()? { - connected = Some(session); - break; - } - } - let session = connected.ok_or_else(|| anyhow!("hub did not receive Connect"))?; - let _ = client.receive_once()?; - assert!(client.connected()); - Ok(session) -} - -fn receive_frame(hub: &mut CultNetRudpServerHub) -> Result { - for _ in 0..20 { - if let Some(event @ CultNetRudpServerEvent::Frame { .. }) = hub.receive_event_once()? { - return Ok(event); - } - } - Err(anyhow!("hub did not receive a frame")) -} - -fn receive_message(client: &mut CultNetRudpSocketTransportConnection) -> Result { - for _ in 0..20 { - if let Some(message) = client.receive_schema_message_once()? { - return Ok(message); - } - } - Err(anyhow!("client did not receive a schema message")) -} - -#[test] -fn hub_keeps_independent_peer_sessions_and_exposes_connect_evidence() -> Result<()> { - let server_socket = socket()?; - let server_addr = server_socket.local_addr()?; - let mut hub = CultNetRudpServerHub::new(CultNetRudpServerHubOptions::new( - "odin", - server_socket, - CONNECTION_ID, - ))?; - let mut first = client(server_addr)?; - let mut second = client(server_addr)?; - - let first_session = connect(&mut hub, &mut first, b"session-token-one")?; - let second_session = connect(&mut hub, &mut second, b"session-token-two")?; - assert_ne!(first_session.remote_addr, second_session.remote_addr); - assert_eq!(first_session.connect_payload, b"session-token-one"); - assert_eq!(second_session.connect_payload, b"session-token-two"); - assert_eq!(hub.sessions().len(), 2); - - let first_request = create_provider_session_request( - "register-first", - CULTMESH_PROVIDER_REGISTER_OPERATION, - CULTMESH_PROVIDER_REGISTRATION_SCHEMA, - &CultMeshProviderRegistrationWire { - provider_id: "first".to_string(), - service_instance_id: "first-1".to_string(), - endpoint_id: "first-public".to_string(), - verse_id: "public".to_string(), - requested_lease_duration_ms: 30_000, - authority_lease_id: None, - }, - Some("first-1".to_string()), - Some("odin".to_string()), - )?; - let second_request = create_provider_session_request( - "register-second", - CULTMESH_PROVIDER_REGISTER_OPERATION, - CULTMESH_PROVIDER_REGISTRATION_SCHEMA, - &CultMeshProviderRegistrationWire { - provider_id: "second".to_string(), - service_instance_id: "second-1".to_string(), - endpoint_id: "second-public".to_string(), - verse_id: "public".to_string(), - requested_lease_duration_ms: 30_000, - authority_lease_id: None, - }, - Some("second-1".to_string()), - Some("odin".to_string()), - )?; - first.send_schema_message(&first_request)?; - second.send_schema_message(&second_request)?; - - for _ in 0..2 { - let CultNetRudpServerEvent::Frame { session, frame } = receive_frame(&mut hub)? else { - unreachable!(); - }; - assert_eq!(frame.channel_id, "schema"); - let request = decode_cultnet_message_from_slice( - &frame.payload, - CultNetWireContract::CultNetSchemaV0, - )?; - let CultNetMessage::OperationRequest { message_id, .. } = &request else { - return Err(anyhow!("expected operation request")); - }; - let provider_id = if message_id == "register-first" { - "first" - } else { - "second" - }; - let lease = CultMeshProviderLeaseWire { - provider_id: provider_id.to_string(), - service_instance_id: format!("{provider_id}-1"), - endpoint_id: format!("{provider_id}-public"), - verse_id: "public".to_string(), - lease_id: format!("lease-{provider_id}"), - valid_from_utc: "2026-07-14T12:00:00Z".to_string(), - expires_at_utc: "2026-07-14T12:00:30Z".to_string(), - }; - let response = create_provider_session_response( - &request, - CultMeshProviderOperationStatus::Ok, - CULTMESH_PROVIDER_LEASE_SCHEMA, - &lease, - Some("odin".to_string()), - )?; - hub.send_schema_message(&session, &response)?; - } - - let first_response = receive_message(&mut first)?; - let second_response = receive_message(&mut second)?; - let (_, first_lease) = decode_provider_session_response::( - &first_response, - CULTMESH_PROVIDER_REGISTER_OPERATION, - CULTMESH_PROVIDER_LEASE_SCHEMA, - )?; - let (_, second_lease) = decode_provider_session_response::( - &second_response, - CULTMESH_PROVIDER_REGISTER_OPERATION, - CULTMESH_PROVIDER_LEASE_SCHEMA, - )?; - assert_eq!(first_lease.provider_id, "first"); - assert_eq!(second_lease.provider_id, "second"); - assert_eq!(hub.session(first_session.remote_addr), Some(&first_session)); - assert_eq!( - hub.session(second_session.remote_addr), - Some(&second_session) - ); - Ok(()) -} - -#[test] -fn hub_fences_replaced_generations_and_does_not_replace_connect_retransmits() -> Result<()> { - let server_socket = socket()?; - let server_addr = server_socket.local_addr()?; - let mut hub = CultNetRudpServerHub::new(CultNetRudpServerHubOptions::new( - "odin", - server_socket, - CONNECTION_ID, - ))?; - let mut provider = client(server_addr)?; - let original_evidence = - encode_provider_connect_evidence(&CultMeshProviderConnectEvidenceWire { - client_session_id: "provider-client-1".to_string(), - session_token: Some("shared-session-token".to_string()), - })?; - let replacement_evidence = - encode_provider_connect_evidence(&CultMeshProviderConnectEvidenceWire { - client_session_id: "provider-client-2".to_string(), - session_token: Some("shared-session-token".to_string()), - })?; - let original = connect(&mut hub, &mut provider, &original_evidence)?; - - provider.connect(original_evidence.clone())?; - for _ in 0..3 { - assert!(hub.receive_event_once()?.is_none()); - } - assert_eq!(hub.session(original.remote_addr), Some(&original)); - - provider.connect(replacement_evidence.clone())?; - let mut disconnected = None; - for _ in 0..20 { - if let Some(event @ CultNetRudpServerEvent::Disconnected { .. }) = - hub.receive_event_once()? - { - disconnected = Some(event); - break; - } - } - let disconnected = - disconnected.ok_or_else(|| anyhow!("replacement did not disconnect old generation"))?; - let CultNetRudpServerEvent::Disconnected { session, .. } = disconnected else { - return Err(anyhow!("expected old generation Disconnected event")); - }; - assert_eq!(session, original); - let connected = hub - .receive_event_once()? - .ok_or_else(|| anyhow!("replacement did not connect new generation"))?; - let CultNetRudpServerEvent::Connected { - session: replacement, - } = connected - else { - return Err(anyhow!("expected replacement Connected event")); - }; - assert_ne!(replacement.session_generation, original.session_generation); - let original_decoded = decode_provider_connect_evidence(&original.connect_payload)?; - let replacement_decoded = decode_provider_connect_evidence(&replacement.connect_payload)?; - assert_eq!( - original_decoded.session_token, - replacement_decoded.session_token - ); - assert_ne!( - original_decoded.client_session_id, - replacement_decoded.client_session_id - ); - assert!( - hub.send(&original, "schema", vec![0x80]) - .unwrap_err() - .to_string() - .contains("no longer active") - ); - assert_eq!(hub.session(replacement.remote_addr), Some(&replacement)); - Ok(()) -} - -#[test] -fn identical_connect_retransmit_reuses_pending_accept_after_loss() -> Result<()> { - let server_socket = socket()?; - let server_addr = server_socket.local_addr()?; - let mut hub = CultNetRudpServerHub::new(CultNetRudpServerHubOptions::new( - "odin", - server_socket, - CONNECTION_ID, - ))?; - let client_socket = socket()?; - let mut client_session = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: CONNECTION_ID, - initial_sequence: 1, - resend_delay_ms: 250, - max_pending_reliable_packets: None, - }); - let evidence = encode_provider_connect_evidence(&CultMeshProviderConnectEvidenceWire { - client_session_id: "provider-client-lost-accept".to_string(), - session_token: Some("shared-session-token".to_string()), - })?; - let connect_packet = client_session.create_connect(0, evidence)?; - let connect_wire = encode_rudp_packet(&connect_packet)?; - - client_socket.send_to(&connect_wire, server_addr)?; - let CultNetRudpServerEvent::Connected { session } = hub - .receive_event_once()? - .ok_or_else(|| anyhow!("hub did not accept first Connect"))? - else { - return Err(anyhow!("hub did not emit Connected")); - }; - let mut buffer = vec![0_u8; 65_535]; - let (first_len, _) = client_socket.recv_from(&mut buffer)?; - let lost_accept = decode_rudp_packet(&buffer[..first_len])?; - assert_eq!(lost_accept.packet_type, CultNetRudpPacketType::Accept); - - client_socket.send_to(&connect_wire, server_addr)?; - assert!(hub.receive_event_once()?.is_none()); - let (resent_len, _) = client_socket.recv_from(&mut buffer)?; - let resent_accept = decode_rudp_packet(&buffer[..resent_len])?; - assert_eq!(resent_accept, lost_accept); - assert_eq!(hub.session(session.remote_addr), Some(&session)); - - client_session.receive(&resent_accept, 1)?; - assert!(client_session.connected()); - Ok(()) -} - -#[test] -fn exact_reliable_acks_clear_more_than_one_ack_window_of_fragments() -> Result<()> { - let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: CONNECTION_ID, - initial_sequence: 1, - resend_delay_ms: 250, - max_pending_reliable_packets: None, - }); - let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: CONNECTION_ID, - initial_sequence: 1, - resend_delay_ms: 250, - max_pending_reliable_packets: None, - }); - let connect_packet = sender.create_connect(0, Vec::new())?; - let accept = receiver.accept_connect(&connect_packet, 0, Vec::new())?; - sender.receive(&accept, 0)?; - receiver.receive(&sender.create_ack_for(accept.sequence), 0)?; - - let packets = sender.send_many( - "schema", - vec![0x5a; 40], - CultNetRudpSendOptions { - reliable: true, - ordered: true, - sequenced: false, - now_ms: 1, - reliable_expire_after_ms: None, - }, - Some(1), - )?; - // Forty one-byte fragments cannot leave inside one reliable send window. The - // window's worth goes out now and the rest queue until acknowledgements free - // space, which is the crossing this test exists to prove. - assert_eq!(packets.len(), CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS); - assert_eq!( - sender.queued_reliable_packet_count(), - 40 - CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS - ); - - let mut wire = VecDeque::from(packets); - let mut delivered = Vec::new(); - while !wire.is_empty() { - let admitted = wire.len(); - for _ in 0..admitted { - let packet = wire.pop_front().expect("admitted packet remains queued"); - let received = receiver.receive(&packet, 2)?; - delivered.extend(received.delivered); - } - let acknowledged = sender.receive(&receiver.create_ack(), 3)?; - wire.extend(acknowledged.ready_to_send); - } - - assert_eq!(delivered.len(), 1); - assert_eq!(delivered[0].payload, vec![0x5a; 40]); - assert!(sender.pending_reliable_sequences().is_empty()); - assert_eq!(sender.outstanding_reliable_packet_count(), 0); - Ok(()) -} - -#[test] -fn session_and_hub_reject_configured_memory_bounds() -> Result<()> { - let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: CONNECTION_ID, - initial_sequence: 1, - resend_delay_ms: 250, - max_pending_reliable_packets: None, - }); - receiver.set_max_payload_bytes(Some(4)); - receiver.set_max_pending_fragment_sets(1)?; - let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: CONNECTION_ID, - initial_sequence: 1, - resend_delay_ms: 250, - max_pending_reliable_packets: None, - }); - let connect_packet = sender.create_connect(0, Vec::new())?; - let accept = receiver.accept_connect(&connect_packet, 0, Vec::new())?; - sender.receive(&accept, 0)?; - - let oversized = sender.send_many( - "schema", - vec![1, 2, 3, 4, 5], - CultNetRudpSendOptions { - reliable: true, - ordered: true, - sequenced: false, - now_ms: 1, - reliable_expire_after_ms: None, - }, - Some(2), - )?; - receiver.receive(&oversized[0], 1)?; - receiver.receive(&oversized[1], 1)?; - assert!(receiver.receive(&oversized[2], 1).is_err()); - - let fragment_options = CultNetRudpSendOptions { - reliable: true, - ordered: true, - sequenced: false, - now_ms: 2, - reliable_expire_after_ms: None, - }; - let first_set = sender.send_many( - "schema", - vec![1, 2, 3, 4], - fragment_options.clone(), - Some(2), - )?; - let second_set = sender.send_many("schema", vec![5, 6, 7, 8], fragment_options, Some(2))?; - receiver.receive(&first_set[0], 2)?; - // The fragment-set bound evicts the stalest incomplete set rather than - // refusing the packet: a receiver that dies at its bound under loss is a - // fuse, not a limit (measured 2026-09-10, see tests/cultnet.rs). - assert_eq!(receiver.fragment_sets_evicted(), 0); - receiver.receive(&second_set[0], 2)?; - assert_eq!(receiver.fragment_sets_evicted(), 1, "the first set was evicted for the second"); - - let server_socket = socket()?; - let server_addr = server_socket.local_addr()?; - let mut options = CultNetRudpServerHubOptions::new("odin", server_socket, CONNECTION_ID); - options.max_peers = 1; - let mut hub = CultNetRudpServerHub::new(options)?; - let mut first = client(server_addr)?; - let mut second = client(server_addr)?; - connect(&mut hub, &mut first, b"first")?; - second.connect(b"second".to_vec())?; - for _ in 0..20 { - match hub.receive_event_once() { - Err(error) => { - assert!(error.to_string().contains("peer limit")); - return Ok(()); - } - Ok(_) => continue, - } - } - Err(anyhow!("hub did not enforce peer limit")) -} - -#[test] -fn replay_history_is_bounded_and_idle_hub_sessions_expire() -> Result<()> { - let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: CONNECTION_ID, - initial_sequence: 1, - resend_delay_ms: 250, - max_pending_reliable_packets: None, - }); - let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions { - connection_id: CONNECTION_ID, - initial_sequence: 1, - resend_delay_ms: 250, - max_pending_reliable_packets: None, - }); - let connect_packet = sender.create_connect(0, Vec::new())?; - let accept = receiver.accept_connect(&connect_packet, 0, Vec::new())?; - sender.receive(&accept, 0)?; - let mut oldest = None; - for sequence in 0..4_100 { - let packet = sender.send( - "realtime", - vec![(sequence % 251) as u8], - CultNetRudpSendOptions { - reliable: false, - ordered: false, - sequenced: false, - now_ms: sequence, - reliable_expire_after_ms: None, - }, - )?; - if oldest.is_none() { - oldest = Some(packet.clone()); - } - assert_eq!(receiver.receive(&packet, sequence)?.delivered.len(), 1); - } - // Separating the sequence domains moved duplicate suppression onto the - // reliable domain, so a replayed lossy datagram is delivered again rather - // than silently dropped. Lossy traffic is remembered nowhere, which is what - // bounds its history. - assert_eq!( - receiver - .receive(&oldest.expect("oldest packet"), 4_101)? - .delivered - .len(), - 1 - ); - - // The bounded replay window still guards the reliable domain, and nothing - // else asserts it once the lossy path stopped being remembered. - let mut oldest_reliable = None; - for step in 0..4_100u64 { - let packet = sender.send( - "schema", - vec![(step % 251) as u8], - CultNetRudpSendOptions { - reliable: true, - ordered: false, - sequenced: false, - now_ms: step, - reliable_expire_after_ms: None, - }, - )?; - if oldest_reliable.is_none() { - oldest_reliable = Some(packet.clone()); - } - assert_eq!(receiver.receive(&packet, step)?.delivered.len(), 1); - sender.receive(&receiver.create_ack_for(packet.sequence), step)?; - } - assert!( - receiver - .receive(&oldest_reliable.expect("oldest reliable packet"), 4_101)? - .delivered - .is_empty() - ); - - let server_socket = socket()?; - let server_addr = server_socket.local_addr()?; - let mut hub = CultNetRudpServerHub::new(CultNetRudpServerHubOptions::new( - "odin", - server_socket, - CONNECTION_ID, - ))?; - let mut provider = client(server_addr)?; - let context = connect(&mut hub, &mut provider, b"expiring-session")?; - std::thread::sleep(Duration::from_millis(2)); - assert_eq!(hub.remove_timed_out_sessions(0), vec![context]); - assert!(hub.sessions().is_empty()); - Ok(()) -} - -/// A packet that carries no event — an acknowledgement, a ping — must not -/// make the reader report "nothing waiting" while frames sit behind it. -/// A caller that polls once per send fell behind its peer's acknowledgements -/// this way and a reliable channel resent everything. -#[test] -fn a_reader_does_not_stop_at_a_packet_that_carries_no_event() -> Result<()> { - let mut hub = CultNetRudpServerHub::new(CultNetRudpServerHubOptions::new( - "hub", - socket()?, - CONNECTION_ID, - ))?; - let mut peer = client(hub.local_addr()?)?; - let session = connect(&mut hub, &mut peer, b"evidence")?; - - // Hub side: a ping (reply, no event) ahead of a reliable frame. - peer.ping(b"keepalive".to_vec())?; - peer.send("schema", b"after the ping".to_vec())?; - std::thread::sleep(Duration::from_millis(20)); - match hub.receive_event_once()? { - Some(CultNetRudpServerEvent::Frame { frame, .. }) => { - assert_eq!(frame.payload, b"after the ping"); - } - other => panic!("the first read must reach the frame behind the ping, got {other:?}"), - } - - // Client side: the hub's acknowledgement of that frame is queued ahead of - // two unreliable frames; the client must reach both without a "nothing" - // in between, and must not acknowledge unreliable frames back. - let mut unreliable_hub_options = - CultNetRudpServerHubOptions::new("hub-unreliable", socket()?, CONNECTION_ID); - unreliable_hub_options.media_delivery = Some(CultNetTransportDelivery::Unreliable); - let mut media_hub = CultNetRudpServerHub::new(unreliable_hub_options)?; - let mut media_client = client(media_hub.local_addr()?)?; - let media_session = connect(&mut media_hub, &mut media_client, b"media")?; - media_client.send("schema", b"reliable, will be acked".to_vec())?; - std::thread::sleep(Duration::from_millis(20)); - while media_hub.receive_event_once()?.is_some() {} - media_hub.send(&media_session, "media", b"one".to_vec())?; - media_hub.send(&media_session, "media", b"two".to_vec())?; - std::thread::sleep(Duration::from_millis(20)); - let before = media_hub.stats().bytes_received; - let first = media_client.receive_once()?.expect("the first read reaches the frame behind the ack"); - let second = media_client.receive_once()?.expect("the second frame follows"); - assert_eq!((first.payload, second.payload), (b"one".to_vec(), b"two".to_vec())); - assert!(media_client.receive_once()?.is_none()); - std::thread::sleep(Duration::from_millis(20)); - while media_hub.receive_event_once()?.is_some() {} - assert_eq!( - media_hub.stats().bytes_received, - before, - "unreliable frames are not acknowledged; the hub read nothing back" - ); - let _ = session; - Ok(()) -} diff --git a/packages/cultnet-ts/README.md b/packages/cultnet-ts/README.md index 75e77b88..5ff66e7e 100644 --- a/packages/cultnet-ts/README.md +++ b/packages/cultnet-ts/README.md @@ -195,7 +195,7 @@ the helper path. ## Quick Example ```ts -import { CultCache, defineDocumentType } from "@gamecult/cultcache-ts"; +import { CultCache, defineDocumentType } from "cultcache-ts"; import { CultNetDocumentRegistry, createTcpFramedCultNetPeer, diff --git a/packages/cultnet-ts/contracts/cultmesh.session-accepted.schema.json b/packages/cultnet-ts/contracts/cultmesh.session-accepted.schema.json deleted file mode 100644 index aca1ff2b..00000000 --- a/packages/cultnet-ts/contracts/cultmesh.session-accepted.schema.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.session-accepted.schema.json", - "title": "CultMesh Session Accepted", - "type": "object", - "required": ["schemaVersion", "messageId", "accepted", "verseId", "authorityRuntimeId", "protocolId", "routeGeneration", "clientNonce", "providerKeyId", "providerSignature"], - "additionalProperties": false, - "properties": { - "schemaVersion": { "const": "cultmesh.session_accepted.v2" }, - "messageId": { "type": "string", "minLength": 1 }, - "accepted": { "type": "boolean" }, - "verseId": { "type": "string", "minLength": 1 }, - "authorityRuntimeId": { "type": "string", "minLength": 1 }, - "protocolId": { "type": "string", "minLength": 1 }, - "routeGeneration": { "type": "string" }, - "clientNonce": { "type": "string" }, - "providerKeyId": { "type": "string" }, - "providerSignature": { "type": "string" }, - "error": { "type": "string", "minLength": 1 } - } -} diff --git a/packages/cultnet-ts/contracts/cultmesh.session-open.schema.json b/packages/cultnet-ts/contracts/cultmesh.session-open.schema.json deleted file mode 100644 index 937f3e28..00000000 --- a/packages/cultnet-ts/contracts/cultmesh.session-open.schema.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.session-open.schema.json", - "title": "CultMesh Session Open", - "type": "object", - "required": ["schemaVersion", "messageId", "sourceRuntimeId", "verseId", "authorityRuntimeId", "protocolId", "routeGeneration", "clientNonce"], - "additionalProperties": false, - "properties": { - "schemaVersion": { "const": "cultmesh.session_open.v2" }, - "messageId": { "type": "string", "minLength": 1 }, - "sourceRuntimeId": { "type": "string", "minLength": 1 }, - "verseId": { "type": "string", "minLength": 1 }, - "authorityRuntimeId": { "type": "string", "minLength": 1 }, - "protocolId": { "type": "string", "minLength": 1 }, - "routeGeneration": { "type": "string" }, - "clientNonce": { "type": "string", "minLength": 1 } - } -} diff --git a/packages/cultnet-ts/contracts/cultmesh.verse-catalog-request.schema.json b/packages/cultnet-ts/contracts/cultmesh.verse-catalog-request.schema.json deleted file mode 100644 index e99b9dea..00000000 --- a/packages/cultnet-ts/contracts/cultmesh.verse-catalog-request.schema.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.verse-catalog-request.schema.json", - "title": "CultMesh Verse Catalog Request", - "type": "object", - "required": ["schemaVersion", "messageId"], - "additionalProperties": false, - "properties": { - "schemaVersion": { "const": "cultmesh.verse_catalog_request.v0" }, - "messageId": { "type": "string", "minLength": 1 }, - "verseIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, - "transportVersion": { "type": "string", "minLength": 1 } - } -} diff --git a/packages/cultnet-ts/contracts/cultmesh.verse-catalog-response.schema.json b/packages/cultnet-ts/contracts/cultmesh.verse-catalog-response.schema.json deleted file mode 100644 index 464908ad..00000000 --- a/packages/cultnet-ts/contracts/cultmesh.verse-catalog-response.schema.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.verse-catalog-response.schema.json", - "title": "CultMesh Verse Catalog Response", - "type": "object", - "required": ["schemaVersion", "messageId", "verses"], - "additionalProperties": false, - "$defs": { - "compatibility": { - "type": "object", - "required": ["transportVersion", "rulesHash", "compatibleVerseIds", "requiredPluginIds", "optionalPluginIds"], - "additionalProperties": false, - "properties": { - "transportVersion": { "type": "string", "minLength": 1 }, - "rulesHash": { "type": "string", "minLength": 1 }, - "compatibleVerseIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, - "requiredPluginIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, - "optionalPluginIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true } - } - }, - "authorityRoute": { - "type": "object", - "required": ["authorityRuntimeId", "endpoint", "protocolIds", "priority", "generation"], - "additionalProperties": false, - "properties": { - "authorityRuntimeId": { "type": "string", "minLength": 1 }, - "endpoint": { "type": "string", "minLength": 1 }, - "protocolIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, - "priority": { "type": "integer", "minimum": 0 }, - "generation": { "type": "string", "minLength": 1 }, - "certificate": { - "anyOf": [{ "$ref": "#/$defs/routeCertificate" }, { "type": "null" }] - } - } - }, - "routeCertificate": { - "type": "object", - "required": ["providerKeyId", "providerPublicKeyX", "providerPublicKeyY", "odinKeyId", "issuedAtUnixMilliseconds", "expiresAtUnixMilliseconds", "signature"], - "additionalProperties": false, - "properties": { - "providerKeyId": { "type": "string", "minLength": 1 }, - "providerPublicKeyX": { "type": "string", "minLength": 1 }, - "providerPublicKeyY": { "type": "string", "minLength": 1 }, - "odinKeyId": { "type": "string", "minLength": 1 }, - "issuedAtUnixMilliseconds": { "type": "integer", "minimum": 0 }, - "expiresAtUnixMilliseconds": { "type": "integer", "minimum": 1 }, - "signature": { "type": "string", "minLength": 1 } - } - }, - "verse": { - "type": "object", - "required": ["verseId", "displayName", "authorityModel", "compatibility", "discoveryEndpoints", "authorityRuntimeIds"], - "additionalProperties": false, - "properties": { - "verseId": { "type": "string", "minLength": 1 }, - "displayName": { "type": "string", "minLength": 1 }, - "authorityModel": { "type": "string", "minLength": 1 }, - "compatibility": { "$ref": "#/$defs/compatibility" }, - "discoveryEndpoints": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, - "authorityRuntimeIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, - "authorityRoutes": { "type": "array", "items": { "$ref": "#/$defs/authorityRoute" } }, - "parentVerseId": { "type": "string", "minLength": 1 }, - "description": { "type": "string" } - } - } - }, - "properties": { - "schemaVersion": { "const": "cultmesh.verse_catalog_response.v0" }, - "messageId": { "type": "string", "minLength": 1 }, - "verses": { "type": "array", "items": { "$ref": "#/$defs/verse" } } - } -} diff --git a/packages/cultnet-ts/contracts/cultnet.database-change-raw.schema.json b/packages/cultnet-ts/contracts/cultnet.database-change-raw.schema.json deleted file mode 100644 index dd650fa0..00000000 --- a/packages/cultnet-ts/contracts/cultnet.database-change-raw.schema.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.database-change-raw.schema.json", - "title": "CultNet Raw Database Change Message", - "type": "object", - "required": ["schemaVersion", "messageId", "subscriptionId", "changeKind"], - "additionalProperties": false, - "properties": { - "schemaVersion": { "const": "cultnet.database_change_raw.v0" }, - "messageId": { "type": "string", "minLength": 1 }, - "subscriptionId": { "type": "string", "minLength": 1 }, - "changeKind": { "enum": ["added", "updated", "removed"] }, - "document": { "$ref": "./cultnet.raw-document-record.schema.json" }, - "recordKey": { "type": "string", "minLength": 1 }, - "schemaId": { "type": "string", "minLength": 1 } - }, - "allOf": [ - { - "if": { "properties": { "changeKind": { "const": "removed" } } }, - "then": { "required": ["recordKey", "schemaId"] }, - "else": { "required": ["document"] } - } - ] -} diff --git a/packages/cultnet-ts/contracts/cultnet.database-subscribe.schema.json b/packages/cultnet-ts/contracts/cultnet.database-subscribe.schema.json deleted file mode 100644 index 08304199..00000000 --- a/packages/cultnet-ts/contracts/cultnet.database-subscribe.schema.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.database-subscribe.schema.json", - "title": "CultNet Database Subscribe Message", - "type": "object", - "required": ["schemaVersion", "messageId", "subscriptionId", "includeSnapshot"], - "additionalProperties": false, - "properties": { - "schemaVersion": { "const": "cultnet.database_subscribe.v0" }, - "messageId": { "type": "string", "minLength": 1 }, - "subscriptionId": { "type": "string", "minLength": 1 }, - "schemaIds": { "type": "array", "items": { "type": "string", "minLength": 1 } }, - "recordKeys": { "type": "array", "items": { "type": "string", "minLength": 1 } }, - "includeSnapshot": { "type": "boolean" }, - "consumerRuntimeId": { "type": "string", "minLength": 1 }, - "bodyIds": { "type": "array", "items": { "type": "string", "minLength": 1 } }, - "supportedBodyTransports": { "type": "array", "items": { "type": "string", "minLength": 1 } } - } -} diff --git a/packages/cultnet-ts/contracts/cultnet.database-unsubscribe.schema.json b/packages/cultnet-ts/contracts/cultnet.database-unsubscribe.schema.json deleted file mode 100644 index 7d4b0855..00000000 --- a/packages/cultnet-ts/contracts/cultnet.database-unsubscribe.schema.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.database-unsubscribe.schema.json", - "title": "CultNet Database Unsubscribe Message", - "type": "object", - "required": ["schemaVersion", "messageId", "subscriptionId"], - "additionalProperties": false, - "properties": { - "schemaVersion": { "const": "cultnet.database_unsubscribe.v0" }, - "messageId": { "type": "string", "minLength": 1 }, - "subscriptionId": { "type": "string", "minLength": 1 } - } -} diff --git a/packages/cultnet-ts/package-lock.json b/packages/cultnet-ts/package-lock.json new file mode 100644 index 00000000..349dfe87 --- /dev/null +++ b/packages/cultnet-ts/package-lock.json @@ -0,0 +1,322 @@ +{ + "name": "cultnet-ts", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cultnet-ts", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@msgpack/msgpack": "^3.1.3", + "ajv": "^8.17.1", + "cultcache-ts": "github:GameCult/cultcache-ts" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "json-schema-to-typescript": "^15.0.4", + "typescript": "^5.9.0", + "zod": "^3.25.76" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.9.3", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", + "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@msgpack/msgpack": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz", + "integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==", + "license": "ISC", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/cultcache-ts": { + "version": "0.1.0", + "resolved": "git+ssh://git@github.com/GameCult/cultcache-ts.git#6c2e1e3691146f99c44c098371cc8c787b4a8d0c", + "license": "MIT", + "dependencies": { + "@msgpack/msgpack": "^3.1.3", + "zod": "^3.25.76" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-to-typescript": { + "version": "15.0.4", + "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz", + "integrity": "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^11.5.5", + "@types/json-schema": "^7.0.15", + "@types/lodash": "^4.17.7", + "is-glob": "^4.0.3", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "prettier": "^3.2.5", + "tinyglobby": "^0.2.9" + }, + "bin": { + "json2ts": "dist/src/cli.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/packages/cultnet-ts/package.json b/packages/cultnet-ts/package.json index 2f4b74f1..b4c17ade 100644 --- a/packages/cultnet-ts/package.json +++ b/packages/cultnet-ts/package.json @@ -1,32 +1,19 @@ { "name": "cultnet-ts", - "version": "0.2.0", + "version": "0.1.0", "description": "CultNet-style typed direct-pipe messaging and state replication for TypeScript runtimes.", "main": "dist/index.js", "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "require": "./dist/index.js", - "default": "./dist/index.js" - }, - "./contracts": { - "types": "./dist/contracts.d.ts", - "require": "./dist/contracts.js", - "default": "./dist/contracts.js" - } - }, "files": [ "dist", "contracts" ], "scripts": { "contracts:generate": "node tools/generate-swarm-contracts.mjs", - "build": "node tools/generate-swarm-contracts.mjs && node ../../node_modules/typescript/bin/tsc -p tsconfig.json", - "prepack": "node tools/generate-swarm-contracts.mjs && node ../../node_modules/typescript/bin/tsc -p tsconfig.json", - "typecheck": "node tools/generate-swarm-contracts.mjs && node ../../node_modules/typescript/bin/tsc -p tsconfig.json --pretty false", - "test": "node tools/generate-swarm-contracts.mjs && 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", - "test:interop": "node tools/generate-swarm-contracts.mjs && node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --test --test-force-exit dist-test/test/interop/*.test.js" + "build": "npm run contracts:generate && tsc -p tsconfig.json", + "typecheck": "npm run contracts:generate && tsc -p tsconfig.json --pretty false", + "test": "npm run build && tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js", + "test:interop": "npm run build && tsc -p tsconfig.test.json && node --test dist-test/test/interop/*.test.js" }, "keywords": [ "messagepack", @@ -38,22 +25,10 @@ ], "author": "metacratic", "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/GameCult/CultLib.git", - "directory": "packages/cultnet-ts" - }, - "bugs": { - "url": "https://github.com/GameCult/CultLib/issues" - }, - "homepage": "https://github.com/GameCult/CultLib/tree/main/packages/cultnet-ts#readme", - "engines": { - "node": ">=18" - }, "dependencies": { "@msgpack/msgpack": "^3.1.3", "ajv": "^8.17.1", - "@gamecult/cultcache-ts": "^0.14.0" + "cultcache-ts": "file:../cultcache-ts" }, "devDependencies": { "@types/node": "^24.0.0", diff --git a/packages/cultnet-ts/src/contracts.ts b/packages/cultnet-ts/src/contracts.ts index c71cdcc1..8795b06a 100644 --- a/packages/cultnet-ts/src/contracts.ts +++ b/packages/cultnet-ts/src/contracts.ts @@ -18,9 +18,6 @@ import transportProfileSchema from "../contracts/cultnet.transport-profile.schem import snapshotRequestSchema from "../contracts/cultnet.snapshot-request.schema.json"; import snapshotResponseSchema from "../contracts/cultnet.snapshot-response.schema.json"; import snapshotResponseRawSchema from "../contracts/cultnet.snapshot-response-raw.schema.json"; -import databaseSubscribeSchema from "../contracts/cultnet.database-subscribe.schema.json"; -import databaseUnsubscribeSchema from "../contracts/cultnet.database-unsubscribe.schema.json"; -import databaseChangeRawSchema from "../contracts/cultnet.database-change-raw.schema.json"; import schemaDescriptorSchema from "../contracts/cultnet.schema-descriptor.schema.json"; import schemaCatalogRequestSchema from "../contracts/cultnet.schema-catalog-request.schema.json"; import schemaCatalogResponseSchema from "../contracts/cultnet.schema-catalog-response.schema.json"; @@ -28,10 +25,6 @@ import shardCatalogRequestSchema from "../contracts/cultnet.shard-catalog-reques import shardCatalogResponseSchema from "../contracts/cultnet.shard-catalog-response.schema.json"; import operationRequestSchema from "../contracts/cultnet.operation-request.schema.json"; import operationResponseSchema from "../contracts/cultnet.operation-response.schema.json"; -import verseCatalogRequestSchema from "../contracts/cultmesh.verse-catalog-request.schema.json"; -import verseCatalogResponseSchema from "../contracts/cultmesh.verse-catalog-response.schema.json"; -import sessionOpenSchema from "../contracts/cultmesh.session-open.schema.json"; -import sessionAcceptedSchema from "../contracts/cultmesh.session-accepted.schema.json"; import ghostlightAgentStateSchema from "../contracts/ghostlight.agent-state.schema.json"; export type CultNetWireContract = "cultnet.schema.v0" | "gamecult.networking.v0"; @@ -51,19 +44,12 @@ export type CultNetSchemaVersion = | "cultnet.snapshot_request.v0" | "cultnet.snapshot_response.v0" | "cultnet.snapshot_response_raw.v0" - | "cultnet.database_subscribe.v0" - | "cultnet.database_unsubscribe.v0" - | "cultnet.database_change_raw.v0" | "cultnet.schema_catalog_request.v0" | "cultnet.schema_catalog_response.v0" | "cultnet.shard_catalog_request.v0" | "cultnet.shard_catalog_response.v0" | "cultnet.operation_request.v0" - | "cultnet.operation_response.v0" - | "cultmesh.verse_catalog_request.v0" - | "cultmesh.verse_catalog_response.v0" - | "cultmesh.session_open.v2" - | "cultmesh.session_accepted.v2"; + | "cultnet.operation_response.v0"; export type CultNetSchemaKind = "wire_message" | "document_payload" | "shared_contract"; export type CultNetRawPayloadEncoding = "messagepack"; @@ -194,9 +180,6 @@ export interface CultNetDocumentDeleteMessage { export interface CultNetRawDocumentRecord { schemaId: string; - schemaName?: string; - schemaVersion?: string; - schemaContentHash?: string; recordKey: string; storedAt: string; payloadEncoding: CultNetRawPayloadEncoding; @@ -234,34 +217,6 @@ export interface CultNetSnapshotResponseRawMessage { documents: CultNetRawDocumentRecord[]; } -export interface CultNetDatabaseSubscribeMessage { - schemaVersion: "cultnet.database_subscribe.v0"; - messageId: string; - subscriptionId: string; - schemaIds?: string[]; - recordKeys?: string[]; - includeSnapshot: boolean; - consumerRuntimeId?: string; - bodyIds?: string[]; - supportedBodyTransports?: string[]; -} - -export interface CultNetDatabaseUnsubscribeMessage { - schemaVersion: "cultnet.database_unsubscribe.v0"; - messageId: string; - subscriptionId: string; -} - -export interface CultNetDatabaseChangeRawMessage { - schemaVersion: "cultnet.database_change_raw.v0"; - messageId: string; - subscriptionId: string; - changeKind: "added" | "updated" | "removed"; - document?: CultNetRawDocumentRecord; - recordKey?: string; - schemaId?: string; -} - export interface CultNetSchemaDescriptor { schemaId: string; kind: CultNetSchemaKind; @@ -339,83 +294,6 @@ export interface CultNetOperationResponseMessage { sourceRuntimeId?: string; } -export interface CultMeshVerseCompatibilityMessage { - transportVersion: string; - rulesHash: string; - compatibleVerseIds: string[]; - requiredPluginIds: string[]; - optionalPluginIds: string[]; -} - -export interface CultMeshVerseDescriptorMessage { - verseId: string; - displayName: string; - authorityModel: string; - compatibility: CultMeshVerseCompatibilityMessage; - discoveryEndpoints: string[]; - authorityRuntimeIds: string[]; - authorityRoutes?: CultMeshAuthorityRouteMessage[]; - parentVerseId?: string; - description?: string; -} - -export interface CultMeshAuthorityRouteMessage { - authorityRuntimeId: string; - endpoint: string; - protocolIds: string[]; - priority: number; - generation: string; - certificate?: CultMeshRouteCertificateMessage; -} - -export interface CultMeshRouteCertificateMessage { - providerKeyId: string; - providerPublicKeyX: string; - providerPublicKeyY: string; - odinKeyId: string; - issuedAtUnixMilliseconds: number; - expiresAtUnixMilliseconds: number; - signature: string; -} - -export interface CultMeshSessionOpenMessage { - schemaVersion: "cultmesh.session_open.v2"; - messageId: string; - sourceRuntimeId: string; - verseId: string; - authorityRuntimeId: string; - protocolId: string; - routeGeneration: string; - clientNonce: string; -} - -export interface CultMeshSessionAcceptedMessage { - schemaVersion: "cultmesh.session_accepted.v2"; - messageId: string; - accepted: boolean; - verseId: string; - authorityRuntimeId: string; - protocolId: string; - routeGeneration: string; - clientNonce: string; - providerKeyId: string; - providerSignature: string; - error?: string; -} - -export interface CultMeshVerseCatalogRequestMessage { - schemaVersion: "cultmesh.verse_catalog_request.v0"; - messageId: string; - verseIds?: string[]; - transportVersion?: string; -} - -export interface CultMeshVerseCatalogResponseMessage { - schemaVersion: "cultmesh.verse_catalog_response.v0"; - messageId: string; - verses: CultMeshVerseDescriptorMessage[]; -} - export interface GhostlightPressure { pressure_id: string; label: string; @@ -657,19 +535,12 @@ export type CultNetMessage = | CultNetSnapshotRequestMessage | CultNetSnapshotResponseMessage | CultNetSnapshotResponseRawMessage - | CultNetDatabaseSubscribeMessage - | CultNetDatabaseUnsubscribeMessage - | CultNetDatabaseChangeRawMessage | CultNetSchemaCatalogRequestMessage | CultNetSchemaCatalogResponseMessage | CultNetShardCatalogRequestMessage | CultNetShardCatalogResponseMessage | CultNetOperationRequestMessage - | CultNetOperationResponseMessage - | CultMeshVerseCatalogRequestMessage - | CultMeshVerseCatalogResponseMessage - | CultMeshSessionOpenMessage - | CultMeshSessionAcceptedMessage; + | CultNetOperationResponseMessage; const ajv = new Ajv2020({ allErrors: true, @@ -691,19 +562,12 @@ const CULTNET_MESSAGE_SCHEMAS = [ snapshotRequestSchema, snapshotResponseSchema, snapshotResponseRawSchema, - databaseSubscribeSchema, - databaseUnsubscribeSchema, - databaseChangeRawSchema, schemaCatalogRequestSchema, schemaCatalogResponseSchema, shardCatalogRequestSchema, shardCatalogResponseSchema, operationRequestSchema, operationResponseSchema, - verseCatalogRequestSchema, - verseCatalogResponseSchema, - sessionOpenSchema, - sessionAcceptedSchema, ] as const; const cultNetValidators = new Map(); @@ -756,11 +620,6 @@ export function parseCultNetMessage(input: unknown, wireContract: CultNetWireCon return input as CultNetSnapshotResponseRawMessage; } - if (schemaVersion === "cultnet.database_change_raw.v0") { - validateDatabaseChangeRawMessage(input); - return input as CultNetDatabaseChangeRawMessage; - } - if (!validator(input)) { const keys = Object.keys(input as Record).join(","); throw new Error(`${renderValidationErrors(schemaVersion, validator)}; keys=${keys}`); @@ -1047,11 +906,8 @@ function encodeLegacySchemaDescriptor(schema: CultNetSchemaDescriptor): Record { if (!input || typeof input !== "object") { throw new Error(`Validation failed for ${schemaVersion}: /: must be an object`); @@ -1160,9 +980,6 @@ function validateRawDocumentRecord( const candidate = input as Record; requireNonEmptyString(candidate.schemaId, `${instancePath}/schemaId`); - requireOptionalNonEmptyString(candidate.schemaName, `${instancePath}/schemaName`); - requireOptionalNonEmptyString(candidate.schemaVersion, `${instancePath}/schemaVersion`); - requireOptionalNonEmptyString(candidate.schemaContentHash, `${instancePath}/schemaContentHash`); requireNonEmptyString(candidate.recordKey, `${instancePath}/recordKey`); requireNonEmptyString(candidate.storedAt, `${instancePath}/storedAt`); if (candidate.payloadEncoding !== "messagepack") { @@ -1226,34 +1043,6 @@ function normalizeCultNetOptionalNulls( acceptLegacyPropertyName(candidate, "ShardEpoch", "shardEpoch"); stripNullProperties(candidate, ["schemaIds", "recordKeys", "shardId", "shardEpoch"]); return; - case "cultnet.database_subscribe.v0": - stripNullProperties(candidate, [ - "schemaIds", - "recordKeys", - "consumerRuntimeId", - "bodyIds", - "supportedBodyTransports", - ]); - return; - case "cultnet.operation_request.v0": - stripNullProperties(candidate, ["payloadEncoding", "sourceRuntimeId", "targetRuntimeId"]); - return; - case "cultnet.operation_response.v0": - stripNullProperties(candidate, ["payloadEncoding", "diagnostics", "sourceRuntimeId"]); - return; - case "cultmesh.verse_catalog_request.v0": - stripNullProperties(candidate, ["verseIds", "transportVersion"]); - return; - case "cultmesh.verse_catalog_response.v0": - for (const verse of Array.isArray(candidate.verses) ? candidate.verses : []) { - if (verse && typeof verse === "object") { - stripNullProperties(verse as Record, ["parentVerseId", "description"]); - } - } - return; - case "cultmesh.session_accepted.v2": - stripNullProperties(candidate, ["error"]); - return; case "cultnet.schema_catalog_request.v0": stripNullProperties(candidate, ["schemaIds", "kinds"]); return; @@ -1303,9 +1092,6 @@ export const cultNetSchemas = { snapshotRequestSchema, snapshotResponseSchema, snapshotResponseRawSchema, - databaseSubscribeSchema, - databaseUnsubscribeSchema, - databaseChangeRawSchema, schemaDescriptorSchema, schemaCatalogRequestSchema, schemaCatalogResponseSchema, diff --git a/packages/cultnet-ts/src/generated/swarm-contracts.generated.ts b/packages/cultnet-ts/src/generated/swarm-contracts.generated.ts index f6f673a3..cfa33070 100644 --- a/packages/cultnet-ts/src/generated/swarm-contracts.generated.ts +++ b/packages/cultnet-ts/src/generated/swarm-contracts.generated.ts @@ -124,7 +124,13 @@ export interface Belief { confidence: number; subject_id?: string; claim_type?: - 'fact' | 'identity_inference' | 'motive_inference' | 'prediction' | 'norm' | 'value_read' | 'relationship_read'; + | 'fact' + | 'identity_inference' + | 'motive_inference' + | 'prediction' + | 'norm' + | 'value_read' + | 'relationship_read'; evidence_event_ids?: string[]; visibility?: 'private' | 'shared' | 'public' | 'rumor'; emotional_charge?: number; diff --git a/packages/cultnet-ts/src/index.ts b/packages/cultnet-ts/src/index.ts index 140526c5..cd602952 100644 --- a/packages/cultnet-ts/src/index.ts +++ b/packages/cultnet-ts/src/index.ts @@ -8,9 +8,6 @@ export { type CultNetDocumentPutMessage, type CultNetDocumentPutRawMessage, type CultNetDocumentRecord, - type CultNetDatabaseSubscribeMessage, - type CultNetDatabaseUnsubscribeMessage, - type CultNetDatabaseChangeRawMessage, type CultNetRawDocumentRecord, type CultNetRawPayloadEncoding, type CultNetErrorMessage, @@ -83,7 +80,6 @@ export { createRudpTransportProfile, decodeRudpPacket, encodeRudpPacket, - CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS, CultNetRudpReconnectLoop, CultNetRudpSession, CultNetRudpSocketTransportConnection, @@ -144,33 +140,3 @@ export { validateGhostlightAgentStateGenerated, type GhostlightAgentStateShape, } from "./generated/swarm-contracts.generated"; -export { - CULTNET_RUDP_PROTOCOL_ID, - createSignedDaemonHealthPublisher, - parseEndpoint as parseDaemonHealthEndpoint, - publishSignedDaemonHealth, - signedDaemonHealthPayload, - signedDaemonHealthSigningMessage, - type DaemonHealth, - type SignedDaemonHealthContract, - type SignedDaemonHealthPublisher, - type SignedDaemonHealthPublisherOptions, -} from "./signed-daemon-health"; - -export { - GAMECULT_RUNTIME_ACTIVATION_PROOF_SIGNING_PURPOSE, - GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA, - GAMECULT_RUNTIME_PRESENCE_HEALTH_SIGNING_PURPOSE, - GAMECULT_RUNTIME_PRESENCE_IDENTITY_NAME, - IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME, - RUNTIME_PRESENCE_FIELD_COUNT, - RUNTIME_PRESENCE_SLOT, - encodeRuntimePresenceHealth, - runtimePresenceActivationSigningMessage, - runtimePresenceProofPayload, - runtimePresenceProviderSigningMessage, - validateRuntimePresence, - type GameCultRuntimeCapability, - type RuntimePresenceHealth, - type RuntimePresenceState, -} from "./runtime-presence-health"; diff --git a/packages/cultnet-ts/src/json-schema-contract.ts b/packages/cultnet-ts/src/json-schema-contract.ts index 4d8fb85d..b97a6762 100644 --- a/packages/cultnet-ts/src/json-schema-contract.ts +++ b/packages/cultnet-ts/src/json-schema-contract.ts @@ -1,5 +1,5 @@ import Ajv2020, { type ErrorObject, type ValidateFunction } from "ajv/dist/2020"; -import type { CultCacheSchema } from "@gamecult/cultcache-ts"; +import type { CultCacheSchema } from "cultcache-ts"; export interface JsonSchemaContract extends CultCacheSchema { readonly schemaId: string; diff --git a/packages/cultnet-ts/src/operation-service.ts b/packages/cultnet-ts/src/operation-service.ts index ee13457f..362e4a06 100644 --- a/packages/cultnet-ts/src/operation-service.ts +++ b/packages/cultnet-ts/src/operation-service.ts @@ -145,7 +145,6 @@ async function handleServerDatagram( if (!peer) return; const result = peer.session.receive(packet, Date.now()); if (result.reply) sendPacket(remote, result.reply); - for (const ready of result.readyToSend ?? []) sendPacket(remote, ready); if (result.disconnected) { sessions.delete(key); return; @@ -163,9 +162,7 @@ async function handleServerDatagram( maxFragmentBytes: options.maxFragmentBytes ?? 2048, })) sendPacket(remote, responsePacket); } - if (packet.packetType === "data" || result.delivered.length > 0) { - sendPacket(remote, peer.session.createAckForReceived(packet.sequence)); - } + if (packet.packetType === "data" || result.delivered.length > 0) sendPacket(remote, peer.session.createAck()); } function parseRudpEndpoint(endpoint: string): { host: string; port: number } { diff --git a/packages/cultnet-ts/src/peer.ts b/packages/cultnet-ts/src/peer.ts index 0e54ee58..816fcfd6 100644 --- a/packages/cultnet-ts/src/peer.ts +++ b/packages/cultnet-ts/src/peer.ts @@ -106,10 +106,6 @@ export class CultNetPeer extends EventEmitter { this.#stream?.write(encodeFrame(payload)); } - async flush(timeoutMs?: number): Promise { - await this.#transport?.flush?.(timeoutMs); - } - sendHello(message: CultNetHelloMessage): void { this.send(message); } diff --git a/packages/cultnet-ts/src/replication.ts b/packages/cultnet-ts/src/replication.ts index 77dab70f..d5706bea 100644 --- a/packages/cultnet-ts/src/replication.ts +++ b/packages/cultnet-ts/src/replication.ts @@ -6,7 +6,7 @@ import type { CultCacheDocumentFormatter, CultCacheDocumentValue, CultCacheEnvelope, -} from "@gamecult/cultcache-ts"; +} from "cultcache-ts"; import { type CultNetDocumentDeleteMessage, diff --git a/packages/cultnet-ts/src/rudp.ts b/packages/cultnet-ts/src/rudp.ts index 56dc67fa..06c09bc3 100644 --- a/packages/cultnet-ts/src/rudp.ts +++ b/packages/cultnet-ts/src/rudp.ts @@ -16,8 +16,6 @@ const RUDP_MAGIC = [0x43, 0x4e, 0x52, 0x30] as const; // CNR0 const RUDP_VERSION = 0; const RUDP_FIXED_HEADER_BYTES = 36; const MAX_CHANNEL_ID_BYTES = 255; -export const CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS = 32; -const RUDP_RECEIVED_SEQUENCE_WINDOW = 4_096; export type CultNetRudpPacketType = | "connect" @@ -59,7 +57,6 @@ export interface CultNetRudpSessionOptions { export interface CultNetRudpReceiveResult { delivered: CultNetRudpDeliveredFrame[]; - readyToSend?: CultNetRudpPacket[]; reply?: CultNetRudpPacket; pong?: boolean; pongPayload?: Uint8Array; @@ -147,25 +144,16 @@ export class CultNetRudpSession { readonly connectionId: number; readonly resendDelayMs: number; #nextSequence: number; - readonly #nextSequencedByChannel = new Map(); #nextFragmentId = 1; #connected = false; readonly #maxPendingReliablePackets: number | undefined; #lastReceivedAtMs: number | undefined; #highestReceivedSequence: number | undefined; readonly #receivedSequences = new Set(); - readonly #latestSequencedByChannel = new Map(); readonly #pendingReliable = new Map(); - readonly #queuedReliable: CultNetRudpPacket[] = []; readonly #orderedNextSequenceByChannel = new Map(); readonly #orderedBuffers = new Map>(); readonly #fragmentBuffers = new Map(); - /// Matches cultnet-rs's `max_pending_fragment_sets`. A fragment set is only - /// removed on successful reassembly, so a set that loses one fragment is - /// stranded for the life of the session. Without a bound the map grows - /// forever under any loss on a fragmenting channel. - #maxPendingFragmentSets = 64; - #fragmentSetsEvicted = 0; constructor(options: CultNetRudpSessionOptions) { this.connectionId = toUint32(options.connectionId, "connectionId"); @@ -185,25 +173,6 @@ export class CultNetRudpSession { return [...this.#pendingReliable.keys()].sort((left, right) => left - right); } - get queuedReliablePacketCount(): number { - return this.#queuedReliable.length; - } - - get outstandingReliablePacketCount(): number { - return this.#pendingReliable.size + this.#queuedReliable.length; - } - - /// Incomplete fragment sets dropped to stay within the pending bound. - /// Non-zero means the peer is losing fragments: the receiver is still - /// serving, and something upstream is not. - get fragmentSetsEvicted(): number { - return this.#fragmentSetsEvicted; - } - - get pendingFragmentSetCount(): number { - return this.#fragmentBuffers.size; - } - get lastReceivedAtMs(): number | undefined { return this.#lastReceivedAtMs; } @@ -213,14 +182,10 @@ export class CultNetRudpSession { this.#lastReceivedAtMs = undefined; this.#highestReceivedSequence = undefined; this.#receivedSequences.clear(); - this.#nextSequencedByChannel.clear(); - this.#latestSequencedByChannel.clear(); this.#pendingReliable.clear(); - this.#queuedReliable.splice(0); this.#orderedNextSequenceByChannel.clear(); this.#orderedBuffers.clear(); this.#fragmentBuffers.clear(); - this.#fragmentSetsEvicted = 0; } createConnect(nowMs = 0, payload = new Uint8Array()): CultNetRudpPacket { @@ -242,18 +207,10 @@ export class CultNetRudpSession { throw new Error(`Expected RUDP connect packet, got ${packet.packetType}.`); } - const pendingAccept = [...this.#pendingReliable.values()] - .find(pending => pending.packet.packetType === "accept"); - if (pendingAccept) { - this.#rememberReceived(packet.sequence); - this.#lastReceivedAtMs = nowMs; - pendingAccept.lastSentAtMs = nowMs; - return { - ...pendingAccept.packet, - payload: new Uint8Array(pendingAccept.packet.payload ?? new Uint8Array()), - }; + const wasConnected = this.#connected; + if (!wasConnected) { + this.#ensureReliableCapacity(1); } - this.#ensureReliableCapacity(1); this.#rememberReceived(packet.sequence); this.#lastReceivedAtMs = nowMs; this.#connected = true; @@ -264,7 +221,9 @@ export class CultNetRudpSession { ordered: true, payload, }); - this.#trackReliable(response, nowMs); + if (!wasConnected) { + this.#trackReliable(response, nowMs); + } return response; } @@ -273,9 +232,6 @@ export class CultNetRudpSession { payload: Uint8Array, options: { reliable?: boolean; ordered?: boolean; sequenced?: boolean; nowMs?: number } = {}, ): CultNetRudpPacket { - if (options.reliable && this.#pendingReliable.size >= CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS) { - throw new Error("RUDP reliable send window is full; receive acknowledgements before sending."); - } return this.sendMany(channelId, payload, options)[0]!; } @@ -293,9 +249,6 @@ export class CultNetRudpSession { if (!this.#connected) { throw new Error("Cannot send RUDP data before the session is connected."); } - if (options.ordered && !options.reliable) { - throw new Error("RUDP ordered delivery requires reliability."); - } const maxFragmentBytes = options.maxFragmentBytes; if (maxFragmentBytes !== undefined && maxFragmentBytes <= 0) { @@ -312,9 +265,10 @@ export class CultNetRudpSession { ordered: options.ordered, sequenced: options.sequenced, }); - return packet.reliable - ? this.#admitReliablePackets([packet], options.nowMs ?? 0) - : [packet]; + if (packet.reliable) { + this.#trackReliable(packet, options.nowMs ?? 0); + } + return [packet]; } const fragmentCount = Math.ceil(payload.byteLength / maxFragmentBytes); @@ -338,17 +292,17 @@ export class CultNetRudpSession { fragmentIndex: index, fragmentCount, }); + if (packet.reliable) { + this.#trackReliable(packet, options.nowMs ?? 0); + } packets.push(packet); } - return options.reliable - ? this.#admitReliablePackets(packets, options.nowMs ?? 0) - : packets; + return packets; } receive(packet: CultNetRudpPacket, nowMs = 0): CultNetRudpReceiveResult { this.#requireConnection(packet); this.#applyAcknowledgements(packet); - const readyToSend = this.#promoteQueuedReliable(nowMs); this.#lastReceivedAtMs = nowMs; const expectedSequenceIfUninitialized = this.#highestReceivedSequence === undefined ? packet.sequence @@ -357,13 +311,13 @@ export class CultNetRudpSession { if (packet.packetType === "accept") { this.#rememberReceived(packet.sequence); this.#connected = true; - return { delivered: [], readyToSend }; + return { delivered: [] }; } if (packet.packetType === "ping") { + this.#rememberReceived(packet.sequence); return { delivered: [], - readyToSend, reply: this.#createPacket({ packetType: "pong", channelId: "control", @@ -373,61 +327,44 @@ export class CultNetRudpSession { } if (packet.packetType === "ack" || packet.packetType === "pong") { + if (packet.packetType === "pong") this.#rememberReceived(packet.sequence); return { - delivered: [], - readyToSend, + delivered: this.#drainUnblockedOrdered(), pong: packet.packetType === "pong", pongPayload: packet.packetType === "pong" ? packet.payload ?? new Uint8Array() : undefined, }; } if (packet.packetType === "disconnect") { + this.#rememberReceived(packet.sequence); this.#connected = false; return { delivered: [], - readyToSend, disconnected: true, disconnectReason: packet.payload ?? new Uint8Array(), }; } if (packet.packetType !== "data") { - return { delivered: [], readyToSend }; + return { delivered: [] }; } - const isDuplicate = (packet.reliable ?? false) - && (this.#receivedSequences.has(packet.sequence) - || (this.#highestReceivedSequence !== undefined - && packet.sequence < this.#highestReceivedSequence - && this.#highestReceivedSequence - packet.sequence >= RUDP_RECEIVED_SEQUENCE_WINDOW)); - if (packet.reliable ?? false) this.#rememberReceived(packet.sequence); + const isDuplicate = this.#receivedSequences.has(packet.sequence); + this.#rememberReceived(packet.sequence); if (isDuplicate) { - return { delivered: [], readyToSend }; + return { delivered: [] }; } const reassembled = this.#reassemble(packet); if (!reassembled) { - return { delivered: [], readyToSend }; - } - - if (!reassembled.ordered && (packet.sequenced ?? false)) { - const newestSequence = reassembled.nextSequence - 1; - const latestSequence = this.#latestSequencedByChannel.get(reassembled.frame.channelId); - if (latestSequence !== undefined && newestSequence <= latestSequence) { - return { delivered: [], readyToSend }; - } - this.#latestSequencedByChannel.set(reassembled.frame.channelId, newestSequence); - return { delivered: [reassembled.frame], readyToSend }; + return { delivered: [] }; } if (!reassembled.ordered) { - return { delivered: [reassembled.frame], readyToSend }; + return { delivered: [reassembled.frame] }; } - return { - delivered: this.#deliverOrdered(reassembled.frame, reassembled.nextSequence, expectedSequenceIfUninitialized), - readyToSend, - }; + return { delivered: this.#deliverOrdered(reassembled.frame, reassembled.nextSequence, expectedSequenceIfUninitialized) }; } createAck(): CultNetRudpPacket { @@ -449,32 +386,6 @@ export class CultNetRudpSession { }; } - createAckFor(sequence: number): CultNetRudpPacket { - return { - packetType: "ack", - connectionId: this.connectionId, - sequence: 0, - ack: toUint32(sequence, "ack sequence"), - ackMask: 0, - channelId: "control", - reliable: false, - ordered: false, - sequenced: false, - fragmentId: 0, - fragmentIndex: 0, - fragmentCount: 0, - payload: new Uint8Array(), - }; - } - - createAckForReceived(sequence: number): CultNetRudpPacket { - const receivedSequence = toUint32(sequence, "received sequence"); - const { ack } = this.#ackState(); - return ack >= receivedSequence && ack - receivedSequence <= 32 - ? this.createAck() - : this.createAckFor(receivedSequence); - } - createPing(payload = new Uint8Array()): CultNetRudpPacket { return this.#createPacket({ packetType: "ping", @@ -525,17 +436,8 @@ export class CultNetRudpSession { fragmentIndex?: number; fragmentCount?: number; }): CultNetRudpPacket { - let sequence = 0; - if (packet.reliable ?? false) { - sequence = this.#nextSequence; - this.#nextSequence = toUint32(this.#nextSequence + 1, "reliable sequence"); - } else if (packet.sequenced ?? false) { - sequence = this.#nextSequencedByChannel.get(packet.channelId) ?? 1; - this.#nextSequencedByChannel.set( - packet.channelId, - toUint32(sequence + 1, "sequenced channel sequence"), - ); - } + const sequence = this.#nextSequence; + this.#nextSequence = toUint32(this.#nextSequence + 1, "sequence"); const { ack, ackMask } = this.#ackState(); return { packetType: packet.packetType, @@ -561,26 +463,11 @@ export class CultNetRudpSession { }); } - #admitReliablePackets(packets: CultNetRudpPacket[], nowMs: number): CultNetRudpPacket[] { - const available = Math.max(0, CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS - this.#pendingReliable.size); - const ready = packets.slice(0, available); - for (const packet of ready) this.#trackReliable(packet, nowMs); - this.#queuedReliable.push(...packets.slice(available)); - return ready; - } - - #promoteQueuedReliable(nowMs: number): CultNetRudpPacket[] { - const available = Math.max(0, CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS - this.#pendingReliable.size); - const ready = this.#queuedReliable.splice(0, available); - for (const packet of ready) this.#trackReliable(packet, nowMs); - return ready; - } - #ensureReliableCapacity(packetCount: number): void { if (packetCount === 0 || this.#maxPendingReliablePackets === undefined) { return; } - if (this.outstandingReliablePacketCount + packetCount > this.#maxPendingReliablePackets) { + if (this.#pendingReliable.size + packetCount > this.#maxPendingReliablePackets) { throw new Error("RUDP reliable send queue is full."); } } @@ -599,12 +486,6 @@ export class CultNetRudpSession { if (this.#highestReceivedSequence === undefined || sequence > this.#highestReceivedSequence) { this.#highestReceivedSequence = sequence; } - if (this.#receivedSequences.size > RUDP_RECEIVED_SEQUENCE_WINDOW) { - const keepFrom = Math.max(0, (this.#highestReceivedSequence ?? sequence) - RUDP_RECEIVED_SEQUENCE_WINDOW + 1); - for (const received of this.#receivedSequences) { - if (received < keepFrom) this.#receivedSequences.delete(received); - } - } } #ackState(): { ack: number; ackMask: number } { @@ -654,20 +535,6 @@ export class CultNetRudpSession { payloads: new Map(), sequences: new Map(), }; - // Evict the oldest stranded set rather than refusing the payload. A - // receiver that stops accepting fragmented traffic after a bounded - // number of losses is a denial of service delivered by the network; - // dropping the least recent incomplete set costs one payload that was - // already incomplete. Map iteration is insertion-ordered, so the first - // key is the oldest. - while (this.#fragmentBuffers.size >= this.#maxPendingFragmentSets) { - const oldest = this.#fragmentBuffers.keys().next(); - if (oldest.done) { - break; - } - this.#fragmentBuffers.delete(oldest.value); - this.#fragmentSetsEvicted += 1; - } this.#fragmentBuffers.set(key, buffer); } if (buffer.fragmentCount !== fragmentCount || buffer.ordered !== (packet.ordered ?? false)) { @@ -768,6 +635,15 @@ export class CultNetRudpSession { return delivered; } + #drainUnblockedOrdered(): CultNetRudpDeliveredFrame[] { + const delivered: CultNetRudpDeliveredFrame[] = []; + for (const channelId of this.#orderedBuffers.keys()) { + this.#skipReceivedNonChannelSequences(channelId); + delivered.push(...this.#drainOrdered(channelId)); + } + return delivered; + } + #skipReceivedNonChannelSequences(channelId: string): void { let next = this.#orderedNextSequenceByChannel.get(channelId); while (next !== undefined && this.#receivedSequences.has(next) && !this.#orderedBuffers.get(channelId)?.has(next)) { @@ -854,10 +730,6 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement return { ...this.#stats }; } - get outstandingReliablePacketCount(): number { - return this.#session.outstandingReliablePacketCount; - } - connect(payload = new Uint8Array()): void { if (this.#mode !== "client") { throw new Error("Only a client RUDP socket transport can initiate connect."); @@ -877,21 +749,6 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement this.#stats.framesSent += 1; } - async flush(timeoutMs = 30_000): Promise { - const deadline = Date.now() + Math.max(0, timeoutMs); - while (this.#session.outstandingReliablePacketCount > 0) { - if (this.#closed) { - throw new Error("RUDP transport closed before reliable packets were acknowledged."); - } - if (Date.now() >= deadline) { - throw new Error( - `RUDP reliable flush timed out with ${this.#session.outstandingReliablePacketCount} packets outstanding.`, - ); - } - await new Promise(resolve => setTimeout(resolve, 5)); - } - } - ping(payload = new Uint8Array()): void { this.#sendPacket(this.#session.createPing(payload)); } @@ -953,9 +810,6 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement if (result.reply) { this.#sendPacket(result.reply); } - for (const ready of result.readyToSend ?? []) { - this.#sendPacket(ready); - } if (result.pong) { this.emit("pong", { payload: result.pongPayload ?? new Uint8Array() }); } @@ -972,7 +826,7 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement return; } if (packet.packetType === "accept" || packet.packetType === "data" || result.delivered.length > 0) { - this.#sendPacket(this.#session.createAckForReceived(packet.sequence)); + this.#sendPacket(this.#session.createAck()); } } catch (error) { this.emit("error", error instanceof Error ? error : new Error(String(error))); diff --git a/packages/cultnet-ts/src/runtime-presence-health.ts b/packages/cultnet-ts/src/runtime-presence-health.ts deleted file mode 100644 index 8d70da8e..00000000 --- a/packages/cultnet-ts/src/runtime-presence-health.ts +++ /dev/null @@ -1,256 +0,0 @@ -// Runtime presence health: the dual-proved statement one launched incarnation -// makes about itself. -// -// This is the TypeScript half of `gamecult.runtime_presence_health.v2`, the -// contract `cultnet-rs` defines in `runtime_authority_contracts.rs`. Odin admits -// a document as runtime presence only when it carries this schema id and its -// record key equals the signed target; anything else it files away as an -// ordinary document, and no topology correlation is ever produced for it. -// -// Two proofs cover one payload. The stable provider key says "this service made -// this statement"; the activation key Idunn hands the process at launch says -// "and it is the incarnation Idunn started". Neither establishes admission -// alone, which is why both signature fields are empty in the signed bytes: it -// avoids a circular signature while binding every authority-bearing field and -// both signer identities. -// -// The wire shape is a positional tuple indexed by the same field slots the Rust -// derive emits. Field N here is field N there; that correspondence is the whole -// contract, and the constants below name the slots so it stays checkable. - -import { encode } from "@msgpack/msgpack"; - -export const GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA = "gamecult.runtime_presence_health.v2"; - -/** Purpose for the stable provider-health signature over the proof payload. */ -export const GAMECULT_RUNTIME_PRESENCE_HEALTH_SIGNING_PURPOSE = - "gamecult.runtime_presence_health.v2"; - -/** Purpose for the activation-scoped proof over the same payload. */ -export const GAMECULT_RUNTIME_ACTIVATION_PROOF_SIGNING_PURPOSE = - "gamecult.runtime_presence.activation-proof.v1"; - -/** systemd file-descriptor name Idunn uses for the activation signing key. */ -export const IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME = "gamecult-idunn-runtime-activation-key"; - -/** systemd file-descriptor name Idunn uses for the provider-health identity. */ -export const GAMECULT_RUNTIME_PRESENCE_IDENTITY_NAME = "gamecult-runtime-presence-identity"; - -const PROVIDER_HEALTH_SIGNATURE_DOMAIN = Buffer.from( - "gamecult.provider-health.signature.v1\0", - "utf8", -); - -const RUNTIME_ACTIVATION_SIGNATURE_DOMAIN = Buffer.from( - "idunn.runtime-activation.signature.v1\0", - "utf8", -); - -/** - * Positional slots, mirroring the `#[cultcache(key = N)]` attributes on - * `GameCultRuntimePresenceHealthRecord`. The tuple is always this long: the - * Rust derive serialises `max_slot + 1` elements regardless of which are set. - */ -export const RUNTIME_PRESENCE_SLOT = { - schemaVersion: 0, - target: 1, - expectedProjectionSha256: 2, - planId: 3, - incarnationId: 4, - sealedReleaseId: 5, - activationWitnessSha256: 6, - stateSchemaGeneration: 7, - stateContractSha256: 8, - runtimeId: 9, - runtimeInstanceId: 10, - boundEndpoint: 11, - capabilities: 12, - healthContract: 13, - state: 14, - detail: 15, - writeLeaseSha256: 16, - signerIdentityId: 17, - publisherSequence: 18, - observedAtUnixMillis: 19, - signatureAlgorithm: 20, - signature: 21, - activationSignerIdentityId: 22, - activationSignature: 23, -} as const; - -export const RUNTIME_PRESENCE_FIELD_COUNT = 24; - -/** A capability claim. Nested as its own positional tuple, not a map. */ -export interface GameCultRuntimeCapability { - capability: string; - schema: string; - compatibility: string; - capacity: number; -} - -/** The states the contract admits. */ -export type RuntimePresenceState = "warming" | "active" | "degraded" | "failed"; - -export interface RuntimePresenceHealth { - target: string; - expectedProjectionSha256: string; - planId: string; - incarnationId: string; - sealedReleaseId: string; - activationWitnessSha256: string; - stateSchemaGeneration: string | null; - stateContractSha256: string | null; - runtimeId: string; - runtimeInstanceId: string; - boundEndpoint: string | null; - capabilities: GameCultRuntimeCapability[]; - healthContract: string; - state: RuntimePresenceState; - detail: string; - writeLeaseSha256: string | null; - signerIdentityId: string; - publisherSequence: number; - observedAtUnixMillis: number; - activationSignerIdentityId: string; -} - -function encodeCapability(capability: GameCultRuntimeCapability): unknown[] { - // Plain serde struct on the Rust side, and the record is encoded with - // rmp_serde's compact form, so a capability is a positional tuple too. - return [capability.capability, capability.schema, capability.compatibility, capability.capacity]; -} - -/** - * Capabilities are canonical only when strictly increasing by - * `(capability, schema, compatibility)`. Sorting here rather than trusting the - * caller keeps a correct statement from being rejected over field order. - */ -function canonicalCapabilities(capabilities: GameCultRuntimeCapability[]): unknown[][] { - const sorted = [...capabilities].sort((left, right) => { - const leftKey = [left.capability, left.schema, left.compatibility]; - const rightKey = [right.capability, right.schema, right.compatibility]; - for (let index = 0; index < leftKey.length; index += 1) { - if (leftKey[index]! < rightKey[index]!) return -1; - if (leftKey[index]! > rightKey[index]!) return 1; - } - return 0; - }); - return sorted.map(encodeCapability); -} - -function presenceTuple( - presence: RuntimePresenceHealth, - signature: Uint8Array, - activationSignature: Uint8Array, -): unknown[] { - const fields = new Array(RUNTIME_PRESENCE_FIELD_COUNT); - fields[RUNTIME_PRESENCE_SLOT.schemaVersion] = GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA; - fields[RUNTIME_PRESENCE_SLOT.target] = presence.target; - fields[RUNTIME_PRESENCE_SLOT.expectedProjectionSha256] = presence.expectedProjectionSha256; - fields[RUNTIME_PRESENCE_SLOT.planId] = presence.planId; - fields[RUNTIME_PRESENCE_SLOT.incarnationId] = presence.incarnationId; - fields[RUNTIME_PRESENCE_SLOT.sealedReleaseId] = presence.sealedReleaseId; - fields[RUNTIME_PRESENCE_SLOT.activationWitnessSha256] = presence.activationWitnessSha256; - fields[RUNTIME_PRESENCE_SLOT.stateSchemaGeneration] = presence.stateSchemaGeneration; - fields[RUNTIME_PRESENCE_SLOT.stateContractSha256] = presence.stateContractSha256; - fields[RUNTIME_PRESENCE_SLOT.runtimeId] = presence.runtimeId; - fields[RUNTIME_PRESENCE_SLOT.runtimeInstanceId] = presence.runtimeInstanceId; - fields[RUNTIME_PRESENCE_SLOT.boundEndpoint] = presence.boundEndpoint; - fields[RUNTIME_PRESENCE_SLOT.capabilities] = canonicalCapabilities(presence.capabilities); - fields[RUNTIME_PRESENCE_SLOT.healthContract] = presence.healthContract; - fields[RUNTIME_PRESENCE_SLOT.state] = presence.state; - fields[RUNTIME_PRESENCE_SLOT.detail] = presence.detail; - fields[RUNTIME_PRESENCE_SLOT.writeLeaseSha256] = presence.writeLeaseSha256; - fields[RUNTIME_PRESENCE_SLOT.signerIdentityId] = presence.signerIdentityId; - fields[RUNTIME_PRESENCE_SLOT.publisherSequence] = presence.publisherSequence; - fields[RUNTIME_PRESENCE_SLOT.observedAtUnixMillis] = presence.observedAtUnixMillis; - fields[RUNTIME_PRESENCE_SLOT.signatureAlgorithm] = "ed25519"; - fields[RUNTIME_PRESENCE_SLOT.signature] = signature; - fields[RUNTIME_PRESENCE_SLOT.activationSignerIdentityId] = presence.activationSignerIdentityId; - fields[RUNTIME_PRESENCE_SLOT.activationSignature] = activationSignature; - return fields; -} - -/** - * Reject statements the contract forbids, before they are signed. - * - * These are the same conditions `validate_shape` enforces on the Rust side. A - * statement that fails here would be refused after a round trip and a signature; - * failing early names the actual problem instead. - */ -export function validateRuntimePresence(presence: RuntimePresenceHealth): void { - if (presence.publisherSequence <= 0 || !Number.isSafeInteger(presence.publisherSequence)) { - throw new Error("runtime presence publisher sequence must be a positive integer"); - } - if (presence.observedAtUnixMillis <= 0 || !Number.isSafeInteger(presence.observedAtUnixMillis)) { - throw new Error("runtime presence observation time must be a positive integer"); - } - if (presence.detail.length > 512 || /\p{Cc}/u.test(presence.detail)) { - throw new Error("runtime presence detail is too long or carries control characters"); - } - if (presence.state === "warming" && presence.writeLeaseSha256 !== null) { - throw new Error("warming runtime presence cannot claim a process write lease"); - } - if (presence.stateSchemaGeneration === null && presence.writeLeaseSha256 !== null) { - throw new Error("stateless runtime presence cannot claim a process write lease"); - } - const generationPresent = presence.stateSchemaGeneration !== null; - const contractPresent = presence.stateContractSha256 !== null; - if (generationPresent !== contractPresent) { - throw new Error("runtime presence state lineage is partial"); - } -} - -/** - * The one payload both proofs cover: the complete record with both signature - * fields empty. - */ -export function runtimePresenceProofPayload(presence: RuntimePresenceHealth): Uint8Array { - validateRuntimePresence(presence); - return encode(presenceTuple(presence, new Uint8Array(), new Uint8Array())); -} - -/** The complete signed record, as put on the wire. */ -export function encodeRuntimePresenceHealth( - presence: RuntimePresenceHealth, - signature: Uint8Array, - activationSignature: Uint8Array, -): Uint8Array { - if (signature.length !== 64 || activationSignature.length !== 64) { - throw new Error("runtime presence proofs must both be 64-byte ed25519 signatures"); - } - validateRuntimePresence(presence); - return encode(presenceTuple(presence, signature, activationSignature)); -} - -function domainSeparated(domain: Buffer, purpose: string, payload: Uint8Array): Buffer { - const purposeBytes = Buffer.from(purpose, "utf8"); - const lengths = Buffer.alloc(16); - lengths.writeBigUInt64BE(BigInt(purposeBytes.length), 0); - lengths.writeBigUInt64BE(BigInt(payload.length), 8); - return Buffer.concat([ - domain, - lengths.subarray(0, 8), - purposeBytes, - lengths.subarray(8, 16), - Buffer.from(payload), - ]); -} - -/** Message the stable provider-health key signs. */ -export function runtimePresenceProviderSigningMessage(payload: Uint8Array): Buffer { - return domainSeparated( - PROVIDER_HEALTH_SIGNATURE_DOMAIN, - GAMECULT_RUNTIME_PRESENCE_HEALTH_SIGNING_PURPOSE, - payload, - ); -} - -/** Message the activation key Idunn passed at launch signs. */ -export function runtimePresenceActivationSigningMessage(payload: Uint8Array): Buffer { - return domainSeparated( - RUNTIME_ACTIVATION_SIGNATURE_DOMAIN, - GAMECULT_RUNTIME_ACTIVATION_PROOF_SIGNING_PURPOSE, - payload, - ); -} diff --git a/packages/cultnet-ts/src/signed-daemon-health.ts b/packages/cultnet-ts/src/signed-daemon-health.ts deleted file mode 100644 index d4f0f0ce..00000000 --- a/packages/cultnet-ts/src/signed-daemon-health.ts +++ /dev/null @@ -1,377 +0,0 @@ -// Signed daemon-health publication over CultNet RUDP. -// -// This is the transport half of daemon health publication: bind a socket, shake -// hands, sign the statement, put the document, wait for the ack. It is generic -// across services. The receiving service owns the connection id and the schema -// names, and supplies them as a contract; nothing service-specific is hardcoded -// here. -// -// The signature and identity domains are deliberately not parameterised. They -// are the GameCult provider-health domains, shared by every publisher and -// verifier, and a caller that could vary them could silently produce statements -// nobody verifies. - -import dgram from "node:dgram"; -import crypto from "node:crypto"; -import fs from "node:fs"; -import { encode } from "@msgpack/msgpack"; - -import { CultNetRudpSession, decodeRudpPacket, encodeRudpPacket } from "./rudp"; -import type { CultNetRudpPacket } from "./rudp"; -import { encodeCultNetMessageForWire } from "./contracts"; - -export const CULTNET_RUDP_PROTOCOL_ID = "cultnet.transport.rudp.v0"; - -const SIGNATURE_DOMAIN = Buffer.from("gamecult.provider-health.signature.v1\0", "utf8"); -const ID_DOMAIN = Buffer.from("gamecult.provider-health.identity.v1\0", "utf8"); - -/** What the receiving service owns: where to connect and what to call the documents. */ -export interface SignedDaemonHealthContract { - /** RUDP connection id the receiving service listens on. */ - connectionId: number; - /** Schema id for a signed statement. Also the signing purpose. */ - signedSchemaId: string; - /** Schema id used when no signing key is configured. */ - unsignedSchemaId: string; - /** Prefix for generated message ids. Defaults to "daemon-health". */ - messageIdPrefix?: string; -} - -export interface SignedDaemonHealthPublisherOptions { - /** host:port, or [ipv6]:port. */ - endpoint: string; - daemonId: string; - healthContract: string; - sourceRuntimeId?: string; - /** Omit to publish unsigned. */ - privateKeyPath?: string; - contract: SignedDaemonHealthContract; -} - -export interface DaemonHealth { - state: string; - detail?: string; - observedAt?: string; -} - -export interface SignedDaemonHealthPublisher { - daemonId: string; - endpoint: { host: string; port: number }; - healthContract: string; - publisherIncarnationId: string; - publisherSequence: number; - sourceRuntimeId: string; - contract: SignedDaemonHealthContract; - privateKey?: crypto.KeyObject; - publicKey?: Buffer; - signerIdentityId?: string; -} - -export function createSignedDaemonHealthPublisher( - options: SignedDaemonHealthPublisherOptions | null | undefined, -): SignedDaemonHealthPublisher | null { - if (!options) return null; - if (!options.contract) { - throw new Error("Signed daemon health publisher requires a contract naming the connection id and schema ids."); - } - - const publisher: SignedDaemonHealthPublisher = { - daemonId: options.daemonId, - endpoint: parseEndpoint(options.endpoint), - healthContract: options.healthContract, - publisherIncarnationId: crypto.randomUUID(), - publisherSequence: 0, - sourceRuntimeId: options.sourceRuntimeId || "daemon-health-publisher", - contract: options.contract, - }; - - if (options.privateKeyPath) { - publisher.privateKey = crypto.createPrivateKey(fs.readFileSync(options.privateKeyPath)); - publisher.publicKey = rawEd25519PublicKey(publisher.privateKey); - publisher.signerIdentityId = crypto - .createHash("sha256") - .update(ID_DOMAIN) - .update(publisher.publicKey) - .digest("hex"); - } - return publisher; -} - -export async function publishSignedDaemonHealth( - publisher: SignedDaemonHealthPublisher | null, - health: DaemonHealth, -): Promise { - if (!publisher) return; - const { contract } = publisher; - - const socket = dgram.createSocket(endpointFamily(publisher.endpoint.host)); - await bindSocket(socket, publisher.endpoint); - const receiver = createPacketReceiver(socket); - const session = new CultNetRudpSession({ - connectionId: contract.connectionId, - initialSequence: 1, - resendDelayMs: 100, - }); - - try { - const connect = session.createConnect(Date.now(), new Uint8Array()); - await sendPacket(socket, publisher.endpoint, connect); - await receiveUntil(receiver, session, publisher.endpoint, (packet) => packet.packetType === "accept", 5000, "accept"); - - const observedAt = health.observedAt || new Date().toISOString(); - const signed = publisher.privateKey ? signedDaemonHealthPayload(publisher, health, observedAt) : null; - const payload = signed?.payload || encode([ - publisher.daemonId, - health.state, - String(health.detail || "").slice(0, 512), - observedAt, - publisher.healthContract, - "daemon-published", - CULTNET_RUDP_PROTOCOL_ID, - ]); - - const message = { - schemaVersion: "cultnet.document_put_raw.v0", - messageId: `${contract.messageIdPrefix || "daemon-health"}:${publisher.daemonId}:${observedAt.replace(/[:.]/g, "-")}`, - document: { - schemaId: signed ? contract.signedSchemaId : contract.unsignedSchemaId, - recordKey: publisher.daemonId, - storedAt: observedAt, - payloadEncoding: "messagepack", - payload, - sourceRuntimeId: publisher.sourceRuntimeId, - sourceRole: "daemon-health-publisher", - tags: [CULTNET_RUDP_PROTOCOL_ID], - }, - }; - - const wirePayload = encode(encodeCultNetMessageForWire(message as never, "cultnet.schema.v0")); - const dataPackets = session.sendMany("schema", wirePayload, { reliable: true, ordered: true, nowMs: Date.now() }); - const ack = receiveUntil( - receiver, - session, - publisher.endpoint, - (packet) => packet.packetType === "ack", - 500, - "ack", - ).catch(() => undefined); - for (const packet of dataPackets) { - await sendPacket(socket, publisher.endpoint, packet); - } - await ack; - } finally { - receiver.close(); - socket.close(); - } -} - -export function signedDaemonHealthPayload( - publisher: SignedDaemonHealthPublisher, - health: DaemonHealth, - observedAt: string, -): { payload: Uint8Array; statement: unknown[]; unsignedPayload: Uint8Array } { - if (!publisher.privateKey) { - throw new Error("Signed daemon health requires a configured private key."); - } - publisher.publisherSequence += 1; - const observedAtUnixMillis = Date.parse(observedAt); - if (!Number.isSafeInteger(observedAtUnixMillis) || observedAtUnixMillis <= 0) { - throw new Error("Signed daemon health observation time is invalid."); - } - - const unsigned: unknown[] = [ - publisher.contract.signedSchemaId, - publisher.daemonId, - publisher.healthContract, - publisher.sourceRuntimeId, - health.state, - String(health.detail || "").slice(0, 512), - publisher.signerIdentityId, - publisher.publisherIncarnationId, - publisher.publisherSequence, - observedAtUnixMillis, - null, - null, - null, - null, - "ed25519", - new Uint8Array(), - false, - ]; - - const unsignedPayload = encode(unsigned); - const signature = crypto.sign( - null, - signedDaemonHealthSigningMessage(publisher.contract.signedSchemaId, unsignedPayload), - publisher.privateKey, - ); - const statement = unsigned.slice(); - statement[15] = new Uint8Array(signature); - return { payload: encode(statement), statement, unsignedPayload }; -} - -/** - * Domain-separated signing preimage. The signing purpose is the signed schema - * id, so a statement signed for one service's schema cannot verify as another's. - */ -export function signedDaemonHealthSigningMessage(signedSchemaId: string, payload: Uint8Array): Buffer { - const purpose = Buffer.from(signedSchemaId, "utf8"); - const purposeLength = Buffer.alloc(8); - purposeLength.writeBigUInt64BE(BigInt(purpose.length)); - const payloadLength = Buffer.alloc(8); - payloadLength.writeBigUInt64BE(BigInt(payload.length)); - return Buffer.concat([SIGNATURE_DOMAIN, purposeLength, purpose, payloadLength, Buffer.from(payload)]); -} - -function rawEd25519PublicKey(privateKey: crypto.KeyObject): Buffer { - const der = crypto.createPublicKey(privateKey).export({ type: "spki", format: "der" }) as Buffer; - if (der.length < 32) throw new Error("Ed25519 public key export is too short."); - return Buffer.from(der.subarray(der.length - 32)); -} - -async function bindSocket(socket: dgram.Socket, endpoint: { host: string }): Promise { - await new Promise((resolve, reject) => { - socket.once("error", reject); - socket.bind(0, endpoint.host.includes(":") ? "::" : "0.0.0.0", () => { - socket.off("error", reject); - resolve(); - }); - }); -} - -export function parseEndpoint(value: string): { host: string; port: number } { - const text = String(value || "").trim(); - const ipv6 = text.match(/^\[([^\]]+)\]:(\d+)$/); - if (ipv6) return { host: ipv6[1], port: parsePort(ipv6[2]) }; - const index = text.lastIndexOf(":"); - if (index <= 0) { - throw new Error(`Daemon health RUDP endpoint must be host:port, got "${value}".`); - } - return { host: text.slice(0, index), port: parsePort(text.slice(index + 1)) }; -} - -function parsePort(value: string): number { - const port = Number(value); - if (!Number.isInteger(port) || port <= 0 || port > 65535) { - throw new Error(`Daemon health RUDP endpoint port is invalid: ${value}`); - } - return port; -} - -function endpointFamily(host: string): "udp4" | "udp6" { - return host.includes(":") ? "udp6" : "udp4"; -} - -interface PacketReceiver { - socket: dgram.Socket; - next(timeoutMs: number, label?: string): Promise; - close(): void; -} - -async function receiveUntil( - receiver: PacketReceiver, - session: CultNetRudpSession, - endpoint: { host: string; port: number }, - predicate: (packet: CultNetRudpPacket) => boolean, - timeoutMs: number, - label: string, -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const packet = await receiver.next(Math.min(100, deadline - Date.now()), label); - const result = session.receive(packet, Date.now()); - if (result.reply) { - throw new Error("Daemon health publisher received an unexpected reply-required packet."); - } - if (predicate(packet)) return; - } catch (error) { - if ((error as NodeJS.ErrnoException)?.code !== "ETIMEDOUT") throw error; - } - for (const packet of session.dueResends(Date.now())) { - await sendPacket(receiver.socket, endpoint, packet); - } - } - throw new Error(`timed out waiting for daemon health RUDP ${label} response after ${timeoutMs}ms`); -} - -function createPacketReceiver(socket: dgram.Socket): PacketReceiver { - const packets: CultNetRudpPacket[] = []; - const waiters: Array<{ - resolve: (packet: CultNetRudpPacket) => void; - reject: (error: Error) => void; - timer: NodeJS.Timeout; - }> = []; - const errors: Error[] = []; - - const resolveNext = () => { - while (waiters.length > 0 && (packets.length > 0 || errors.length > 0)) { - const waiter = waiters.shift()!; - clearTimeout(waiter.timer); - if (errors.length > 0) waiter.reject(errors.shift()!); - else waiter.resolve(packets.shift()!); - } - }; - const onMessage = (wire: Buffer) => { - try { - packets.push(decodeRudpPacket(wire)); - } catch (error) { - errors.push(error as Error); - } - resolveNext(); - }; - const onError = (error: Error) => { - errors.push(error); - resolveNext(); - }; - - socket.on("message", onMessage); - socket.on("error", onError); - - return { - socket, - next(timeoutMs: number, label = "packet") { - if (packets.length > 0) return Promise.resolve(packets.shift()!); - if (errors.length > 0) return Promise.reject(errors.shift()!); - return new Promise((resolve, reject) => { - const waiter = { - resolve, - reject, - timer: setTimeout(() => { - const index = waiters.indexOf(waiter); - if (index >= 0) waiters.splice(index, 1); - const error = new Error(`timed out waiting for daemon health RUDP ${label}`) as NodeJS.ErrnoException; - error.code = "ETIMEDOUT"; - reject(error); - }, Math.max(1, timeoutMs)), - }; - waiters.push(waiter); - }); - }, - close() { - socket.off("message", onMessage); - socket.off("error", onError); - while (waiters.length > 0) { - const waiter = waiters.shift()!; - clearTimeout(waiter.timer); - const error = new Error("Daemon health publisher closed.") as NodeJS.ErrnoException; - error.code = "ECLOSED"; - waiter.reject(error); - } - }, - }; -} - -async function sendPacket( - socket: dgram.Socket, - endpoint: { host: string; port: number }, - packet: CultNetRudpPacket, -): Promise { - const wire = encodeRudpPacket(packet); - await new Promise((resolve, reject) => { - socket.send(wire, endpoint.port, endpoint.host, (error) => { - if (error) reject(error); - else resolve(); - }); - }); -} diff --git a/packages/cultnet-ts/src/transport.ts b/packages/cultnet-ts/src/transport.ts index a1b9b3ce..53f64127 100644 --- a/packages/cultnet-ts/src/transport.ts +++ b/packages/cultnet-ts/src/transport.ts @@ -131,7 +131,6 @@ export interface CultNetTransportConnection { readonly profile: CultNetTransportProfile; readonly stats: CultNetTransportStats; send(channelId: string, payload: Uint8Array): void; - flush?(timeoutMs?: number): Promise; close(): void; on( eventName: EventName, diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index 2f540a86..0677d421 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -12,7 +12,7 @@ import { CultCache, SingleFileMessagePackBackingStore, defineDocumentType, -} from "@gamecult/cultcache-ts"; +} from "cultcache-ts"; import { CultNetClientSecurityOptions, @@ -21,7 +21,6 @@ import { CultNetRudpReconnectLoop, CultNetRudpSession, CultNetRudpSocketTransportConnection, - CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS, CultNetSchemaCatalog, CultNetSchemaRegistry, CultNetSecret, @@ -59,64 +58,6 @@ import { type InteropNote, } from "./interop/cultnet-interop-shared"; -test("CultNet validates the canonical CultMesh Verse catalog messages", () => { - const request = parseCultNetMessage({ - schemaVersion: "cultmesh.verse_catalog_request.v0", - messageId: "catalog-1", - verseIds: ["sample.counter"], - transportVersion: "cultmesh.v1", - }); - assert.equal(request.schemaVersion, "cultmesh.verse_catalog_request.v0"); - - const response = parseCultNetMessage({ - schemaVersion: "cultmesh.verse_catalog_response.v0", - messageId: "catalog-1", - verses: [{ - verseId: "sample.counter", - displayName: "Counter", - authorityModel: "OperatorCluster", - compatibility: { - transportVersion: "cultmesh.v1", - rulesHash: "counter-v1", - compatibleVerseIds: [], - requiredPluginIds: [], - optionalPluginIds: [], - }, - discoveryEndpoints: ["wss://provider.example/cultmesh"], - authorityRuntimeIds: ["sample.counter-provider"], - authorityRoutes: [{ - authorityRuntimeId: "sample.counter-provider", - endpoint: "wss://provider.example/cultmesh", - protocolIds: ["cultmesh.documents.v1"], - priority: 0, - generation: "provider-route-1", - }], - }], - }); - assert.equal(response.schemaVersion, "cultmesh.verse_catalog_response.v0"); - assert.equal(response.verses[0].authorityRuntimeIds[0], "sample.counter-provider"); - assert.equal(response.verses[0].authorityRoutes![0].generation, "provider-route-1"); - - const session = parseCultNetMessage({ - schemaVersion: "cultmesh.session_accepted.v2", - messageId: "session-1", - accepted: true, - verseId: "sample.counter", - authorityRuntimeId: "sample.counter-provider", - protocolId: "cultmesh.documents.v1", - routeGeneration: "provider-route-1", - clientNonce: "bm9uY2U=", - providerKeyId: "provider-key-1", - providerSignature: "c2lnbmF0dXJl", - }); - assert.equal(session.schemaVersion, "cultmesh.session_accepted.v2"); - - assert.throws(() => parseCultNetMessage({ - ...response, - verses: [{ ...response.verses[0], compatibility: { ...response.verses[0].compatibility, rulesHash: "" } }], - }), /rulesHash/); -}); - class FakeRudpReconnectTransport extends EventEmitter { public connectCalls = 0; public closeCalls = 0; @@ -474,14 +415,6 @@ test("rudp session handshake acks reliable connect and accept packets", () => { assert.equal(server.connected, true); assert.deepEqual(server.pendingReliableSequences, [100]); - const retransmittedAccept = server.acceptConnect(connect, 15, Buffer.from("ok", "utf8")); - assert.deepEqual( - encodeRudpPacket(retransmittedAccept), - encodeRudpPacket(accept), - "a retransmitted Connect reuses the pending Accept packet", - ); - assert.deepEqual(server.pendingReliableSequences, [100]); - client.receive(accept, 20); assert.equal(client.connected, true); assert.deepEqual(client.pendingReliableSequences, []); @@ -553,53 +486,6 @@ test("rudp session pings and detects receive timeout", () => { assert.equal(client.connected, false); }); -test("rudp lossy packets cannot create reliable ordered gaps", () => { - const client = new CultNetRudpSession({ connectionId: 198, initialSequence: 1 }); - const server = new CultNetRudpSession({ connectionId: 198, initialSequence: 100 }); - const connect = client.createConnect(0); - const accept = server.acceptConnect(connect, 1); - client.receive(accept, 2); - - const ping = client.createPing(Buffer.from("pulse")); - const pong = server.receive(ping, 3).reply!; - const realtime = server.send("realtime", Buffer.from("discarded realtime")); - const latest = server.send("latest", Buffer.from("discarded latest state"), { sequenced: true }); - const schema = server.send("schema", Buffer.from("committed response"), { reliable: true, ordered: true }); - - assert.equal(ping.sequence, 0); - assert.equal(pong.sequence, 0); - assert.equal(realtime.sequence, 0); - assert.equal(latest.sequence, 1); - assert.equal(schema.sequence, accept.sequence + 1); - assert.deepEqual(client.receive(schema, 4).delivered.map(frame => Buffer.from(frame.payload).toString("utf8")), [ - "committed response", - ]); -}); - -test("rudp unreliable sequenced delivery is scoped to its channel", () => { - const sender = new CultNetRudpSession({ connectionId: 197, initialSequence: 50 }); - const receiver = new CultNetRudpSession({ connectionId: 197, initialSequence: 100 }); - const connect = sender.createConnect(0); - const accept = receiver.acceptConnect(connect, 1); - sender.receive(accept, 2); - const older = sender.send("latest", Buffer.from("older"), { sequenced: true }); - const newer = sender.send("latest", Buffer.from("newer"), { sequenced: true }); - - assert.equal(older.sequence, 1); - assert.equal(newer.sequence, 2); - assert.deepEqual(receiver.receive(newer, 3).delivered.map(frame => Buffer.from(frame.payload).toString("utf8")), ["newer"]); - assert.deepEqual(receiver.receive(older, 4).delivered, []); -}); - -test("rudp rejects unreliable ordered delivery", () => { - const session = new CultNetRudpSession({ connectionId: 196 }); - session.receive({ packetType: "accept", connectionId: 196, sequence: 50, ack: 0, ackMask: 0, channelId: "control" }); - assert.throws( - () => session.send("schema", Buffer.from("cannot order what will not retransmit"), { ordered: true }), - /ordered delivery requires reliability/, - ); -}); - test("rudp session bounds pending reliable packets before enqueue", () => { const session = new CultNetRudpSession({ connectionId: 102, initialSequence: 1, maxPendingReliablePackets: 2 }); session.receive({ packetType: "accept", connectionId: 102, sequence: 50, ack: 0, ackMask: 0, channelId: "control" }); @@ -927,9 +813,9 @@ test("CultNet can round-trip gamecult.networking.v0 auth messages through the ex assert.deepEqual(wireValue, [ 0, [ - new TextEncoder().encode("nonce"), - new TextEncoder().encode("auth"), - new TextEncoder().encode("password"), + Buffer.from("nonce", "utf8"), + Buffer.from("auth", "utf8"), + Buffer.from("password", "utf8"), ], ]); @@ -1367,124 +1253,6 @@ test("operation envelopes preserve typed service routing and payload correlation assert.equal(response.messageId, request.messageId); }); -test("rudp session advances large fragment sets through a bounded reliable window", () => { - const connectionId = 457; - const sender = new CultNetRudpSession({ connectionId, initialSequence: 1, resendDelayMs: 25 }); - const receiver = new CultNetRudpSession({ connectionId, initialSequence: 100, resendDelayMs: 25 }); - sender.receive({ packetType: "accept", connectionId, sequence: 0, ack: 0, ackMask: 0, channelId: "control" }); - receiver.receive({ packetType: "accept", connectionId, sequence: 0, ack: 0, ackMask: 0, channelId: "control" }); - - const fragmentCount = CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS + 17; - const payload = Uint8Array.from( - { length: fragmentCount * 8 }, - (_, index) => index % 251, - ); - const wire = sender.sendMany("schema", payload, { - reliable: true, - ordered: true, - nowMs: 1, - maxFragmentBytes: 8, - }); - assert.equal(wire.length, CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS); - const oldestSequence = wire[0]!.sequence; - assert.equal(sender.pendingReliableSequences.length, wire.length); - assert.equal(sender.queuedReliablePacketCount, 17); - - const delivered = []; - while (wire.length > 0) { - const admitted = wire.length; - for (let index = 0; index < admitted; index += 1) { - const packet = wire.shift()!; - delivered.push(...receiver.receive(packet, 2).delivered); - } - const acknowledged = sender.receive(receiver.createAck(), 3); - wire.push(...(acknowledged.readyToSend ?? [])); - } - - assert.equal(sender.outstandingReliablePacketCount, 0); - assert.equal(delivered.length, 1); - assert.deepEqual(delivered[0]?.payload, payload); - const oldAck = receiver.createAckForReceived(oldestSequence); - assert.equal(oldAck.ack, oldestSequence); - assert.equal(oldAck.ackMask, 0); -}); - -test("CultNet contracts encode legacy bytes without Node Buffer authority", () => { - const originalBuffer = globalThis.Buffer; - try { - Object.defineProperty(globalThis, "Buffer", { configurable: true, value: undefined }); - const message = parseCultNetMessage({ - schemaVersion: "cultnet.login.v0", - nonce: "AQID", - auth: "BAUG", - password: "BwgJ", - }); - const wire = encodeCultNetMessageForWire(message, "gamecult.networking.v0") as [number, Uint8Array[]]; - assert.equal(wire[0], 0); - assert.deepEqual([...wire[1][0]], [1, 2, 3]); - } finally { - Object.defineProperty(globalThis, "Buffer", { configurable: true, value: originalBuffer }); - } -}); - -test("database subscription contracts match the C# live document lane", () => { - const subscribe = parseCultNetMessage({ - schemaVersion: "cultnet.database_subscribe.v0", - messageId: "subscribe-counter", - subscriptionId: "counter:browser-1", - schemaIds: ["sample.counter_state.v1"], - recordKeys: ["counter:main"], - includeSnapshot: true, - consumerRuntimeId: "browser-1", - }); - assert.equal(subscribe.schemaVersion, "cultnet.database_subscribe.v0"); - assert.equal(subscribe.subscriptionId, "counter:browser-1"); - - const added = parseCultNetMessage({ - schemaVersion: "cultnet.database_change_raw.v0", - messageId: "change-counter-1", - subscriptionId: "counter:browser-1", - changeKind: "added", - document: { - schemaId: "sample.counter_state.v1", - recordKey: "counter:main", - storedAt: "2026-08-17T00:00:00Z", - payloadEncoding: "messagepack", - payload: new Uint8Array([0x81, 0xa5, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x01]), - }, - }); - assert.equal(added.schemaVersion, "cultnet.database_change_raw.v0"); - assert.equal(added.document?.recordKey, "counter:main"); - - const removed = parseCultNetMessage({ - schemaVersion: "cultnet.database_change_raw.v0", - messageId: "change-counter-2", - subscriptionId: "counter:browser-1", - changeKind: "removed", - recordKey: "counter:main", - schemaId: "sample.counter_state.v1", - }); - assert.equal(removed.schemaVersion, "cultnet.database_change_raw.v0"); - assert.equal(removed.recordKey, "counter:main"); - - const unsubscribe = parseCultNetMessage({ - schemaVersion: "cultnet.database_unsubscribe.v0", - messageId: "unsubscribe-counter", - subscriptionId: "counter:browser-1", - }); - assert.equal(unsubscribe.schemaVersion, "cultnet.database_unsubscribe.v0"); - - assert.throws( - () => parseCultNetMessage({ - schemaVersion: "cultnet.database_change_raw.v0", - messageId: "invalid-change", - subscriptionId: "counter:browser-1", - changeKind: "updated", - }), - /document: is required/, - ); -}); - test("RUDP operation service correlates typed payload envelopes", async () => { const server = await startCultNetOperationServer({ runtimeId: "sai-sidecar", @@ -1821,49 +1589,3 @@ test("Generated Ghostlight contracts can feed CultCacheTS directly without a Zod rmSync(tempDir, { recursive: true, force: true }); } }); - -test("rudp receiver evicts stranded fragment sets instead of refusing new ones", () => { - // A fragment set is removed only on successful reassembly, so every set that - // loses a fragment is stranded for the life of the session. Ghostlight sends - // Heimdall fragmented envelopes (max_fragment_bytes = 2048), so on the live - // auth path this map grew for every lost fragment and was never swept. - const receiver = new CultNetRudpSession({ connectionId: 9, initialSequence: 1, resendDelayMs: 50 }); - receiver.receive({ packetType: "accept", connectionId: 9, sequence: 1, ack: 0, ackMask: 0, channelId: "control" }); - - // 200 fragment sets that each lose their second fragment. - for (let id = 1; id <= 200; id += 1) { - const stranded = receiver.receive({ - packetType: "data", - connectionId: 9, - sequence: 100 + id, - ack: 0, - ackMask: 0, - channelId: "schema", - fragmentId: id, - fragmentIndex: 0, - fragmentCount: 2, - payload: Buffer.from([id & 0xff]), - }); - assert.equal(stranded.delivered.length, 0, "an incomplete set must not reassemble"); - } - - // Bounded rather than unbounded, and still serving. - assert.ok(receiver.pendingFragmentSetCount <= 64, `pending ${receiver.pendingFragmentSetCount}`); - assert.ok(receiver.fragmentSetsEvicted > 0, "nothing was evicted"); - - // The receiver still completes a fresh fragmented payload, which is what - // erroring at the cap took away: any nonzero loss killed the channel. - const first = receiver.receive({ - packetType: "data", connectionId: 9, sequence: 900, ack: 0, ackMask: 0, - channelId: "schema", fragmentId: 1000, fragmentIndex: 0, fragmentCount: 2, - payload: Buffer.from("he"), - }); - assert.equal(first.delivered.length, 0); - const second = receiver.receive({ - packetType: "data", connectionId: 9, sequence: 901, ack: 0, ackMask: 0, - channelId: "schema", fragmentId: 1000, fragmentIndex: 1, fragmentCount: 2, - payload: Buffer.from("llo"), - }); - assert.equal(second.delivered.length, 1); - assert.equal(Buffer.from(second.delivered[0]!.payload).toString(), "hello"); -}); diff --git a/packages/cultnet-ts/test/fixtures/runtime-presence-vectors.json b/packages/cultnet-ts/test/fixtures/runtime-presence-vectors.json deleted file mode 100644 index 043689f4..00000000 --- a/packages/cultnet-ts/test/fixtures/runtime-presence-vectors.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "proof": "dc0018d92367616d6563756c742e72756e74696d655f70726573656e63655f6865616c74682e7632a86865696d64616c6cd9477368613235362d61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161d9477368613235362d62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262b5736572766963652d696e6361726e6174696f6e2d31d9477368613235362d63636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363d9477368613235362d64646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464a27631d9477368613235362d65656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565b26865696d64616c6c2d79676764726173696cd9477368613235362d66666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666b6687474703a2f2f3132372e302e302e313a31343130319294af6865696d64616c6c2e616363657373b26865696d64616c6c2e6163636573732e7631a276310294ac7a6574612e72756e74696d65af7a6574612e72756e74696d652e7631a2763101d9256865696d64616c6c2e63756c746e65742d727564702d70726f76696465722d6865616c7468a77761726d696e67ba4865696d64616c6c2063616e646964617465207761726d696e67c0d9403131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313107cf00000199155c6200a765643235353139c400d94032323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232c400", - "signed": "dc0018d92367616d6563756c742e72756e74696d655f70726573656e63655f6865616c74682e7632a86865696d64616c6cd9477368613235362d61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161d9477368613235362d62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262b5736572766963652d696e6361726e6174696f6e2d31d9477368613235362d63636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363d9477368613235362d64646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464a27631d9477368613235362d65656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565b26865696d64616c6c2d79676764726173696cd9477368613235362d66666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666b6687474703a2f2f3132372e302e302e313a31343130319294af6865696d64616c6c2e616363657373b26865696d64616c6c2e6163636573732e7631a276310294ac7a6574612e72756e74696d65af7a6574612e72756e74696d652e7631a2763101d9256865696d64616c6c2e63756c746e65742d727564702d70726f76696465722d6865616c7468a77761726d696e67ba4865696d64616c6c2063616e646964617465207761726d696e67c0d9403131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313107cf00000199155c6200a765643235353139c44011111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111d94032323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232c44022222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222" -} diff --git a/packages/cultnet-ts/test/interop/cultnet-interop-peer.ts b/packages/cultnet-ts/test/interop/cultnet-interop-peer.ts index 57b7fa37..0af43301 100644 --- a/packages/cultnet-ts/test/interop/cultnet-interop-peer.ts +++ b/packages/cultnet-ts/test/interop/cultnet-interop-peer.ts @@ -6,7 +6,7 @@ import process from "node:process"; import { setTimeout as delay } from "node:timers/promises"; import { decode, encode } from "@msgpack/msgpack"; -import { CultCache, SingleFileMessagePackBackingStore, defineDocumentType, type AnyCultCacheDocumentDefinition } from "@gamecult/cultcache-ts"; +import { CultCache, SingleFileMessagePackBackingStore, defineDocumentType, type AnyCultCacheDocumentDefinition } from "cultcache-ts"; import { CultNetDocumentRegistry, CultNetPeer, diff --git a/packages/cultnet-ts/test/interop/cultnet-interop.test.ts b/packages/cultnet-ts/test/interop/cultnet-interop.test.ts index 00fff83d..bec86942 100644 --- a/packages/cultnet-ts/test/interop/cultnet-interop.test.ts +++ b/packages/cultnet-ts/test/interop/cultnet-interop.test.ts @@ -24,9 +24,7 @@ const pythonCommand = process.env.PYTHON ?? resolvePythonCommand(); const cultNetTsRoot = resolve(__dirname, "../../.."); const cultLibRoot = findAncestor(cultNetTsRoot, "CultLib.sln") ?? resolve(cultNetTsRoot, "..", "CultLib"); const cultcachePyRoot = resolve(cultLibRoot, "packages", "cultcache-py"); -const cultcachePySrc = ["cultcache-py", "cultnet-py", "cultmesh-py"] - .map((name) => resolve(cultLibRoot, "packages", name, "src")) - .join(delimiter); +const cultcachePySrc = resolve(cultcachePyRoot, "src"); const cultmeshKotlinRoot = resolve(cultLibRoot, "packages", "cultmesh-kotlin"); const kotlinHome = process.env.KOTLIN_HOME ?? "C:\\Program Files\\Android\\Android Studio\\plugins\\Kotlin\\kotlinc"; const kotlinJavaHome = process.env.KOTLIN_JAVA_HOME ?? "C:\\Program Files\\Android\\Android Studio\\jbr"; @@ -416,12 +414,6 @@ test("CultNet TS/Rust/C#/Python peers discover each other and exchange raw state const advertiseHost = findAdvertiseHost(); const servers: RunningServeProcess[] = []; - // Registered before anything is spawned. A peer whose readiness rejects - // would otherwise skip this and strand every peer already running. - t.after(async () => { - await Promise.all(servers.map(stopProcess)); - }); - logInteropPhase("schema-v0", "start rust peer"); servers.push(await spawnServeProcess("rust", { command: rustBinaryPath, @@ -506,6 +498,10 @@ test("CultNet TS/Rust/C#/Python peers discover each other and exchange raw state })); await servers[servers.length - 1].ready; + t.after(async () => { + await Promise.all(servers.map(stopProcess)); + }); + logInteropPhase("schema-v0", "typescript dials rust"); const tsDial = await runJsonCommand("ts-dial", process.execPath, [ tsPeerScript, @@ -993,12 +989,6 @@ test("CultNet TypeScript and Rust full interop peers exchange schema-v0 over RUD const discoveryPort = await getFreePort(); const advertiseHost = findAdvertiseHost(); const servers: RunningServeProcess[] = []; - // Registered before anything is spawned. A peer whose readiness rejects - // would otherwise skip this and strand every peer already running. - t.after(async () => { - await Promise.all(servers.map(stopProcess)); - }); - servers.push(await spawnServeProcess("rust-rudp", { command: rustBinaryPath, args: [ @@ -1040,6 +1030,10 @@ test("CultNet TypeScript and Rust full interop peers exchange schema-v0 over RUD })); await servers[servers.length - 1].ready; + t.after(async () => { + await Promise.all(servers.map(stopProcess)); + }); + logInteropPhase("ts-rust-full-rudp", "typescript dials rust over rudp"); const tsRudpDial = await runJsonCommand("ts-rust-rudp-dial", process.execPath, [ tsPeerScript, @@ -1145,7 +1139,6 @@ test("CultNet Python and TypeScript exchange schema frames when Python dials a T assert.equal(exitCode, 0, pythonClient.stderr.join("")); } finally { server.close(); - closeUdpSocket(serverSocket); if (pythonClient.child.exitCode === null && !pythonClient.child.killed) { pythonClient.child.kill("SIGTERM"); await once(pythonClient.child, "exit").catch(() => undefined); @@ -1253,7 +1246,6 @@ test("CultNet Python and TypeScript exchange schema-v0 MessagePack messages over assert.equal(exitCode, 0, pythonClient.stderr.join("")); } finally { server.close(); - closeUdpSocket(serverSocket); if (pythonClient.child.exitCode === null && !pythonClient.child.killed) { pythonClient.child.kill("SIGTERM"); await once(pythonClient.child, "exit").catch(() => undefined); @@ -1330,7 +1322,6 @@ test("CultNet Rust and TypeScript exchange schema frames when Rust dials a TypeS assert.equal(exitCode, 0, rustClient.stderr.join("")); } finally { server.close(); - closeUdpSocket(serverSocket); if (rustClient.child.exitCode === null && !rustClient.child.killed) { rustClient.child.kill("SIGTERM"); await once(rustClient.child, "exit").catch(() => undefined); @@ -1440,7 +1431,6 @@ test("CultNet Rust and TypeScript exchange schema-v0 MessagePack messages over R assert.equal(exitCode, 0, rustClient.stderr.join("")); } finally { server.close(); - closeUdpSocket(serverSocket); if (rustClient.child.exitCode === null && !rustClient.child.killed) { rustClient.child.kill("SIGTERM"); await once(rustClient.child, "exit").catch(() => undefined); @@ -1517,7 +1507,6 @@ test("CultNet C# and TypeScript exchange schema frames when C# dials a TypeScrip assert.equal(exitCode, 0, csharpClient.stderr.join("")); } finally { server.close(); - closeUdpSocket(serverSocket); if (csharpClient.child.exitCode === null && !csharpClient.child.killed) { csharpClient.child.kill("SIGTERM"); await once(csharpClient.child, "exit").catch(() => undefined); @@ -1627,7 +1616,6 @@ test("CultNet C# and TypeScript exchange schema-v0 MessagePack messages over RUD assert.equal(exitCode, 0, csharpClient.stderr.join("")); } finally { server.close(); - closeUdpSocket(serverSocket); if (csharpClient.child.exitCode === null && !csharpClient.child.killed) { csharpClient.child.kill("SIGTERM"); await once(csharpClient.child, "exit").catch(() => undefined); @@ -1704,7 +1692,6 @@ test("CultNet Kotlin and TypeScript exchange schema frames when Kotlin dials a T assert.equal(exitCode, 0, kotlinClient.stderr.join("")); } finally { server.close(); - closeUdpSocket(serverSocket); if (kotlinClient.child.exitCode === null && !kotlinClient.child.killed) { kotlinClient.child.kill("SIGTERM"); await once(kotlinClient.child, "exit").catch(() => undefined); @@ -1814,7 +1801,6 @@ test("CultNet Kotlin and TypeScript exchange schema-v0 MessagePack messages over assert.equal(exitCode, 0, kotlinClient.stderr.join("")); } finally { server.close(); - closeUdpSocket(serverSocket); if (kotlinClient.child.exitCode === null && !kotlinClient.child.killed) { kotlinClient.child.kill("SIGTERM"); await once(kotlinClient.child, "exit").catch(() => undefined); @@ -1837,12 +1823,6 @@ test("CultNet TypeScript and C# full interop peers exchange schema-v0 over RUDP" const discoveryPort = await getFreePort(); const advertiseHost = findAdvertiseHost(); const servers: RunningServeProcess[] = []; - // Registered before anything is spawned. A peer whose readiness rejects - // would otherwise skip this and strand every peer already running. - t.after(async () => { - await Promise.all(servers.map(stopProcess)); - }); - servers.push(await spawnServeProcess("csharp-rudp", { command: dotnetCommand, args: [ @@ -1885,6 +1865,10 @@ test("CultNet TypeScript and C# full interop peers exchange schema-v0 over RUDP" })); await servers[servers.length - 1].ready; + t.after(async () => { + await Promise.all(servers.map(stopProcess)); + }); + logInteropPhase("ts-csharp-full-rudp", "typescript dials csharp over rudp"); const tsRudpDial = await runJsonCommand("ts-csharp-rudp-dial", process.execPath, [ tsPeerScript, @@ -1939,12 +1923,6 @@ test("CultNet TypeScript and Kotlin full interop peers exchange schema-v0 over R const discoveryPort = await getFreePort(); const advertiseHost = findAdvertiseHost(); const servers: RunningServeProcess[] = []; - // Registered before anything is spawned. A peer whose readiness rejects - // would otherwise skip this and strand every peer already running. - t.after(async () => { - await Promise.all(servers.map(stopProcess)); - }); - servers.push(await spawnServeProcess("kotlin-rudp", { command: kotlinJavaCommand, args: [ @@ -1988,6 +1966,10 @@ test("CultNet TypeScript and Kotlin full interop peers exchange schema-v0 over R })); await servers[servers.length - 1].ready; + t.after(async () => { + await Promise.all(servers.map(stopProcess)); + }); + logInteropPhase("ts-kotlin-full-rudp", "typescript dials kotlin over rudp"); const tsRudpDial = await runJsonCommand("ts-kotlin-rudp-dial", process.execPath, [ tsPeerScript, @@ -3178,20 +3160,6 @@ function spawnRustRudpMessageClient(remotePort: number): Omit { const socket = dgram.createSocket("udp4"); await new Promise((resolveBind, rejectBind) => { diff --git a/packages/cultnet-ts/test/runtime-presence-health.test.ts b/packages/cultnet-ts/test/runtime-presence-health.test.ts deleted file mode 100644 index fa21aeaa..00000000 --- a/packages/cultnet-ts/test/runtime-presence-health.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -// Cross-fork conformance for `gamecult.runtime_presence_health.v2`. -// -// The vectors below were produced by `cultnet-rs`, not written by hand: -// -// cargo run --example runtime_presence_vector -p cultnet-rs -// -// That matters. A fixture authored for both sides at once agrees with itself -// and proves nothing; the contract is only real if one fork emits the bytes and -// the other reproduces them. Regenerate these vectors from the Rust example if -// the record ever changes, and never by editing them to match this code. - -import test from "node:test"; -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - -import { - RUNTIME_PRESENCE_FIELD_COUNT, - encodeRuntimePresenceHealth, - runtimePresenceProofPayload, - validateRuntimePresence, - type RuntimePresenceHealth, -} from "../src/runtime-presence-health"; - -// Read from the source tree, not a copy beside the compiled test: the vectors -// are generated output and must have exactly one home. -const VECTORS = JSON.parse( - readFileSync( - join(__dirname, "..", "..", "test", "fixtures", "runtime-presence-vectors.json"), - "utf8", - ), -) as { proof: string; signed: string }; - -/** The same statement the Rust example builds. */ -function referencePresence(): RuntimePresenceHealth { - return { - target: "heimdall", - expectedProjectionSha256: `sha256-${"a".repeat(64)}`, - planId: `sha256-${"b".repeat(64)}`, - incarnationId: "service-incarnation-1", - sealedReleaseId: `sha256-${"c".repeat(64)}`, - activationWitnessSha256: `sha256-${"d".repeat(64)}`, - stateSchemaGeneration: "v1", - stateContractSha256: `sha256-${"e".repeat(64)}`, - runtimeId: "heimdall-yggdrasil", - runtimeInstanceId: `sha256-${"f".repeat(64)}`, - boundEndpoint: "http://127.0.0.1:14101", - capabilities: [ - { - capability: "heimdall.access", - schema: "heimdall.access.v1", - compatibility: "v1", - capacity: 2, - }, - { capability: "zeta.runtime", schema: "zeta.runtime.v1", compatibility: "v1", capacity: 1 }, - ], - healthContract: "heimdall.cultnet-rudp-provider-health", - state: "warming", - detail: "Heimdall candidate warming", - writeLeaseSha256: null, - signerIdentityId: "1".repeat(64), - publisherSequence: 7, - observedAtUnixMillis: 1_757_000_000_000, - activationSignerIdentityId: "2".repeat(64), - }; -} - -function hex(bytes: Uint8Array): string { - return Buffer.from(bytes).toString("hex"); -} - -test("the proof payload is byte-identical to the one cultnet-rs signs", () => { - assert.equal(hex(runtimePresenceProofPayload(referencePresence())), VECTORS.proof); -}); - -test("the signed record carries both proofs in their own slots", () => { - const signed = encodeRuntimePresenceHealth( - referencePresence(), - new Uint8Array(64).fill(0x11), - new Uint8Array(64).fill(0x22), - ); - // Byte-identical to the fully signed record cultnet-rs emits, so the proofs - // land in their own slots rather than being appended. - assert.equal(hex(signed), VECTORS.signed); -}); - -test("capabilities are ordered canonically regardless of caller order", () => { - const presence = referencePresence(); - const reversed = { ...presence, capabilities: [...presence.capabilities].reverse() }; - assert.equal( - hex(runtimePresenceProofPayload(reversed)), - hex(runtimePresenceProofPayload(presence)), - ); -}); - -test("the tuple is always the full contract width", () => { - assert.equal(RUNTIME_PRESENCE_FIELD_COUNT, 24); -}); - -test("statements the contract forbids are refused before they are signed", () => { - const warmingWithLease = { - ...referencePresence(), - writeLeaseSha256: `sha256-${"0".repeat(64)}`, - }; - assert.throws(() => validateRuntimePresence(warmingWithLease), /cannot claim a process write/); - - const statelessWithLease = { - ...referencePresence(), - state: "active" as const, - stateSchemaGeneration: null, - stateContractSha256: null, - writeLeaseSha256: `sha256-${"0".repeat(64)}`, - }; - assert.throws(() => validateRuntimePresence(statelessWithLease), /stateless runtime presence/); - - assert.throws( - () => validateRuntimePresence({ ...referencePresence(), publisherSequence: 0 }), - /publisher sequence/, - ); - - const partialLineage = { ...referencePresence(), stateContractSha256: null }; - assert.throws(() => validateRuntimePresence(partialLineage), /state lineage is partial/); -}); diff --git a/samples/eve-browser-network/EveBrowserNetworkSample.csproj b/samples/eve-browser-network/EveBrowserNetworkSample.csproj deleted file mode 100644 index 50b41781..00000000 --- a/samples/eve-browser-network/EveBrowserNetworkSample.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - net10.0 - enable - enable - $(MSBuildThisFileDirectory)..\..\..\Eve - false - 1.0.35 - 0.3.3 - $(PackageFeed) - - - - - - - - - - - - diff --git a/samples/eve-browser-network/Program.cs b/samples/eve-browser-network/Program.cs deleted file mode 100644 index c26d654b..00000000 --- a/samples/eve-browser-network/Program.cs +++ /dev/null @@ -1,520 +0,0 @@ -using System.Net; -using System.Diagnostics; -using System.Text.Json; -using GameCult.Caching; -using GameCult.Caching.MessagePack; -using GameCult.Eve.Surface; -using GameCult.Mesh; -using GameCult.Networking; -using GameCult.Networking.WebSockets; -using MessagePack; -using Microsoft.AspNetCore.Hosting.Server; -using Microsoft.AspNetCore.Hosting.Server.Features; -using Microsoft.Extensions.DependencyInjection; - -const string counterKey = "counter:main"; -const string surfaceKey = "sample.counter"; -const string providerKey = "sample.counter-ui"; -const string commandBoundary = "sample.counter.commands"; -var arguments = Args.Parse(args); -if (arguments.Mode == "provider") -{ - await RunProviderAsync(arguments); - return; -} -if (arguments.Mode == "headless") -{ - await RunHeadlessAsync(arguments); - return; -} -if (arguments.Mode == "odin") -{ - await RunOdinAsync(arguments); - return; -} -throw new InvalidOperationException("Use 'provider', 'headless', or 'odin'."); - -static async Task RunOdinAsync(Args arguments) -{ - if (string.IsNullOrWhiteSpace(arguments.ProviderEndpoint)) - throw new ArgumentException("The Odin fixture requires --provider-endpoint."); - using var catalog = new CultMeshVerseCatalog(); - var routes = new List - { - new( - arguments.AuthorityRuntimeId, - arguments.ProviderEndpoint, - [CultMeshProtocols.Documents.Value], - priority: 10, - generation: arguments.RouteGeneration) - }; - if (!string.IsNullOrWhiteSpace(arguments.DecoyEndpoint)) - { - routes.Add(new CultMeshAuthorityRoute( - arguments.DecoyAuthorityRuntimeId, - arguments.DecoyEndpoint, - [CultMeshProtocols.Documents.Value], - priority: 0, - generation: arguments.DecoyRouteGeneration)); - } - catalog.Upsert(new CultMeshVerseDescriptor( - arguments.VerseId, - arguments.VerseName, - CultMeshVerseAuthorityModel.OperatorCluster, - new CultMeshVerseCompatibility(arguments.TransportVersion, arguments.RulesHash), - authorityRoutes: routes, - description: "Local executable Odin fixture for the browser/network onboarding witness.")); - - await using var schemaServer = new CultNetWebSocketSchemaServer(); - schemaServer.OnCultNet((request, peer) => - { - peer.SendCultNet(CultMeshVerseMessages.CreateCatalogResponse(catalog, request)); - return Task.CompletedTask; - }); - var builder = WebApplication.CreateSlimBuilder(); - builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, arguments.Port)); - var app = builder.Build(); - app.UseWebSockets(); - app.MapCultNetWebSocket("/odin", schemaServer, new CultNetWebSocketEndpointOptions - { - AuthorizeAsync = context => ValueTask.FromResult( - context.Request.Cookies.TryGetValue("cultnet_session", out var token) && - string.Equals(token, arguments.Token, StringComparison.Ordinal)) - }); - await app.StartAsync(); - var address = app.Services.GetRequiredService() - .Features.Get()!.Addresses.Single(); - Console.WriteLine("ODIN_READY " + address.Replace("http://", "ws://", StringComparison.Ordinal) + "/odin"); - await app.WaitForShutdownAsync(); - await app.DisposeAsync(); -} - -static async Task RunProviderAsync(Args arguments) -{ - var documentTypes = new[] { typeof(CounterState), typeof(EveSurfaceDocument), typeof(EveProviderAdvertisementDocument) }; - var registry = CultMesh.CreateCultCacheDocumentRegistry(documentTypes); - var documents = CultMesh.CreateCultNetDocumentRegistry(documentTypes, registry); - using var node = await CultMesh.CreateNodeAsync(arguments.StatePath, new CultMeshNodeOptions - { - StartServer = false, - CacheOptions = new CultCacheOpenOptions - { - Registry = registry, - FlushOnDispose = true, - StoreFlushOnDispose = true - }, - DatabaseOptions = new CultNetDatabaseOptions - { - RuntimeId = arguments.AuthorityRuntimeId, - DocumentRegistry = documents - } - }); - if (node.Cache.Get(new CultRecordKey(counterKey)) == null) - await node.Database.PutAsync(new CultRecordKey(counterKey), CounterState.Initial(counterKey)); - if (node.Cache.Get(new CultRecordKey(surfaceKey)) == null) - await node.Database.PutAsync(new CultRecordKey(surfaceKey), CreateCounterSurface()); - // The advertisement is how a lowering learns where this surface's commands - // go and what comes back. Eve refuses to build a command intent without it, - // and the browser must not have to restate it: the provider is the owner. - await node.Database.PutAsync(new CultRecordKey(providerKey), CreateProviderAdvertisement(arguments)); - await node.FlushAsync(); - - await using var schemaServer = new CultNetWebSocketSchemaServer(); - using var identity = new CultMeshSessionIdentityServer( - schemaServer, - arguments.AuthorityRuntimeId, - [arguments.VerseId], - [CultMeshProtocols.Documents.Value], - [arguments.RouteGeneration]); - using var subscriptions = new CultNetDatabaseSubscriptionServer(schemaServer, node.Database); - using var operationGate = new SemaphoreSlim(1, 1); - using var operations = new CultNetOperationServer(schemaServer, arguments.AuthorityRuntimeId) - .Register( - "sample.counter", - "sample.counter.ping", - "sample.ping_request.v1", - "sample.ping_receipt.v1", - context => Task.FromResult(new PingReceipt { Sequence = context.Value.Sequence })) - .Register( - "sample.counter", - "sample.counter.increment", - "sample.increment.v1", - "gamecult.eve.command_receipt.v1", - async context => - { - await operationGate.WaitAsync(); - try - { - var current = node.Cache.Get(new CultRecordKey(counterKey)) - ?? throw new InvalidOperationException("Counter state is unavailable."); - if (current.Receipts.TryGetValue(context.IdempotencyKey, out var existing)) - return existing; - - var receipt = IncrementReceipt.Accepted( - context.IdempotencyKey, - current.Count + context.Value.Amount); - var receipts = new Dictionary(current.Receipts, StringComparer.Ordinal) - { - [context.IdempotencyKey] = receipt - }; - await node.Database.PutAsync(new CultRecordKey(counterKey), new CounterState - { - CounterId = counterKey, - Count = receipt.Count, - Receipts = receipts - }); - await node.FlushAsync(); - return receipt; - } - finally - { - operationGate.Release(); - } - }); - - var builder = WebApplication.CreateSlimBuilder(); - builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, arguments.Port)); - var app = builder.Build(); - app.UseWebSockets(); - app.MapCultNetWebSocket("/cultmesh", schemaServer, new CultNetWebSocketEndpointOptions - { - AuthorizeAsync = context => ValueTask.FromResult( - context.Request.Cookies.TryGetValue("cultnet_session", out var token) && - string.Equals(token, arguments.Token, StringComparison.Ordinal)) - }); - await app.StartAsync(); - var address = app.Services.GetRequiredService() - .Features.Get()!.Addresses.Single(); - Console.WriteLine("PROVIDER_READY " + address.Replace("http://", "ws://", StringComparison.Ordinal) + "/cultmesh"); - await app.WaitForShutdownAsync(); - await app.DisposeAsync(); -} - -static async Task RunHeadlessAsync(Args arguments) -{ - if (string.IsNullOrWhiteSpace(arguments.OdinEndpoint)) - throw new ArgumentException("The headless client requires --odin."); - CultNetWebSocketSchemaClient CreateClient() => new(options => - options.SetRequestHeader("Cookie", $"cultnet_session={arguments.Token}")); - using var mesh = new CultMeshClient(new CultMeshClientOptions - { - RendezvousEndpoints = [arguments.OdinEndpoint], - Discovery = new CultMeshVerseDiscoveryClientOptions - { - CreateClient = CreateClient, - TransportVersion = arguments.TransportVersion - }, - Sessions = new CultMeshSessionManagerOptions - { - Trust = new CultMeshAuthorityTrustPolicy(CultMeshAuthorityTrustMode.LocalDevelopment) - }, - Connectors = - [ - new CultMeshUriSchemaTransportConnector( - "cultnet-websocket", - ["ws", "wss"], - _ => CreateClient()) - ] - }); - var target = new CultMeshSessionTarget(arguments.VerseId, arguments.AuthorityRuntimeId); - using var lease = await mesh.LeaseDocumentAsync(target, counterKey); - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var lastCount = -1; - using var watch = lease.Handle.Watch(counter => - { - if (counter.Count <= lastCount) return; - lastCount = counter.Count; - if (counter.Count == 0) - Console.WriteLine("HEADLESS_READY " + JsonSerializer.Serialize(new { count = counter.Count })); - else - Console.WriteLine($"HEADLESS_UPDATE_{counter.Count} " + JsonSerializer.Serialize(new - { - count = counter.Count, - receiptIds = counter.Receipts.Values - .Select(receipt => receipt.ReceiptId) - .OrderBy(value => value, StringComparer.Ordinal) - .ToArray() - })); - if (counter.Count >= arguments.ExpectedCount) completion.TrySetResult(); - }); - var initial = await lease.Handle.LatestAsync(); - if (lastCount < 0) - { - lastCount = initial.Count; - Console.WriteLine("HEADLESS_READY " + JsonSerializer.Serialize(new { count = initial.Count })); - } - if (initial.Count >= arguments.ExpectedCount) completion.TrySetResult(); - var commandTask = Task.Run(async () => - { - var line = await Console.In.ReadLineAsync(); - if (line == null || !line.StartsWith("INVOKE ", StringComparison.Ordinal)) - throw new InvalidOperationException("The headless sample expected 'INVOKE ' on stdin."); - var commandId = line["INVOKE ".Length..].Trim(); - var result = await mesh.InvokeAsync( - target, - "sample.counter", - "sample.counter.increment", - "sample.increment.v1", - "gamecult.eve.command_receipt.v1", - new IncrementRequest { Amount = 1 }, - sourceRuntimeId: "sample.csharp", - idempotencyKey: commandId); - Console.WriteLine("HEADLESS_RECEIPT " + JsonSerializer.Serialize(new - { - receiptId = result.Value.ReceiptId, - count = result.Value.Count, - status = result.Status - })); - Console.WriteLine("HEADLESS_NETWORK_BENCHMARK " + JsonSerializer.Serialize( - await MeasureOperationSessionAsync(mesh, target, arguments))); - }); - await Task.WhenAll(completion.Task, commandTask).WaitAsync(TimeSpan.FromSeconds(45)); -} - -static async Task MeasureOperationSessionAsync( - CultMeshClient mesh, - CultMeshSessionTarget target, - Args arguments) -{ - const int warmupOperations = 100; - if (arguments.BenchmarkOperations <= 0) - throw new ArgumentOutOfRangeException(nameof(arguments.BenchmarkOperations)); - for (var index = 0; index < warmupOperations; index++) - await PingAsync(mesh, target, "warmup:" + index, index); - - ForceCollection(); - using var process = Process.GetCurrentProcess(); - process.Refresh(); - var managedHeapBefore = GC.GetTotalMemory(forceFullCollection: false); - var privateBytesBefore = process.PrivateMemorySize64; - var latencies = new double[arguments.BenchmarkOperations]; - var clock = Stopwatch.StartNew(); - for (var index = 0; index < arguments.BenchmarkOperations; index++) - { - var started = Stopwatch.GetTimestamp(); - await PingAsync(mesh, target, "benchmark:" + index, index); - latencies[index] = Stopwatch.GetElapsedTime(started).TotalMilliseconds; - } - clock.Stop(); - ForceCollection(); - process.Refresh(); - var managedHeapAfter = GC.GetTotalMemory(forceFullCollection: false); - var privateBytesAfter = process.PrivateMemorySize64; - Array.Sort(latencies); - var p99 = latencies[Math.Clamp((int)Math.Ceiling(latencies.Length * 0.99) - 1, 0, latencies.Length - 1)]; - var managedHeapGrowth = managedHeapAfter - managedHeapBefore; - if (managedHeapGrowth > 8 * 1024 * 1024) - throw new InvalidOperationException($"Retained operation session grew the managed heap by {managedHeapGrowth} bytes."); - if (p99 > 250) - throw new InvalidOperationException($"Retained operation session p99 was {p99:F2} ms."); - return new - { - operations = arguments.BenchmarkOperations, - p99Milliseconds = p99, - operationsPerSecond = arguments.BenchmarkOperations / clock.Elapsed.TotalSeconds, - managedHeapBefore, - managedHeapAfter, - managedHeapGrowth, - privateBytesBefore, - privateBytesAfter, - privateBytesGrowth = privateBytesAfter - privateBytesBefore - }; -} - -static async Task PingAsync( - CultMeshClient mesh, - CultMeshSessionTarget target, - string idempotencyKey, - int sequence) -{ - var result = await mesh.InvokeAsync( - target, - "sample.counter", - "sample.counter.ping", - "sample.ping_request.v1", - "sample.ping_receipt.v1", - new PingRequest { Sequence = sequence }, - sourceRuntimeId: "sample.csharp", - idempotencyKey: idempotencyKey); - if (result.Status != "accepted" || result.Value.Sequence != sequence) - throw new InvalidOperationException("The typed ping receipt did not match its request."); -} - -static void ForceCollection() -{ - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); -} - -static EveProviderAdvertisementDocument CreateProviderAdvertisement(Args arguments) -{ - var worldInteraction = new EveWorldInteractionAdvertisement( - projectionKind: "surface-state", - stateSchemas: ["sample.counter_state.v1"], - commandBoundary: commandBoundary, - commandRecordRef: "", - receiptSchema: EveCommandReceiptDocument.SchemaId, - receiptRecordRef: "", - assetManifestRecordRef: "", - loweringTargets: ["browser"], - ownership: arguments.AuthorityRuntimeId); - return new EveProviderAdvertisementDocument( - providerId: providerKey, - serviceId: arguments.VerseId, - verseId: arguments.VerseId, - title: "CultMesh browser counter", - kind: "sample.daemon", - cultMeshAddress: arguments.AuthorityRuntimeId, - updatedAtUtc: DateTime.UtcNow.ToString("O"), - freshness: new EveProviderFreshness("live", DateTime.UtcNow.ToString("O"), 0), - schemas: ["sample.counter_state.v1", EveSurfaceDocument.SchemaId, EveCommandReceiptDocument.SchemaId], - witnesses: [], - surfaces: - [ - new EveAdvertisedSurface( - surfaceKey, - EveSurfaceDocument.SchemaId, - surfaceKey, - "cultmesh", - "live", - "surface", - worldInteraction) - ], - commands: [new EveAdvertisedCommand("sample.counter.increment", surfaceKey, "cultmesh", "Increment")]); -} - -static EveSurfaceDocument CreateCounterSurface() -{ - var networkRoute = new CultMeshRouteHint(CultMeshLocalityKind.Network, "cultmesh"); - var count = new CultMeshStateBindingDescriptor( - "value", - "sample.counter.count", - "sample.counter_state:counter:main", - "sample.counter_state.v1", - networkRoute); - var increment = CultMesh.OperationBinding( - "sample.counter.increment", - "Increment", - "sample.increment.v1", - networkRoute); - return EveSurface.Create(surfaceKey) - .Provider("sample.counter-ui", "sample.daemon") - .Title("CultMesh browser counter") - .Version(1) - .UpdatedAtUtc("2026-08-17T00:00:00Z") - .RootColumn("counter.root", root => root - .Title("counter.title", "CultMesh browser counter") - .Metric("counter.value", "Canonical count", "0", count) - .Button("counter.increment", "Increment", increment)) - .Build(); -} - -sealed class Args -{ - public string Mode { get; private init; } = ""; - public int Port { get; private init; } - public string StatePath { get; private init; } = "sample-counter.cc"; - public string OdinEndpoint { get; private init; } = ""; - public string ProviderEndpoint { get; private init; } = ""; - public string DecoyEndpoint { get; private init; } = ""; - public string Token { get; private init; } = "sample-session"; - public string VerseId { get; private init; } = "sample.counter"; - public string VerseName { get; private init; } = "CultMesh browser counter"; - public string AuthorityRuntimeId { get; private init; } = "sample.counter-daemon"; - public string DecoyAuthorityRuntimeId { get; private init; } = "sample.decoy-daemon"; - public string TransportVersion { get; private init; } = "cultmesh.v0"; - public string RulesHash { get; private init; } = "sample-counter-v1"; - public string RouteGeneration { get; private init; } = "sample-counter-route-1"; - public string DecoyRouteGeneration { get; private init; } = "sample-decoy-route-1"; - public int ExpectedCount { get; private init; } = 2; - public int BenchmarkOperations { get; private init; } = 10_000; - - public static Args Parse(string[] values) - { - if (values.Length == 0) return new Args(); - var named = new Dictionary(StringComparer.Ordinal); - for (var index = 1; index + 1 < values.Length; index += 2) - named[values[index]] = values[index + 1]; - return new Args - { - Mode = values[0], - Port = named.TryGetValue("--port", out var port) ? int.Parse(port) : 0, - StatePath = named.GetValueOrDefault("--state", "sample-counter.cc"), - OdinEndpoint = named.GetValueOrDefault("--odin", ""), - ProviderEndpoint = named.GetValueOrDefault("--provider-endpoint", ""), - DecoyEndpoint = named.GetValueOrDefault("--decoy-endpoint", ""), - Token = named.GetValueOrDefault("--token", "sample-session"), - VerseId = named.GetValueOrDefault("--verse-id", "sample.counter"), - VerseName = named.GetValueOrDefault("--verse-name", "CultMesh browser counter"), - AuthorityRuntimeId = named.GetValueOrDefault("--authority-runtime-id", "sample.counter-daemon"), - DecoyAuthorityRuntimeId = named.GetValueOrDefault("--decoy-authority-runtime-id", "sample.decoy-daemon"), - TransportVersion = named.GetValueOrDefault("--transport-version", "cultmesh.v0"), - RulesHash = named.GetValueOrDefault("--rules-hash", "sample-counter-v1"), - RouteGeneration = named.GetValueOrDefault("--route-generation", "sample-counter-route-1"), - DecoyRouteGeneration = named.GetValueOrDefault("--decoy-route-generation", "sample-decoy-route-1"), - ExpectedCount = named.TryGetValue("--expected-count", out var expectedCount) - ? int.Parse(expectedCount) - : 2, - BenchmarkOperations = named.TryGetValue("--benchmark-operations", out var benchmarkOperations) - ? int.Parse(benchmarkOperations) - : 10_000 - }; - } -} - -[CultDocument("sample.counter_state", "sample.counter_state.v1")] -[MessagePackObject] -public sealed class CounterState -{ - [Key(0)] public string CounterId { get; set; } = ""; - [Key(1)] public int Count { get; set; } - [Key(2)] public Dictionary Receipts { get; set; } = new(StringComparer.Ordinal); - public static CounterState Initial(string counterId) => new() { CounterId = counterId }; -} - -[MessagePackObject] -public sealed class IncrementRequest -{ - [Key("amount")] public int Amount { get; set; } = 1; -} - -[MessagePackObject] -public sealed class IncrementReceipt -{ - [Key("receiptId")] public string ReceiptId { get; set; } = ""; - [Key("schema")] public string Schema { get; set; } = "gamecult.eve.command_receipt.v1"; - [Key("commandId")] public string CommandId { get; set; } = ""; - [Key("command")] public string Command { get; set; } = "sample.counter.increment"; - [Key("state")] public string State { get; set; } = "accepted"; - [Key("ownerRepo")] public string OwnerRepo { get; set; } = "CultLib"; - [Key("authority")] public string Authority { get; set; } = "provider-daemon"; - [Key("providerId")] public string ProviderId { get; set; } = "sample.counter-ui"; - [Key("surfaceId")] public string SurfaceId { get; set; } = "sample.counter"; - [Key("issuedAtUtc")] public string IssuedAtUtc { get; set; } = ""; - [Key("sourceVersion")] public int SourceVersion { get; set; } - [Key("idempotencyKey")] public string IdempotencyKey { get; set; } = ""; - [Key("count")] public int Count { get; set; } - - public static IncrementReceipt Accepted(string id, int count) => new() - { - ReceiptId = $"receipt:{id}", - CommandId = id, - IssuedAtUtc = DateTimeOffset.UtcNow.ToString("O"), - SourceVersion = count, - IdempotencyKey = id, - Count = count - }; -} - -[MessagePackObject] -public sealed class PingRequest -{ - [Key("sequence")] public int Sequence { get; set; } -} - -[MessagePackObject] -public sealed class PingReceipt -{ - [Key("sequence")] public int Sequence { get; set; } -} diff --git a/samples/eve-browser-network/README.md b/samples/eve-browser-network/README.md deleted file mode 100644 index 445bfb9d..00000000 --- a/samples/eve-browser-network/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# Eve Browser + Headless Counter - -This is the real-process continuation of `samples/eve-two-runtime`: - -- one C# provider owns a durable `.cc` counter, the Eve surface, command - idempotency, and canonical receipts; -- one real Chromium page leases both documents through `cultmesh-browser` and - lowers the provider-owned Eve surface; -- one retained C# headless client discovers the provider through the same Odin - identity, observes the typed counter and canonical receipt ids, survives the - provider route replacement, and invokes through the generic CultMesh client - without application transport or reconnect code; -- one separate local Odin fixture answers the canonical CultMesh Verse catalog - over binary CultNet WebSocket messages; -- one wrong-authority provider is deliberately advertised on a better-priority - route, and both clients must ignore it because the route is bound to another - runtime; -- the browser and C# clients speak the same binary CultNet schema-v0 WebSocket - messages through the authenticated host adapter. - -The provider registers typed commands through `CultNetOperationServer`. -Application code receives `CultNetOperationContext` and returns a -typed receipt; it does not parse base64, switch on wire envelopes, or construct -correlation responses. The counter's durable transaction still owns -idempotency and state mutation. The provider opens one public `CultMeshNode`; -it does not assemble a parallel CultCache/CultNet database host by hand. - -Run the proof from the CultLib repository: - -```powershell -pwsh -File ./scripts/verify-eve-getting-started.ps1 -``` - -The wrapper first proves clean package consumption, then runs this real-process -checkpoint. Run `node scripts/verify-eve-browser-network.mjs --eve-root ../Eve` -directly only to diagnose the network layer. Browser discovery is portable: -`CHROME_PATH` wins when configured, then common Chrome/Chromium/Edge locations -and Playwright's installed Chromium are checked. - -The verifier clicks the Eve button twice with one idempotency key and requires -one state transition. It then restarts the provider on a different physical -port, updates the local Odin fixture, and requires both retained clients to -rediscover and resubscribe. The C# client invokes the second command through -`CultMeshClient.InvokeAsync`; Chromium observes the resulting count and both -clients observe both canonical receipt ids. -The retained C# client then sends 10,000 typed no-op operations over the same -real WebSocket session. The gate records p99 latency, throughput, managed heap, -and private memory, and rejects more than 8 MiB of post-GC managed growth or a -250 ms p99. The no-op provider handler stores no receipt history, so persistence -growth cannot impersonate a client leak. -The fixture speaks the production Verse-catalog contract; a deployment smoke -against the full Odin daemon remains a separate infrastructure gate. - -The `odin` fixture defaults to this sample's Verse, but its `--verse-id`, -`--verse-name`, `--authority-runtime-id`, `--transport-version`, and -`--rules-hash` arguments can advertise another real provider during a product -route-rotation witness. The provider endpoint remains data owned by Odin; the -consumer still connects only by stable Verse and provider identity. diff --git a/samples/eve-browser-network/browser/index.html b/samples/eve-browser-network/browser/index.html deleted file mode 100644 index d23ad8ab..00000000 --- a/samples/eve-browser-network/browser/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - CultMesh Eve browser counter - - -
Connecting to the Verse…
- - - - diff --git a/samples/eve-browser-network/browser/main.ts b/samples/eve-browser-network/browser/main.ts deleted file mode 100644 index fac4cc43..00000000 --- a/samples/eve-browser-network/browser/main.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { parseEveCommandReceipt, parseEveProviderAdvertisement, parseEveSurfaceDocument } from "@gamecult/eve-contracts"; -import { renderEveSurface } from "@gamecult/eve-browser-lowering"; -import { - CultMeshBrowserClient, - CultMeshBrowserOdinRendezvous, - decodeCultNetOperationPayload, - decodeCultNetPayload, -} from "cultmesh-browser"; - -// CounterState is an integer-keyed MessagePack object: [counterId, count, receipts]. -const counterCount = (record: Parameters[0]) => - decodeCultNetPayload<[string, number, unknown]>(record)[1]; - -declare global { - interface Window { - __sampleReady?: boolean; - __sampleCount?: number; - __sampleReceipt?: { receiptId: string; count: number }; - __sampleCommandId?: string; - __sampleConnectionStates?: string[]; - __sampleError?: string; - } -} - -const params = new URLSearchParams(location.search); -const odinEndpoint = params.get("odin"); -const token = params.get("token") || "sample-session"; -if (!odinEndpoint) throw new Error("The sample requires an Odin endpoint query parameter."); -document.cookie = `cultnet_session=${encodeURIComponent(token)}; Path=/; SameSite=Strict`; - -const host = document.querySelector("#surface")!; -const receiptOutput = document.querySelector("#receipt")!; - -try { - const mesh = await CultMeshBrowserClient.connect({ - verseId: "sample.counter", - authorityRuntimeId: "sample.counter-daemon", - runtimeId: "sample.chromium", - trust: { mode: "local-development" }, - rendezvous: new CultMeshBrowserOdinRendezvous({ - endpoints: [odinEndpoint], - runtimeId: "sample.chromium.discovery", - transportVersion: "cultmesh.v0", - }), - }); - window.__sampleConnectionStates = []; - mesh.watchState(state => window.__sampleConnectionStates!.push(state)); - window.__sampleCommandId = "browser-click-1"; - const counter = await mesh.leaseRawDocument({ - schemaId: "sample.counter_state.v1", - recordKey: "counter:main", - subscriptionId: "browser-counter", - }); - const surface = await mesh.leaseRawDocument({ - schemaId: "gamecult.eve.surface.v1", - recordKey: "sample.counter", - subscriptionId: "browser-surface", - }); - // The provider owns the declaration of where this surface's commands go and - // what comes back; the page reads it rather than restating it. - const advertisement = await mesh.leaseRawDocument({ - schemaId: "gamecult.eve.provider_advertisement.v1", - recordKey: "sample.counter-ui", - subscriptionId: "browser-provider", - }); - const provider = parseEveProviderAdvertisement(decodeCultNetPayload(advertisement.current!)); - const currentSurface = parseEveSurfaceDocument(decodeCultNetPayload(surface.current!)); - renderEveSurface(currentSurface, host, { - activeSurfaceId: "sample.counter", - clientId: "sample.chromium", - provider, - commandSink: async intent => { - const response = await mesh.invoke({ - serviceId: "sample.counter", - operation: intent.operation.operationId, - payloadSchema: "sample.increment.v1", - payload: { amount: 1 }, - idempotencyKey: window.__sampleCommandId, - }); - const receipt = parseEveCommandReceipt(decodeCultNetOperationPayload(response)) as { - receiptId: string; - count: number; - }; - window.__sampleReceipt = receipt; - receiptOutput.textContent = receipt.receiptId; - }, - stateBindingResolver: async binding => { - if (binding.pointerId !== "sample.counter.count") return undefined; - return { - latest: async () => counterCount(counter.current!), - watch: callback => counter.watch(record => { - if (!record) return; - const count = counterCount(record); - window.__sampleCount = count; - callback(count); - }), - }; - }, - }); - window.__sampleCount = counterCount(counter.current!); - window.__sampleReady = true; -} catch (error) { - const details = error instanceof AggregateError - ? error.errors.map(inner => inner instanceof Error ? inner.stack || inner.message : String(inner)).join("\n---\n") - : ""; - window.__sampleError = (error instanceof Error ? error.stack || error.message : String(error)) - + (details ? `\n${details}` : ""); - host.textContent = window.__sampleError; - throw error; -} diff --git a/samples/eve-two-runtime/README.md b/samples/eve-two-runtime/README.md deleted file mode 100644 index 0981ad1a..00000000 --- a/samples/eve-two-runtime/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# Eve Two-Consumer Counter - -This is the smallest executable package-consumer checkpoint for CultMesh plus -Eve. It intentionally has one owner for each decision: - -- the provider node owns the counter document and command transaction; -- Eve owns the validated surface and command-receipt contracts; -- CultMesh owns typed observation and the operation boundary; -- the DOM lowerer owns HTML only; -- the headless observer owns no state. - -Run it from the CultLib repository: - -```powershell -pwsh -File ./scripts/verify-eve-getting-started.ps1 -``` - -That command continues into the real Chromium/C# network checkpoint after this -artifact layer passes. Run `verify-eve-two-runtime-sample.ps1` directly only to -diagnose clean package consumption in isolation. - -From clean CultLib and Eve checkouts, the verifier installs missing build -dependencies, builds package artifacts, installs them into a new empty -temporary consumer, and runs `sample.mjs`. Success proves: - -1. no source-tree import is required by the consumer; -2. the provider surface and receipt pass Eve's generated runtime validators; -3. a DOM click executes one idempotent provider operation; -4. the DOM binding and a separate headless observer converge on the same count; -5. the canonical receipt and state survive reopening the `.cc` store. - -This artifact checkpoint is deliberately not the network proof. The DOM lowerer -is hosted by jsdom in Node and both consumers share one local CultMesh node. The -same wrapper continues into a separate real Chromium/C# process chronology with -Odin discovery, route replacement, and persistent provider state. Keeping those -evidence layers separate prevents a fast package test from impersonating the -network. diff --git a/samples/eve-two-runtime/sample.mjs b/samples/eve-two-runtime/sample.mjs deleted file mode 100644 index 93ed1dc8..00000000 --- a/samples/eve-two-runtime/sample.mjs +++ /dev/null @@ -1,247 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtemp } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { JSDOM } from "jsdom"; -import { z } from "zod"; -import { defineDocumentType } from "@gamecult/cultcache-ts"; -import { CultMesh } from "cultmesh-ts"; -import { - parseEveCommandReceipt, - parseEveSurfaceDocument, -} from "@gamecult/eve-contracts"; -import { renderEveSurface } from "@gamecult/eve-browser-lowering"; - -const providerId = "sample.counter-provider"; -const surfaceId = "sample.counter"; -const stateKey = "counter:main"; - -const counterDocument = defineDocumentType({ - type: "sample.counter_state", - schemaId: "sample.counter_state.v1", - schema: z.object({ counterId: z.string(), count: z.number().int() }), - name: "counterId", -}); - -const surfaceDocument = defineDocumentType({ - type: "gamecult.eve.surface", - schemaId: "gamecult.eve.surface.v1", - schema: z.unknown().transform(value => parseEveSurfaceDocument(value)), -}); - -const receiptDocument = defineDocumentType({ - type: "gamecult.eve.command_receipt", - schemaId: "gamecult.eve.command_receipt.v1", - schema: z.unknown().transform(value => parseEveCommandReceipt(value)), - name: "receiptId", -}); - -function counterSurface() { - return { - type: "surface-state", - schema: "gamecult.eve.surface.v1", - providerId, - providerKind: "sample.daemon", - title: "CultMesh two-runtime counter", - version: 1, - updatedAtUtc: "2026-08-17T00:00:00Z", - surface: { - id: surfaceId, - title: "Counter", - root: { - id: "counter.root", - kind: "column", - props: {}, - children: [ - { - id: "counter.value", - kind: "metric", - props: { label: "Canonical count", value: 0 }, - children: [], - stateBindings: [{ - targetProp: "value", - pointerId: "sample.counter.count", - sourceId: `${counterDocument.type}:${stateKey}`, - schemaId: counterDocument.schemaId, - routeKind: "shared-memory", - }], - }, - { - id: "counter.increment", - kind: "control.button", - props: { label: "Increment", command: "sample.counter.increment" }, - children: [], - }, - ], - }, - styles: {}, - }, - commands: [{ - schema: "gamecult.eve.command.v1", - command: "sample.counter.increment", - label: "Increment", - surfaceId, - transport: "cultmesh", - authority: "provider-daemon", - result: "gamecult.eve.command_receipt.v1", - }], - }; -} - -function installDom() { - const dom = new JSDOM("
", { - url: "https://eve.sample/", - pretendToBeVisual: true, - }); - for (const name of [ - "document", - "window", - "Element", - "HTMLElement", - "HTMLButtonElement", - "HTMLInputElement", - "HTMLTextAreaElement", - "HTMLImageElement", - ]) globalThis[name] = dom.window[name]; - return dom; -} - -async function waitFor(predicate, description) { - const deadline = Date.now() + 2_000; - while (!predicate()) { - if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${description}.`); - await new Promise(resolve => setTimeout(resolve, 5)); - } -} - -assert.throws( - () => parseEveSurfaceDocument({ providerId, surface: { id: surfaceId } }), - /Invalid Eve surface/, -); -assert.throws( - () => parseEveCommandReceipt({ receiptId: "unowned" }), - /Invalid Eve commandReceipt/, -); - -const statePath = join(await mkdtemp(join(tmpdir(), "cultmesh-eve-two-runtime-")), "state.cc"); -const node = await CultMesh.startNode(statePath, { - documents: [counterDocument, surfaceDocument, receiptDocument], -}); -await node.put(counterDocument, stateKey, { counterId: stateKey, count: 0 }); -await node.put(surfaceDocument, surfaceId, counterSurface()); -await node.flush(); - -const state = node.document(counterDocument, stateKey, { pollMs: 5 }); -const headless = state.observe({ context: "headless-client" }); -await headless.ready; -const receiptsByIdempotencyKey = new Map(); -const increment = CultMesh.operation("sample.counter.increment", async (_request, context) => { - const idempotencyKey = context.idempotencyKey || "missing"; - const existing = receiptsByIdempotencyKey.get(idempotencyKey) - || node.get(receiptDocument, `receipt:${idempotencyKey}`); - if (existing) return existing; - const next = await state.authoritativeWriter("provider-daemon").update(current => ({ - ...current, - count: current.count + 1, - })); - const receipt = { - receiptId: `receipt:${idempotencyKey}`, - schema: "gamecult.eve.command_receipt.v1", - commandId: idempotencyKey, - command: "sample.counter.increment", - state: "accepted", - ownerRepo: "CultLib", - authority: "provider-daemon", - providerId, - surfaceId, - issuedAtUtc: new Date().toISOString(), - sourceVersion: next.count, - idempotencyKey, - count: next.count, - }; - receiptsByIdempotencyKey.set(idempotencyKey, receipt); - await node.put(receiptDocument, receipt.receiptId, receipt); - await node.flush(); - return receipt; -}); - -const dom = installDom(); -const host = document.querySelector("#surface"); -const browserVerse = CultMesh.verse("local", "browser-client"); -let browserReceipt; -renderEveSurface(await node.document(surfaceDocument, surfaceId).latest(), host, { - activeSurfaceId: surfaceId, - clientId: "browser-client", - // Eve refuses to build a command intent for a surface that has not - // advertised where its commands go and what comes back. The provider - // advertisement is that declaration; a lowering cannot infer it. - provider: { - providerId, - surfaces: [{ - surfaceId, - worldInteraction: { - commandBoundary: "sample.counter.commands", - receiptSchema: "gamecult.eve.command_receipt.v1", - }, - }], - }, - commandSink: async intent => { - browserReceipt = await increment.bind(browserVerse).invoke(intent.payload, { - idempotencyKey: "browser-click-1", - }); - }, - stateBindingResolver: async binding => { - if (binding.pointerId !== "sample.counter.count") return undefined; - return { - latest: async () => (await state.latest("browser-client")).count, - watch: callback => state.watch("browser-client", value => callback(value.count)), - }; - }, -}); - -await waitFor(() => host.querySelector(".metric-value")?.textContent === "0", "initial browser binding"); -host.querySelector("button").click(); -await waitFor(() => browserReceipt?.count === 1, "typed command receipt"); -await waitFor(() => headless.current.count === 1, "headless convergence"); -await waitFor(() => host.querySelector(".metric-value")?.textContent === "1", "browser convergence"); - -const duplicate = await increment.bind(browserVerse).invoke({}, { idempotencyKey: "browser-click-1" }); -assert.equal(duplicate.receiptId, browserReceipt.receiptId); -assert.equal(headless.current.count, 1); - -headless.dispose(); -renderEveSurface(parseEveSurfaceDocument({ - type: "surface-state", - schema: "gamecult.eve.surface.v1", - providerId, - providerKind: "sample.daemon", - title: "Closed counter", - version: 2, - updatedAtUtc: "2026-08-17T00:00:01Z", - surface: { - id: "sample.closed", - root: { id: "closed.root", kind: "column", props: {}, children: [] }, - styles: {}, - }, - commands: [], -}), host); -dom.window.close(); -await node.flush(); -const reopened = await CultMesh.startNode(statePath, { - documents: [counterDocument, surfaceDocument, receiptDocument], -}); -assert.equal(reopened.getRequired(counterDocument, stateKey).count, 1); -assert.equal( - reopened.getRequired(receiptDocument, "receipt:browser-click-1").receiptId, - browserReceipt.receiptId, -); - -console.log(JSON.stringify({ - providerId, - surfaceId, - receiptId: browserReceipt.receiptId, - loweringRuntime: "jsdom", - browserCount: 1, - headlessCount: 1, - restartCount: 1, -})); diff --git a/scripts/build-quic-native.ps1 b/scripts/build-quic-native.ps1 deleted file mode 100644 index 68496180..00000000 --- a/scripts/build-quic-native.ps1 +++ /dev/null @@ -1,61 +0,0 @@ -param( - [string] $Configuration = "Release", - [string] $Architecture = "x64", - [string] $OutputDirectory = "artifacts\quic-native" -) - -$ErrorActionPreference = "Stop" -if ($Architecture -ne "x64") { - throw "The first CultMesh native QUIC package supports x64 Windows desktop only." -} - -$repoRoot = Split-Path -Parent $PSScriptRoot -$version = "2.5.9" -$expectedPackageSha256 = "9877A919E1AA73AA4800F1E8A06B6539021F376228910138EB04540CD956617B" -$cacheRoot = Join-Path $repoRoot "artifacts\dependencies\msquic-schannel-$version" -$packagePath = Join-Path $cacheRoot "Microsoft.Native.Quic.MsQuic.Schannel.$version.zip" -$extractRoot = Join-Path $cacheRoot "package" -$nativeRoot = Join-Path $extractRoot "build\native" -$buildRoot = Join-Path $repoRoot "artifacts\quic-native-build\$Architecture\$Configuration" -$outputRoot = if ([IO.Path]::IsPathRooted($OutputDirectory)) { - $OutputDirectory -} else { - Join-Path $repoRoot $OutputDirectory -} - -New-Item -ItemType Directory -Force -Path $cacheRoot | Out-Null -if (-not (Test-Path -LiteralPath $packagePath)) { - & curl.exe -L --fail --silent --show-error ` - -o $packagePath ` - "https://www.nuget.org/api/v2/package/Microsoft.Native.Quic.MsQuic.Schannel/$version" - if ($LASTEXITCODE -ne 0) { throw "MsQuic package download failed with exit code $LASTEXITCODE" } -} -$sha256 = [Security.Cryptography.SHA256]::Create() -try { - $packageBytes = [IO.File]::ReadAllBytes($packagePath) - $packageSha256 = [BitConverter]::ToString($sha256.ComputeHash($packageBytes)).Replace("-", "") -} finally { - $sha256.Dispose() -} -if ($packageSha256 -ne $expectedPackageSha256) { - throw "MsQuic package digest does not match the pinned release." -} -if (-not (Test-Path -LiteralPath (Join-Path $nativeRoot "include\msquic.h"))) { - if (Test-Path -LiteralPath $extractRoot) { Remove-Item -LiteralPath $extractRoot -Recurse -Force } - Expand-Archive -LiteralPath $packagePath -DestinationPath $extractRoot -} - -cmake -S (Join-Path $repoRoot "native\GameCult.Mesh.Quic.Native") -B $buildRoot -A x64 ` - "-DMSQUIC_ROOT=$($nativeRoot.Replace('\', '/'))" -if ($LASTEXITCODE -ne 0) { throw "CultMesh native QUIC configure failed with exit code $LASTEXITCODE" } -cmake --build $buildRoot --config $Configuration -if ($LASTEXITCODE -ne 0) { throw "CultMesh native QUIC build failed with exit code $LASTEXITCODE" } - -New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null -Copy-Item -LiteralPath (Join-Path $buildRoot "bin\$Configuration\gamecult_mesh_quic_native.dll") ` - -Destination $outputRoot -Force -Copy-Item -LiteralPath (Join-Path $nativeRoot "bin\x64\msquic.dll") -Destination $outputRoot -Force -Copy-Item -LiteralPath (Join-Path $extractRoot "LICENSE") -Destination (Join-Path $outputRoot "MSQUIC-LICENSE.txt") -Force - -Write-Host "CultMesh native QUIC runtime: $outputRoot" -Get-ChildItem -LiteralPath $outputRoot -File | Select-Object Name,Length diff --git a/scripts/build-unity-package.ps1 b/scripts/build-unity-package.ps1 index 804f78df..e5cc82aa 100644 --- a/scripts/build-unity-package.ps1 +++ b/scripts/build-unity-package.ps1 @@ -1,22 +1,14 @@ param( [string] $Configuration = "Release", - [string] $OutputDirectory = "artifacts\unity\org.gamecult.cultlib", - [string] $NuGetConfig = "", - [switch] $NoRestore, - [switch] $UpdateTemplate + [string] $OutputDirectory = "artifacts\unity\org.gamecult.cultlib" ) $ErrorActionPreference = "Stop" $repoRoot = Split-Path -Parent $PSScriptRoot $projectPath = Join-Path $repoRoot "src\GameCult.Mesh\GameCult.Mesh.csproj" -$webSocketProjectPath = Join-Path $repoRoot "src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj" -$quicProjectPath = Join-Path $repoRoot "src\GameCult.Mesh.Quic.Native\GameCult.Mesh.Quic.Native.csproj" $templateRoot = Join-Path $repoRoot "unity\org.gamecult.cultlib" $publishRoot = Join-Path $repoRoot "artifacts\unity-publish" -$webSocketPublishRoot = Join-Path $repoRoot "artifacts\unity-websocket-publish" -$quicPublishRoot = Join-Path $repoRoot "artifacts\unity-quic-publish" -$quicNativeRoot = Join-Path $repoRoot "artifacts\unity-quic-native" $outputRoot = if ([System.IO.Path]::IsPathRooted($OutputDirectory)) { $OutputDirectory } else { @@ -27,47 +19,14 @@ $pluginRoot = Join-Path $outputRoot "Runtime\Plugins" if (Test-Path -LiteralPath $publishRoot) { Remove-Item -LiteralPath $publishRoot -Recurse -Force } -if (Test-Path -LiteralPath $quicPublishRoot) { - Remove-Item -LiteralPath $quicPublishRoot -Recurse -Force -} -if (Test-Path -LiteralPath $webSocketPublishRoot) { - Remove-Item -LiteralPath $webSocketPublishRoot -Recurse -Force -} if (Test-Path -LiteralPath $outputRoot) { Remove-Item -LiteralPath $outputRoot -Recurse -Force } -$unityPackageVersion = (Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json).version -$publishArguments = @("publish", $projectPath, "-c", $Configuration, "-o", $publishRoot, "-p:CultLibPackageVersion=$unityPackageVersion") -if ($NoRestore) { $publishArguments += "--no-restore" } -if (-not [string]::IsNullOrWhiteSpace($NuGetConfig)) { - $publishArguments += "-p:RestoreConfigFile=$([IO.Path]::GetFullPath($NuGetConfig))" -} -& dotnet @publishArguments +dotnet publish $projectPath -c $Configuration -o $publishRoot if ($LASTEXITCODE -ne 0) { throw "CultLib publish failed with exit code $LASTEXITCODE" } -$webSocketPublishArguments = @("publish", $webSocketProjectPath, "-c", $Configuration, "-f", "netstandard2.1", "-o", $webSocketPublishRoot, "-p:CultLibPackageVersion=$unityPackageVersion") -if ($NoRestore) { $webSocketPublishArguments += "--no-restore" } -if (-not [string]::IsNullOrWhiteSpace($NuGetConfig)) { - $webSocketPublishArguments += "-p:RestoreConfigFile=$([IO.Path]::GetFullPath($NuGetConfig))" -} -& dotnet @webSocketPublishArguments -if ($LASTEXITCODE -ne 0) { - throw "CultLib WebSocket publish failed with exit code $LASTEXITCODE" -} -$quicPublishArguments = @("publish", $quicProjectPath, "-c", $Configuration, "-o", $quicPublishRoot, "-p:CultLibPackageVersion=$unityPackageVersion") -if ($NoRestore) { $quicPublishArguments += "--no-restore" } -if (-not [string]::IsNullOrWhiteSpace($NuGetConfig)) { - $quicPublishArguments += "-p:RestoreConfigFile=$([IO.Path]::GetFullPath($NuGetConfig))" -} -& dotnet @quicPublishArguments -if ($LASTEXITCODE -ne 0) { - throw "CultLib native QUIC managed publish failed with exit code $LASTEXITCODE" -} -& (Join-Path $PSScriptRoot "build-quic-native.ps1") ` - -Configuration $Configuration ` - -OutputDirectory $quicNativeRoot New-Item -ItemType Directory -Force -Path $pluginRoot | Out-Null Copy-Item -LiteralPath (Join-Path $templateRoot "package.json") -Destination $outputRoot @@ -79,9 +38,7 @@ $expectedAssemblies = @( "GameCult.Caching.MessagePack.dll", "GameCult.Logging.dll", "GameCult.Mesh.dll", - "GameCult.Mesh.Quic.Native.dll", "GameCult.Networking.dll", - "GameCult.Networking.WebSockets.dll", "Isopoh.Cryptography.Argon2.dll", "Isopoh.Cryptography.Blake2b.dll", "Isopoh.Cryptography.SecureArray.dll", @@ -104,12 +61,6 @@ $publishedByName = @{} foreach ($assembly in Get-ChildItem -LiteralPath $publishRoot -Filter "*.dll") { $publishedByName[$assembly.Name] = $assembly } -foreach ($assembly in Get-ChildItem -LiteralPath $quicPublishRoot -Filter "*.dll") { - $publishedByName[$assembly.Name] = $assembly -} -foreach ($assembly in Get-ChildItem -LiteralPath $webSocketPublishRoot -Filter "*.dll") { - $publishedByName[$assembly.Name] = $assembly -} foreach ($assemblyName in $expectedAssemblies) { if (-not $publishedByName.ContainsKey($assemblyName)) { throw "CultLib Unity package is missing expected assembly: $assemblyName" @@ -126,59 +77,8 @@ foreach ($assemblyName in $expectedAssemblies) { Copy-Item -LiteralPath $pdb -Destination $pluginRoot } } -$nativePluginRoot = Join-Path $pluginRoot "x86_64" -New-Item -ItemType Directory -Force -Path $nativePluginRoot | Out-Null -Copy-Item -LiteralPath (Join-Path $quicNativeRoot "gamecult_mesh_quic_native.dll") -Destination $nativePluginRoot -Copy-Item -LiteralPath (Join-Path $quicNativeRoot "msquic.dll") -Destination $nativePluginRoot -New-Item -ItemType Directory -Force -Path (Join-Path $outputRoot "Third Party Notices") | Out-Null -Copy-Item -LiteralPath (Join-Path $quicNativeRoot "MSQUIC-LICENSE.txt") ` - -Destination (Join-Path $outputRoot "Third Party Notices\MSQUIC-LICENSE.txt") - -$meshPackagePath = Join-Path $pluginRoot "GameCult.Mesh.dll" -$meshPackageVersion = [Reflection.AssemblyName]::GetAssemblyName($meshPackagePath).Version.ToString() -if ($meshPackageVersion -ne "$unityPackageVersion.0") { - throw "Packaged GameCult.Mesh.dll version '$meshPackageVersion' does not match package '$unityPackageVersion'." -} -$meshPackageMetadata = [Text.Encoding]::UTF8.GetString([IO.File]::ReadAllBytes($meshPackagePath)) -foreach ($requiredType in @( - "GameCult.Mesh.CultMeshAuthorityTrustPolicy", - "GameCult.Mesh.CultMeshRouteCertificate", - "GameCult.Mesh.CultMeshHttpsContentTransportConnector")) { - if (-not $meshPackageMetadata.Contains($requiredType.Split('.')[-1])) { - throw "Packaged GameCult.Mesh.dll is missing required API '$requiredType'." - } -} -$webSocketPackageMetadata = [Text.Encoding]::UTF8.GetString( - [IO.File]::ReadAllBytes((Join-Path $pluginRoot "GameCult.Networking.WebSockets.dll"))) -if (-not $webSocketPackageMetadata.Contains("CultNetWebSocketSchemaClient")) { - throw "Packaged WebSocket assembly is missing the Unity-compatible CultNet client." -} - -if ($UpdateTemplate) { - $templatePluginRoot = Join-Path $templateRoot "Runtime\Plugins" - foreach ($assemblyName in $expectedAssemblies) { - Copy-Item -LiteralPath (Join-Path $pluginRoot $assemblyName) -Destination $templatePluginRoot -Force - $pdbName = [System.IO.Path]::ChangeExtension($assemblyName, ".pdb") - $pdb = Join-Path $pluginRoot $pdbName - if (Test-Path -LiteralPath $pdb) { - Copy-Item -LiteralPath $pdb -Destination $templatePluginRoot -Force - } - } - $templateNativePluginRoot = Join-Path $templatePluginRoot "x86_64" - New-Item -ItemType Directory -Force -Path $templateNativePluginRoot | Out-Null - Copy-Item -LiteralPath (Join-Path $nativePluginRoot "gamecult_mesh_quic_native.dll") ` - -Destination $templateNativePluginRoot -Force - Copy-Item -LiteralPath (Join-Path $nativePluginRoot "msquic.dll") ` - -Destination $templateNativePluginRoot -Force - $templateNoticesRoot = Join-Path $templateRoot "Third Party Notices" - New-Item -ItemType Directory -Force -Path $templateNoticesRoot | Out-Null - Copy-Item -LiteralPath (Join-Path $outputRoot "Third Party Notices\MSQUIC-LICENSE.txt") ` - -Destination $templateNoticesRoot -Force - Write-Host "Updated tracked Unity package assemblies: $templatePluginRoot" -} $manifest = Get-Content -LiteralPath (Join-Path $outputRoot "package.json") -Raw | ConvertFrom-Json Write-Host "CultLib Unity package: $outputRoot" Write-Host "Package: $($manifest.name)@$($manifest.version)" Write-Host "Managed assemblies: $($expectedAssemblies.Count)" -Write-Host "Native realtime: MsQuic Schannel 2.5.9 (Windows x64)" diff --git a/scripts/measure-reactive-document-performance.mjs b/scripts/measure-reactive-document-performance.mjs deleted file mode 100644 index 786cb3ed..00000000 --- a/scripts/measure-reactive-document-performance.mjs +++ /dev/null @@ -1,141 +0,0 @@ -import { monitorEventLoopDelay, performance } from "node:perf_hooks"; -import process from "node:process"; - -import { CultMesh } from "../packages/cultmesh-ts/dist/index.js"; - -const quick = process.argv.includes("--quick"); -const idleMs = quick ? 250 : 10_000; -const activeMs = quick ? 500 : 10_000; -const results = []; - -for (const documentCount of [1, 100, 1000]) { - results.push(await measure(documentCount)); -} - -console.log(JSON.stringify({ - runtime: "typescript", - workload: { - documentCounts: [1, 100, 1000], - payloadBytes: 16 * 1024, - idleSeconds: idleMs / 1000, - activeSeconds: activeMs / 1000, - updateRateHz: 60, - changedFraction: 0.01, - }, - results, -}, undefined, 2)); - -if (results.some(result => - result.actualPublishes !== result.expectedPublishes || - result.p99PublishLatencyMilliseconds >= 250 || - result.activeHeapPeakGrowthBytes >= 128 * 1024 * 1024)) { - process.exitCode = 1; -} - -async function measure(documentCount) { - const payload = "x".repeat(16 * 1024); - const changedDocumentCount = Math.max(1, Math.floor(documentCount / 100)); - const reactiveDocuments = []; - const publishSignals = new Array(documentCount); - const updateStartedAt = new Float64Array(documentCount); - const latencies = []; - let payloadBytesPublished = 0; - let publishes = 0; - - for (let index = 0; index < documentCount; index++) { - let current = { id: `performance:${index}`, payload, revision: 1 }; - let watcher; - const document = CultMesh.document( - `performance:${index}`, - { schemaId: "gamecult.mesh.performance_probe.v1" }, - async () => current, - { - submitPrediction: async (_context, value) => { - payloadBytesPublished += Buffer.byteLength(JSON.stringify(value)); - latencies.push(performance.now() - updateStartedAt[index]); - publishes++; - current = value; - watcher?.(value); - publishSignals[index]?.(); - }, - watchDocument: (_context, callback) => { - watcher = callback; - return () => { watcher = undefined; }; - }, - }, - ); - const reactive = document.predictionWriter().reactive(); - reactiveDocuments.push(reactive); - } - - await Promise.all(reactiveDocuments.map(document => document.ready)); - collect(); - const idleHeapStart = process.memoryUsage().heapUsed; - const idleCpuStart = process.cpuUsage(); - await delay(idleMs); - collect(); - const idleHeapGrowthBytes = process.memoryUsage().heapUsed - idleHeapStart; - const idleCpu = process.cpuUsage(idleCpuStart); - - collect(); - const activeHeapStart = process.memoryUsage().heapUsed; - let activeHeapPeak = activeHeapStart; - const activeCpuStart = process.cpuUsage(); - const eventLoop = monitorEventLoopDelay({ resolution: 10 }); - eventLoop.enable(); - const activeStartedAt = performance.now(); - let frames = 0; - while (performance.now() - activeStartedAt < activeMs) { - const completions = []; - for (let index = 0; index < changedDocumentCount; index++) { - completions.push(new Promise(resolve => { publishSignals[index] = resolve; })); - updateStartedAt[index] = performance.now(); - reactiveDocuments[index].update(value => { value.revision++; }); - } - await Promise.all(completions); - frames++; - activeHeapPeak = Math.max(activeHeapPeak, process.memoryUsage().heapUsed); - const remaining = frames * (1000 / 60) - (performance.now() - activeStartedAt); - if (remaining > 0) await delay(remaining); - } - eventLoop.disable(); - const activeElapsedSeconds = (performance.now() - activeStartedAt) / 1000; - const activeCpu = process.cpuUsage(activeCpuStart); - const ordered = latencies.toSorted((left, right) => left - right); - - for (const document of reactiveDocuments) document.dispose(); - - return { - documentCount, - changedDocumentCount, - frames, - expectedPublishes: frames * changedDocumentCount, - actualPublishes: publishes, - payloadBytesPublished, - idleHeapGrowthBytes, - idleCpuMilliseconds: (idleCpu.user + idleCpu.system) / 1000, - activeHeapPeakGrowthBytes: activeHeapPeak - activeHeapStart, - activeCpuMilliseconds: (activeCpu.user + activeCpu.system) / 1000, - publishedPayloadBytesPerSecond: payloadBytesPublished / activeElapsedSeconds, - p50PublishLatencyMilliseconds: percentile(ordered, 0.50), - p95PublishLatencyMilliseconds: percentile(ordered, 0.95), - p99PublishLatencyMilliseconds: percentile(ordered, 0.99), - p99EventLoopDelayMilliseconds: eventLoop.percentile(99) / 1_000_000, - }; -} - -function percentile(ordered, ratio) { - if (ordered.length === 0) return 0; - return ordered[Math.max(0, Math.ceil(ordered.length * ratio) - 1)]; -} - -function collect() { - if (typeof globalThis.gc !== "function") { - throw new Error("Run this probe with node --expose-gc."); - } - globalThis.gc(); -} - -function delay(milliseconds) { - return new Promise(resolve => setTimeout(resolve, milliseconds)); -} diff --git a/scripts/measure-reactive-document-performance.ps1 b/scripts/measure-reactive-document-performance.ps1 deleted file mode 100644 index c0f44eaf..00000000 --- a/scripts/measure-reactive-document-performance.ps1 +++ /dev/null @@ -1,88 +0,0 @@ -param( - [switch] $Quick, - [switch] $SkipRuntimeBuild, - [string] $PythonPath = "", - [string] $NodePath = "" -) - -$ErrorActionPreference = "Stop" -$root = Split-Path -Parent $PSScriptRoot -$nodeExecutable = if ([string]::IsNullOrWhiteSpace($NodePath)) { - (Get-Command node -ErrorAction Stop).Source -} -else { - [IO.Path]::GetFullPath($NodePath) -} -$pythonExecutable = if ([string]::IsNullOrWhiteSpace($PythonPath)) { - (Get-Command python -ErrorAction Stop).Source -} -else { - [IO.Path]::GetFullPath($PythonPath) -} -foreach ($executable in @($nodeExecutable, $pythonExecutable)) { - if (-not (Test-Path -LiteralPath $executable -PathType Leaf)) { - throw "Performance probe runtime not found: $executable" - } -} - -if (-not $SkipRuntimeBuild) { - $typescript = Join-Path $root "node_modules\typescript\bin\tsc" - foreach ($dependency in @("cultcache-ts", "cultnet-ts", "cultmesh-ts")) { - $dependencyRoot = Join-Path $root "packages\$dependency" - if ($dependency -eq "cultnet-ts") { - & $nodeExecutable (Join-Path $dependencyRoot "tools\generate-swarm-contracts.mjs") - if ($LASTEXITCODE -ne 0) { throw "CultNet TypeScript contract generation failed." } - } - & $nodeExecutable $typescript -p (Join-Path $dependencyRoot "tsconfig.json") --pretty false - if ($LASTEXITCODE -ne 0) { throw "TypeScript performance dependency build failed: $dependency" } - } -} - -$project = Join-Path $root "tools\GameCult.Mesh.PerformanceProbe\GameCult.Mesh.PerformanceProbe.csproj" -$buildArguments = @( - "build", - $project, - "--configuration", "Release", - "--verbosity", "quiet", - "/p:NoWarn=1591%3BCS8632" -) -& dotnet @buildArguments -if ($LASTEXITCODE -ne 0) { - throw "CultMesh reactive document performance probe build failed." -} - -$arguments = @("run", "--project", $project, "--configuration", "Release", "--no-build") -if ($Quick) { - $arguments += @("--", "--quick") -} - -& dotnet @arguments -if ($LASTEXITCODE -ne 0) { - throw "CultMesh C# reactive document performance probe failed." -} - -$runtimeArguments = @() -if ($Quick) { $runtimeArguments += "--quick" } - -& $nodeExecutable --expose-gc (Join-Path $root "scripts\measure-reactive-document-performance.mjs") @runtimeArguments -if ($LASTEXITCODE -ne 0) { - throw "CultMesh TypeScript reactive document performance probe failed." -} - -$pythonSource = (@("cultcache-py", "cultnet-py", "cultmesh-py") | ForEach-Object { Join-Path $root "packages\$_\src" }) -join [IO.Path]::PathSeparator -$previousPythonPath = $env:PYTHONPATH -$env:PYTHONPATH = if ([string]::IsNullOrWhiteSpace($previousPythonPath)) { - $pythonSource -} -else { - $pythonSource + [IO.Path]::PathSeparator + $previousPythonPath -} -try { - & $pythonExecutable (Join-Path $root "packages\cultmesh-py\tools\reactive_performance_probe.py") @runtimeArguments - if ($LASTEXITCODE -ne 0) { - throw "CultMesh Python reactive document performance probe failed." - } -} -finally { - $env:PYTHONPATH = $previousPythonPath -} diff --git a/scripts/pack-nuget.ps1 b/scripts/pack-nuget.ps1 index 95e5cd2f..b027c492 100644 --- a/scripts/pack-nuget.ps1 +++ b/scripts/pack-nuget.ps1 @@ -1,7 +1,6 @@ param( [string] $Configuration = "Release", [string] $OutputDirectory = "artifacts\nuget", - [string] $PackageVersion = "", [switch] $SkipConsumerSmoke ) @@ -16,9 +15,9 @@ $outputRoot = if ([IO.Path]::IsPathRooted($OutputDirectory)) { $projects = @( "src\GameCult.Logging\GameCult.Logging.csproj", "src\GameCult.Caching\GameCult.Caching.csproj", + "src\GameCult.Caching.MessagePack.Analyzers\GameCult.Caching.MessagePack.Analyzers.csproj", "src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj", "src\GameCult.Networking\GameCult.Networking.csproj", - "src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj", "src\GameCult.Mesh\GameCult.Mesh.csproj" ) @@ -28,14 +27,7 @@ if (Test-Path -LiteralPath $outputRoot) { New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null foreach ($project in $projects) { - $packArguments = @( - "pack", (Join-Path $repoRoot $project), "-c", $Configuration, "-o", $outputRoot, - "--nologo", "--verbosity", "quiet", "-p:NoWarn=1591%3BCS8632" - ) - if (-not [string]::IsNullOrWhiteSpace($PackageVersion)) { - $packArguments += "-p:CultLibPackageVersion=$PackageVersion" - } - & dotnet @packArguments + dotnet pack (Join-Path $repoRoot $project) -c $Configuration -o $outputRoot if ($LASTEXITCODE -ne 0) { throw "NuGet pack failed for $project with exit code $LASTEXITCODE" } @@ -52,9 +44,9 @@ $version = [regex]::Match($meshPackage.Name, '^GameCult\.Mesh\.(.+)\.nupkg$').Gr $expectedPackages = @( "GameCult.Logging.$version.nupkg", "GameCult.Caching.$version.nupkg", + "GameCult.Caching.MessagePack.Analyzers.$version.nupkg", "GameCult.Caching.MessagePack.$version.nupkg", "GameCult.Networking.$version.nupkg", - "GameCult.Networking.WebSockets.$version.nupkg", "GameCult.Mesh.$version.nupkg" ) foreach ($package in $expectedPackages) { @@ -69,7 +61,6 @@ if (-not $SkipConsumerSmoke) { Remove-Item -LiteralPath $smokeRoot -Recurse -Force } New-Item -ItemType Directory -Force -Path $smokeRoot | Out-Null - $consumerPackages = Join-Path $smokeRoot "packages" $projectDocument = @" @@ -77,8 +68,6 @@ if (-not $SkipConsumerSmoke) { Exe enable $outputRoot;https://api.nuget.org/v3/index.json - $consumerPackages - true @@ -93,8 +82,7 @@ Console.WriteLine(verse.Context.VerseId); "@ [IO.File]::WriteAllText((Join-Path $smokeRoot "Consumer.csproj"), $projectDocument, [Text.UTF8Encoding]::new($false)) [IO.File]::WriteAllText((Join-Path $smokeRoot "Program.cs"), $program, [Text.UTF8Encoding]::new($false)) - dotnet run --project (Join-Path $smokeRoot "Consumer.csproj") -c Release ` - --nologo --verbosity quiet + dotnet run --project (Join-Path $smokeRoot "Consumer.csproj") -c Release if ($LASTEXITCODE -ne 0) { throw "GameCult.Mesh NuGet consumer smoke failed with exit code $LASTEXITCODE" } diff --git a/scripts/run-typescript-workspaces.mjs b/scripts/run-typescript-workspaces.mjs deleted file mode 100644 index 933079da..00000000 --- a/scripts/run-typescript-workspaces.mjs +++ /dev/null @@ -1,30 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { dirname, join } from "node:path"; - -const command = process.argv[2]; -if (!command) throw new Error("Usage: node scripts/run-typescript-workspaces.mjs