Compare commits

..
Author SHA1 Message Date
Meta Vens
b65619ff95 Reference Geometry test plugins explicitly 2026-07-22 05:44:44 +01:00
Meta Vens
e3e1ac0420 Merge CultMesh reliability into Geometry Unity release 2026-07-22 05:38:21 +01:00
Meta Vens
3f454f2a8b Refresh CultLib Unity binaries 2026-07-22 05:23:40 +01:00
Meta Vens
98d15eaf65 Harden CultLib Unity package build 2026-07-22 05:19:48 +01:00
Meta Vens
4df2c2a42b Add Geometry Unity EditMode package tests 2026-07-22 05:17:07 +01:00
Meta Vens
d91b59eb4a Disambiguate Unity mesh adapter type 2026-07-22 05:05:56 +01:00
Meta Vens
4fcb181d8d Make Geometry Unity sources C# 9 compatible 2026-07-22 04:59:59 +01:00
Meta Vens
ace29fe2b5 Verify Geometry Unity package release version 2026-07-22 04:42:56 +01:00
Meta Vens
a9b8eb0fb6 Stabilize Geometry Unity release binaries 2026-07-22 04:40:44 +01:00
Meta Vens
383a13b5e2 Refresh Geometry Unity release assembly 2026-07-22 04:38:57 +01:00
Meta Vens
0a3aebd12e Normalize Geometry release package versions 2026-07-22 04:37:09 +01:00
Meta Vens
77fe95c7b9 Make Geometry Git UPM package self-contained 2026-07-22 04:25:04 +01:00
Meta Vens
37e70a346c Record consumer cutover and authority audit 2026-07-22 04:16:16 +01:00
Meta Vens
b9f20a640e Update Geometry repository scope 2026-07-22 04:14:10 +01:00
Meta Vens
8a7eee717e Document generated geometry registry ownership 2026-07-22 04:13:43 +01:00
Meta Vens
381d23acee Add coordinated Geometry candidate build 2026-07-22 04:08:32 +01:00
Meta Vens
cdeea47454 Use compiler-portable geometry shader includes 2026-07-22 04:07:20 +01:00
Meta Vens
9758940b35 Integrate geometry worker through CultMesh 2026-07-22 04:01:20 +01:00
Meta Vens
6b44f969a9 Absorb CSG kernel into GameCult.Geometry 2026-07-22 03:56:28 +01:00
Meta Vens
99c893bd64 Fix remaining planetary ownership labels 2026-07-22 03:52:56 +01:00
Meta Vens
756f5a3337 Transfer planetary geometry into GameCult.Geometry 2026-07-22 03:47:57 +01:00
Meta Vens
9293471630 Add cross-runtime geometry v2 contract 2026-07-22 03:25:46 +01:00
Meta Vens
8a219a479a Publish typed geometry v2 contracts 2026-07-22 03:12:55 +01:00
Meta Vens
317b7d538e Delete duplicate geometry vectors 2026-07-22 03:07:31 +01:00
Meta Vens
0f16f14415 Add dedicated Geometry Unity package 2026-07-22 03:01:08 +01:00
Meta Vens
343e03d8a2 Record geometry wire witnesses 2026-07-22 02:59:20 +01:00
Meta Vens
364c9cdf48 Integrate CultMath vectors into geometry 2026-07-22 02:58:42 +01:00
Meta Vens
9698805588 Add geometry serialization and SoA foundations 2026-07-22 02:53:29 +01:00
Meta Vens
c83ce2e7e6 Record geometry migration baseline 2026-07-22 02:47:08 +01:00
Meta Vens
13e8c56f5f Plan geometry ownership migration 2026-07-22 02:26:02 +01:00
Meta Vens
9295278531 Return RUDP application receipts 2026-07-13 17:30:08 +01:00
569 changed files with 35392 additions and 60341 deletions

View file

@ -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"

View file

@ -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

View file

@ -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

29
.gitignore vendored
View file

@ -206,6 +206,17 @@ PublishScripts/
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
!/packages/gamecult-geometry-rs/
!/packages/gamecult-geometry-rs/**
packages/gamecult-geometry-rs/target/
.tools/
!/packages/gamecult-geometry-ts/
!/packages/gamecult-geometry-ts/**
!/unity/org.gamecult.geometry/Samples~/
!/unity/org.gamecult.geometry/Samples~/**
!/unity/org.gamecult.geometry/Runtime/PlanetaryPatchMeshAdapter.cs.meta
!/unity/org.gamecult.geometry/Runtime/PlanetaryPageUpload.cs.meta
!/src/GameCult.Geometry/Shaders/*.hlsl.meta
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
@ -241,6 +252,8 @@ _pkginfo.txt
ClientBin/
~$*
*~
!/unity/org.gamecult.geometry/Samples~/
!/unity/org.gamecult.geometry/Samples~/**
*.dbmdl
*.dbproj.schemaview
*.jfm
@ -303,22 +316,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 +517,3 @@ $RECYCLE.BIN/
# Vim temporary swap files
*.swp
# Python package build output
packages/*-py/build/
*.egg-info/

View 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"
}

View file

@ -1,3 +0,0 @@
Repository doctrine for this project lives in AGENTS.md, shared with Codex.
@AGENTS.md

View file

@ -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}"
@ -25,30 +29,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Geometry", "src\Ga
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Geometry.Tests", "tests\GameCult.Geometry.Tests\GameCult.Geometry.Tests.csproj", "{C95A9A21-34FA-4D82-BE5B-88FBDA836D31}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Geometry.Planetary.Tests", "tests\GameCult.Geometry.Planetary.Tests\GameCult.Geometry.Planetary.Tests.csproj", "{CE447820-B407-48DE-8957-F373E032C4AE}"
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 +83,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
@ -167,6 +179,18 @@ Global
{C95A9A21-34FA-4D82-BE5B-88FBDA836D31}.Release|x64.Build.0 = Release|Any CPU
{C95A9A21-34FA-4D82-BE5B-88FBDA836D31}.Release|x86.ActiveCfg = Release|Any CPU
{C95A9A21-34FA-4D82-BE5B-88FBDA836D31}.Release|x86.Build.0 = Release|Any CPU
{CE447820-B407-48DE-8957-F373E032C4AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CE447820-B407-48DE-8957-F373E032C4AE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CE447820-B407-48DE-8957-F373E032C4AE}.Debug|x64.ActiveCfg = Debug|Any CPU
{CE447820-B407-48DE-8957-F373E032C4AE}.Debug|x64.Build.0 = Debug|Any CPU
{CE447820-B407-48DE-8957-F373E032C4AE}.Debug|x86.ActiveCfg = Debug|Any CPU
{CE447820-B407-48DE-8957-F373E032C4AE}.Debug|x86.Build.0 = Debug|Any CPU
{CE447820-B407-48DE-8957-F373E032C4AE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CE447820-B407-48DE-8957-F373E032C4AE}.Release|Any CPU.Build.0 = Release|Any CPU
{CE447820-B407-48DE-8957-F373E032C4AE}.Release|x64.ActiveCfg = Release|Any CPU
{CE447820-B407-48DE-8957-F373E032C4AE}.Release|x64.Build.0 = Release|Any CPU
{CE447820-B407-48DE-8957-F373E032C4AE}.Release|x86.ActiveCfg = Release|Any CPU
{CE447820-B407-48DE-8957-F373E032C4AE}.Release|x86.Build.0 = Release|Any CPU
{F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Debug|x64.ActiveCfg = Debug|Any CPU
@ -203,78 +227,6 @@ Global
{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,21 +235,17 @@ 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}
{EB15435B-7147-4BD9-BB9C-B0A14318D3D5} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
{94C086DA-3359-424B-9688-29A198806481} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{C95A9A21-34FA-4D82-BE5B-88FBDA836D31} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
{CE447820-B407-48DE-8957-F373E032C4AE} = {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
View 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>

View file

@ -14,8 +14,9 @@
<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.33</Version>
<CultMathCandidateFeed Condition="'$(CultMathCandidateFeed)' == ''">$(MSBuildThisFileDirectory)..\CultMath\.tools\local-feed</CultMathCandidateFeed>
<RestoreAdditionalProjectSources Condition="Exists('$(CultMathCandidateFeed)')">$(RestoreAdditionalProjectSources);$(CultMathCandidateFeed)</RestoreAdditionalProjectSources>
<!-- Symbols/debugging -->
<EmbedUntrackedSources>true</EmbedUntrackedSources>

111
README.md
View file

@ -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`: geometry, planetary mapping, CSG, LOD selection, mesh assembly, and CultCache-native geometry documents built on CultMath primitives
- `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)

View 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.

View file

@ -0,0 +1,320 @@
# CultMath and GameCult.Geometry Migration Progress
Plan: `docs/cultmath-gamecult-geometry-migration-plan.md` (locked)
Started: 2026-07-22
Status: Stages 0-5 complete; Stage 6 consumer cutover implemented with two
external verification/publication gates; Stage 7 source-authority cut complete
## Objective
Implement the locked migration so `GameCult.Geometry` becomes the sole owner of
geometry, planetary geometry, CSG, geometry schemas, and runtime projections,
while CultMath remains the HLSL-shaped numeric substrate and VibeGeometry loses
all live authority.
## Execution Authority Map
- Owner: `GameCult.Geometry` owns geometry decisions and geometry state.
- Inputs: CultMath numeric primitives, mined `vg-csg` implementation/evidence,
current geometry v1 documents, and named downstream consumer contracts.
- Outputs: GameCult.Geometry C#/Rust/HLSL/Unity/web surfaces, v2 typed schemas,
persisted artifacts, and CultMesh projections.
- Derived state: JSON inspection, UI/debug projections, caches, migration
receipts, and compatibility reads are non-authoritative.
- Forbidden writers: CultMath planetary APIs, v1 geometry runtime writers,
`CultVec*`, VibeGeometry/`vg-csg`, legacy shader entrypoints, and anonymous
fixed-vector arrays.
- Shared paths: direct API calls, persisted reloads, imports, network envelopes,
worker commands, Unity consumption, web tile reads, and Rust emitters must use
the same current schema/layout/fingerprint rules.
- Deletion line: `CultVec2`, `CultVec3`, `CultMath.rect`, CultMath planetary and
erosion ownership, v1 writers, VibeGeometry wiring/submodule authority, and
stale package/docs/schema paths.
Demotions:
- Geometry v1 DTOs are no longer owners; when a durable corpus requires them,
they are bounded read-only migration inputs to v2.
- JSON is no longer an owner; it is an inspection or xenos-boundary projection.
- VibeGeometry is no longer an owner; it is a provenance-bearing source quarry.
- CultMath planetary state is no longer an owner; it is source material pending
transfer and deletion in the same unpublished migration cut.
## Worktree Safety
- CultLib began with unrelated modified `.voidbot`, CultMesh, CultCache TS,
CultNet Rust, README, and test files plus scratch PID files. These changes are
outside the migration unless an exact overlap is required and reviewed.
- CultMath began clean on `codex/rust-cultmath-core` at `cb481b4`.
- VibeGeometry began clean on `main` at `85ac60a`.
- The locked plan is not edited during implementation.
## Stage Ledger
| Stage | State | Evidence / next gate |
| --- | --- | --- |
| 0. Freeze and baseline | Complete | Source bodies, contracts, consumers, licenses, fixtures, and focused baselines mapped |
| 1. Destination/dependency/formatters/SoA | Complete | NuGet and dedicated UPM artifacts prove dependency direction and owned vector integration |
| 2. Primitive deletion and v2 schemas | Complete | C# and Geometry-owned Rust emit identical v2 bytes and record keys for all four roots |
| 3. Planetary transfer | Complete | Managed, HLSL, CMPT, Unity, web, tool, docs, tests, and package authority moved; CultMath owners deleted |
| 4. Mine `vg-csg` | Complete | Geometry-owned Rust kernel passes correctness, parity, performance, formatting, and warning-denied lint gates |
| 5. CultCache/CultNet/CultMesh integration | Complete | One provider commit path persists, watches, replicates, and probes typed geometry state |
| 6. Consumer cutover/release | Gate pending | Fensalir green; Aetheria source cut pushed but full build/Unity regeneration await external dependency/editor state and immutable UPM tag |
| 7. Delete obsolete authorities/audit | Complete | VibeGeometry workspace authority archived; tracked negative audit is clean apart from retained provenance/witnesses |
## Verification Receipts
Receipts will be appended here with command, repository, commit, result, and
relevant artifact/version. A passing narrow test is not accepted as evidence for
a broader stage gate.
### Stage 0 receipts
- CultLib: `dotnet test tests/GameCult.Geometry.Tests/GameCult.Geometry.Tests.csproj --no-build --no-restore --disable-build-servers` passed 13/13. A full rebuild remains a Stage 1 gate; shared .NET build contention made attached build attempts exceed 60 seconds.
- CultMath: `dotnet test CultMath.slnx --no-restore` passed 72/72.
- CultMath native: `cargo test --locked` passed 1/1.
- CultMath web tile unit: `node --test web/planetary-tile.test.mjs` passed 1/1.
- CultMath pack and the combined web/C# cross-runtime script exceeded the
baseline time window and remain required package-transfer gates.
- VibeGeometry: `cargo test` passed 56 unit and 9 integration tests; doc tests
passed; `cargo clippy --all-targets --all-features` passed.
- Authoritative Rust quarry: VibeGeometry-pinned `vg-csg` submodule `a3197f4`.
Standalone `E:\Projects\vg-csg` is stale and excluded.
- v1 witnesses: current C#/Rust slots and hash algorithms match; Rust expected
record keys include domain `02c9...ed95` and chunk `b94d...21af5`. Exact
checked-in MessagePack byte witnesses still need to be generated before v2.
- Persisted corpus: targeted file inventory covered CultLib, CultMath,
VibeGeometry, Fensalir, and Aetheria. CultLib contains only the two checked-in
`vg-csg-ragnarok` payload fixtures. Binary scanning of Aetheria `.cc`,
`.msgpack`, and `.mpack` artifacts found no `gamecult.geometry.*.v1` catalog
identity. Indexed-source retrieval found definitions/tests/docs but no durable
geometry store. Execution therefore uses v2 plus retained test witnesses,
with no live v1 runtime migrator.
### Stage 1 receipts
- CultCache MessagePack now resolves external member values through a
document-owner assembly declaration. Resolver precedence is core CultCache,
owner-declared resolvers, then MessagePack standard fallback; the options are
cached per owner assembly and generated document codecs use that same path.
CultCache does not reference Geometry or CultMath.
- CultCache SoA discovery now admits direct reference-free value types as one
exact typed column. It rejects managed-reference and byref-like values and
does not synthesize component columns from nested struct fields.
- `dotnet test tests/GameCult.Caching.Tests/GameCult.Caching.Tests.csproj
--no-restore --filter
"FullyQualifiedName~DocumentAssemblyResolverTests|FullyQualifiedName~SoaValueColumnTests"
--verbosity minimal -m:1 -nodeReuse:false` passed 4/4 after a full rebuild.
- The initial SoA reflection probe used `Type.IsFunctionPointer`, which is not
available on the library's `netstandard2.1` target. It was removed rather
than papered over; structural managed-reference inspection is the owning
eligibility rule.
- CultMath candidate `0.1.0-geometry-migration.1` was packed from commit
`cb481b4c75444426a693781a0e5b35ade08a8938` with build servers disabled. The
123,307-byte package SHA-256 is
`07C0CE79453AF9BA29910809F8779916E7B8F92DCA7ACEB5E2B8F9D3EBED3E93`;
a local-feed consumer restored, compiled, and executed `CultMath.float3`.
- GameCult.Geometry consumes that candidate as an explicit NuGet dependency;
its sibling `.tools/local-feed` is only an optional restore source, not a
project-reference authority. Geometry owns strict positional formatters for
`float2`, `float3`, `float4`, and `quaternion`.
- `dotnet test tests/GameCult.Geometry.Tests/GameCult.Geometry.Tests.csproj
--no-restore --filter FullyQualifiedName~CultMathValueIntegrationTests
--verbosity minimal --disable-build-servers -p:UseSharedCompilation=false
-m:1`
passed 6/6. Evidence covers exact bytes, round trips, malformed-width
rejection, actual `Column<float2>`/`Column<float3>` access, and absence of
synthetic component columns.
- GameCult.Geometry candidate `0.1.0-geometry-migration.1` packed successfully;
its nuspec declares exact candidate dependencies on CultMath, CultCache, and
CultCache.MessagePack. The dedicated UPM artifact remains the open Stage 1
gate.
- Authoritative `vg-csg` commit `8f070f4` now pins exact v1 MessagePack bytes
for representative domain/chunk tuples plus complete domain, request,
selected-cut, and chunk record keys. Focused Rust tests passed 4/4; formatting
and clippy passed. VibeGeometry commit `82b56db` advances its submodule to
that witness commit. Both migration branches are pushed.
- `scripts/build-geometry-unity-package.ps1` staged
`org.gamecult.geometry@0.1.0` successfully. Its manifest depends explicitly
on `org.gamecult.cultmath@0.1.0` and `org.gamecult.cultlib@1.0.7`; the package
contains only its owned `GameCult.Geometry.dll`/symbols plus its asmdef and
documentation. It neither duplicates CultMath nor buries Geometry inside the
CultLib Unity package.
## Decisions and Deviations
- Licensing: no relicensing is assumed. Moved CultMath files retain MPL-2.0
notices and provenance; the GameCult.Geometry package will carry explicit
mixed-license package documentation rather than falsely stamping those files
MIT. `AdvancedErosionFilter` source and shader notices remain verbatim.
- Serialization: Geometry-owned external value formatters require a declarative
per-document-assembly resolver seam in `GameCult.Caching.MessagePack`.
CultMath remains annotation-free and core caching does not reference Geometry.
- v1 persistence: no durable corpus was found in the named repositories and
artifacts. v1 remains fixture evidence only; it will not survive as a runtime
writer or general compatibility model.
- No deviations from the locked target architecture.
- Stage 2 versions all four root geometry documents as one v2 cohort. Domain's
nested CLR layout changes are invisible to the current root-only schema
fingerprint, and leaving SelectedCut as the lone live v1 writer would retain
a split cohort even though its direct fields are unchanged.
- Typed vectors preserve existing record keys by flattening components in the
same authored order into the existing IEEE-754 bit canonicalization. Wire
payloads still change—especially typed mesh arrays—so byte fixtures and v2
identities remain mandatory.
### Stage 2 receipts
- `CultVec2` and `CultVec3` definitions were deleted; `CultRect`, `CultCircle`,
and `CultSphere` now expose CultMath `float2`/`float3` directly. No alias or
conversion owner remains.
- `dotnet test tests/GameCult.Geometry.Tests/GameCult.Geometry.Tests.csproj
--no-restore --verbosity minimal --disable-build-servers
-p:UseSharedCompilation=false -m:1` passed 19/19 for the primitive cut.
- CultMesh native-slice metadata fixtures now name `CultMath.float2` and
`CultMath.float3` while retaining 8/12-byte element widths. Only those three
overlapping lines were staged from the already-dirty TypeScript test file.
- All four root geometry schemas now form a v2 cohort with their original slot
numbers. Semantic single vectors use `float3`/`quaternion`; mesh vertices,
normals, and UVs use `float3[]`/`float2[]`. The runtime contains no v1 DTO,
reader, writer, or conversion authority.
- Live v2 semantic SchemaIds are pinned as domain `sha256:e335...421d`, build
request `sha256:2de3...21d3`, selected cut `sha256:8644...9ff8`, and chunk
artifact `sha256:2d28...6156`. Full values live in the executable tests.
- Typed stable fingerprints flatten components into the prior ordered IEEE-bit
stream. Domain/chunk round trips preserve record identity, while the checked-
in flat-mesh v1 payload is retained as negative evidence and rejected by the
v2 decoder. The Geometry suite passes 19/19 after the cohort cut.
- `packages/gamecult-geometry-rs` is the Geometry-owned Rust v2 destination.
It has no VibeGeometry, `vg-csg`, `bevy_math`, CultCache, or CultNet
dependency and preserves the MIT provenance of the mined `vg-csg` contract.
- C# and Rust pin identical exact MessagePack bytes and record keys for domain,
build request, selected cut, and chunk roots. C# decodes the Rust witnesses
and re-emits the same bytes. The Geometry suite passes 24/24; Rust passes 4/4,
doc tests, rustfmt, and warning-denied clippy.
- The parity pass exposed an old split identity: C# request scalar floats used
round-trip decimal text while `vg-csg` used IEEE-bit hex. v2 deliberately
standardizes every float on IEEE bits. The request key therefore changes at
the v2 boundary; signed zero, a chosen NaN payload, and infinity are pinned
across MessagePack so this cannot quietly diverge again.
### Stage 3 receipts
- Thirteen planetary/erosion C# sources and thirteen xUnit suites now live
under GameCult.Geometry; `CultMath.rect`, its factories, and all original
planetary sources/tests are deleted. Destination planetary tests pass 41/41;
remaining CultMath tests pass 28/28 for both target frameworks.
- Geometry owns five HLSL files behind `GameCult.Geometry.hlsl`; public geometry
symbols use `gamecult_geometry_*`. CultMath's umbrella and NuGet candidate
contain only numeric `CultMath.hlsl`. A DXC SPIR-V smoke compiled the joined
umbrella successfully to a 1,428-byte module.
- The CMPT v1 tool and TypeScript decoder moved intact. The end-to-end fixture
is exactly 1,221 bytes with SHA-256
`8b2bc46e4123b8ac8936b43f8d08a34e3f2e74f38d0bb3f101cf3728cd201962`;
the C# emitter and TypeScript decoder pass together.
- Unity adapters retain their original `.meta` GUIDs; the Planetary Viewer uses
the Geometry umbrella and owned shader symbols. The staged UPM artifact has
one owned DLL, both adapters, the sample, all five shaders, and explicit
MIT/MPL license and provenance documents.
- Stale candidate cache was made impossible by rotating CultMath and Geometry
to `0.1.0-geometry-migration.2`. CultMath's candidate contains only its DLLs,
README, and numeric shader. Geometry's candidate declares the exact CultMath
`.2` dependency and contains all Geometry shaders/docs/notices under a
mixed-license file declaration rather than the inherited blanket MIT claim.
- Full Geometry tests pass 27/27 after the joined shader/Unity integration;
planetary tests pass 41/41. Negative searches find obsolete CultMath geometry
names only inside tests that assert their absence or ignored generated output.
### Stage 4 receipts
- `packages/gamecult-geometry-rs` now owns the mined convex splitting, ordered
brush CSG, tree lowering, dirty frontiers, prefix checkpoints, domain/LOD
selection, feature lowering, and triangle/collider assembly kernels. No build
or runtime dependency points at VibeGeometry or `vg-csg`.
- The public `GeometryBrushAssembler` facade accepts Geometry-owned
`Float3` values and returns `GeometryTriangleMesh`; `bevy_math` remains a
private implementation dependency and is not re-exported.
- The duplicate quarry persistence authority (`DomainSpecDocument`,
`DomainNodeDocument`, `FeatureClaimDocument`, and its local schema version)
was deleted. Private specs are in-memory builders; the v2 `Geometry*`
documents remain the sole wire authority.
- Nine RealtimeCSG behavioral fixtures and a bounded 512-distant-cutter
performance fixture moved under Geometry ownership. `cargo test --locked`
passes 67/67 plus doc tests; rustfmt and warning-denied Clippy pass.
- The authority transfer commits are CultLib `756f5a33`, CultMath `6a16260`,
and the post-transfer label cleanup `99c893bd`. The Rust absorption commit is
CultLib `6b44f969`.
### Stage 5 receipts
- `CultGeometryWorkerProvider` owns the build invocation and output commit
sequence. Direct calls and the `gamecult.geometry.worker.build` CultMesh
operation share `BuildAsync`; compute is injected through the persistence-free
`ICultGeometryBuildPipeline` port.
- Domain/request reads and selected-cut, artifact, and observational worker-state
writes use `CultNetDatabase` typed APIs. Watches and raw MessagePack envelopes
are consequences of that commit path rather than parallel publishers.
- The development probe derives owner, v2 schema, request/cut keys, selected
nodes, artifact identities, content hashes, and served assembly version from
persisted documents. Mutating worker display state cannot overwrite geometry
outputs or forge the served version.
- Worker state registers through the generated CultDocument registry. Focused
provider tests pass 4/4 and the full GameCult.Geometry suite passes 31/31.
- Rust raw-envelope duplication was deliberately omitted: CultNet owns that
protocol. Existing C#/Rust exact v2 document bytes and record keys remain the
cross-runtime geometry witness.
### Stage 6 receipts
- Coordinated local candidates use
`scripts/build-geometry-candidate.ps1`. CultMath `.2` is primitives-only;
Geometry `.2` contains its managed assembly, notices/docs, and all five owned
shaders with exact support-package and CultMath candidate dependencies. A
clean net10 consumer restored and built; the Unity package staged one owned
assembly plus adapters, sample, and shaders. The Rust crate passed an
independent `cargo package --allow-dirty --no-verify` dry run.
- Fensalir cutover commit `42778c1` adds direct Geometry dependencies, migrates
planetary C# consumers, compiler include roots, shaders, and parity harnesses
while retaining CultMath for numeric primitives. The solution builds with no
warnings/errors; fractal tests pass 170/170, erosion parity 1/1, topology
parity 19/19, and planetary page tests 16/16.
- Aetheria cutover commits `26948f81` and `557bc71f` replace `CultVec*` fixtures
with CultMath vectors under an exact legacy MessagePack byte witness, move
spatial rectangles to `GameCult.Geometry.CultRect`, add the real .NET/Unity
Geometry dependencies, and remove stale ownership guidance.
- Aetheria's geometry compiler errors are gone. Its full build is presently
blocked by concurrent CultMesh API drift (`CultMeshBody*` and persisted-record
types), outside this migration. Unity regeneration is also deferred while an
active Unity editor owns the project. The coordinated local file dependency
must become an immutable Geometry Unity tag before publication.
- Legacy D3DCompiler exposed that macro-expanded HLSL includes were not
portable. CultLib commit `cdeea474` gives the Geometry umbrella direct default
and Unity include branches; Geometry shader ownership tests pass 3/3 and the
downstream D3D12 corpus passes.
### Stage 7 receipts
- Tracked negative audits across CultLib, CultMath, Fensalir, Aetheria, and
VibeGeometry find no live `CultVec*`, `CultMath.rect`, CultMath planetary
implementation, old planetary shader vocabulary, geometry v1 writer, or
VibeGeometry/`vg-csg` build dependency. Frozen fixtures, source attribution,
and negative ownership tests remain deliberately.
- VibeGeometry commit `bc9604a` removes its tracked Cargo workspace, lockfile,
submodule declaration/gitlink, scratch/build wiring, and live mission claims.
Its remaining documents point to source witness `8f070f4f` and Geometry
absorption commit `6b44f969`; repository history preserves provenance.
- CultLib commits `8a7eee71` and `b9f20a64` remove manual-registry guidance and
update the root package map. Generated CultDocument metadata remains the only
registration owner.
## Next Actions
1. Resolve Aetheria's concurrent CultMesh API/worktree drift, then rerun its
State/Freeze verifier and daemon smoke against this migration branch.
2. Close the active Unity editor/import workers, regenerate Aetheria Unity
project/lock files through Unity, and run the canonical EditMode suite.
3. Publish an immutable `org.gamecult.geometry` Unity package tag in the
coordinated release window, replace Aetheria's local candidate reference,
and repeat package/version inspection before declaring Stage 6 complete.

View file

@ -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

View file

@ -19,5 +19,5 @@ closure through UPM rather than NuGet restore.
Install the Unity package from the immutable release tag:
```text
https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.46
https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.8
```

View file

@ -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`).

View file

@ -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.

View file

@ -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.

556
package-lock.json generated
View file

@ -8,15 +8,8 @@
"workspaces": [
"packages/cultcache-ts",
"packages/cultnet-ts",
"packages/cultmesh-ts",
"packages/cultmesh-browser"
],
"devDependencies": {
"esbuild": "^0.28.1",
"fast-uri": "3.1.5",
"js-yaml": "4.3.1",
"playwright-core": "^1.62.1"
}
"packages/cultmesh-ts"
]
},
"node_modules/@apidevtools/json-schema-ref-parser": {
"version": "11.9.3",
@ -36,452 +29,6 @@
"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",
@ -545,8 +92,8 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/cultmesh-browser": {
"resolved": "packages/cultmesh-browser",
"node_modules/cultcache-ts": {
"resolved": "packages/cultcache-ts",
"link": true
},
"node_modules/cultmesh-ts": {
@ -557,48 +104,6 @@
"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",
@ -606,9 +111,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
"funding": [
{
"type": "github",
@ -663,9 +168,9 @@
}
},
"node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true,
"funding": [
{
@ -745,19 +250,6 @@
"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",
@ -831,8 +323,7 @@
}
},
"packages/cultcache-ts": {
"name": "@gamecult/cultcache-ts",
"version": "0.14.0",
"version": "0.13.5",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
@ -846,28 +337,13 @@
"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"
"cultcache-ts": "^0.13.5",
"cultnet-ts": "^0.1.1"
},
"devDependencies": {
"@types/node": "^24.0.0",
@ -879,12 +355,12 @@
}
},
"packages/cultnet-ts": {
"version": "0.2.0",
"version": "0.1.1",
"license": "MIT",
"dependencies": {
"@gamecult/cultcache-ts": "^0.14.0",
"@msgpack/msgpack": "^3.1.3",
"ajv": "^8.17.1"
"ajv": "^8.17.1",
"cultcache-ts": "^0.13.5"
},
"devDependencies": {
"@types/node": "^24.0.0",

View file

@ -5,18 +5,11 @@
"packages/cultcache-ts",
"packages/cultnet-ts",
"packages/cultmesh-ts",
"packages/cultmesh-browser"
"packages/gamecult-geometry-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"
"test:pack:ts": "node scripts/test-typescript-package-closure.mjs"
}
}

View file

@ -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

View file

@ -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.

View file

@ -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"]

View file

@ -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)

View file

@ -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:

View file

@ -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)

View file

@ -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

View file

@ -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",

View file

@ -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,

View file

@ -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,

View file

@ -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(

View file

@ -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))

View file

@ -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,

View file

@ -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

View 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)))

View file

@ -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)))

View file

@ -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",

View file

@ -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",

View file

@ -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

View file

@ -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; }
}

View file

@ -1,8 +1,7 @@
[package]
name = "cultcache-rs"
version = "0.2.0"
version = "0.1.0"
edition = "2024"
license = "MIT"
publish = false
[workspace]
@ -18,15 +17,14 @@ 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"
uuid = { version = "1", features = ["v4"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem"] }
[dev-dependencies]
pretty_assertions = "1.4"
tempfile = "3"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] }

View file

@ -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
@ -282,8 +250,38 @@ marked `default` use `Default::default()` when older payloads are read. Deleted
field slots should stay reserved until an explicit store migration rewrites the
data.
The single-file MessagePack store rewrites an atomic snapshot. That is a sane
starting point for small typed state surfaces, settings, Epiphany agent memory,
The single-file MessagePack store rewrites one complete snapshot. Under the
exclusive sidecar lock it removes abandoned candidates without following them,
creates a unique same-directory candidate with create-new semantics, writes and
flushes it, and then replaces the committed pathname without deleting that
pathname first. A process interruption before replacement leaves the previous
snapshot authoritative. Once replacement succeeds, readers observe the complete
new snapshot rather than a partial file.
Unix requests a parent-directory flush after replacement. Windows uses
`MoveFileExW` with replace-existing and write-through flags. If the final Unix
directory flush fails, the method returns a downcastable
`CommittedSnapshotDurabilityUncertain`: the complete new snapshot may already be
visible, so the caller must fail-stop or reopen and reconcile before issuing
higher-level work again. Treating that error as "not committed" and blindly
retrying can duplicate commands or receipts.
Abandoned `.<uuid>.tmp` candidates are never read as committed state and are
removed by the next exclusive writer. Cleanup recognizes only the exact
canonical UUID form emitted for that target, including targets whose native
filename is not UTF-8; similar `.tmp` names remain untouched. Candidate creation
uses create-new semantics, so an unexpected file or symlink collision fails
instead of being opened or truncated. The canonical candidate filename namespace
is reserved to the store.
These guarantees assume a local filesystem that honors the platform rename and
flush primitives, and writers that honor the sidecar lock. Network filesystems,
faulty storage hardware, and unrelated writers may provide weaker behavior.
Fault-injection tests prove the process-visible old-or-new outcomes around the
replacement boundary; they do not simulate sudden power loss or prove that a
device honored a successful flush.
This is a sane starting point for small typed state surfaces, settings, Epiphany agent memory,
heartbeat state, and other compact control-plane data. Large corpora should use
a sharded store or a real database instead of asking one file to become a
warehouse and then acting wounded when physics invoices us.
@ -292,6 +290,9 @@ The single-file store uses a sidecar lock file for shared reads and exclusive
writes. That protects ordinary multi-process access to the same file. It is
still not a multi-master replication protocol, and it is not a substitute for a
coordinator when higher-level write ordering matters.
An explicit unlock error cannot replace the write outcome: dropping the lock-file
handle is the final OS lock-release path, so a committed-durability-uncertain
result remains inspectable rather than being masked by cleanup noise.
## Near-Term Ergonomic Improvements

View file

@ -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,
}

View file

@ -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

View file

@ -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 });

View file

@ -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",

File diff suppressed because it is too large Load diff

View file

@ -27,8 +27,3 @@ export type {
InspectedCatalogMember,
InspectedRecord,
} from "./cult-cache-inspector";
export {
defineSwarmDocuments,
type DefineDocumentType,
type SwarmDocumentCatalog,
} from "./swarm-documents";

View file

@ -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,
};
}

View file

@ -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 () => {

View file

@ -1,9 +0,0 @@
bin/
obj/
**/bin/
**/obj/
target/
**/target/
/native/cultmath-core/target.meta
.tools/
/artifacts/

View file

@ -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.

View file

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 867daebafe0ce48479cf043461ee6bdf
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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.

View file

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 7c28c8631c7d4054eb751d7ce952df33
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: de535b89253410b41b5989f712b80af4
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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.

View file

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 07021a8be816e3448a9490c10845b0aa
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 9d1cb2ac88c530b4d9da456523115e12
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: a42d65478346c354c8b4f353c5581b49
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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"

View file

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: f1c365f7eead2684ba621124e653bdab
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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"

View file

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: cdd2d4eab053b764b9a06d36b080e43e
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 7de448b7e258d2c448bb7391051cc628
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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));
}
}

View file

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: b06c69c432a633f4584830d6b28d4473
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 0ddff5b782cf14e43ae5cda048e040d8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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)"

View file

@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 3bed74ebcba445543800218dbe185034
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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

View file

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 33174674795143c4a905408718f956f8
ShaderIncludeImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 64f48d0cae0810e4ba9903e9094fe4c6
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: e857b457b93397d4fb4ad8481cb72e4c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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];
}
}
}

View file

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: d4771320e190bdd408dd296c293a9d0e

View file

@ -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);

View file

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: bfa14a9aacb8ac4448761568643f2bc6

View file

@ -1,14 +0,0 @@
{
"name": "CultMath",
"rootNamespace": "CultMath",
"references": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": true,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": true
}

View file

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: f0b66b909d13e18468f7aa56221531e9
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,26 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0;netstandard2.1</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<RootNamespace>CultMath</RootNamespace>
<AssemblyName>CultMath</AssemblyName>
<PackageId>GameCult.Math</PackageId>
<Version>0.2.0</Version>
<Description>Clean-room C# math shaped for HLSL-style renderer code.</Description>
<Authors>GameCult</Authors>
<PackageReadmeFile>README.md</PackageReadmeFile>
<NoWarn>$(NoWarn);CS8981</NoWarn>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<!-- The Unity DLL is committed beside its source, so its bytes must not name a commit. -->
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
</PropertyGroup>
<ItemGroup>
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
<None Include="..\..\shaders\CultMath.hlsl" Pack="true" PackagePath="contentFiles\any\any\shaders\CultMath.hlsl" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View file

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 77e1a0a2512910f41885650a7d0d6f60
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,10 +0,0 @@
namespace CultMath;
public enum CultMathTone : byte
{
Background = 0,
Panel = 1,
Edge = 2,
Header = 3,
Body = 4,
}

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