Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13e8c56f5f | ||
|
|
9295278531 |
509 changed files with 21169 additions and 85802 deletions
97
.github/workflows/cultmesh-portability.yml
vendored
97
.github/workflows/cultmesh-portability.yml
vendored
|
|
@ -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"
|
||||
24
.github/workflows/cultnet-interop.yml
vendored
24
.github/workflows/cultnet-interop.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
146
.github/workflows/publish-packages.yml
vendored
146
.github/workflows/publish-packages.yml
vendored
|
|
@ -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
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
7
.voidbot/status/void-memory-maintenance.json
Normal file
7
.voidbot/status/void-memory-maintenance.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
Repository doctrine for this project lives in AGENTS.md, shared with Codex.
|
||||
|
||||
@AGENTS.md
|
||||
157
CultLib.sln
157
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
|
||||
|
|
|
|||
132
CultLib.txt
Normal file
132
CultLib.txt
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
|
||||
|
||||
// E:\Projects\CultLib\src\GameCult.Caching\GameCult.Caching.csproj
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<Description>CultCache: Concurrent polymorphic cache with modular backing stores for .NET</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GameCult.Logging\GameCult.Logging.csproj" />
|
||||
<PackageReference Include="R3" Version="1.3.0" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||
<PackageReference Include="ConcurrentHashSet " Version="1.3.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
// E:\Projects\CultLib\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<Description>File-based MessagePack backing store for CultCache</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GameCult.Caching\GameCult.Caching.csproj" />
|
||||
<ProjectReference Include="..\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj"
|
||||
OutputItemType="Analyzer"
|
||||
ReferenceOutputAssembly="false" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
// E:\Projects\CultLib\src\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- This tells MSBuild we are producing an analyzer -->
|
||||
<IsRoslynComponent>true</IsRoslynComponent>
|
||||
<Nullable>enable</Nullable>
|
||||
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
||||
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||
|
||||
<!-- Optional: Pack this as a NuGet analyzer -->
|
||||
<Description>Source generator that emits MessagePack formatters for DatabaseEntry subclasses.</Description>
|
||||
<PackageTags>MessagePack;SourceGenerator;DatabaseEntry</PackageTags>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Core Roslyn packages for incremental generators -->
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.10.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.10.0" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Mark this output as an analyzer -->
|
||||
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
// E:\Projects\CultLib\src\GameCult.Logging\GameCult.Logging.csproj
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<Description>Simple pluggable logging interface for CultLib</Description>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
// E:\Projects\CultLib\src\GameCult.Logging.Unity\GameCult.Logging.Unity.csproj
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<Description>Unity integration for GameCult.Logging</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GameCult.Logging\GameCult.Logging.csproj" />
|
||||
<PackageReference Include="R3" Version="1.3.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
// E:\Projects\CultLib\src\GameCult.Networking\GameCult.Networking.csproj
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<Description>CultNet: Secure, high-performance messaging for games/servers</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
|
||||
<ProjectReference Include="..\GameCult.Logging\GameCult.Logging.csproj" />
|
||||
<ProjectReference Include="..\GameCult.Caching\GameCult.Caching.csproj" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||
<PackageReference Include="R3" Version="1.3.0" />
|
||||
<PackageReference Include="LiteNetLib" Version="1.3.1" />
|
||||
<PackageReference Include="Isopoh.Cryptography.Argon2" Version="2.0.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
// E:\Projects\CultLib\tests\GameCult.Networking.Tests\GameCult.Networking.Tests.csproj
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>GameCult.Networking.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\GameCult.Networking\GameCult.Networking.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- NUnit framework -->
|
||||
<PackageReference Include="coverlet.msbuild" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="NUnit" Version="4.*" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.*" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.*" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.*">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -14,8 +14,7 @@
|
|||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||
<Copyright>© Meta Vens and contributors</Copyright>
|
||||
<PublishRepositoryUrl>true</PublishRepositoryUrl>
|
||||
<Version>1.0.35</Version>
|
||||
<Version Condition="'$(CultLibPackageVersion)' != ''">$(CultLibPackageVersion)</Version>
|
||||
<Version>1.0.3</Version>
|
||||
|
||||
<!-- Symbols/debugging -->
|
||||
<EmbedUntrackedSources>true</EmbedUntrackedSources>
|
||||
|
|
|
|||
111
README.md
111
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)
|
||||
|
|
|
|||
552
docs/cultmath-gamecult-geometry-migration-plan.md
Normal file
552
docs/cultmath-gamecult-geometry-migration-plan.md
Normal file
|
|
@ -0,0 +1,552 @@
|
|||
# CultMath and GameCult.Geometry Staged Migration Plan
|
||||
|
||||
Status: proposed for execution
|
||||
|
||||
Date: 2026-07-22
|
||||
|
||||
## Decision
|
||||
|
||||
`GameCult.Geometry` is the sole owner of geometric meaning and geometry
|
||||
machinery. It builds on CultMath numeric primitives and participates in
|
||||
CultCache, CultNet, and CultMesh through the normal CultLib integration paths.
|
||||
|
||||
CultMath owns the HLSL-shaped numeric substrate: scalar operations, vectors,
|
||||
quaternions, and general numeric kernels that have no spatial domain authority.
|
||||
It does not own regions, topology, projections, surfaces, planetary fields,
|
||||
patches, geometry residency, meshes, or geometry workflow state.
|
||||
|
||||
VibeGeometry and `vg-csg` are sources to mine. They are not surviving owners or
|
||||
dependencies.
|
||||
|
||||
Source transfer does not erase provenance. CultMath is MPL-2.0 and CultLib is
|
||||
MIT-licensed; moved files require an explicit licensing decision and preserved
|
||||
notices before they enter a differently licensed package.
|
||||
|
||||
## Objective
|
||||
|
||||
Produce one coherent geometry stack in which:
|
||||
|
||||
- consumers use `CultMath.float2`, `float3`, and `float4` as the canonical
|
||||
floating-point vector primitives;
|
||||
- `GameCult.Geometry` owns reusable geometry, planetary geometry, CSG, mesh
|
||||
production, spatial queries, and the typed documents that coordinate those
|
||||
systems;
|
||||
- CultMath no longer publishes planetary or other geometry-owned APIs;
|
||||
- `CultVec2` and `CultVec3` no longer exist;
|
||||
- VibeGeometry can be archived after its useful implementation, evidence, and
|
||||
doctrine have been absorbed;
|
||||
- persisted geometry remains readable through an explicit migration path when
|
||||
a live persisted corpus requires one.
|
||||
|
||||
## Authority Map
|
||||
|
||||
| Organ | Owns | Does not own |
|
||||
| --- | --- | --- |
|
||||
| CultMath | `float2`, `float3`, `float4`, integer/double/bool vector primitives, quaternion algebra, scalar and component-wise intrinsics, coordinate-agnostic numeric kernels, CPU/HLSL parity for those primitives | Rectangles, spheres, topology, projections, planetary fields, geometry pages, meshes, CSG, persistence schemas, worker commands |
|
||||
| GameCult.Geometry | Spatial value types built from CultMath primitives, spatial queries, topology and projection, planetary field and page machinery, mesh and CSG implementation, geometry schema documents, stable geometry identities, geometry-specific HLSL and cross-runtime fixtures | Competing vector types, generic storage/network ownership, application gameplay consequences |
|
||||
| CultCache/CultNet/CultMesh | Typed persistence, schema resolution, transport, discovery, publication, commands, and interface projection | Geometry semantics or geometry algorithm ownership |
|
||||
| VibeGeometry / `vg-csg` | Nothing after migration | Any live runtime, schema, package, or compatibility authority |
|
||||
|
||||
Authority sentence:
|
||||
|
||||
> CultMath owns how numeric primitives behave; GameCult.Geometry owns what those
|
||||
> primitives mean in space.
|
||||
|
||||
## Current Mechanism
|
||||
|
||||
The live body is split across three places:
|
||||
|
||||
1. CultMath publishes the canonical HLSL-shaped vector types, but also contains
|
||||
`rect`, planetary topology/projection/field/page/residency systems, spherical
|
||||
erosion, planetary HLSL, Unity adapters, web fixtures, and planetary tools.
|
||||
2. `GameCult.Geometry` contains duplicate `CultVec2` and `CultVec3` values,
|
||||
geometric primitives, and four CultCache document roots. Its document
|
||||
members still encode many vectors, bounds, transforms, vertices, normals,
|
||||
and UVs as anonymous `float[]` values.
|
||||
3. VibeGeometry wraps the `vg-csg` Rust implementation for domain-tree intent,
|
||||
LOD-aware selected cuts, CSG lowering, and triangle/collider output.
|
||||
|
||||
CultCache's current managed SoA projection only admits primitive scalar members.
|
||||
It neither preserves arbitrary unmanaged structs as columns nor recursively
|
||||
splits them. A `float2` or `float3` member is therefore omitted from the SoA
|
||||
until that storage seam is repaired.
|
||||
|
||||
## Invariants
|
||||
|
||||
The migration must preserve these statements at every published boundary:
|
||||
|
||||
1. There is one canonical vector vocabulary: CultMath's HLSL-shaped primitive
|
||||
types.
|
||||
2. Geometry algorithms and geometry state have one owner: `GameCult.Geometry`.
|
||||
3. CultMath never depends on CultLib, CultCache, MessagePack, or
|
||||
`GameCult.Geometry`.
|
||||
4. `GameCult.Geometry` may depend on CultMath and CultLib.
|
||||
5. Persistence decoration and formatters do not contaminate CultMath primitive
|
||||
definitions.
|
||||
6. A geometry schema has one active writer. Legacy readers or offline
|
||||
migrators may exist temporarily, but they cannot decide live behavior.
|
||||
7. Record keys and stable fingerprints are identical across supported runtimes
|
||||
for the same semantic geometry value.
|
||||
8. SoA storage preserves each authored unmanaged member as one column of its
|
||||
exact type. It never decomposes `float2`, `float3`, quaternion, or another
|
||||
value type by reflection.
|
||||
9. CPU, HLSL, Unity, web, and Rust fixtures compare the same claimed layer of
|
||||
geometry behavior.
|
||||
10. VibeGeometry is not retained as a wrapper, submodule, compatibility crate,
|
||||
or second source of truth.
|
||||
|
||||
## Target Package Shape
|
||||
|
||||
The exact directory names may follow existing CultLib conventions, but the
|
||||
ownership should lower into these runtime surfaces:
|
||||
|
||||
```text
|
||||
CultMath
|
||||
C# and Unity numeric primitives
|
||||
CultMath.hlsl numeric mirror
|
||||
coordinate-agnostic native numeric kernels
|
||||
|
||||
CultLib
|
||||
src/GameCult.Geometry/
|
||||
C# geometry API
|
||||
planetary geometry
|
||||
CultCache document contracts and formatters
|
||||
geometry-specific HLSL packaged as content
|
||||
packages/gamecult-geometry-rs/
|
||||
Rust geometry, CSG, and cross-runtime fixtures
|
||||
packages/gamecult-geometry-ts/ # only if the web runtime remains earned
|
||||
web projection/tile helpers and parity fixtures
|
||||
src/GameCult.Unity/Assets/Geometry/ # Unity-owned lowering/adapters
|
||||
mesh/page upload and presentation adapters
|
||||
Packages/org.gamecult.geometry/ # final location follows packaging audit
|
||||
Unity package depending on CultMath and CultLib
|
||||
```
|
||||
|
||||
The runtime directories are projections of one geometry authority. They are not
|
||||
independent products allowed to invent their own vector, topology, schema, or
|
||||
fingerprint semantics.
|
||||
|
||||
Each stage is a commit/review boundary. Do not begin the next stage until the
|
||||
current gate passes, and never publish a state in which both repositories claim
|
||||
the same geometry authority.
|
||||
|
||||
## Source Classification
|
||||
|
||||
### Keep in CultMath
|
||||
|
||||
- HLSL-shaped primitive types such as `float2`, `float3`, and `float4`.
|
||||
- Integer, double, and boolean vector primitives.
|
||||
- Quaternion algebra.
|
||||
- `math` scalar/component-wise operations and the core `CultMath.hlsl` mirror.
|
||||
- Numeric batch kernels whose contracts are independent of regions, topology,
|
||||
surfaces, planets, meshes, CSG, rendering policy, and persistence.
|
||||
|
||||
### Move from CultMath to GameCult.Geometry
|
||||
|
||||
The initial move set is:
|
||||
|
||||
- `rect` and other spatial-region values;
|
||||
- `PlanetaryTopology`;
|
||||
- `PlanetaryProjection`;
|
||||
- `PlanetaryQueries`;
|
||||
- `PlanetaryPatch`;
|
||||
- `PlanetaryPages`;
|
||||
- `PlanetaryGpuPages`;
|
||||
- `PlanetaryResidency`;
|
||||
- `PlanetaryRadialRefinement`;
|
||||
- `PlanetaryMapTileEncoding`;
|
||||
- `PlanetaryField`;
|
||||
- `AdvancedErosionFilter`;
|
||||
- `ErosionFrequencyBands`;
|
||||
- `SphericalErosion`;
|
||||
- `AdvancedErosionFilter.hlsl`;
|
||||
- `Planetary.hlsl`;
|
||||
- spherical-erosion and planetary-radial-refinement shader bodies currently
|
||||
embedded in `CultMath.hlsl`;
|
||||
- the versioned `CMPT` planetary tile format and its TypeScript decoder;
|
||||
- planetary tests, docs, tools, Unity adapters, web fixtures, and samples.
|
||||
|
||||
`AdvancedErosionFilter` and `ErosionFrequencyBands` move with planetary
|
||||
geometry. Their public contracts encode terrain-field policy, they are direct
|
||||
dependencies of the planetary field definition, and their HLSL is coupled to
|
||||
that field. Voronoi tone rendering and `native/cultmath-core` are outside this
|
||||
migration: the native crate currently owns only that tone kernel and has no
|
||||
planetary implementation. Audit those separately rather than dragging them
|
||||
across this boundary because their names look spatial.
|
||||
|
||||
### Rebuild inside GameCult.Geometry
|
||||
|
||||
- Make `CultRect`, `CultCircle`, and `CultSphere` the GameCult.Geometry-owned
|
||||
spatial values, implemented over CultMath primitives, and delete
|
||||
`CultMath.rect` at cutover.
|
||||
- Replace `CultVec2` and `CultVec3` with `float2` and `float3` at every typed
|
||||
boundary.
|
||||
- Replace vector-shaped `float[]` document members with typed CultMath values or
|
||||
intentional packed buffers.
|
||||
- Keep packed mesh buffers packed when they are genuinely bulk artifact storage;
|
||||
do not turn every vertex buffer into a forest of object wrappers.
|
||||
- Put MessagePack/CultCache formatters for CultMath values in the integration
|
||||
package or generated formatter output, not on the CultMath types.
|
||||
|
||||
### Mine from VibeGeometry and `vg-csg`
|
||||
|
||||
Evaluate and extract, in dependency order:
|
||||
|
||||
- convex plane splitting and brush operations;
|
||||
- CSG tree and ordered operation semantics;
|
||||
- domain-tree DSL and lowering;
|
||||
- dirty-frontier and prefix-checkpoint work;
|
||||
- selected-cut and LOD planning;
|
||||
- triangle/collider assembly;
|
||||
- performance fixtures and parity evidence;
|
||||
- useful RealtimeCSG-derived behavioral fixtures;
|
||||
- runtime doctrine that still describes the adopted machine.
|
||||
|
||||
Rewrite extracted code under GameCult.Geometry ownership. Preserve third-party
|
||||
license notices and source provenance. Do not retain a `vg-csg` forwarding
|
||||
crate or a VibeGeometry submodule after consumers have moved.
|
||||
|
||||
## Staged Migration
|
||||
|
||||
### Stage 0: Freeze and Baseline
|
||||
|
||||
Purpose: establish the evidence needed to move authority without guessing.
|
||||
|
||||
Work:
|
||||
|
||||
- Freeze new planetary and geometry API additions in CultMath, VibeGeometry,
|
||||
and the current duplicate primitive surface.
|
||||
- Do not publish an intermediate release containing duplicate live planetary
|
||||
implementations. A coordinated working branch may hold source temporarily
|
||||
for comparison, but only one owner may enter a package feed.
|
||||
- Inventory public symbols, package versions, HLSL entrypoints, Unity samples,
|
||||
web fixtures, native exports, persisted schema IDs, record-key algorithms,
|
||||
and known consumers.
|
||||
- Inventory exact contract constants: `FieldVersion`, enum ordinals, tile stable
|
||||
keys, `CMPT` versions and bytes, MessagePack slots, GPU buffer layouts,
|
||||
integer hashes, topology face/seam/corner rules, and shader include paths.
|
||||
- Search deployed and checked-in `.cc` stores for the four
|
||||
`gamecult.geometry.*.v1` schemas.
|
||||
- Record whether any external consumer relies on CultMath planetary namespaces,
|
||||
`CultVec*`, `CultMath.rect`, `vg-csg`, or the current HLSL function names.
|
||||
- Capture passing baselines for CultMath tests, GameCult.Geometry tests,
|
||||
`vg-csg` tests/clippy, cross-runtime planetary fixtures, and package builds.
|
||||
- Add representative fixture hashes for projection, topology, field sampling,
|
||||
patch selection, page encoding, CSG output, and geometry record keys.
|
||||
- Resolve licensing and provenance for MPL-2.0 CultMath sources and any
|
||||
third-party-derived `vg-csg` behavior before copying source into CultLib.
|
||||
|
||||
Gate:
|
||||
|
||||
- Every published surface has a named consumer or is marked for deletion.
|
||||
- The persisted v1 corpus is known rather than assumed.
|
||||
- Baseline fixtures can be run without booting an entire daemon.
|
||||
|
||||
### Stage 1: Establish the Destination and Dependency Direction
|
||||
|
||||
Purpose: make `GameCult.Geometry` capable of receiving geometry without growing
|
||||
a second primitive vocabulary.
|
||||
|
||||
Work:
|
||||
|
||||
- Add the explicit `GameCult.Geometry -> CultMath` dependency.
|
||||
- Define the target namespaces and package content layout for C#, Rust, HLSL,
|
||||
Unity, and any retained web surface.
|
||||
- Establish distribution before source transfer: an explicit NuGet dependency
|
||||
and a dedicated `org.gamecult.geometry` Unity package depending on
|
||||
`org.gamecult.cultmath` and `org.gamecult.cultlib`, unless package inspection
|
||||
proves an existing surface already owns that role.
|
||||
- Retain `net8.0` and `netstandard2.1` targets and Unity 2021.3 compatibility.
|
||||
The geometry asmdef references CultMath; the Unity lowering assembly
|
||||
references both. CultMath's asmdef remains reference-free with
|
||||
`noEngineReferences: true`.
|
||||
- Add GameCult.Geometry-owned MessagePack formatters for `float2`, `float3`,
|
||||
`float4`, and quaternion with fixed positional layouts.
|
||||
- Add cross-runtime byte fixtures for those value encodings.
|
||||
- Repair CultCache's SoA member discovery so any direct unmanaged member is one
|
||||
exact typed column. Add `Column<float2>` and `Column<float3>` tests and a
|
||||
negative test proving no scalar decomposition occurs.
|
||||
- Ensure generated schema metadata describes the authored vector member rather
|
||||
than synthetic x/y/z members.
|
||||
|
||||
Gate:
|
||||
|
||||
- CultMath has no new dependency.
|
||||
- `GameCult.Geometry` can serialize, deserialize, index, and expose intact
|
||||
CultMath vectors.
|
||||
- CultCache hot-path access returns intact vector spans.
|
||||
- Built NuGet and UPM artifacts contain the expected dependency metadata,
|
||||
formatters, shaders, and owned runtime assets.
|
||||
|
||||
### Stage 2: Replace Duplicate Primitives and Repair Geometry Schemas
|
||||
|
||||
Purpose: remove the duplicate vocabulary before moving larger geometry systems.
|
||||
|
||||
Work:
|
||||
|
||||
- Rewrite `CultRect`, `CultCircle`, and `CultSphere` with CultMath primitives.
|
||||
- Delete `CultVec2` and `CultVec3`; do not add aliases or conversion wrappers.
|
||||
- Replace typed references and opaque native-slice metadata using the obsolete
|
||||
names.
|
||||
- Classify every vector-shaped `float[]` in `CultGeometryDocuments.cs`:
|
||||
- `Translation`, `SupportCenter`, `SupportSize`, `CameraPosition`,
|
||||
`FrustumMin`, `FrustumMax`, `BoundsMin`, and `BoundsMax` become `float3`;
|
||||
- `RotationXyzw` becomes quaternion;
|
||||
- mesh `Positions` and `Normals` become `float3[]`;
|
||||
- mesh `Uvs` becomes `float2[]`;
|
||||
- scalar and index buffers remain packed arrays;
|
||||
- use a named geometry value when bounds/topology carries an invariant.
|
||||
- Define v2 schemas for every document whose field types change. CultCache
|
||||
includes CLR type identity in the canonical schema and must reject an in-place
|
||||
slot type mutation even when no durable v1 corpus is found.
|
||||
- If live v1 documents exist, add bounded v1 reader DTOs and an offline
|
||||
v1-to-v2 migrator. Stop all v1 writers at cutover. If no live persisted corpus
|
||||
exists, omit the migrator and delete v1 runtime support, but still publish the
|
||||
corrected contract under v2 identity.
|
||||
- Reject malformed legacy vectors: fixed vectors must have the exact expected
|
||||
component count, while packed mesh streams must be divisible by their element
|
||||
width before conversion.
|
||||
- Recompute and cross-check stable fingerprints and record keys against Rust
|
||||
fixtures before accepting the new schemas. Preserve an old record key only
|
||||
when the typed representation reproduces the prior IEEE-bit canonicalization
|
||||
exactly; otherwise version the identity deliberately.
|
||||
- Use CultMath's lowercase component names in v2 JSON inspection projections.
|
||||
JSON is not the runtime transport, and uppercase `X/Y/Z` aliases must not keep
|
||||
the deleted vector vocabulary alive. Any xenos-facing alternate casing is an
|
||||
explicit boundary projection.
|
||||
|
||||
Gate:
|
||||
|
||||
- Repository-wide searches find no `CultVec2` or `CultVec3` outside historical
|
||||
evidence.
|
||||
- Repository and package searches find no `CultMath.rect`.
|
||||
- No live writer emits vector-shaped anonymous arrays for semantic single
|
||||
values.
|
||||
- The old schema cannot overwrite, repair, or become a second owner of v2
|
||||
state.
|
||||
|
||||
### Stage 3: Transfer Planetary Geometry out of CultMath
|
||||
|
||||
Purpose: move planetary authority as one coherent vertical slice.
|
||||
|
||||
Work:
|
||||
|
||||
- Move the classified planetary C# implementation and tests into
|
||||
`GameCult.Geometry`.
|
||||
- Move `Planetary.hlsl` and rename its include ownership and public entrypoints
|
||||
to GameCult.Geometry. It may include CultMath numeric HLSL; CultMath must no
|
||||
longer include planetary geometry. Remove the current implicit planetary
|
||||
include from `CultMath.hlsl` only after consumers include the new owner.
|
||||
- Move `AdvancedErosionFilter.hlsl` plus the spherical-erosion and
|
||||
planetary-radial-refinement bodies embedded in `CultMath.hlsl`. Publish one
|
||||
geometry-owned umbrella include that imports CultMath first and geometry
|
||||
organs afterward.
|
||||
- Move planetary documentation, fixture tools, web tile code, Unity page/mesh
|
||||
adapters, and the planetary viewer sample to their consumer-owned runtime
|
||||
locations.
|
||||
- Move ownership of the `CMPT` little-endian tile format and TypeScript decoder.
|
||||
Preserve v1 bytes exactly or introduce a new format version; never reuse a
|
||||
wire version after changing its bytes.
|
||||
- Rename public namespaces and symbols to their GameCult.Geometry authority.
|
||||
Prefer a coordinated breaking release over permanent forwarding types.
|
||||
- Move spherical/terrain erosion components classified as geometry. Keep only
|
||||
genuinely coordinate-agnostic numeric kernels in CultMath.
|
||||
- Run CPU/HLSL and cross-runtime fixtures from the new owner before deleting
|
||||
the old files.
|
||||
- Preserve exact fixtures for field versions, enum ordinals, integer hashes,
|
||||
tile keys/bytes, GPU buffer packing, MessagePack slots, topology seams and
|
||||
corners, and record hashes. Use explicit tolerances only for floating
|
||||
CPU/HLSL comparisons.
|
||||
- Delete the corresponding CultMath sources, package entries, and implicit
|
||||
includes in the same authority-transfer stage after the destination passes.
|
||||
Do not defer the actual ownership cut to consumer cleanup.
|
||||
|
||||
Gate:
|
||||
|
||||
- GameCult.Geometry passes the captured planetary baseline and owns the fixture
|
||||
commands.
|
||||
- CultMath packages no `Planetary.hlsl`, planetary sample, planetary tool, or
|
||||
planetary public namespace.
|
||||
- No source or shader include can select an old CultMath planetary path.
|
||||
- Package inspection finds no planetary API, Unity adapter, sample, tile
|
||||
decoder, documentation claim, or implicit shader include in CultMath.
|
||||
|
||||
### Stage 4: Mine and Absorb `vg-csg`
|
||||
|
||||
Purpose: preserve useful CSG machinery without preserving the defunct owner.
|
||||
|
||||
Work:
|
||||
|
||||
- Import the earned Rust implementation into the GameCult.Geometry runtime
|
||||
surface with source provenance and applicable license notices.
|
||||
- Replace `bevy_math`-shaped public contracts where they conflict with the
|
||||
canonical cross-runtime primitive/layout contract. Internal use may remain
|
||||
where it protects a measured invariant and does not leak a second public
|
||||
vocabulary.
|
||||
- Connect domain trees, selected cuts, build requests, and emitted artifacts to
|
||||
the GameCult.Geometry schema definitions.
|
||||
- Port useful fixtures before refactoring algorithms so observable behavior has
|
||||
a witness.
|
||||
- Preserve the existing Rust v1 tuple MessagePack and stable-hash fixtures as
|
||||
migration witnesses, then make the GameCult.Geometry-owned Rust package emit
|
||||
the v2 contract.
|
||||
- Re-run performance fixtures and retain thresholds only where the fixture
|
||||
represents a real workload.
|
||||
- Move surviving doctrine into GameCult.Geometry docs and discard historical
|
||||
instructions that would recreate VibeGeometry ownership.
|
||||
|
||||
Gate:
|
||||
|
||||
- No production build or test depends on the VibeGeometry repository or
|
||||
`vg-csg` submodule.
|
||||
- CSG output satisfies the adopted correctness and performance fixtures from
|
||||
its new package.
|
||||
- The Rust runtime speaks the same schema IDs, vector layouts, fingerprints,
|
||||
and record keys as C#.
|
||||
|
||||
### Stage 5: Integrate Geometry Through CultCache, CultNet, and CultMesh
|
||||
|
||||
Purpose: prove the new owner through the real typed-state path.
|
||||
|
||||
Work:
|
||||
|
||||
- Register the adopted geometry schemas and formatters through the generated
|
||||
registry path.
|
||||
- Prove raw envelope interchange for geometry documents across C# and Rust,
|
||||
adding TypeScript only if an actual web consumer remains.
|
||||
- Publish geometry worker state and commands through the provider-owned
|
||||
CultMesh surface.
|
||||
- Ensure geometry build requests, selected cuts, chunk artifacts, and planetary
|
||||
page state share one commit/derivation path for direct and programmatic use.
|
||||
- Add a development probe exposing schema version, owner, source record key,
|
||||
selected cut/page, content hash, and served package version.
|
||||
|
||||
Gate:
|
||||
|
||||
- A pipeline smoke proves typed handoff from domain input through selected cut
|
||||
or planetary page selection to a persisted and republished artifact.
|
||||
- The visible/debug projection and persisted state identify the same owner and
|
||||
version.
|
||||
- No ad hoc JSON or parallel status model becomes load-bearing geometry truth.
|
||||
|
||||
### Stage 6: Consumer Cutover and Coordinated Release
|
||||
|
||||
Purpose: switch consumers without maintaining two live authorities.
|
||||
|
||||
Release order:
|
||||
|
||||
1. Build an unpublished CultMath candidate that contains the stable primitive
|
||||
contract and removes geometry-owned APIs.
|
||||
2. Build an unpublished GameCult.Geometry candidate against that exact CultMath
|
||||
version, containing adopted schemas, planetary geometry, and runtime packages.
|
||||
3. Update and test GameCult services, Unity projects, web consumers, and tools
|
||||
against a local candidate feed.
|
||||
4. Publish the CultMath and GameCult.Geometry candidates in one coordinated
|
||||
release window, with the primitives-only CultMath dependency entering the
|
||||
feed first.
|
||||
5. Publish consumer updates after both owning packages are available.
|
||||
6. Convert persisted v1 geometry stores, if any, and remove migration tooling
|
||||
after the retained-corpus policy is satisfied.
|
||||
|
||||
Work:
|
||||
|
||||
- Pin compatible package versions across the coordinated release.
|
||||
- Update examples and developer-facing entrypoints to use
|
||||
`GameCult.Geometry` plus CultMath primitives directly.
|
||||
- Cut Fensalir's Engine/Contracts public planetary types and shader includes to
|
||||
the new assembly before removing CultMath geometry. Its CPU/GPU parity tests
|
||||
are release gates.
|
||||
- Add the geometry package to Aetheria/Unity, move its directly compiled
|
||||
planetary sources to the new assembly, preserve moved Unity `.meta` GUIDs,
|
||||
and regenerate Unity project files.
|
||||
- Verify served package, shader, schema, and migration versions in at least one
|
||||
real consumer for each retained runtime.
|
||||
- Run the preserved parity corpus through known consumers including Fensalir,
|
||||
Zyphos, Aetheria/Unity, the web tile decoder when retained, C#, Rust, and HLSL.
|
||||
- Refuse mixed old/new geometry writers rather than reconciling them later.
|
||||
|
||||
Gate:
|
||||
|
||||
- Every known consumer runs on the new owner.
|
||||
- No published package graph has `CultMath -> CultLib` or
|
||||
`CultMath -> GameCult.Geometry`.
|
||||
- Old and new writers cannot target the same state surface.
|
||||
|
||||
### Stage 7: Delete Obsolete Authorities
|
||||
|
||||
Purpose: make the old ownership structurally unable to return.
|
||||
|
||||
Work:
|
||||
|
||||
- Confirm deletion of all CultMath planetary/geometry sources, tests, shaders,
|
||||
samples, docs, tools, web surfaces, package content entries, and Unity
|
||||
adapters; remove any residue found after consumer cutover.
|
||||
- Delete obsolete GameCult.Geometry duplicate primitive code and stale schema
|
||||
paths.
|
||||
- Delete VibeGeometry workspace wiring and the `vg-csg` submodule dependency;
|
||||
archive the repositories after provenance links and release tags are
|
||||
recorded.
|
||||
- Remove compatibility aliases, forwarding namespaces, dual registries, and
|
||||
temporary migration flags.
|
||||
- Update source indexes and repository memory so future agents retrieve the new
|
||||
authority first.
|
||||
|
||||
Gate:
|
||||
|
||||
- Negative searches and package inspection prove the old paths are absent.
|
||||
- Building CultMath alone cannot access planetary or geometry APIs.
|
||||
- Building GameCult.Geometry cannot access `CultVec*` or VibeGeometry.
|
||||
- The old persisted schema path cannot write or override current geometry.
|
||||
|
||||
## Schema Migration Policy
|
||||
|
||||
Do not create a permanent compatibility model pre-emptively.
|
||||
|
||||
- If no durable v1 corpus exists, change the schema once, update fixtures, and
|
||||
publish the corrected contract as v2, then delete the old form without
|
||||
building a migrator.
|
||||
- If a durable v1 corpus exists, create an offline migrator with explicit input
|
||||
and output schema IDs, content hashes, counts, and a dry-run report.
|
||||
- A v1 reader may exist only inside migration/inspection tooling.
|
||||
- The runtime writes only the current schema after cutover.
|
||||
- Migration completes only when retained stores have receipts and no active
|
||||
service advertises or writes v1.
|
||||
|
||||
## Verification Matrix
|
||||
|
||||
| Layer | Required evidence |
|
||||
| --- | --- |
|
||||
| CultMath primitives | C#/HLSL parity, unmanaged layout/size checks, package inspection |
|
||||
| Geometry values | Exact `float2`/`float3` field ownership, intersection/query unit tests, MessagePack byte fixtures |
|
||||
| CultCache SoA | Intact vector columns, no recursive flattening, add/update/remove/snapshot behavior |
|
||||
| Geometry schemas | Catalog IDs and slots, v1 inventory or migration receipts, stable record-key parity |
|
||||
| Planetary geometry | Projection round trips, topology adjacency, field/normal samples, radial refinement, page encoding/residency, CPU/HLSL parity |
|
||||
| CSG | Boolean/convex fixtures, dirty rebuild behavior, selected-cut correctness, triangle/collider receipts, performance fixtures |
|
||||
| Cross-runtime | C#/Rust payload and fingerprint parity; web parity only for retained web consumers |
|
||||
| Packaging | CultMath contains no geometry assets; GameCult.Geometry includes its owned HLSL/docs/runtime assets |
|
||||
| End to end | Domain or planetary input to persisted artifact through CultMesh with visible owner/version evidence |
|
||||
|
||||
## Negative Completion Checks
|
||||
|
||||
The migration is not complete until all are true:
|
||||
|
||||
- `CultVec2` and `CultVec3` cannot appear in newly generated schemas or native
|
||||
slice metadata.
|
||||
- CultMath cannot publish or execute planetary geometry through an old entrypoint.
|
||||
- VibeGeometry and `vg-csg` cannot influence a build, runtime, fixture, or
|
||||
generated artifact.
|
||||
- VibeGeometry is absent from project references, package manifests,
|
||||
submodules, CI, runtime identifiers, and authoritative documentation.
|
||||
- Legacy v1 state cannot override or repair current geometry after the fact.
|
||||
- CultCache cannot decompose a vector into independently owned scalar columns.
|
||||
- Manual, programmatic, imported, persisted, and network-received geometry use
|
||||
the same schema and commit paths.
|
||||
- Passing unit tests are accompanied by package inspection and cross-runtime
|
||||
evidence at the layer users actually consume.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
The work is complete when `GameCult.Geometry` is the only geometry authority,
|
||||
CultMath is a clean numeric substrate, all retained runtime projections agree on
|
||||
typed layouts and stable identities, VibeGeometry has no live dependency path,
|
||||
and the old owners are structurally unable to emit current geometry state.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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<T>`, `CultSoaColumn<T>`, `CultCacheSoaStore`,
|
||||
`CultCacheSoaTypeTable`, `CultCacheSoaMember`); `CultCache.Soa<T>()` 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<T>` (`CultManagedDocument.cs:72-131` at
|
||||
the tag) with `CultCache.Document<T>` and `CultNetDatabase.Document<T>`, 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<T>` was "deleted separately, not parked". This note
|
||||
supersedes it: `CultManagedDocument<T>` 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<T>`, restore both `Document<T>`
|
||||
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`).
|
||||
|
|
@ -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<u8>` 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<u8>` 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<Vec<u8>>` 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<u8>`. Both are type
|
||||
errors against an `Option<Vec<u8>>`, 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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.
|
||||
|
|
@ -1,440 +0,0 @@
|
|||
#include <windows.h>
|
||||
#include <wincrypt.h>
|
||||
#include <bcrypt.h>
|
||||
#include <msquic.h>
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<uint8_t> 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<uint8_t, 32> certificate_pin{};
|
||||
std::atomic<ClientState> state{ClientState::Connecting};
|
||||
std::atomic<bool> closing{false};
|
||||
std::mutex gate;
|
||||
std::deque<std::vector<uint8_t>> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<uint8_t>(value - '0');
|
||||
else if (value >= 'a' && value <= 'f') nibble = static_cast<uint8_t>(value - 'a' + 10);
|
||||
else if (value >= 'A' && value <= 'F') nibble = static_cast<uint8_t>(value - 'A' + 10);
|
||||
else return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ParsePin(const char* value, std::array<uint8_t, 32>& 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<uint8_t>((high << 4) | low);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
uint32_t ReadUInt32LittleEndian(const uint8_t* value) {
|
||||
return static_cast<uint32_t>(value[0]) |
|
||||
(static_cast<uint32_t>(value[1]) << 8) |
|
||||
(static_cast<uint32_t>(value[2]) << 16) |
|
||||
(static_cast<uint32_t>(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<size_t>(length);
|
||||
if (bytes.size() < framed_length) return true;
|
||||
std::vector<uint8_t> frame(length);
|
||||
std::memcpy(frame.data(), bytes.data() + sizeof(uint32_t), length);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(stream->owner->gate);
|
||||
stream->owner->received.emplace_back(std::move(frame));
|
||||
}
|
||||
bytes.erase(bytes.begin(), bytes.begin() + static_cast<std::ptrdiff_t>(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<StreamContext*>(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<const QUIC_BUFFER*>(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<wchar_t, 32768> path{};
|
||||
const auto length = GetModuleFileNameW(module, path.data(), static_cast<DWORD>(path.size()));
|
||||
if (length == 0 || length >= path.size()) return "unresolved";
|
||||
const auto required = WideCharToMultiByte(
|
||||
CP_UTF8, 0, path.data(), static_cast<int>(length), nullptr, 0, nullptr, nullptr);
|
||||
if (required <= 0) return "unresolved";
|
||||
std::string utf8(static_cast<size_t>(required), '\0');
|
||||
WideCharToMultiByte(
|
||||
CP_UTF8, 0, path.data(), static_cast<int>(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<Client*>(context);
|
||||
AppendTrace(client, "event=" + std::to_string(static_cast<int>(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<void*>(StreamCallback),
|
||||
stream);
|
||||
break;
|
||||
}
|
||||
case QUIC_CONNECTION_EVENT_PEER_CERTIFICATE_RECEIVED:
|
||||
{
|
||||
AppendTrace(
|
||||
client,
|
||||
"certificate-status=" +
|
||||
Hex(static_cast<uint64_t>(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<uint64_t>(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<uint64_t>(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<Client>();
|
||||
if (!ParsePin(certificate_sha256, client->certificate_pin)) return -2;
|
||||
|
||||
QUIC_STATUS status = MsQuicOpenVersion(
|
||||
kApiVersion,
|
||||
reinterpret_cast<const void**>(&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<uint32_t>(sizeof(kAlpn) - 1),
|
||||
reinterpret_cast<uint8_t*>(const_cast<char*>(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_FLAGS>(
|
||||
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<int32_t>(ClientState::Failed);
|
||||
return static_cast<int32_t>(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<std::mutex> 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<size_t>(INT32_MAX)) return -3;
|
||||
*required_length = static_cast<int32_t>(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<std::mutex> lock(client->gate);
|
||||
const size_t count = (std::min)(client->error.size(), static_cast<size_t>(destination_length - 1));
|
||||
std::memcpy(destination, client->error.data(), count);
|
||||
destination[count] = '\0';
|
||||
return static_cast<int32_t>(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;
|
||||
}
|
||||
900
package-lock.json
generated
900
package-lock.json
generated
|
|
@ -1,900 +0,0 @@
|
|||
{
|
||||
"name": "cultlib",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cultlib",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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/@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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"undici-types": "~7.18.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/cultmesh-browser": {
|
||||
"resolved": "packages/cultmesh-browser",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/cultmesh-ts": {
|
||||
"resolved": "packages/cultmesh-ts",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/cultnet-ts": {
|
||||
"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==",
|
||||
"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.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"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.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"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": {
|
||||
"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.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"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.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",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@msgpack/msgpack": "^3.1.3",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"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",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@gamecult/cultcache-ts": "^0.14.0",
|
||||
"@msgpack/msgpack": "^3.1.2",
|
||||
"cultnet-ts": "^0.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"typescript": "^5.9.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/cultnet-ts": {
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@gamecult/cultcache-ts": "^0.14.0",
|
||||
"@msgpack/msgpack": "^3.1.3",
|
||||
"ajv": "^8.17.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"json-schema-to-typescript": "^15.0.4",
|
||||
"typescript": "^5.9.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
package.json
15
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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
@ -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,
|
||||
|
|
@ -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,
|
||||
|
|
@ -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(
|
||||
|
|
@ -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))
|
||||
|
|
@ -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,
|
||||
|
|
@ -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
|
||||
135
packages/cultcache-py/src/cultmesh_py/simulation.py
Normal file
135
packages/cultcache-py/src/cultmesh_py/simulation.py
Normal file
|
|
@ -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)))
|
||||
|
|
@ -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)))
|
||||
|
|
@ -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",
|
||||
|
|
@ -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",
|
||||
|
|
@ -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))
|
||||
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ struct Settings {
|
|||
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<Settings>()?;
|
||||
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::<T>`
|
||||
- `get_required_envelope::<T>`
|
||||
- `get_all::<T>`
|
||||
- `get_all_with_keys::<T>`
|
||||
- `soa::<T>`
|
||||
- `put::<T>`
|
||||
- `put_envelope::<T>`
|
||||
- `update::<T>`
|
||||
- `delete::<T>`
|
||||
- `snapshot`
|
||||
- `snapshot_soa`
|
||||
- `load_soa`
|
||||
- `SingleFileMessagePackBackingStore`
|
||||
|
||||
## Structure Of Arrays
|
||||
|
||||
The C# CultCache hot path exposes CPU-local SoA scans as:
|
||||
|
||||
```csharp
|
||||
Span<float> x = cache.Soa<PlayerTransform>()
|
||||
.Column<float>(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::<PlayerTransform>()?;
|
||||
let health = table.column::<i32>("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
|
||||
|
||||
|
|
|
|||
|
|
@ -75,7 +75,6 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
|
|||
};
|
||||
let mut key: Option<usize> = 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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ fn read_note(file: &str) -> Result<()> {
|
|||
fn build_cache(file: &str) -> Result<CultCache> {
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<CultCacheInteropNote>()?;
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(file))?;
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(file));
|
||||
Ok(cache)
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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 });
|
||||
|
|
|
|||
70
packages/cultcache-ts/package-lock.json
generated
Normal file
70
packages/cultcache-ts/package-lock.json
generated
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -27,8 +27,3 @@ export type {
|
|||
InspectedCatalogMember,
|
||||
InspectedRecord,
|
||||
} from "./cult-cache-inspector";
|
||||
export {
|
||||
defineSwarmDocuments,
|
||||
type DefineDocumentType,
|
||||
type SwarmDocumentCatalog,
|
||||
} from "./swarm-documents";
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>) => unknown;
|
||||
export type SwarmDocumentCatalog = Record<string, unknown>;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -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<string[]> {
|
||||
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<void>((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<void>((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<unknown>[] = [];
|
||||
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 () => {
|
||||
|
|
|
|||
9
packages/cultmath/.gitignore
vendored
9
packages/cultmath/.gitignore
vendored
|
|
@ -1,9 +0,0 @@
|
|||
bin/
|
||||
obj/
|
||||
**/bin/
|
||||
**/obj/
|
||||
target/
|
||||
**/target/
|
||||
/native/cultmath-core/target.meta
|
||||
.tools/
|
||||
/artifacts/
|
||||
|
|
@ -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<float>` / `ReadOnlySpan<float>` using
|
||||
`System.Numerics.Vector<float>` 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.
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 867daebafe0ce48479cf043461ee6bdf
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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: <https://github.com/ashima/webgl-noise>
|
||||
- License: MIT.
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 7c28c8631c7d4054eb751d7ce952df33
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: de535b89253410b41b5989f712b80af4
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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.
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 07021a8be816e3448a9490c10845b0aa
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 9d1cb2ac88c530b4d9da456523115e12
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: a42d65478346c354c8b4f353c5581b49
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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"
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: f1c365f7eead2684ba621124e653bdab
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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"
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: cdd2d4eab053b764b9a06d36b080e43e
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 7de448b7e258d2c448bb7391051cc628
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: b06c69c432a633f4584830d6b28d4473
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 0ddff5b782cf14e43ae5cda048e040d8
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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)"
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 3bed74ebcba445543800218dbe185034
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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<float> 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<float> 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
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 33174674795143c4a905408718f956f8
|
||||
ShaderIncludeImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 64f48d0cae0810e4ba9903e9094fe4c6
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: e857b457b93397d4fb4ad8481cb72e4c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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<float>.Count;
|
||||
|
||||
public static bool IsHardwareAccelerated => Vector.IsHardwareAccelerated;
|
||||
|
||||
public static void Clear(Span<float> values) => values.Clear();
|
||||
|
||||
public static void AddRadialFalloffAcceleration2D(
|
||||
ReadOnlySpan<float> positionX,
|
||||
ReadOnlySpan<float> positionY,
|
||||
float centerX,
|
||||
float centerY,
|
||||
float strength,
|
||||
float radius,
|
||||
Span<float> accelerationX,
|
||||
Span<float> 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<float>(radius);
|
||||
var centerXVector = new Vector<float>(centerX);
|
||||
var centerYVector = new Vector<float>(centerY);
|
||||
var strengthVector = new Vector<float>(strength);
|
||||
var one = Vector<float>.One;
|
||||
var zero = Vector<float>.Zero;
|
||||
var minimumDistanceSquared = new Vector<float>(MinimumDistanceSquared);
|
||||
|
||||
var i = 0;
|
||||
var lanes = Vector<float>.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<float>(pxBuffer);
|
||||
var py = new Vector<float>(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<float>(axBuffer) + ax).CopyTo(axBuffer);
|
||||
(new Vector<float>(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<float2> positions,
|
||||
float2 center,
|
||||
float strength,
|
||||
float radius,
|
||||
Span<float2> 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<float> dynamicMask,
|
||||
Span<float> positionX,
|
||||
Span<float> positionY,
|
||||
Span<float> velocityX,
|
||||
Span<float> velocityY,
|
||||
ReadOnlySpan<float> accelerationX,
|
||||
ReadOnlySpan<float> 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<float>(deltaTime);
|
||||
var i = 0;
|
||||
var lanes = Vector<float>.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<float>(maskBuffer);
|
||||
var vx = new Vector<float>(vxBuffer);
|
||||
var vy = new Vector<float>(vyBuffer);
|
||||
var px = new Vector<float>(pxBuffer);
|
||||
var py = new Vector<float>(pyBuffer);
|
||||
var ax = new Vector<float>(axBuffer);
|
||||
var ay = new Vector<float>(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<float> dynamicMask,
|
||||
Span<float2> position,
|
||||
Span<float2> velocity,
|
||||
ReadOnlySpan<float2> 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<float> source, float[] destination)
|
||||
{
|
||||
for (var i = 0; i < source.Length; i++)
|
||||
{
|
||||
destination[i] = source[i];
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyArrayToSpan(float[] source, Span<float> destination)
|
||||
{
|
||||
for (var i = 0; i < destination.Length; i++)
|
||||
{
|
||||
destination[i] = source[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: d4771320e190bdd408dd296c293a9d0e
|
||||
|
|
@ -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);
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: bfa14a9aacb8ac4448761568643f2bc6
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"name": "CultMath",
|
||||
"rootNamespace": "CultMath",
|
||||
"references": [],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": true,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": true
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue