Compare commits

..
488 changed files with 5272 additions and 78566 deletions

29
.gitattributes vendored
View file

@ -1,31 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
# Shell scripts keep LF in the working tree as well as in the index. Left to
# `text=auto`, a Windows clone writes them with carriage returns, and the
# documented Linux path then dies inside the container in under a second: the
# shebang reads as the interpreter `bash\r`, which does not exist. Nothing about
# that failure names line endings, and the same clone builds and tests fine
# everywhere else, so it reads as a broken image rather than a broken checkout.
#
# A clone checked out before this rule keeps its carriage returns, because the
# blob is unchanged and git has no reason to rewrite the file. The one-line
# repair is in native/GameCult.Mesh.Quic.Native/README.md, beside the container
# path it unblocks.
*.sh text eol=lf
# The committed CultMesh QUIC native binaries (cut 6, packaging) ride the
# deploy `git archive` as raw bytes. `* text=auto` already autodetects them as
# binary by content, but that autodetection is a heuristic; say so explicitly
# so no future text-processing rule (a diff driver, an export filter) can
# apply text conversion and poison the committed digests in SHA256SUMS.
packages/cultmesh-ts/native/**/*.dll binary
packages/cultmesh-ts/native/**/*.so binary
packages/cultmesh-ts/native/**/*.so.* binary
# The digest list and manifest beside them keep LF on every checkout. Left to
# `text=auto`, a Windows clone writes them with carriage returns, and the
# documented `sha256sum -c SHA256SUMS` then fails on every line (each filename
# ends in `\r`) while CI, checking out on Linux, stays green.
packages/cultmesh-ts/native/SHA256SUMS text eol=lf
packages/cultmesh-ts/native/MANIFEST.txt text eol=lf

View file

@ -95,46 +95,3 @@ jobs:
dotnet test tests/GameCult.Mesh.Tests/GameCult.Mesh.Tests.csproj
--filter FullyQualifiedName~Document_OneHundredThousandDistinctLeaseCyclesLeaveNoResourcesOrCallbacks
--logger "console;verbosity=minimal"
# The TypeScript side of the CultMesh authority rules. This job is the
# only one that runs on both Windows and Linux for main, codex/** and
# pull requests: cultnet-interop.yml is windows-latest and main-only, so
# until these steps existed the shared verifier's tests never ran on
# Linux at all, and the browser client's never ran in CI on any OS.
# packages/cultnet-ts verifies both vector files; cultmesh-browser proves
# the browser client reads the shared rules rather than its own.
- name: Verify the CultMesh authority rules with the TypeScript verifier
run: npm run test --workspace packages/cultnet-ts
- name: Verify the browser client against the shared TypeScript verifier
run: npm run test --workspace packages/cultmesh-browser
# The realtime frame codec. Its byte parity rests on two vector files,
# contracts/cultmesh/realtime-frame-vectors.json written by the C#
# reference and decoded by this step, and realtime-frame-vectors.ts-written.json
# written by this codec and decoded by the C# step below. Neither half ran
# in CI on any OS until these two steps existed, so a codec that agreed
# only with itself would have gone green.
- name: Verify the realtime frame codec with the TypeScript codec
run: npm run test --workspace packages/cultmesh-ts
# Both directions of the CultMesh authority vectors: the C#-written
# file (contracts/cultmesh/authority-route-vectors.json) and the
# TypeScript-signed file (authority-route-vectors.ts-signed.json) are
# verified by the C# reference here; the TypeScript side verifies both
# in the step above. CULTMESH_WRITE_VECTORS is never set in CI: the
# committed files are the contract.
- name: Verify the CultMesh authority vectors with the C# reference
run: >-
dotnet test tests/GameCult.Mesh.Tests/GameCult.Mesh.Tests.csproj
--filter FullyQualifiedName~AuthorityProof
--logger "console;verbosity=minimal"
# The other half of the frame parity: the C# reference decodes and
# re-encodes the vector file the TypeScript codec wrote. A fixture
# authored on one side alone would only agree with itself.
- name: Verify the realtime frame vectors with the C# reference
run: >-
dotnet test tests/GameCult.Mesh.Tests/GameCult.Mesh.Tests.csproj
--filter FullyQualifiedName~RealtimeWireProtocol
--logger "console;verbosity=minimal"

View file

@ -65,32 +65,6 @@ jobs:
"KOTLIN_JAVA_HOME=$env:JAVA_HOME" | Out-File -FilePath $env:GITHUB_ENV -Append
"$kotlinHome\bin" | Out-File -FilePath $env:GITHUB_PATH -Append
- name: Verify CultNet schema contracts stayed consolidated
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$stray = git ls-files '*.schema.json' | Where-Object { $_ -notmatch '^contracts/cultnet/' }
# -I skips binary files: unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll
# still embeds the retired namespace until it is rebuilt (REC-032 D4, deferred).
$oldNamespace = git grep -I -lE 'GameCult/cultnet-ts/(integration/)?contracts' -- ':!.voidbot'
if ($LASTEXITCODE -eq 2) {
throw "git grep failed"
}
if ($stray) {
Write-Host "Schema files found outside contracts/cultnet/:"
$stray | ForEach-Object { Write-Host " $_" }
}
if ($oldNamespace) {
Write-Host "Files still referencing the retired GameCult/cultnet-ts contracts namespace:"
$oldNamespace | ForEach-Object { Write-Host " $_" }
}
if ($stray -or $oldNamespace) {
throw "CultNet schema contracts drifted out of contracts/cultnet/ (REC-032). See CultLib/contracts/cultnet/ and rewrite any `$id back to https://github.com/GameCult/CultLib/contracts/cultnet/."
}
# git grep exits 1 when nothing matches, and the runner's pwsh wrapper
# ends the step with that $LASTEXITCODE; a clean tree must pass.
exit 0
- name: Install Node dependencies
run: npm install --no-package-lock

View file

@ -42,12 +42,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
# fetch-depth: 0 so the semver check below can see every previously
# published tag, not just the one that triggered this run.
- name: Check out CultLib
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node
uses: actions/setup-node@v4
@ -68,19 +64,6 @@ jobs:
exit 1
fi
# docs/semver-policy.md: a breaking change under too small a bump, or a
# version that skips or reverses the previous release, fails here
# instead of shipping with a number that lies.
- name: Verify semver policy
if: startsWith(github.ref, 'refs/tags/cultcache-ts-v')
run: |
tagged="${GITHUB_REF_NAME#cultcache-ts-v}"
node scripts/check-changelog-semver.mjs \
--package "@gamecult/cultcache-ts" \
--changelog packages/cultcache-ts/CHANGELOG.md \
--version "$tagged" \
--tag-prefix cultcache-ts
- name: Build and test
working-directory: packages/cultcache-ts
run: npm test
@ -122,13 +105,9 @@ jobs:
echo "run=false" >> "$GITHUB_OUTPUT"
fi
# fetch-depth: 0 so the semver check below can see every previously
# published tag, not just the one that triggered this run.
- name: Check out CultLib
if: steps.selected.outputs.run == 'true'
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
if: steps.selected.outputs.run == 'true'
@ -136,12 +115,6 @@ jobs:
with:
python-version: "3.12"
- name: Set up Node
if: steps.selected.outputs.run == 'true'
uses: actions/setup-node@v4
with:
node-version: 24.x
- name: Verify the tag matches the manifest version
if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/')
run: |
@ -152,19 +125,6 @@ jobs:
exit 1
fi
# docs/semver-policy.md: a breaking change under too small a bump, or a
# version that skips or reverses the previous release, fails here
# instead of shipping with a number that lies.
- name: Verify semver policy
if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/')
run: |
tagged="${GITHUB_REF_NAME#${{ matrix.package }}-v}"
node scripts/check-changelog-semver.mjs \
--package "${{ matrix.package }}" \
--changelog packages/${{ matrix.package }}/CHANGELOG.md \
--version "$tagged" \
--tag-prefix "${{ matrix.package }}"
# Lower layers install from source so a release of one package is tested
# against the sibling revision it was cut with, not whatever PyPI has.
- name: Install and test

View file

@ -1,202 +0,0 @@
name: CultMesh QUIC native
# Builds and, on a tag, releases the CultMesh QUIC native binaries that
# packages/cultmesh-ts/native/{win32-x64,linux-x64} commits (cut 6,
# docs/typescript-quic-realtime-cut.md:2108-2159). Under Q8 Option 1 the
# operator downloads a tagged run's Release assets, verifies every digest
# against this workflow's own logs and SHA256SUMS, and commits the bytes;
# nothing here writes to the tree. Under Q6 A, the Linux artifact comes only
# from the pinned debian:13 container below, never from a local or Yggdrasil
# build.
on:
push:
branches:
- main
tags:
- "cultmesh-quic-native-v*"
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
# Push to main and pull_request run this job only: it proves the tree's
# committed bytes still match the manifest they shipped with. A tag push
# also runs it, alongside the two builds and the release.
verify-committed:
name: Verify committed binaries match SHA256SUMS
if: >-
github.event_name == 'pull_request' ||
(github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v')))
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check out CultLib
uses: actions/checkout@v4
- name: sha256sum -c SHA256SUMS
run: |
set -euo pipefail
if [ ! -f packages/cultmesh-ts/native/SHA256SUMS ]; then
echo "packages/cultmesh-ts/native/SHA256SUMS is missing" >&2
exit 1
fi
cd packages/cultmesh-ts/native
sha256sum -c SHA256SUMS
# workflow_dispatch and a cultmesh-quic-native-v* tag run the two builds.
# A dry (workflow_dispatch) run uploads artifacts and creates no release.
build-win32-x64:
name: Build win32-x64
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v'))
runs-on: windows-latest
timeout-minutes: 20
steps:
- name: Check out CultLib
uses: actions/checkout@v4
- name: Build the native QUIC bridge
shell: pwsh
run: .\scripts\build-quic-native.ps1 -OutputDirectory artifacts\quic-native\win32-x64
- name: Print toolchain version and digests
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$compilerCmake = Get-ChildItem -Recurse -LiteralPath "artifacts\quic-native-build\x64\Release" -Filter "CMakeCXXCompiler.cmake" |
Select-Object -First 1
if ($compilerCmake) {
Select-String -LiteralPath $compilerCmake.FullName -Pattern "CMAKE_CXX_COMPILER_VERSION" |
ForEach-Object { Write-Host $_.Line.Trim() }
}
Get-FileHash -Algorithm SHA256 artifacts\quic-native\win32-x64\gamecult_mesh_quic_native.dll
Get-FileHash -Algorithm SHA256 artifacts\quic-native\win32-x64\msquic.dll
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: quic-native-win32-x64
path: artifacts/quic-native/win32-x64
if-no-files-found: error
build-linux-x64:
name: Build linux-x64
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v'))
runs-on: ubuntu-latest
# Q6 A: this pinned Debian 13 container is the only permitted Linux
# release builder. The digest is the pin; the `debian:13` tag is only a
# label. The artifact links against this image's glibc (2.41); a bare
# ubuntu-latest runner carries glibc 2.39, which is not the deploy host's
# OS (docs/typescript-quic-realtime-cut.md:2192-2197).
container:
image: debian:13@sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9ffc4b1
timeout-minutes: 20
steps:
- name: Check out CultLib
uses: actions/checkout@v4
- name: Install build tools
run: |
set -euo pipefail
apt-get update
apt-get install -y --no-install-recommends cmake ninja-build g++ curl ca-certificates dpkg
- name: Build the native QUIC bridge
run: ./scripts/build-quic-native.sh artifacts/quic-native/linux-x64
- name: Print toolchain version and digests
run: |
g++ --version
sha256sum artifacts/quic-native/linux-x64/libgamecult_mesh_quic_native.so
sha256sum artifacts/quic-native/linux-x64/libmsquic.so.2
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: quic-native-linux-x64
path: artifacts/quic-native/linux-x64
if-no-files-found: error
# Only this job has write permission, and only a tag push runs it. It
# attaches a draft MANIFEST.txt: the fields both build jobs cannot know
# (host names, MSVC version, the pinned dependency sources) are left for
# the operator to fill in from this run's own logs before committing the
# binaries, per 11.5's manifest paragraph.
release:
name: Release native binaries
needs: [build-win32-x64, build-linux-x64]
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Check out CultLib
uses: actions/checkout@v4
- name: Download win32-x64 artifact
uses: actions/download-artifact@v4
with:
name: quic-native-win32-x64
path: release/win32-x64
- name: Download linux-x64 artifact
uses: actions/download-artifact@v4
with:
name: quic-native-linux-x64
path: release/linux-x64
- name: Write SHA256SUMS and a draft MANIFEST.txt
run: |
set -euo pipefail
cd release
# Relative paths matching packages/cultmesh-ts/native/ exactly, so
# this file is the one the operator commits verbatim (11.5, 11.7).
sha256sum \
win32-x64/gamecult_mesh_quic_native.dll \
win32-x64/msquic.dll \
linux-x64/libgamecult_mesh_quic_native.so \
linux-x64/libmsquic.so.2 \
> SHA256SUMS
cat > MANIFEST.txt <<EOF
# Draft written by ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}.
# The operator fills the remaining fields from this run's job logs
# (build-win32-x64 and build-linux-x64 each print their toolchain
# version) before committing packages/cultmesh-ts/native/MANIFEST.txt.
msquic.version=2.5.9
msquic.windows.source=
msquic.linux.source=
bridge.commit=${GITHUB_SHA}
build.workflow_run=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}
build.release=${GITHUB_REF_NAME}
build.win32-x64.host=
build.linux-x64.host=
date=$(date -u +%Y-%m-%dT%H:%M:%SZ)
EOF
# Named after the upload-artifact names above, not after the parked
# scoped per-platform npm package names from the registry-publication
# seam (11.9): these are release archives, not npm packages, and
# 11.7's negative grep for that parked naming must stay clean.
tar -C win32-x64 -czf quic-native-win32-x64.tar.gz .
tar -C linux-x64 -czf quic-native-linux-x64.tar.gz .
- name: Create release
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
cd release
gh release create "$GITHUB_REF_NAME" --verify-tag \
--title "$GITHUB_REF_NAME" \
--notes-file MANIFEST.txt \
quic-native-win32-x64.tar.gz \
quic-native-linux-x64.tar.gz \
SHA256SUMS \
MANIFEST.txt

3
.gitignore vendored
View file

@ -6,9 +6,6 @@
# dotenv files
.env
# Stryker.NET mutation-testing output
StrykerOutput/
# User-specific files
*.rsuser
*.suo

View file

@ -1,44 +0,0 @@
{
"nowUnixMilliseconds": 1800000000000,
"odinRoot": {
"keyId": "odin-vector",
"x": "shJLGtmRrW6D87VQKv0xYQekLIfEkeN7dVawwpmIKWM=",
"y": "iR6Gx3aVCghzSyo7TqXqr+1397L73KDNZuzN2cZ55IE="
},
"route": {
"verseId": "aetheria",
"authorityRuntimeId": "aetheria-daemon",
"endpoint": "wss://provider.example/mesh",
"protocolIds": [
"cultmesh.content.v1",
"cultmesh.documents.v1"
],
"priority": 7,
"generation": "vector-generation-1",
"certificate": {
"providerKey": {
"keyId": "provider-vector",
"x": "6XIvgx3W1q7lJFEV0yYbeKWNFgssq54BHBuh9gCwcH8=",
"y": "oi1FiKraueLic+VemzOx18MNwG6thmZMUWeqNw6vNOk="
},
"odinKeyId": "odin-vector",
"issuedAtUnixMilliseconds": 1799999940000,
"expiresAtUnixMilliseconds": 1800003600000,
"signature": "ZHuELFuCNNfbJt1T2mx1D9ZHXyxBedacDetmryMYhWKQxfROncltymkmL5KXl+1h8tDK6NytJCGy+7mYlb/DMA=="
}
},
"sessionProof": {
"request": {
"schemaVersion": "cultmesh.session_open.v2",
"messageId": "vector-message-1",
"sourceRuntimeId": "vector-consumer",
"verseId": "aetheria",
"authorityRuntimeId": "aetheria-daemon",
"protocolId": "cultmesh.documents.v1",
"routeGeneration": "vector-generation-1",
"clientNonce": "VczqjH8NdBaOb+rh8xb6P0xZjWD1el2ReNB8Vbp52ko="
},
"providerKeyId": "provider-vector",
"providerSignature": "6kGxSu/pwcR4lzh/7Av9nhblVIwIwVFzB7kY34vVvkpaFAbAxt8mWqIyZDTftE28G1g+NL4Ea41C3QsuImmkaA=="
}
}

View file

@ -1,45 +0,0 @@
{
"nowUnixMilliseconds": 1800000000000,
"odinRoot": {
"keyId": "odin-ts-vector",
"x": "lqw9TE0EyuDU5Ghe8SafIbBBYkhHMUF7HXJQUc0nXsw=",
"y": "nUj+Xhakih97JiJcTUNMHM9JIo7MVQaauuAkCXph+tw="
},
"route": {
"verseId": "aetheria",
"authorityRuntimeId": "aetheria-daemon",
"endpoint": "cultmesh-state+quic://provider.example:4433/?cert-sha256=ABCDEF",
"protocolIds": [
"cultmesh.documents.v1",
"cultmesh.content.v1",
"cultmesh.realtime.v1"
],
"priority": 13,
"generation": "ts-generation-ünïcode-∞-1",
"certificate": {
"providerKey": {
"keyId": "provider-ts-vector",
"x": "JKyjtWnoE5fhSWbeqr188SbReQWCjssf5mSMEa5pV+E=",
"y": "vtduzojDpbT49sbxBSsBQ+f3PG3rm+CUk/MTy2MM/ws="
},
"odinKeyId": "odin-ts-vector",
"issuedAtUnixMilliseconds": 1799999940000,
"expiresAtUnixMilliseconds": 1800003600000,
"signature": "NS5a7X8fNqTi1CdpPAcJaMmEsozwFRoygTW4z+JlhHFq7uaZcb4G7NwoEpPqo/Fv+UA7pNl5N+qUMuSD/oAq1Q=="
}
},
"sessionProof": {
"request": {
"schemaVersion": "cultmesh.session_open.v2",
"messageId": "ts-vector-message-1",
"sourceRuntimeId": "ts-vector-consumer",
"verseId": "aetheria",
"authorityRuntimeId": "aetheria-daemon",
"protocolId": "cultmesh.documents.v1",
"routeGeneration": "ts-generation-ünïcode-∞-1",
"clientNonce": "HICtAmV++1MarssY04Tkyn1j9qVEcOMp/P5Ce1M0Yec="
},
"providerKeyId": "provider-ts-vector",
"providerSignature": "OgJHkmw3fhPRS5TXxZm94yZvkWAV5TPDuvG+/zIVlnU7OFENZ0Nn0Pdx0IT/vDfEWXD+8/+sCZVFIFwa4tUjsg=="
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,177 +0,0 @@
{
"frames": [
{
"label": "reliable-ordered, small payload",
"fields": {
"channelId": "aetheria.zone-1",
"schemaId": "cultmesh.realtime.v1",
"bodyId": "body-7",
"producerEpoch": 1,
"sequence": 2,
"delivery": "reliable-ordered",
"payload": "AH+A/yo="
},
"encoding": "Q1NUMQABAAAAAAAAAAIAAAAAAAAADwAUAAYABQAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03AH+A/yo=",
"encodedLength": 83,
"sha256OfEncoding": "d04539b99790560e5e570208d92d3ac25f85909a36bfeb190c2080288cc9f1d4"
},
{
"label": "latest-only, small payload",
"fields": {
"channelId": "aetheria.zone-1",
"schemaId": "cultmesh.realtime.v1",
"bodyId": "body-7",
"producerEpoch": 3,
"sequence": 4,
"delivery": "latest-only",
"payload": "AQID"
},
"encoding": "Q1NUMQEDAAAAAAAAAAQAAAAAAAAADwAUAAYAAwAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03AQID",
"encodedLength": 81,
"sha256OfEncoding": "2c875718cf4968979f3cd4083ba2b279e8c03815ad3447c66037ee4530b2814a"
},
{
"label": "unreliable, small payload",
"fields": {
"channelId": "aetheria.zone-1",
"schemaId": "cultmesh.realtime.v1",
"bodyId": "body-7",
"producerEpoch": 5,
"sequence": 6,
"delivery": "unreliable",
"payload": "/g=="
},
"encoding": "Q1NUMQIFAAAAAAAAAAYAAAAAAAAADwAUAAYAAQAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03/g==",
"encodedLength": 79,
"sha256OfEncoding": "b932adf333a0ca73d3a9077e15cf9062070a3084a50a3a30c3baa53312d864b1"
},
{
"label": "empty payload",
"fields": {
"channelId": "aetheria.zone-1",
"schemaId": "cultmesh.realtime.v1",
"bodyId": "body-7",
"producerEpoch": 0,
"sequence": 0,
"delivery": "reliable-ordered",
"payload": ""
},
"encoding": "Q1NUMQAAAAAAAAAAAAAAAAAAAAAADwAUAAYAAAAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03",
"encodedLength": 78,
"sha256OfEncoding": "06c0362f2b395bf7855522190f6a59c45c46947ce7a9a4b8be115cf54a48f419"
},
{
"label": "multi-byte identities",
"fields": {
"channelId": "aetheria.zöne-∞",
"schemaId": "cultmesh.realtime.€1",
"bodyId": "bødy-\uD83D\uDD25",
"producerEpoch": 11,
"sequence": 12,
"delivery": "latest-only",
"payload": "cGF5bG9hZC3iiJ4="
},
"encoding": "Q1NUMQELAAAAAAAAAAwAAAAAAAAAEgAWAAoACwAAACUAAAABAGFldGhlcmlhLnrDtm5lLeKInmN1bHRtZXNoLnJlYWx0aW1lLuKCrDFiw7hkeS3wn5SlcGF5bG9hZC3iiJ4=",
"encodedLength": 98,
"sha256OfEncoding": "90c6ae8f1362d9e000aa57682612bb1d7606cabe2cd0c0969fdd07b816f44b1c"
},
{
"label": "epoch and sequence at long.MaxValue",
"fields": {
"channelId": "aetheria.zone-1",
"schemaId": "cultmesh.realtime.v1",
"bodyId": "body-7",
"producerEpoch": 9223372036854775807,
"sequence": 9223372036854775807,
"delivery": "unreliable",
"payload": "Wg=="
},
"encoding": "Q1NUMQL/////////f/////////9/DwAUAAYAAQAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03Wg==",
"encodedLength": 79,
"sha256OfEncoding": "208068a668b48dc1abde9b3d7f025e7f7b6de6c480faea25c1383b6de9fd37ca"
},
{
"label": "identities at exactly 65535 UTF-8 bytes",
"fields": {
"channelId": {
"repeat": "ä€",
"byteLength": 65535
},
"schemaId": {
"repeat": "ۊ",
"byteLength": 65535
},
"bodyId": {
"repeat": "ñ€",
"byteLength": 65535
},
"producerEpoch": 13,
"sequence": 14,
"delivery": "reliable-ordered",
"payload": "ESI="
},
"encoding": null,
"encodedLength": 196644,
"sha256OfEncoding": "e3c1a353b606e86b35169efb875e76349dcfcb2d036973ee3bbe3c601fbda3e1"
},
{
"label": "64 MiB payload",
"fields": {
"channelId": "aetheria.zone-1",
"schemaId": "cultmesh.realtime.v1",
"bodyId": "body-7",
"producerEpoch": 15,
"sequence": 16,
"delivery": "reliable-ordered",
"payload": {
"ramp": 67108864
}
},
"encoding": null,
"encodedLength": 67108942,
"sha256OfEncoding": "4d99183bdb450af854d87a81ba86a3a4c92ecc1b208675d916f1b75a9f1e7928"
}
],
"malformed": [
{
"label": "truncated header",
"encoding": "Q1NUMQABAAAAAAAAAAIAAAAAAAAADwAUAAYAAwAAACUAAAAB",
"message": "Realtime frame header is truncated."
},
{
"label": "wrong magic",
"encoding": "vFNUMQABAAAAAAAAAAIAAAAAAAAADwAUAAYAAwAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03CQkJ",
"message": "Realtime frame magic is invalid."
},
{
"label": "wire version 2",
"encoding": "Q1NUMQABAAAAAAAAAAIAAAAAAAAADwAUAAYAAwAAACUAAAACAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03CQkJ",
"message": "Realtime frame wire version is unsupported."
},
{
"label": "header size 36",
"encoding": "Q1NUMQABAAAAAAAAAAIAAAAAAAAADwAUAAYAAwAAACQAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03CQkJ",
"message": "Realtime frame wire version is unsupported."
},
{
"label": "delivery 3",
"encoding": "Q1NUMQMBAAAAAAAAAAIAAAAAAAAADwAUAAYAAwAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03CQkJ",
"message": "Realtime frame delivery mode is invalid."
},
{
"label": "delivery 255",
"encoding": "Q1NUMf8BAAAAAAAAAAIAAAAAAAAADwAUAAYAAwAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03CQkJ",
"message": "Realtime frame delivery mode is invalid."
},
{
"label": "payload length -1",
"encoding": "Q1NUMQABAAAAAAAAAAIAAAAAAAAADwAUAAYAAwAAgCUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03CQkJ",
"message": "Realtime frame length is invalid."
},
{
"label": "length sum off by one",
"encoding": "Q1NUMQABAAAAAAAAAAIAAAAAAAAADwAUAAYAAwAAACUAAAABAGFldGhlcmlhLnpvbmUtMWN1bHRtZXNoLnJlYWx0aW1lLnYxYm9keS03CQkJAA==",
"message": "Realtime frame length is invalid."
}
]
}

View file

@ -1,177 +0,0 @@
{
"frames": [
{
"label": "ts reliable-ordered, small payload",
"fields": {
"channelId": "ts.zone-a",
"schemaId": "cultmesh.realtime.v1",
"bodyId": "ts-body-1",
"producerEpoch": 21,
"sequence": 22,
"delivery": "reliable-ordered",
"payload": "AAH+/w=="
},
"encoding": "Q1NUMQAVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xAAH+/w==",
"encodedLength": 79,
"sha256OfEncoding": "68c264a42ce34e540e9dbee31dab5b73dec98c767f0b44591ce6a477cd17fc04"
},
{
"label": "ts latest-only, small payload",
"fields": {
"channelId": "ts.zone-a",
"schemaId": "cultmesh.realtime.v1",
"bodyId": "ts-body-1",
"producerEpoch": 23,
"sequence": 24,
"delivery": "latest-only",
"payload": "f4A="
},
"encoding": "Q1NUMQEXAAAAAAAAABgAAAAAAAAACQAUAAkAAgAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xf4A=",
"encodedLength": 77,
"sha256OfEncoding": "3d4f49f4aa2c1f132c3a09ee83555c13e0bc036d636524a972e2829cf417b959"
},
{
"label": "ts unreliable, small payload",
"fields": {
"channelId": "ts.zone-a",
"schemaId": "cultmesh.realtime.v1",
"bodyId": "ts-body-1",
"producerEpoch": 25,
"sequence": 26,
"delivery": "unreliable",
"payload": "pQ=="
},
"encoding": "Q1NUMQIZAAAAAAAAABoAAAAAAAAACQAUAAkAAQAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xpQ==",
"encodedLength": 76,
"sha256OfEncoding": "2c2faec604850959d44ee65d87d2ea59462b667c5d0db8565a8c159d75301851"
},
{
"label": "ts empty payload",
"fields": {
"channelId": "ts.zone-a",
"schemaId": "cultmesh.realtime.v1",
"bodyId": "ts-body-1",
"producerEpoch": 0,
"sequence": 0,
"delivery": "latest-only",
"payload": ""
},
"encoding": "Q1NUMQEAAAAAAAAAAAAAAAAAAAAACQAUAAkAAAAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0x",
"encodedLength": 75,
"sha256OfEncoding": "6d2a04b399f32db4086e728194249b0279330ffa5517cdd82228f691a0f10708"
},
{
"label": "ts multi-byte identities",
"fields": {
"channelId": "ts.zøne-∂",
"schemaId": "cultmesh.realtime.µ1",
"bodyId": "ts-bödy-🧊",
"producerEpoch": 27,
"sequence": 28,
"delivery": "unreliable",
"payload": "dHMtcGF5bG9hZC3iiII="
},
"encoding": "Q1NUMQIbAAAAAAAAABwAAAAAAAAADAAVAA0ADgAAACUAAAABAHRzLnrDuG5lLeKIgmN1bHRtZXNoLnJlYWx0aW1lLsK1MXRzLWLDtmR5LfCfp4p0cy1wYXlsb2FkLeKIgg==",
"encodedLength": 97,
"sha256OfEncoding": "d5d22458bd50a446c7bdce4e2598fe8e5e7bfac3646d8fcd7215363c7c5c8ca1"
},
{
"label": "ts epoch and sequence at long.MaxValue",
"fields": {
"channelId": "ts.zone-a",
"schemaId": "cultmesh.realtime.v1",
"bodyId": "ts-body-1",
"producerEpoch": 9223372036854775807,
"sequence": 9223372036854775807,
"delivery": "reliable-ordered",
"payload": "Bw=="
},
"encoding": "Q1NUMQD/////////f/////////9/CQAUAAkAAQAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xBw==",
"encodedLength": 76,
"sha256OfEncoding": "2abb67a876dc4ed006f33708b4c4dda55a90a6f95da7352749225195b10d45ba"
},
{
"label": "ts identities at exactly 65535 UTF-8 bytes",
"fields": {
"channelId": {
"repeat": "ü☃",
"byteLength": 65535
},
"schemaId": {
"repeat": "☃ü",
"byteLength": 65535
},
"bodyId": {
"repeat": "ç☃",
"byteLength": 65535
},
"producerEpoch": 29,
"sequence": 30,
"delivery": "latest-only",
"payload": "M0Q="
},
"encoding": null,
"encodedLength": 196644,
"sha256OfEncoding": "8bd4cd4c5232339f76b8e394eb93b66e8d14302398ecce2ae5bdb78070ef783a"
},
{
"label": "ts 64 MiB payload",
"fields": {
"channelId": "ts.zone-a",
"schemaId": "cultmesh.realtime.v1",
"bodyId": "ts-body-1",
"producerEpoch": 31,
"sequence": 32,
"delivery": "unreliable",
"payload": {
"ramp": 67108864
}
},
"encoding": null,
"encodedLength": 67108939,
"sha256OfEncoding": "f2c20e0b47a7681fbaeff6fcba024363542805de4cd95eeb7fa9f125ba646af0"
}
],
"malformed": [
{
"label": "ts truncated header",
"encoding": "Q1NUMQAVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAACUAAAAB",
"message": "Realtime frame header is truncated."
},
{
"label": "ts wrong magic",
"encoding": "Q1OrMQAVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xAAH+/w==",
"message": "Realtime frame magic is invalid."
},
{
"label": "ts wire version 2",
"encoding": "Q1NUMQAVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAACUAAAACAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xAAH+/w==",
"message": "Realtime frame wire version is unsupported."
},
{
"label": "ts header size 38",
"encoding": "Q1NUMQAVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAACYAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xAAH+/w==",
"message": "Realtime frame wire version is unsupported."
},
{
"label": "ts delivery 3",
"encoding": "Q1NUMQMVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xAAH+/w==",
"message": "Realtime frame delivery mode is invalid."
},
{
"label": "ts delivery 128",
"encoding": "Q1NUMYAVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xAAH+/w==",
"message": "Realtime frame delivery mode is invalid."
},
{
"label": "ts payload length -1",
"encoding": "Q1NUMQAVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAgCUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xAAH+/w==",
"message": "Realtime frame length is invalid."
},
{
"label": "ts length sum off by one",
"encoding": "Q1NUMQAVAAAAAAAAABYAAAAAAAAACQAUAAkABAAAACUAAAABAHRzLnpvbmUtYWN1bHRtZXNoLnJlYWx0aW1lLnYxdHMtYm9keS0xAAH+/wA=",
"message": "Realtime frame length is invalid."
}
]
}

View file

@ -1,24 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.database-subscribe.v1.schema.json",
"title": "CultNet Database Subscribe Message v1",
"type": "object",
"required": ["schemaVersion", "messageId", "subscriptionId", "selection"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.database_subscribe.v1" },
"messageId": { "type": "string", "minLength": 1 },
"subscriptionId": { "type": "string", "minLength": 1 },
"selection": { "$ref": "cultnet.selection.schema.json" },
"includeSnapshot": { "type": "boolean", "default": true },
"consumerRuntimeId": { "type": "string", "minLength": 1 },
"bodyIds": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"supportedBodyTransports": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
}
}

View file

@ -1,46 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.error.schema.json",
"title": "CultNet Error Message",
"description": "R-X (docs/cultnet-selection-cut.md, fix batch 4): CultNetErrorMessage is a MessagePack-CSharp map-mode object, so every declared key is written on the wire, nil included - routingHint, code and details are `required` here the same as cultnet.raw-document-header.schema.json's nullable fields, not optional just because a given refusal leaves them null.",
"type": "object",
"required": [
"schemaVersion",
"error",
"routingHint",
"code",
"details"
],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.error.v0" },
"error": { "type": "string", "minLength": 1 },
"routingHint": {
"description": "Optional shard routing detail for an authority failure. Null for every selection/cursor refusal code (R-N).",
"type": ["object", "null"],
"additionalProperties": false,
"required": ["shard", "reason"],
"properties": {
"shard": { "type": ["object", "null"] },
"reason": { "type": "string" }
}
},
"code": {
"description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it. The set is a closed enum: Rust and TypeScript refuse a message whose code is not listed, so adding a code is one coordinated change across every runtime (this schema, the C# factory, the Rust CultNetErrorCode, and a captured-bytes case for each).",
"type": ["string", "null"],
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema"]
},
"details": {
"description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.",
"type": ["object", "null"],
"additionalProperties": false,
"required": ["field", "value", "asOf", "current"],
"properties": {
"field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\".", "type": ["string", "null"] },
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns.", "type": ["string", "null"] },
"asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 },
"current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 }
}
}
}
}

View file

@ -1,35 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.raw-document-header.schema.json",
"title": "CultNet Raw Document Header",
"description": "R-S (docs/cultnet-selection-cut.md, fix batch 3): CultNetRawDocumentRecord minus payload/payloadEncoding - CultNetRawDocumentHeader has no such members at all, so a header must not $ref the record schema (which requires them). Matches MessagePack-CSharp's map-mode output: every declared key is written, nil included, so every property here is `required` even where its value may be null.",
"type": "object",
"required": [
"schemaId",
"schemaName",
"schemaVersion",
"schemaContentHash",
"recordKey",
"storedAt",
"sourceRuntimeId",
"sourceAgentId",
"sourceRole",
"tags"
],
"additionalProperties": false,
"properties": {
"schemaId": { "type": "string", "minLength": 1 },
"schemaName": { "type": ["string", "null"], "minLength": 1 },
"schemaVersion": { "type": ["string", "null"], "minLength": 1 },
"schemaContentHash": { "type": ["string", "null"], "minLength": 1 },
"recordKey": { "type": "string", "minLength": 1 },
"storedAt": { "type": "string", "minLength": 1 },
"sourceRuntimeId": { "type": ["string", "null"], "minLength": 1 },
"sourceAgentId": { "type": ["string", "null"], "minLength": 1 },
"sourceRole": { "type": ["string", "null"], "minLength": 1 },
"tags": {
"type": ["array", "null"],
"items": { "type": "string", "minLength": 1 }
}
}
}

View file

@ -1,43 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.raw-document-record.schema.json",
"title": "CultNet Raw Document Record",
"description": "R-S (docs/cultnet-selection-cut.md, fix batch 3): matches MessagePack-CSharp's map-mode output for CultNetRawDocumentRecord - every declared key is written, nil included, so every property here is `required` (present on the wire) even where its value may be null.",
"type": "object",
"required": [
"schemaId",
"schemaName",
"schemaVersion",
"schemaContentHash",
"recordKey",
"storedAt",
"payloadEncoding",
"payload",
"sourceRuntimeId",
"sourceAgentId",
"sourceRole",
"tags"
],
"additionalProperties": false,
"properties": {
"schemaId": { "type": "string", "minLength": 1 },
"schemaName": { "type": ["string", "null"], "minLength": 1 },
"schemaVersion": { "type": ["string", "null"], "minLength": 1 },
"schemaContentHash": { "type": ["string", "null"], "minLength": 1 },
"recordKey": { "type": "string", "minLength": 1 },
"storedAt": { "type": "string", "minLength": 1 },
"payloadEncoding": { "const": "messagepack" },
"payload": {
"type": "string",
"contentEncoding": "base64",
"x-cult-runtime-type": "bytes"
},
"sourceRuntimeId": { "type": ["string", "null"], "minLength": 1 },
"sourceAgentId": { "type": ["string", "null"], "minLength": 1 },
"sourceRole": { "type": ["string", "null"], "minLength": 1 },
"tags": {
"type": ["array", "null"],
"items": { "type": "string", "minLength": 1 }
}
}
}

View file

@ -1,100 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.selection.schema.json",
"title": "CultNet Typed Selection",
"type": "object",
"additionalProperties": false,
"properties": {
"schemas": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"minItems": 1,
"description": "Absent means every schema. Present and empty is refused at the door (selection_invalid), never treated as \"every schema\": omit the field instead."
},
"keys": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"minItems": 1,
"description": "Absent means every key. Present and empty is refused at the door (selection_invalid), never treated as \"every key\": omit the field instead."
},
"fields": {
"type": "array",
"items": { "$ref": "#/$defs/fieldPredicate" }
},
"cites": { "$ref": "#/$defs/citation" },
"cited": { "$ref": "#/$defs/incoming" },
"projection": {
"type": "string",
"enum": ["header", "document"],
"default": "header"
},
"descending": { "type": "boolean", "default": false },
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"description": "Clamped to 1..=200 by the answering server, not refused outside that range: an absent value or one outside the bound is treated as the nearer bound (absent = 200)."
},
"cursor": { "type": "string" }
},
"$defs": {
"recordRef": {
"type": "object",
"required": ["schemaId", "recordKey"],
"additionalProperties": false,
"properties": {
"schemaId": { "type": "string", "minLength": 1 },
"recordKey": { "type": "string", "minLength": 1 }
}
},
"fieldPredicate": {
"type": "object",
"required": ["index", "op"],
"additionalProperties": false,
"properties": {
"index": { "type": "string", "minLength": 1 },
"op": { "type": "string", "enum": ["any_of", "lt", "le", "ge", "gt"] },
"values": {
"type": "array",
"items": { "type": "string" },
"minItems": 1
},
"number": {
"type": "string",
"description": "Q-J: a canonical decimal string, never a JSON number. Matches ^-?(0|[1-9][0-9]*)(\\.[0-9]*[1-9])?$ and is not \"-0\"; any other spelling is refused typed at the door (selection_invalid), never normalised.",
"pattern": "^(?!-0$)-?(0|[1-9][0-9]*)(\\.[0-9]*[1-9])?$"
}
},
"oneOf": [
{
"required": ["values"],
"not": { "required": ["number"] },
"properties": { "op": { "const": "any_of" } }
},
{
"required": ["number"],
"not": { "required": ["values"] },
"properties": { "op": { "enum": ["lt", "le", "ge", "gt"] } }
}
]
},
"citation": {
"type": "object",
"required": ["target"],
"additionalProperties": false,
"properties": {
"target": { "$ref": "#/$defs/recordRef" },
"role": { "type": "string", "minLength": 1 }
}
},
"incoming": {
"type": "object",
"required": ["role", "exists"],
"additionalProperties": false,
"properties": {
"role": { "type": "string", "minLength": 1 },
"exists": { "type": "boolean" }
}
}
}
}

View file

@ -1,15 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.snapshot-request.v1.schema.json",
"title": "CultNet Snapshot Request Message v1",
"type": "object",
"required": ["schemaVersion", "messageId", "selection"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.snapshot_request.v1" },
"messageId": { "type": "string", "minLength": 1 },
"selection": { "$ref": "cultnet.selection.schema.json" },
"shardId": { "type": "string", "minLength": 1 },
"shardEpoch": { "type": "integer" }
}
}

View file

@ -1,59 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.snapshot-response-raw.v1.schema.json",
"title": "CultNet Snapshot Response Raw Message v1",
"description": "R-S (docs/cultnet-selection-cut.md, fix batch 3): matches MessagePack-CSharp's map-mode output for CultNetSnapshotResponseRawV1Message - every declared key is written, nil included, so every property is `required` (present on the wire) even where its value may be null. `headers`/`documents` are still exactly one populated by projection (C5), but the OTHER key is present too, as an explicit null, not absent - the prior `oneOf: [{required:[headers]},{required:[documents]}]` matched both branches on every real page (both keys always present) and so never validated at all.",
"type": "object",
"required": [
"schemaVersion", "messageId", "matched", "asOf", "next",
"headers", "documents", "edges", "shardId", "shardEpoch", "shardLogSequence"
],
"additionalProperties": false,
"oneOf": [
{
"required": ["headers", "documents"],
"properties": { "headers": { "type": "array" }, "documents": { "type": "null" } }
},
{
"required": ["headers", "documents"],
"properties": { "headers": { "type": "null" }, "documents": { "type": "array" } }
}
],
"properties": {
"schemaVersion": { "const": "cultnet.snapshot_response_raw.v1" },
"messageId": { "type": "string", "minLength": 1 },
"matched": { "type": "integer", "minimum": 0 },
"asOf": { "type": "integer", "minimum": 0 },
"next": { "type": ["string", "null"] },
"headers": {
"type": ["array", "null"],
"items": { "$ref": "cultnet.raw-document-header.schema.json" }
},
"documents": {
"type": ["array", "null"],
"items": { "$ref": "cultnet.raw-document-record.schema.json" }
},
"edges": {
"type": ["array", "null"],
"items": {
"type": "object",
"required": ["from", "role", "to", "payloadEncoding", "payload"],
"additionalProperties": false,
"properties": {
"from": { "$ref": "cultnet.selection.schema.json#/$defs/recordRef" },
"role": { "type": "string", "minLength": 1 },
"to": { "$ref": "cultnet.selection.schema.json#/$defs/recordRef" },
"payloadEncoding": { "type": ["string", "null"] },
"payload": {
"type": ["string", "null"],
"contentEncoding": "base64",
"x-cult-runtime-type": "bytes"
}
}
}
},
"shardId": { "type": ["string", "null"], "minLength": 1 },
"shardEpoch": { "type": ["integer", "null"] },
"shardLogSequence": { "type": ["integer", "null"] }
}
}

View file

@ -1,334 +0,0 @@
{
"vectors": [
{
"name": "any_of_plus_ge_conjunction",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHOShKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORpndlYXBvbqZudW1iZXLAhKVpbmRleKRtYXNzom9womdlpnZhbHVlc8CmbnVtYmVyoTWlY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq"
],
"matched": 1,
"hasNext": false,
"expectedEdges": []
},
{
"name": "descending_with_limit",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHPApWNpdGVzwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8OlbGltaXQDpmN1cnNvcsA=",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema",
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/zz-tie-schema"
],
"matched": 18,
"hasNext": true,
"expectedEdges": []
},
{
"name": "gt_numeric_boundary",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRtYXNzom9womd0pnZhbHVlc8CmbnVtYmVyoTGlY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/b-hi",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass"
],
"matched": 4,
"hasNext": false,
"expectedEdges": []
},
{
"name": "incoming_exists_true",
"selectionMessagePackBase64": "iadzY2hlbWFzkdlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaka2V5c8CmZmllbGRzwKVjaXRlc8ClY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8OqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key"
],
"matched": 2,
"hasNext": false,
"expectedEdges": [
{
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1",
"role": "Design",
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
"payloadBase64": null
},
{
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target",
"role": "Design",
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
"payloadBase64": null
}
]
},
{
"name": "incoming_negation",
"selectionMessagePackBase64": "iadzY2hlbWFzkdlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaka2V5c8CmZmllbGRzwKVjaXRlc8ClY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8KqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/\uD83D\uDE02-row",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema"
],
"matched": 7,
"hasNext": false,
"expectedEdges": []
},
{
"name": "shared_index_value_three_rows",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORpnNoYXJlZKZudW1iZXLApWNpdGVzwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1",
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/shared-2",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3"
],
"matched": 3,
"hasNext": false,
"expectedEdges": []
},
{
"name": "float_tie_exact_ge",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRtYXNzom9womdlpnZhbHVlc8CmbnVtYmVyqjIyMzM3NTkuMjWlY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass"
],
"matched": 1,
"hasNext": false,
"expectedEdges": []
},
{
"name": "hop_by_role_cites_exact_id",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkadjaXRlci0xpmZpZWxkc8ClY2l0ZXOCpnRhcmdldIKoc2NoZW1hSWTZR3NoYTI1NjpmZjM1ZjNkOWQ2ZGUzZjEwNjZiOTM2OWU3M2ZlNmM3NjQ4ZTg4MzY4ZDE4ZWZjMjI3NWM5OTQ3MzBjZDg0Y2ZmqXJlY29yZEtleaRhLWVxpHJvbGWmRGVzaWdupWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1"
],
"matched": 1,
"hasNext": false,
"expectedEdges": [
{
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1",
"role": "Design",
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
"payloadBase64": null
}
]
},
{
"name": "cites_target_by_name_alias",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkadjaXRlci0xpmZpZWxkc8ClY2l0ZXOCpnRhcmdldIKoc2NoZW1hSWSpbGVhZl9hLnY5qXJlY29yZEtleaRhLWVxpHJvbGWmRGVzaWdupWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1"
],
"matched": 1,
"hasNext": false,
"expectedEdges": [
{
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1",
"role": "Design",
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
"payloadBase64": null
}
]
},
{
"name": "schemas_by_name_alias",
"selectionMessagePackBase64": "iadzY2hlbWFzkalsZWFmX2Eudjmka2V5c8CmZmllbGRzwKVjaXRlc8ClY2l0ZWTAqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfCpWxpbWl0wKZjdXJzb3LA",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/\uD83D\uDE02-row",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema"
],
"matched": 9,
"hasNext": false,
"expectedEdges": []
},
{
"name": "astral_key_code_point_order",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORp3VuaWNvZGWmbnVtYmVywKVjaXRlc8ClY2l0ZWTAqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfCpWxpbWl0wKZjdXJzb3LA",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/\uD83D\uDE02-row"
],
"matched": 2,
"hasNext": false,
"expectedEdges": []
},
{
"name": "header_projection",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORp3VuaWNvZGWmbnVtYmVywKVjaXRlc8ClY2l0ZWTAqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfCpWxpbWl0wKZjdXJzb3LA",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/\uD83D\uDE02-row"
],
"matched": 2,
"hasNext": false,
"expectedEdges": []
},
{
"name": "document_projection",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORp3VuaWNvZGWmbnVtYmVywKVjaXRlc8ClY2l0ZWTAqnByb2plY3Rpb26oZG9jdW1lbnSqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/\uD83D\uDE02-row"
],
"matched": 2,
"hasNext": false,
"expectedEdges": []
},
{
"name": "row_tiebreak_schema_id_before_record_key_at_tied_ordinal",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORqnRpZS1zY2hlbWGmbnVtYmVywKVjaXRlc8ClY2l0ZWTAqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfCpWxpbWl0wKZjdXJzb3LA",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/zz-tie-schema",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema"
],
"matched": 2,
"hasNext": false,
"expectedEdges": []
},
{
"name": "shared_key_narrow_ref_resolves_leaf_within_target",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkbVuYXJyb3ctY2l0ZXItYWNjZXB0ZWSmZmllbGRzwKVjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5p2R1cC1rZXmkcm9sZapuYXJyb3dfcmVmpWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d/narrow-citer-accepted"
],
"matched": 1,
"hasNext": false,
"expectedEdges": [
{
"fromId": "sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d/narrow-citer-accepted",
"role": "narrow_ref",
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
"payloadBase64": null
}
]
},
{
"name": "shared_key_design_resolves_the_first_in_target_candidate_in_row_order",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkbRjaXRlci1ib3RoLWluLXRhcmdldKZmaWVsZHPApWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqlyZWNvcmRLZXmnZHVwLWtleaRyb2xlpkRlc2lnbqVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target"
],
"matched": 1,
"hasNext": false,
"expectedEdges": [
{
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target",
"role": "Design",
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
"payloadBase64": null
}
]
},
{
"name": "refuses_empty_schemas_list",
"selectionMessagePackBase64": "iadzY2hlbWFzkKRrZXlzwKZmaWVsZHPApWNpdGVzwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
"asOf": 1,
"expectedRefusalField": "schemas",
"expectedEvaluationRefusal": null,
"expectedIds": [],
"matched": 0,
"hasNext": false,
"expectedEdges": []
},
{
"name": "refuses_blank_key_entry",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkqRhLWVxoyAgIKZmaWVsZHPApWNpdGVzwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
"asOf": 1,
"expectedRefusalField": "keys",
"expectedEvaluationRefusal": null,
"expectedIds": [],
"matched": 0,
"hasNext": false,
"expectedEdges": []
},
{
"name": "refuses_unmatched_cites_target_schema",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHPApWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlkvHNoYTI1Njpub3QtYS1kZWNsYXJlZC1zY2hlbWGpcmVjb3JkS2V5pGEtZXGkcm9sZcClY2l0ZWTAqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfCpWxpbWl0wKZjdXJzb3LA",
"asOf": 1,
"expectedRefusalField": "cites.target.schemaId",
"expectedEvaluationRefusal": null,
"expectedIds": [],
"matched": 0,
"hasNext": false,
"expectedEdges": []
},
{
"name": "refuses_cites_target_by_hash_shaped_alias",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHPApWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UpzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZi52MalyZWNvcmRLZXmkYS1lcaRyb2xlwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
"asOf": 1,
"expectedRefusalField": "cites.target.schemaId",
"expectedEvaluationRefusal": null,
"expectedIds": [],
"matched": 0,
"hasNext": false,
"expectedEdges": []
},
{
"name": "shared_key_narrow_ref_refuses_when_only_candidate_is_outside_target",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkbRuYXJyb3ctY2l0ZXItcmVmdXNlZKZmaWVsZHPApWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqlyZWNvcmRLZXmrbmFycm93LW9ubHmkcm9sZapuYXJyb3dfcmVmpWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": "reference_outside_target",
"expectedIds": [],
"matched": 0,
"hasNext": false,
"expectedEdges": []
}
]
}

View file

@ -1,105 +0,0 @@
{
"//": "CultNet typed selection, Cut 1 fix batch (docs/cultnet-selection-cut.md, Self's rulings for the Cut 1 fix batch, R-E and the shared-fixture ruling, 2026-09-22). The one row set both the C# reference and cultnet-rs build their parity vectors from. schemaId is each schema's real SHA-256 content-hash id, exactly as GameCult.Caching.CultDocumentRegistry computes it for the fixture's C# document types (leaf_a=ParityLeafA, leaf_b=ParityLeafB, citer=ParityCiter, citer_narrow=ParityCiterNarrow, tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs) - CultCache.cs's Sha256(semanticFingerprint), which carries no '.vN' suffix of its own; the version is folded into the hash, never appended as text. Both writers read this file instead of building their own fixture rows.",
"//aliases": "Two DIFFERENT alias forms, now deliberate rather than a cross-runtime defect (S-12, 2026-09-22: the Rust alias port made this so - see docs/cultnet-selection-cut.md, 'Self's ruling, 2026-09-22: the C# reference's alias rule is the rule'). Both runtimes resolve a schema alias the same one way: strip a trailing .vN and compare the remainder against the descriptor's declared SchemaName (CultNetSchemaAliasMatching.Matches(candidate, CultDocumentDescriptor), src/GameCult.Networking/CultNetDatabase.cs:74-87; cultnet_rs::selection::schema_alias::matches ports it name-for-name and deletes the hash-stripping overload it used to carry). nameAlias is <SchemaName>.v9 and resolves through both runtimes under that one rule. hashAlias is <schemaId>.v1 and is refused as unmatched by both - it is not a wire form either runtime's production call sites ever produce or accept, and it is kept in the fixture specifically to pin that neither runtime resolves it.",
"//references": "R-AF, 2026-09-23: a reference's `references[].targetKey` is the only thing a stored edge actually carries on the wire - GameCult.Caching.MessagePack.CultRecordRefFormatter<T> serializes a CultRecordRef<T> as a bare key string (probed: MessagePackSerializer.Serialize on a real Design field produced the single msgpack string 'a-eq', nothing else). This fixture used to also carry a `targetSchema` per reference; it was never anything but documentation - SelectionParityVectorTests.BuildDocument and cultnet-rs's build_row both read only targetKey to construct the edge - and it is removed rather than kept as a field the wire does not have. Resolving which row an edge names is by targetKey alone, filtered to the declared role's target leaves (D9); see citer_narrow below for the shared-key shape that rule is pinned against.",
"schemas": {
"leaf_a": {
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"nameAlias": "leaf_a.v9",
"hashAlias": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff.v1",
"indexes": [
{ "name": "kind", "numeric": false },
{ "name": "mass", "numeric": true }
]
},
"leaf_b": {
"schemaId": "sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42",
"nameAlias": "leaf_b.v9",
"hashAlias": "sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42.v1",
"indexes": [
{ "name": "kind", "numeric": false },
{ "name": "mass", "numeric": true }
]
},
"citer": {
"schemaId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc",
"nameAlias": "citer.v9",
"hashAlias": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc.v1",
"roles": [
{ "name": "Design", "targets": ["leaf_a", "leaf_b"] }
]
},
"citer_narrow": {
"//": "R-AF: a citer whose declared reference target is one leaf only (leaf_a), so a record key shared by a leaf_a row and a leaf_b row has exactly one candidate inside the declared target and exactly one outside it - the shape neither runtime's parity vectors covered before this cut (zero duplicate record keys across the original ten rows).",
"schemaId": "sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d",
"nameAlias": "citer_narrow.v9",
"hashAlias": "sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d.v1",
"roles": [
{ "name": "narrow_ref", "targets": ["leaf_a"] }
]
}
},
"rows": [
{ "schema": "leaf_a", "key": "a-lo", "ordinal": 1, "fields": { "kind": "weapon", "mass": "4" } },
{ "schema": "leaf_a", "key": "a-eq", "ordinal": 2, "fields": { "kind": "weapon", "mass": "5" } },
{ "schema": "leaf_b", "key": "b-hi", "ordinal": 3, "fields": { "kind": "armor", "mass": "6" } },
{ "schema": "leaf_a", "key": "shared-1", "ordinal": 4, "fields": { "kind": "shared", "mass": "1" } },
{ "schema": "leaf_b", "key": "shared-2", "ordinal": 5, "fields": { "kind": "shared", "mass": "1" } },
{ "schema": "leaf_a", "key": "shared-3", "ordinal": 6, "fields": { "kind": "shared", "mass": "1" } },
{
"schema": "citer",
"key": "citer-1",
"ordinal": 7,
"references": [ { "role": "Design", "targetKey": "a-eq" } ]
},
{
"//": "R-D: 2233759.25 is exactly representable as an f32 and sits on a round-to-even tie between two shortest-form spellings; its exact canonical decimal is 2233759.25 either way. kind=tie so no weapon/armor/shared case can sweep it up.",
"schema": "leaf_a", "key": "tie-mass", "ordinal": 8, "fields": { "kind": "tie", "mass": "2233759.25" }
},
{
"//": "R-C: BMP private-use key (U+E000), same ordinal as the astral row below so the tiebreak - not the ordinal - decides order. kind=unicode, mass=0 so no unfiltered or mass>1 case reaches these.",
"schema": "leaf_a", "key": "-row", "ordinal": 9, "fields": { "kind": "unicode", "mass": "0" }
},
{
"//": "R-C: astral key (U+1F602, encoded as a surrogate pair whose high half 0xD83D is less than 0xE000 - a UTF-16 code-unit compare sorts this first; code-point order sorts it after the BMP row above).",
"schema": "leaf_a", "key": "😂-row", "ordinal": 9, "fields": { "kind": "unicode", "mass": "0" }
},
{
"//": "R-AF shape 1: the only row at this key is outside citer_narrow's declared target (leaf_a only) - resolving the edge below must refuse reference_outside_target in both runtimes, not silently accept an empty page. kind=narrow-fixture (not \"shared\") so this row never sweeps into shared_index_value_three_rows, which pins an exact count of 3.",
"schema": "leaf_b", "key": "narrow-only", "ordinal": 10, "fields": { "kind": "narrow-fixture", "mass": "1" }
},
{
"//": "R-AF shape 2: two rows share this key, one inside citer_narrow's declared target and one outside it - resolving the edge below must accept, deterministically picking the leaf_a row, in both runtimes.",
"schema": "leaf_a", "key": "dup-key", "ordinal": 11, "fields": { "kind": "narrow-fixture", "mass": "1" }
},
{
"schema": "leaf_b", "key": "dup-key", "ordinal": 12, "fields": { "kind": "narrow-fixture", "mass": "1" }
},
{
"schema": "citer_narrow",
"key": "narrow-citer-refused",
"ordinal": 13,
"references": [ { "role": "narrow_ref", "targetKey": "narrow-only" } ]
},
{
"schema": "citer_narrow",
"key": "narrow-citer-accepted",
"ordinal": 14,
"references": [ { "role": "narrow_ref", "targetKey": "dup-key" } ]
},
{
"//": "R-AJ: a tied ordinal across two DIFFERENT schemas (leaf_a and leaf_b), not two rows of the same leaf like the astral/BMP pair above - the shape the row order's schemaId tiebreak needs and the original ten rows never had. Keys are anti-correlated with the real schema ids on purpose: leaf_b's real id sorts before leaf_a's (sha256:b1f2... < sha256:ff35...), so leaf_b gets the alphabetically-*later* key (\"zz-tie-schema\") and leaf_a gets the *earlier* one (\"aa-tie-schema\") - a schemaId-first order and a recordKey-first order disagree here on purpose, so only the correct composition produces leaf_b's row first.",
"schema": "leaf_b", "key": "zz-tie-schema", "ordinal": 15, "fields": { "kind": "tie-schema", "mass": "1" }
},
{
"schema": "leaf_a", "key": "aa-tie-schema", "ordinal": 15, "fields": { "kind": "tie-schema", "mass": "1" }
},
{
"//": "R-AO, 2026-09-23: 'dup-key' already carries two rows (leaf_a ordinal 11, leaf_b ordinal 12) - both are in citer's Design role's declared target ([leaf_a, leaf_b], unlike citer_narrow's narrow_ref above, which only ever had one in-target candidate at a shared key). This citer is the first row in this file (hence in both runtimes' row order) to name 'dup-key' through Design, so resolving it must deterministically pick the leaf_a row - the first in-target candidate in the caller's order - never the leaf_b row, in both runtimes.",
"schema": "citer",
"key": "citer-both-in-target",
"ordinal": 16,
"references": [ { "role": "Design", "targetKey": "dup-key" } ]
}
]
}

View file

@ -1,282 +0,0 @@
{
"vectors": [
{
"name": "any_of_plus_ge_conjunction",
"selectionMessagePackBase64": "g6ZmaWVsZHOSg6VpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORpndlYXBvboOlaW5kZXikbWFzc6JvcKJnZaZudW1iZXKhNapwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
"asOf": 1,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq"
],
"matched": 1,
"hasNext": false,
"expectedEdges": []
},
{
"name": "descending_with_limit",
"selectionMessagePackBase64": "g6pwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nw6VsaW1pdAM=",
"asOf": 1,
"expectedIds": [
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema",
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/zz-tie-schema"
],
"matched": 18,
"hasNext": true,
"expectedEdges": []
},
{
"name": "gt_numeric_boundary",
"selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRtYXNzom9womd0pm51bWJlcqExqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfC",
"asOf": 1,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/b-hi",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass"
],
"matched": 4,
"hasNext": false,
"expectedEdges": []
},
{
"name": "incoming_exists_true",
"selectionMessagePackBase64": "hKdzY2hlbWFzkdlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmalY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8OqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
"asOf": 1,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key"
],
"matched": 2,
"hasNext": false,
"expectedEdges": [
{
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1",
"role": "Design",
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
"payloadBase64": null
},
{
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target",
"role": "Design",
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
"payloadBase64": null
}
]
},
{
"name": "incoming_negation",
"selectionMessagePackBase64": "hKdzY2hlbWFzkdlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmalY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8KqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
"asOf": 1,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/😂-row",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema"
],
"matched": 7,
"hasNext": false,
"expectedEdges": []
},
{
"name": "shared_index_value_three_rows",
"selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORpnNoYXJlZKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
"asOf": 1,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1",
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/shared-2",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3"
],
"matched": 3,
"hasNext": false,
"expectedEdges": []
},
{
"name": "hop_by_role_cites_exact_id",
"selectionMessagePackBase64": "hKRrZXlzkadjaXRlci0xpWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqlyZWNvcmRLZXmkYS1lcaRyb2xlpkRlc2lnbqpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
"asOf": 1,
"expectedIds": [
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1"
],
"matched": 1,
"hasNext": false,
"expectedEdges": [
{
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1",
"role": "Design",
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
"payloadBase64": null
}
]
},
{
"name": "hop_by_role_cites_name_alias",
"selectionMessagePackBase64": "hKRrZXlzkadjaXRlci0xpWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlkqWxlYWZfYS52OalyZWNvcmRLZXmkYS1lcaRyb2xlpkRlc2lnbqpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
"asOf": 1,
"expectedIds": [
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1"
],
"matched": 1,
"hasNext": false,
"expectedEdges": [
{
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1",
"role": "Design",
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
"payloadBase64": null
}
]
},
{
"name": "schemas_by_hash_alias",
"selectionMessagePackBase64": "g6dzY2hlbWFzkdlKc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmYudjGqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
"asOf": 1,
"expectedIds": [],
"matched": 0,
"hasNext": false,
"expectedEdges": []
},
{
"name": "schemas_by_name_alias",
"selectionMessagePackBase64": "g6dzY2hlbWFzkalsZWFmX2EudjmqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
"asOf": 1,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/😂-row",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema"
],
"matched": 9,
"hasNext": false,
"expectedEdges": []
},
{
"name": "float_tie_exact_ge",
"selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRtYXNzom9womdlpm51bWJlcqoyMjMzNzU5LjI1qnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfC",
"asOf": 1,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass"
],
"matched": 1,
"hasNext": false,
"expectedEdges": []
},
{
"name": "astral_key_code_point_order",
"selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORp3VuaWNvZGWqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
"asOf": 1,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/😂-row"
],
"matched": 2,
"hasNext": false,
"expectedEdges": []
},
{
"name": "row_tiebreak_schema_id_before_record_key_at_tied_ordinal",
"selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORqnRpZS1zY2hlbWGqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
"asOf": 1,
"expectedIds": [
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/zz-tie-schema",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema"
],
"matched": 2,
"hasNext": false,
"expectedEdges": []
},
{
"name": "shared_key_narrow_ref_resolves_leaf_within_target",
"selectionMessagePackBase64": "hKRrZXlzkbVuYXJyb3ctY2l0ZXItYWNjZXB0ZWSlY2l0ZXOCpnRhcmdldIKoc2NoZW1hSWTZR3NoYTI1NjpmZjM1ZjNkOWQ2ZGUzZjEwNjZiOTM2OWU3M2ZlNmM3NjQ4ZTg4MzY4ZDE4ZWZjMjI3NWM5OTQ3MzBjZDg0Y2ZmqXJlY29yZEtleadkdXAta2V5pHJvbGWqbmFycm93X3JlZqpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
"asOf": 1,
"expectedIds": [
"sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d/narrow-citer-accepted"
],
"matched": 1,
"hasNext": false,
"expectedEdges": [
{
"fromId": "sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d/narrow-citer-accepted",
"role": "narrow_ref",
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
"payloadBase64": null
}
]
},
{
"name": "shared_key_design_resolves_the_first_in_target_candidate_in_row_order",
"selectionMessagePackBase64": "hKRrZXlzkbRjaXRlci1ib3RoLWluLXRhcmdldKVjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5p2R1cC1rZXmkcm9sZaZEZXNpZ26qcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
"asOf": 1,
"expectedIds": [
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target"
],
"matched": 1,
"hasNext": false,
"expectedEdges": [
{
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target",
"role": "Design",
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
"payloadBase64": null
}
]
},
{
"name": "refuses_empty_schemas_list",
"selectionMessagePackBase64": "g6dzY2hlbWFzkKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
"asOf": 1,
"expectedRefusalField": "schemas",
"expectedIds": [],
"matched": 0,
"hasNext": false,
"expectedEdges": []
},
{
"name": "refuses_blank_key_entry",
"selectionMessagePackBase64": "g6RrZXlzkqRhLWVxoyAgIKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
"asOf": 1,
"expectedRefusalField": "keys",
"expectedIds": [],
"matched": 0,
"hasNext": false,
"expectedEdges": []
},
{
"name": "refuses_unmatched_cites_target_schema",
"selectionMessagePackBase64": "g6VjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZLlzaGEyNTY6bm90LWEta25vd24tc2NoZW1hqXJlY29yZEtleaRhLWVxpHJvbGWmRGVzaWduqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfC",
"asOf": 1,
"expectedRefusalField": "cites.target.schemaId",
"expectedIds": [],
"matched": 0,
"hasNext": false,
"expectedEdges": []
},
{
"name": "refuses_cites_target_by_hash_shaped_alias",
"selectionMessagePackBase64": "g6VjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlKc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmYudjGpcmVjb3JkS2V5pGEtZXGkcm9sZaZEZXNpZ26qcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
"asOf": 1,
"expectedRefusalField": "cites.target.schemaId",
"expectedIds": [],
"matched": 0,
"hasNext": false,
"expectedEdges": []
},
{
"name": "shared_key_narrow_ref_refuses_when_only_candidate_is_outside_target",
"selectionMessagePackBase64": "hKRrZXlzkbRuYXJyb3ctY2l0ZXItcmVmdXNlZKVjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5q25hcnJvdy1vbmx5pHJvbGWqbmFycm93X3JlZqpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
"asOf": 1,
"expectedEvaluationRefusal": "reference_outside_target",
"expectedIds": [],
"matched": 0,
"hasNext": false,
"expectedEdges": []
}
]
}

View file

@ -1,840 +0,0 @@
# CultCache publication: the cache owns "published once", and order stays data
Status, 2026-09-29: Cuts 1-3 landed on `main`. Cut 3 merged at `ae8576d`. Imagination pass 4 re-took Cuts 4-6
against `3bf1c0c`. Q-P5 is ruled (A). Cut 4 is ready for Hands.
- F4, pinning `Held`'s publish-on-throw guard, is done: Cut 3 pinned it.
- F3 (observer removal on unsubscribe) and F1 (the false "getters run once" claim for conditional commits on variant
stores) were Cut 3 follow-ups; confirm against the Cut 3 merge.
Recorded:
- F2, the Q-P2 hazard. A rethrown observer exception makes a committed write look cancelled or refused. The
only live typed catch around a cache write is AetheriaEve `Aetheria.State.Daemon/Program.cs:3722`. It needs a
fail-fast handler, and no production code installs one.
- F5: subscribe and unsubscribe cost O(n).
- F6: writes after `Dispose` are dropped silently.
Map written by Imagination pass 1, 2026-09-29. The ends are the
operator ruling of 2026-09-30: every committed cache change reaches subscribers
exactly once. This document owns the means.
- **Body.** CultLib `main` `65311c9ad770828adafe40c2c14b2d3da517628e`. Every
`file:line` is against it unless it names the branch.
- **Superseded attempt.** `origin/hands/cultnet-publish-commits`, tip
`28248eb`, base `f9f107a`. Soul pass 3 said do not merge it, and that stands.
- Main has not touched `CultCache.cs`, `CultNetDatabase.cs`, the composition
doc or the networking tests since `f9f107a`: `git diff --stat` is empty.
- `git merge-tree 65311c9 28248eb` is clean.
- **Scope.** The C# cache's publication and CultNetDatabase's use of it. There
is no wire-format change and no other runtime changes (§6).
## Rulings (operator, 2026-09-30)
- **Q-P1 A: order as data.** Each change is published exactly once, on its writer's thread, before the write
returns, and carries its `Sequence`. The shard log is written in cache order by the gate-held journal. Wire
peers drop changes that are stale by `Sequence`. The turnstile (`bcae483`/`4562340`) and the drainer are
both rejected.
- **Q-P2 A:** a throwing observer does not stop the others. Every observer runs, the exception is rethrown to
the writer afterwards, and the observer stays subscribed. The commit stands.
- **Q-P3 A:** a public `CultCache.AddJournal(...)`, with reentry guarded.
- **Q-P4 A:** Cut 6, which drops stale changes per key on the wire, lands in this campaign.
- **Q-P5 A (2026-09-29):** a primary that cannot log a committed change throws a typed `CultNetShardLogException`
naming the shard and the burned sequence, after publication, not derived from `InvalidOperationException`.
## The finding that shapes this map
The direction Imagination was handed was: "ordered delivery belongs in the
cache's own publication, on the writer's thread, so a writer delivers its own
changes before returning". That design **has been built before, and it was
deleted**.
- `bcae483` (2026-09-13 20:09) made the cache a delivery scheduler: tickets, a
delivery gate, turn waits and per-thread queues for reentrant writes.
- `4562340`, 34 minutes later, deleted it: "It deadlocked inside CultLib: a
load's delivery reaching a CultNetDatabaseSubscriptionServer observer that
needs _lifecycleGate, while a subscribe's demand handler holding that gate
writes the cache and waits for the delivery's turn."
- It replaced it with **order as data**: a per-cache `Sequence`, and no
delivery scheduler.
- The regression tests it left, `ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries`
(`ConditionalCommitTests.cs:763`) and
`SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock`
(`NetworkingTests.cs:2244`), are live on main.
This pass rebuilt that scheduler as a prototype. It is the per-cache turnstile
with same-thread trampolining at `79f4639`, and it reproduces the deletion:
- Caching tests: 278 of 279 pass, and `ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries`
fails.
- The networking suite hangs, and Yggdrasil's idle watchdog killed it at 900 s.
The blame-hang run that names the hung test is recorded below.
The reason is structural, not an implementation slip. Once a writer waits for
another thread's delivery, any lock that an observer takes and a writer holds
becomes a deadlock:
- total cross-thread order;
- a writer delivering before it returns;
- no writer waiting on another thread.
Pick two. The branch's drainer chose order plus no-wait, and lost
before-return (F3 and F4). The turnstile chose order plus before-return, and
lost no-wait (the `bcae483` deadlock).
**So this map keeps order as data (the `4562340` ruling) and moves the one
order-sensitive consumer, the CultNet mutation log, to the place order is
decided: under the cache gate.**
- Every writer keeps delivering its own changes, on its own thread, before it
returns, exactly as main does. F3 is satisfied by not regressing it.
- The log becomes a **journal** written in the admitting hold, in Sequence
order.
- The reorder buffer and drainer die, and no scheduler replaces them. This
departs from Soul's stated direction on purpose, and **Q-P1** puts it to the
operator.
## Probe evidence
All of this ran on Yggdrasil via `ygg-verify.sh`, from scratch commits in a
throwaway worktree that no branch holds.
- Probe file: `tests/GameCult.Caching.Tests/PublicationProbeTests.cs` in scratch commit `79f4639`. The worktree has been removed. The commit is unreferenced in the local object store, where gc can collect it; it also survives on the Yggdrasil mirror as `refs/verify/79f4639f8f5b95db7ce16f244a081c1ec101a468`.
- `9c5abf5` is the probes on main. `79f4639` is the same probes plus the
turnstile prototype in `CultCache.cs`.
| # | Probe | main `65311c9` | Turnstile prototype `79f4639` |
|---|---|---|---|
| Q1 | A batch `{ok, boom}` where boom's `[CultName]` getter throws | `TargetInvocationException`. `_sequence` goes from 0 to 1, because ok was minted. **Both ok and boom are in memory**, and `GetByName("ok")` finds ok. **Nothing is published**, and the next write gets Sequence 2. F1 is worse than stated: memory is corrupt and unpublished. In a store-backed cache, `land` (`:2325`) already ran, so the store holds it too. | No stall, but memory is still corrupt. |
| Q2 | Two `Watch` observers, the first throws | With the default R3 handler, the second observer sees 2 of 2 and the writer sees nothing. **With a rethrowing handler, the second observer sees 0 of 2 and every writer gets the exception.** The comment at `CultCache.cs:2374`, "OnNext does not throw", is false under fail-fast. | With its own observer array and a per-observer catch, the second observer sees 2 of 2 and the writer gets the exception afterwards. |
| Q3 | Writer A's observer blocks on `a`, then B writes `b` | The recorder sees `b(2)` before `a(1)`. There is no cross-thread order, by design since `4562340`. | Sees `a` then `b`, because B waited for A. |
| Q4 | An observer on `x` writes `y` | `y(2)` before `x(1)`: nested, depth-first, and the nested write returns after `y` is published. | `x` then `y`, and the nested write returns before `y` is published. |
| Q5 | `OnUpdate` on Upsert and on Commit | 0 fires, so loads only. `Watch` sees 2. | Unchanged. |
| Q6 | 8 threads × 1000 writes, plus reentrant writes | 67 ms, 2645 of 8800 cross-thread inversions, and 0 writes returned undelivered. | 516 ms, 0 inversions, 0 undelivered. |
| Q7 | Soul's P2 shape: a handler waits 1 s for another thread's write | Observed in time. | Not observed in time. |
| Suites | Caching, Networking and Mesh | (green on main) | Caching fails `ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries`, and Networking **hangs** until the 900 s idle kill. Rerun with `--blame-hang-timeout 90s`: 286 passed, then the test host hung and was dumped. The blame sequence file was not extracted, so **the hung test is not named**. It is consistent with, but not proven to be, `SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock`, whose own comment says "disposal would hang too". |
## Imagination pass 4: Cut 4 re-taken on the landed journal
Cut 3 landed at `ae8576d`. Its Soul left two questions that Cut 4 had to settle before relying on the
journal: (a) what a throwing journal means for the log, and (b) whether a journal needs the change's origin.
Both have one coherent answer. The one fork, Q-P5, was about who hears of a log failure; the operator ruled it A (2026-09-29).
### Probe evidence (pass 4)
- Probe file: `Pub4ProbeTests.cs` (Networking tests). It was run on Yggdrasil from scratch commits:
- `0d7397d`: main plus the probes;
- `6ec2f73`: the same, merged with the branch `28248eb`;
- `376cd74`: main plus probe C.
- The commits survive on the Yggdrasil mirror. The scratch worktree is gone.
- The probes report facts and assert nothing. `FlakyLogStore` is an in-memory `ICultNetShardMutationLogStore`
that can refuse `Append` or run a hook inside `GetCompactedThrough`.
| # | Probe | main `3bf1c0c` | branch `28248eb` (queue) |
|---|---|---|---|
| A1 | Primary with a durable log. Put 1, Put 2 (its `Append` throws), Put 3. A replica pulls, then retries. | Writer gets `IOException`. The cache holds 1, 2 and 3, but **2 is never published** (2 of 3). Memory log `[1,2,3]`, durable `[1,3]`. The replica pull throws "log has a gap. Expected 2, received 3", and so does **every retry**: the replica is stuck for good. | **Writer sees nothing**: R3 swallowed the drainer's exception. 2 is not published. The replica is stuck in the same way. |
| A2 | The same failure at the tail, then the primary restarts over the same cache and log store. | The replica applied 1. After restart, note 3 is **minted sequence 2**. The replica takes it, reports applied 2, and holds `1,3` against the primary's `1,2,3`: **silent divergence**, a reused sequence. | Same. |
| A3 | A replica whose log `Append` throws once. | The pull throws and the cursor stays at 0. The retry converges (`1,2`), but the memory log is `[1,1,2]`: a duplicate, because memory is written before disk and kept as a `List`. | The pull "succeeds": the exception was swallowed and the cursor went to 2. The durable log is `[2]`, so **entry 1 is lost**, and a chained replica sees a gap. |
| B1 | Primary with a file store. Another writer puts `pulled` into the file, the primary runs `PullAll`, and a replica pulls the log. | `pulled` is published but **not logged**. The replica gets nothing. | Logged. The replica converges on `pulled`. |
| B2 | A store attached after the database exists. | Nothing is logged. | All three hydrated records are logged. |
| S | `CreateShardSnapshotResponse` with a write landing between its document read and its sequence read (hooked in `GetCompactedThrough`). | The snapshot holds `[1]` at log sequence 2. The replica applies it and pulls, and still lacks 2: **divergence**. | Same. |
| C | Compact through 2, restart, then Put 3. | Note 3 is **minted sequence 1**, and `GetLatestMutationLogSequence` returns 1, below the floor of 2. | (not run; the code is unchanged) |
What the probes establish:
- A1, A2, S and C are pre-existing defects on main. Cut 4 inherits them and must not build on them: the
resync path it relies on for (a) is itself broken by S and C.
- On a replica, the pull loop already retries. The cursor advances only after an entry applies
(`CultNetDatabase.cs:1038-1083`), so a replica needs no new mechanism. It needs only to keep the exception
reaching that loop, which the branch's queue broke (A3).
- Loads on a primary must be logged, or replicas diverge (B1).
### (a) A journal that cannot log a committed change: one answer
The facts:
- The log store is a second durable store written after the cache's store. Without an atomic commit across
the two, the log can lag the store. It must never lead: a replica would then apply a change the primary
refused.
- So lag is unavoidable, and it has to be detectable and repaired.
- The protocol already has a repair, and nothing else needs to be invented.
- "History the primary cannot serve" is `compacted_history`: `CultNetDatabaseServer.cs:189-201` answers
`ResyncRequired`.
- `CultNetShardReplication.cs:199-214` then snapshot-resyncs.
**Rule: a primary that cannot log a committed change burns that change's sequence and compacts through it.**
- In the database journal, on a primary, for each change that is not `NoLog`:
1. Mint `S`.
2. Build the entry: the wire message from the door, or `ToLogEntryMessage`.
3. `Append` durably, **then** record it in memory. Disk goes first, so memory never serves what disk lacks.
- If step 2 or 3 throws:
- record nothing;
- raise the database's in-memory floor for the shard to `S`;
- try `store.CompactThrough(shard, S)`;
- still set `_lastWriteSequence` to `S`, because the row was written at `S`;
- carry on with the rest of the admission;
- throw `CultNetShardLogException` once at the end of the journal call (Q-P5 A).
- Every replica behind `S` is then told `compacted_history` and resyncs by snapshot. The sequence gap at `S`
is never served, because the floor covers it.
- This **replaces** the old §4 step 3 claim, "a serialization failure then mints nothing, so the log can never
hold a gap". Minting nothing hides a replication gap behind a dense sequence. That is exactly A2's silent
divergence.
- **Replica:** there is no floor. The journal's exception reaches `ApplyShardLogResponseAsync` and the cursor
does not advance. The next pull re-applies the entry: a fresh instance, so the door matches again, and the
entry is re-recorded under the primary's sequence. The branch's absent-key direct log follows the same
disk-first order.
- **Required fixes** that the rule depends on, all in Cut 4:
- `GetCompactedMutationLogSequence` (`CultNetDatabase.cs:597-602`) returns
`max(store floor, in-memory floor)`.
- `InitializeLogSequencesFromStore` (`:1363-1395`) resumes at
`max(highest retained, store.GetCompactedThrough(shard)) + 1`. This is probe C.
- `GetLatestMutationLogSequence` (`:607-616`) never reports below the floor.
- `CreateShardSnapshotResponse` (`:624-670`) reads `GetLatestMutationLogSequence` **before** `SelectAll`, on
both returns, `:645` and `:668`. The documents then reflect at least that sequence, and replaying later
entries converges: they are whole-document puts and deletes, applied in order. This is probe S.
- `RecordMutationLogEntry` (`:1331-1352`) writes disk first. The in-memory log becomes
`SortedList<long, entry>` per shard, replacing by sequence as the durable store does. This fixes A3's
duplicate.
- **Residual, recorded rather than forked.** Suppose the store refuses both `Append` and `CompactThrough`, and
then the process restarts.
- The durable log does not know about the hole, so A2's tail reuse can recur.
- Fixing that needs a durable write per mint, a sequence reservation, which doubles log I/O under the gate.
- The thrown exception names both failures, so the outage is loud.
- Refusing later writes ("fail closed") is not coherent. The database can refuse only its own doors, while
bare cache writes are journaled regardless: that would be split authority.
### (b) A pulled or loaded change on a primary: no origin needed
- On a primary the log is the shard's **state history**, whoever wrote the state. B1 shows that leaving loads
out of the log leaves replicas permanently diverged.
- The only origins that change what is logged are already carried by the door table, keyed by instance
identity and matched by shape. They are:
- `Replica` (log the primary's entry);
- `NoLog` (prediction, snapshot resync);
- `Put`/`Delete` (the wire message);
- `Predicts`/`Authoritative`.
- A load or pull has no door, so on a primary it is logged like any unattributed commit. On a replica it logs
nothing, like any local write. That is what the branch does, and B1 converges under it.
- Cost:
- B2: a store attached **after** the database logs its hydration.
- `CultNetHost` opens the cache before building the database (`CultNetLocal.cs:146-152`), so production
hydration is not journaled.
- A re-pull admits only the records that changed. In B1 the seed was not re-logged.
- `AddJournal` keeps its signature. If a future consumer (audit, say) needs "load or commit", the fact goes on
`CultCacheDocumentChange` as an additive property, as `Sequence` did. The cache already knows it: `Admit`'s
`source != null` at `CultCache.cs:2420`. It never needs a new `AddJournal` shape.
- This is recorded, not forked: no consumer needs it today.
### The two recorded hazards, settled for Cut 4
- **Q-P2 typed-exception hazard.** Cut 4 makes it frequent. Every cache write on a primary with a durable log
now does log I/O inside its admission.
- AetheriaEve opts into durable logs (`Aetheria.State/AetheriaStateNode.cs:144`).
- Its `AetheriaHangarCommandJournal.ValidateAsync` writes an envelope (`Program.cs:6509`) inside the `catch
(InvalidOperationException)` at `Program.cs:3722`.
- A log failure surfacing as `InvalidOperationException` would therefore produce a "denied" receipt for a
committed envelope.
- Q-P5 A (ruled): the writer gets a typed `CultNetShardLogException` that **does not derive from
`InvalidOperationException`**.
- **Journal deadlock (the Cut 3 note).** The database journal waits only on `_logGate` and on the log store's
own lock.
- Lock order: cache gate, then `_logGate`, then the log store's lock.
- Invariant: **no code holding `_logGate` calls the cache, and no log store calls the cache or waits on a
thread that does.**
- Readers that take `_logGate` read only log state: `GetMutationLog`, `LastWriteSequence`, `CurrentAsOf`.
Readers already inside a cache read, such as the selection evaluator's `ordinalOf`, nest `_logGate` under
the cache gate, which is the same order.
- The store rule goes into the `ICultNetShardMutationLogStore` XML doc (`CultNetShardMutationLogStorage.cs:13-34`),
because a user store runs under the cache's gate.
- A log store that writes the cache already throws, through Cut 3's guard. Cut 4 pins it with a test.
## Authority map
- **Owner.** `CultCache`. The hold that mints a Sequence range also:
1. journals the range under the gate, in Sequence order (new);
2. publishes the range to every observer, isolated, on the minting writer's
thread, after it releases the gate and before it returns (main's model,
now isolated).
Nothing waits on another call's delivery.
- **Inputs.**
- The changes returned by `Apply` (`CultCache.cs:2707`), the single mutation
site.
- The pre-mint projection of names and indexes (Cut 1).
- The journal list, the observer array and `OnUpdate`.
- **Outputs.**
- Journal calls: one per admission, under the gate, in Sequence order.
- `Watch` / `WatchRecord` notifications that carry `Sequence`.
- `OnUpdate` for loads.
- Observer and journal exceptions, rethrown to the writer.
- **Derived state.**
- `Sequence` is order as data.
- CultNet's primary log sequence is minted in the journal, so log order is
cache order.
- A replica's log sequence is copied from the primary entry.
- The database change kind (`Predicted`, `Reconciled`, `Added`, `Updated`,
`Removed`) is decided in the journal.
- `CultNetDatabaseChange.Sequence` (new) is copied from the cache change.
- Wire projections drop stale by it (Cut 6).
- **Forbidden writers.**
- Nothing re-sequences or schedules cache changes. The branch's `_parked`,
`_ready`, `_nextSequence`, `_draining`, `PendingChange` and the drain body
of `OnCacheChange` die, and so do the turnstile, `bcae483` and the
prototype.
- No R3 `Subject` fans out cache changes (Q2).
- `Apply` and `Index` run no user code.
- Doors never read or write prediction state: the branch's `HasPrediction`
and `_predictionGate` die.
- The database's Watch observer never logs; only the journal logs.
- A journal never enters the cache.
- **Shared paths.** Every path goes `Held` → `Admit` → `Resolve` → **Project**
→ `land` → `Apply` → **journal** → leave gate → **publish**:
- loads: `AddBackingStore` `:1909`, `Loaded` `:1940`, `PullAll*` `:1959` and
`:3052`;
- `Commit` / `TryCommit` (`Land`, `:2272`);
- `UpsertAsync`, `UpsertVariantAsync`, `FlattenAsync` and `Remove`
(`:2004-2056`, `Write` `:2252`);
- a variant dependent's re-resolution (`VariantPlan.Dependents`, in the same
`Apply`);
- direct calls on an attached store (`:2924`);
- `Dispose` (`:2142`);
- the database doors, replica apply and snapshot resync, which only write
the cache and hand context to the journal.
The single exception is the replica's absent-key delete, which has no cache
change. It logs directly (§4).
- **Deletion line.**
- In the cache: `Subject<Change> _changes` (`:1809`), the `_changes.OnNext`
call and its false comment (`:2374-2375`), `_changes.Dispose()`
(`:2154`), and the accessor calls in `Index` (`:2798-2804`).
- In the database, on the salvaged branch: the reorder buffer and drainer,
`_predictionGate` and `HasPrediction`, the logging in `Deliver`, and the
`Subject<object> _changes` fan-out.
## The design, question by question
### 1. Validation before minting: a refused change mints nothing
- **Where.** Add **Project** to `Admit` (`:2313`), between `Resolve` (`:2324`)
and `land` (`:2325`).
- **What.** Over `plan.Admitted ∪ plan.Dependents`, it evaluates once per
record: `NameAccessor(document)` and every `IndexAccessors[alias](document)`.
It keeps `(Name, (Alias, Value)[])` per key on the plan.
- **A throwing getter refuses the whole admission.** It raises an
`InvalidOperationException` that names the key, the member and the unwrapped
message; today the caller sees a bare `TargetInvocationException`. The
refusal comes before `land`, so the admission writes no store, touches no
memory and mints nothing.
- **Readers take the projection, not the document.**
- `Index` (`:2793-2806`).
- `RefuseVariantIndexSharing` (`:2659-2660`).
- The variant-name rule in `ResolveOne` (`:2616`). Its ancestor read at
`:2621` reads held documents, pre-land, and may stay.
`Resolve` currently runs the refusal rules before any projection exists.
Hands makes Project the first pass over the resolved plan and moves both
rules after it, so rules read the projection and only the projection reads
documents.
- **Apply becomes user-code-free.** It then does only dictionary and
`ConditionalWeakTable` work on precomputed data.
- **Loads refuse too.** A store whose record's getter throws fails to open,
loudly. That is the rule "hydration failure on open is loud" (composition doc
`:88-92`). Today it half-indexes silently.
### 2. Observer isolation
- **Replace the `Subject<Change>` with the cache's own observer array**,
copy-on-write under a small lock.
- `Watch<T>` (`:1887`) becomes `Observable.Create<Change>(register)` followed
by the existing `Where` and `Select`.
- `Dispose` calls `OnCompleted` on each observer, which is what
`Subject.Dispose` did.
- **Publish loops change → observer, and wraps each `OnNext` in its own
try/catch.**
- R3's `Observer<T>` already catches handler exceptions (`OnErrorResume`, then
the unhandled handler). So an exception reaches the cache only when fail-fast
was asked for: a rethrowing `ObservableSystem` handler, or a custom observer.
- Escaped exceptions join the `OnUpdate` failures. They are rethrown to the
writer after every observer has received every change of the hold. That is
the existing `OnUpdate` policy (composition doc `:77-82`).
- The observer stays subscribed (**Q-P2**). The commit stands either way.
- **Prototype.** At `79f4639` (Q2) this took about 15 lines. Hands keeps that
part and drops the turnstile around it.
### 3. Delivery thread, order and reentrancy
- **Unchanged from main, on purpose.** Each outermost hold publishes exactly its
own changes, on its thread, after it leaves the gate and before it returns
(composition doc `:59-65`, `Held` `:2339`).
- A write from inside a handler is its own outermost hold. It delivers
depth-first before returning, as Q4 shows on main.
- There is no cross-thread order. Every change carries `Sequence`, and
latest-state consumers drop stale changes by it (doc `:68-73`, and the Mesh
mirrors at `CultMesh.cs:1534-1540`).
- **Why this is not a regression of the ruling.** The ruling asks for
exactly-once, not for a total delivery order.
- Exactly-once in the cache comes from the single mint site plus isolation
plus Cut 1 (§1-2).
- Order is needed by exactly one consumer, the shard log. It gets that order
from the journal (§4), not from delivery.
- **The journal is the new mechanism.**
- Signature: `IDisposable CultCache.AddJournal(Action<IReadOnlyList<CultCacheDocumentChange<object>>>)`.
Its name and shape are **Q-P3**.
- It is called in `Admit` right after `Apply` (`:2328-2329`), under the gate,
once per admission, with that admission's changes in Sequence order.
Journals run in registration order.
- A journal that enters the cache, meaning any hold on this cache while a
journal runs, throws `InvalidOperationException`. It is guarded by a flag
checked in `Held`'s `Monitor.IsEntered` branch (`:2341`). This is the
store rule ("a store call must not wait for a write to its own cache"),
enforced.
- Journal exceptions follow the observer policy: the admission stands, it is
still published, and the exception is rethrown to the writer after
publication.
- Adding or removing a journal takes the gate, so registration is atomic with
respect to admissions.
- **Lock order.** Cache gate, then journal-internal leaf locks (the database's
`_logGate`). Observers still run outside the gate, so the `bcae483` cycle
(an observer needing `_lifecycleGate` while a gate holder writes the cache)
cannot form. No journal takes `_lifecycleGate`, and no writer waits on a
delivery.
- **Soul's P2 passes.** A handler waiting on another thread's write sees it,
because nothing orders deliveries (Q7 on main).
### 4. What CultNetDatabase keeps
The database is rebuilt on the journal. It has no queue.
- **Journal (under the cache gate, per admitted change, in Sequence order):**
1. `TakeDoor(change)`, with the shape rule salvaged from the branch
(`:1219-1234`). A put door matches a change whose new document is its
instance and whose previous document is not. A removal door matches a
change whose previous document is its instance and which has no new one.
- The consumption now happens inside the door's own hold, before any
observer runs. So an observer's later write to the door's key or
instance can never wear the context (the `f3343c8` defect), and
unconditional `Dispose`-removal stays correct.
2. Predictions. A `Predicts` door adds the prediction. On an `Authoritative`
door (`ApplyPutAsync`, replica put), if a prediction exists for the key,
the kind is `Reconciled` and the prediction is removed.
- The gate serializes this, so `_predictionGate` and `HasPrediction` are
deleted (F5).
- Soul's P6 cannot happen: the prediction is recorded before
`PutPredictedAsync` returns.
3. Primacy.
- On a shard primary, where the door is not `NoLog`: mint, build, append disk-first and record, with a failed
append burning the sequence and compacting through it. See "(a) A journal that cannot log a committed
change" in Imagination pass 4; the earlier claim that a serialization failure mints nothing is withdrawn.
- On a replica, record the primary's entry from the `Replica` door,
under its own sequence.
- For a local write on a replica, log nothing.
4. Stash `(cache Sequence → CultNetDatabaseChange)` under `_logGate`, for the
publisher.
- **Watch observer (after the gate, on the writer's thread, before it
returns):** it removes the stash entry for `change.Sequence` and publishes it.
- A change with no stash was admitted before the database existed and is
ignored.
- Register the observer first and the journal second, in the constructor, so
a stash can never be orphaned.
- **Log-before-publish** is structural: the journal runs before the gate opens.
- **The replica's absent-key delete (F6)** logs directly under `_logGate`. It
is in order because every earlier entry of the same pull loop was journaled
inside its own hold, before that `UpsertAsync`/`Remove` returned. This holds
even when a pull runs inside a handler, since nested holds journal
synchronously.
- **The in-memory log is keyed and ordered by sequence.** It becomes a
`SortedList<long, entry>` per shard, to match the durable store
(`CultNetShardMutationLogStorage.cs:55-64, 75-89`: that store replaces by
sequence and orders by sequence).
- **Kept from the branch:**
- the door table keyed by instance identity, and the shape rule;
- "only a primary mints; replicas publish";
- log-before-publish;
- the snapshot resync "logs nothing" rule. Its removal door (branch `:733`)
must set `Removal = true`, or the shape rule never matches it.
- **Database fan-out isolation.** The database's `Subject<object> _changes`
(main `:487`) has the Q2 defect one layer up: the subscription server's
per-peer handlers sit on it. Replace it with the same isolated
observer-array pattern as §2 (about 20 lines). Its exceptions reach the
writer through the cache's own per-observer catch.
- **`CultNetDatabaseChange<T>` gains `long Sequence`**, the cache change's
Sequence, as an optional constructor argument, exactly as
`CultCacheDocumentChange` did (composition doc `:57-58`). Without it,
in-process latest-state consumers cannot drop stale changes.
- **Gone:** F3 (no deferred delivery), F4 (no drainer), F7 (no queue to
strand) and F8 (no guard).
### 5. Backpressure and memory bounds
- **No buffer exists.** A writer delivers its own changes, and they are held
only in its `mine` list for the length of its own call.
- **The journal stash** holds at most the changes of holds between their
journal and their publication. That is one hold per writing thread, and each
entry is removed by the same thread that added it.
- **A slow observer slows only the writer whose change it is handling.** That
is main's behaviour. It does not slow other writers, and it does not grow
memory. This replaces the branch's unbounded drainer backlog (F4, Soul P5).
- **The journal runs under the gate, so a slow journal slows every writer.**
- The database journal does in-memory work plus one durable
`mutationLogStore.Append` per logged change. `Append` rewrites the shard
file (`CultNetShardMutationLogStorage.cs:83-89`).
- That is I/O under the gate, which the locking doctrine already allows for
stores ("A store doing I/O on behalf of its cache ... holds the gate",
composition doc `:98-101`). It is still a new cost, so Cut 4's
verification measures commit latency with a file log store.
- The whole-file rewrite is a pre-existing monolith. It is recorded as a
follow-up, not fixed here.
### 6. Cross-runtime impact
The Eyes survey was run at `65311c9`.
- **`cultcache-rs`, `cultcache-ts` and `cultcache-py` have no publication
surface:** no watch, no listener and no Sequence.
- Rust: `lib.rs:1970-1975, 2213-2420`.
- TypeScript: `cult-cache.ts:164-181`, "Observer delivery is not scheduled
here".
- Python: `cache.py:30-45`, which refuses reentrant mutation.
- **`cultnet-rs`, `cultnet-ts` and `cultnet-py` have no CultNetDatabase
equivalent and no cache watcher.** The only server-side shard log is the
Python interop test peer (`interop_peer.py:565, 583`), which mints
`len(log)+1` under its own lock, inline with the write. That is already the
journal's shape.
- **Wire parity is unaffected.**
- Cache `Sequence` never goes on the wire (composition doc `:47-49`).
- `CultNetShardLogEntryMessage` does not change.
- `CultNetDatabaseChangeRawMessage` (`CultNetSchemaMessages.cs:1016`) carries
no sequence, and this map adds none: stale protection for wire
subscriptions is server-side (Cut 6).
- No vector in `tests/vectors/` encodes publication order.
- **No other runtime must change.**
- **Follow-up.** `cultmesh-py`'s `CultMeshDatabase` watch (`node.py:421-505,
1059-1065`) has F2 (a throwing callback skips the rest and reaches the
writer), and it has no Sequence. When Python grows cache publication, it
takes this contract.
## Cuts
Every cut is on CultLib, branch `hands/cultcache-publication` from `main`
`65311c9`, in a worktree Self creates. Cuts 1-3 touch only the cache and land
first. Cuts 4-6 depend on Cut 3. Heavy verification runs on Yggdrasil.
**The negative checks that stay green in every cut** are the `4562340` tests:
- `ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries`;
- `SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock`;
- `SequenceIncreasesInAdmissionOrder`;
- `WatchRecordIgnoresStaleSequence`.
They are the proof that no scheduler has come back.
### Cut 1. Project before mint (cache, behaviour)
- **First.** Commit Q1 as a failing test named for the rule.
- **Deletes first.**
- Accessor calls in `Index` (`:2798-2804`).
- Direct accessor reads in `RefuseVariantIndexSharing` (`:2659-2660`) and
`ResolveOne` (`:2616`).
- **Adds.**
- `Project` in `Admit` (`:2324-2325`).
- A per-key projection on `VariantPlan` (`:2456`).
- The early returns in `Resolve` (`:2477-2478`, `:2530-2534`) still pass
through `Project`, because it is called from `Admit`.
- **Authority.** Project owns "this admission's name and index values", and
the rules and `Index` read them. `Apply` runs no user code.
- **Verification.**
- Tests:
- **A refused getter mints nothing and changes nothing.** `GetWithSequence`
is unchanged, both keys are absent, the name lookup misses, nothing is
published, and the next write is previous+1.
- **Store-backed batch:** the store file is byte-identical, and a reopen
holds neither key.
- **A load whose getter throws** refuses the open and leaves the file
byte-identical.
- **Once per landed record:** a counting getter runs exactly once per
landed record, including a variant dependent re-resolved by a base edit.
- **The refusal names** the key and the member.
- Soul P4.
- Negative grep: `rg -n "NameAccessor\?\.Invoke|IndexAccessors" src/GameCult.Caching/CultCache.cs`
has no hit in `Apply`, `Index` or `Unindex`. `:315` is store-side and
legitimate.
- Stryker.NET on the diff.
- **Ledger.** About −15 and +45.
### Cut 2. Observer isolation (cache, behaviour)
- **Deletes first.**
- `Subject<Change> _changes` (`:1809`).
- `_changes.OnNext` and the false comment (`:2374-2375`).
- `_changes.Dispose()` (`:2154`).
- **Adds.**
- The observer array and `Observable.Create` in `Watch<T>` (`:1887-1897`).
- The per-observer catch in `Publish` (`:2369`), with escapes joining
`failures`.
- `OnCompleted` on `Dispose`.
- **Docs.** Composition doc `:77-82`: escaped observer exceptions follow the
`OnUpdate` policy.
- **Verification.**
- Tests:
- **Q2 with a rethrowing handler:** the second observer receives every
change. The writer gets the escaped exception after delivery, and the
thrower is still subscribed (per Q-P2).
- **Subscription order:** subscribing and unsubscribing during a delivery
leaves every other observer's stream complete.
- **Dispose** completes observers.
- Soul P3.
- The four `4562340` tests stay green.
- Stryker on the diff, especially removing the catch and breaking out of
the loop on the first failure.
- **Ledger.** About −5 and +30.
### Cut 3. The journal (cache, behaviour)
- **Adds.**
- `AddJournal` (Q-P3), with registration under the gate.
- The invocation in `Admit` after `Apply` (`:2328`).
- The no-reentry guard in `Held` (`:2341`).
- Exceptions routed like observer failures.
- **Docs.** A composition-doc paragraph in "Store composition" and in
"Locking": journals run under the gate, in Sequence order, and must not
enter the cache or wait for anything that needs it.
- **Verification.**
- Tests:
- **Order:** 8 threads × 1000 writes. The journal sees contiguous,
strictly increasing Sequences, and each call's changes arrive together.
- **Timing:** the journal runs before any observer of the same change, and
after `Apply` (`Get` inside the journal sees the new document).
- **Reentry:** a journal that writes the cache throws, and the admission
stands.
- **Refusal:** a refused admission journals nothing.
- **Dependents:** a variant dependent is journaled in the same call as its
base edit.
- **Failure:** a throwing journal still lets the change publish, and the
writer gets the exception.
- The `4562340` tests stay green.
- Stryker on the diff.
- **Ledger.** About +50.
### Cut 4. Salvage the branch and rebuild CultNetDatabase on the journal (networking)
- **4a (history).** `git merge origin/hands/cultnet-publish-commits` into the cut branch from main
`3bf1c0c`.
- It is clean: the probe merge `6ec2f73` auto-merged `NetworkingTests.cs` and the composition doc, then
built and ran.
- The merge brings the tests. The queue dies in 4b.
- Self may squash-salvage instead, with the same content.
- **4b (subtraction first).** Delete at branch `28248eb` `CultNetDatabase.cs`:
- `_parked`, `_ready`, `_nextSequence` and `_draining` (`:502-505`);
- `_predictionGate` (`:506-507`);
- the Watch subscription and handshake (`:529-531`);
- `OnCacheChange` (`:1080-1135`);
- `PendingChange` (`:1265-1275`);
- `HasPrediction` (`:1209-1213`) and its calls (`:916`, `:1574`);
- the logging and prediction bookkeeping in `Deliver` (`:1137-1201`).
Main's `PublishCacheUpdate` / `OnUpdate +=` (`:505`, `:1157`, `:1161-1191`) is already gone on the branch.
Hands confirms it is absent after the merge.
- **4c (the journal).** Register the Watch observer first and the journal second (`AddJournal`), in the
constructor. Per admission, under the cache gate, per change in Sequence order:
1. `TakeDoor(change)`, keeping the branch's shape rule (`:1219-1234`).
2. Resolve the shard and kind. Predictions: a `Predicts` door adds its key. An `Authoritative` door
(`ApplyPutAsync`, replica put) holding a prediction for the key makes the kind `Reconciled` and removes
the key.
3. Log, per §(a):
- a replica door records the primary's entry, disk first;
- a primary change that is not `NoLog` mints, builds, appends and records, with failure going to the
floor;
- anything else logs nothing.
4. Stash `(cache Sequence → CultNetDatabaseChange)` under `_logGate`.
5. If step 3 failed, throw once at the end of the call, after all changes are handled (Q-P5).
- **4c, publication.** The Watch observer removes the stash entry for `change.Sequence` and publishes it. A
missing stash means the change was admitted before this database existed, so it is ignored. `Dispose`
removes the journal and the observer and clears the stash.
- **4d (the §(a) fixes).**
- The floor: `GetCompactedMutationLogSequence`, `GetLatestMutationLogSequence`, and resume in
`InitializeLogSequencesFromStore`.
- Disk-first `RecordMutationLogEntry`.
- A `SortedList` per shard.
- The snapshot reads its sequence first (`:660` moves below `:668`'s read).
- `_logGate` around `_lastWriteSequence`/`_nextLogSequences` readers (`:1302`, `:1314`, `:1325`).
- **Door changes.**
- Flags: `Predicts` and `Authoritative`.
- The snapshot removal door (branch `:733`) sets `Removal = true`.
- The replica absent-key delete logs directly and publishes nothing (branch `:1612-1621`).
- Doors neither log nor publish. They register context, write the cache, and dispose the registration.
- **Verification.**
- Every salvaged test passes: `CultNetPublishesCommitsTests` (13) and `CultNetPublicationOrderTests` (S1,
S1b, S2, S3, S4, S6, S7, log-before-publish, in-place re-upsert).
- Soul pass 3's P1-P7 are committed and passing.
- The pass 4 probes become tests named for the rule:
- **A1:** a refused primary `Append` publishes the change. The writer gets `CultNetShardLogException`,
which names the shard and `S`. A replica pull answers `compacted_history`, and a snapshot resync
converges.
- **A2:** the same at the tail with a restart: the replica converges after resync, and no sequence is
reused. The store's `CompactThrough` succeeded.
- **A3:** a refused replica `Append` leaves the cursor unchanged. The retry converges, and the memory log
holds `[1,2]`.
- **B1:** a pull on a primary is logged, and the replica converges.
- **S:** a snapshot racing a write converges.
- **C:** after compacting through 2 and restarting, the next mint is 3.
- New tests:
- a reentrant `database.PutAsync` from inside a database handler is logged with its context and published
once;
- a reentrant `ApplyShardLogResponseAsync` from inside a handler keeps the replica log in primary order;
- a log store whose `Append` writes the cache fails with Cut 3's reentry guard, wrapped, and the admission
stands.
- Commit latency with `CultNetFileShardMutationLogStore`, main against Cut 4. Also measure a `CultMesh` node
with `EnableDurableShardLogs`, where every bookkeeping write is now logged. Report it; do not gate on it.
- Negative grep: `rg -n "_parked|_ready|_draining|_nextSequence|_predictionGate|HasPrediction|PublishCacheUpdate" src/GameCult.Networking`
returns nothing.
- Review check: no `_cache.` call inside a `lock (_logGate)` block.
- The `4562340` tests stay green.
- Test runs use `--blame-hang-timeout 2m`.
- Stryker on the diff: dropping the floor raise, memory-before-disk, and the snapshot read order must each
kill a test.
- **Docs.**
- Composition doc `:50-53`: replace the "streams derive from `OnUpdate`" sentence. CultNet derives from
`Watch` plus one journal, and the log is written in cache order.
- A new paragraph: a primary that cannot log a change compacts through it.
- The log-store rule under Locking.
- **Blockers.** None. Q-P5 is ruled A.
- **Ledger.** Against the branch, about −130 and +95. The extra over the old estimate is the floor, the
disk-first order, the `SortedList`, the snapshot order, and the exception type.
### Cut 5. Database fan-out isolation and Sequence (networking, behaviour)
- **Deletes first.** The database's `Subject<object> _changes` (main `:485`, branch `:487`) and its
`Dispose` (`:1158`).
- **Adds.**
- The isolated observer array, the same pattern as the cache's `Register`/`Publish`
(`CultCache.cs:1929-1944`, `:2470-2503`), behind `Watch<T>`, `WatchAllChanges` and the rest (`:1089-1145`).
- `CultNetDatabaseChange<T>.Sequence` (`:319-371`), as an optional constructor argument. The journal's stash
sets it from the cache change. A change with no cache change behind it carries 0.
- **Docs.** The binary-signature note, as the composition doc gave for `CultCacheDocumentChange` (`:57-58`).
- **Verification.**
- With a rethrowing handler and one throwing database subscriber, the other subscriber and a live
subscription-server peer receive every change. The writer gets the exception after delivery (Q-P2).
- The disposed-database test.
- `Sequence` equals the cache change's.
- **Blockers.** Cut 4.
- **Ledger.** About −10 and +35.
### Cut 6. Stale protection on wire projections (networking, behaviour; Q-P4 A)
- **The rule.** A projection applies a change to a key only if its `Sequence` is above what the projection
last applied for that key. A snapshot or reconcile applies everything at or below the cache sequence it read
**before** reading documents.
- That read is `Cache.GetWithSequence(<any key>).Sequence`, the branch's idiom (branch `:531`). No new API
is needed.
- **Subscription server** (`CultNetDatabaseSubscriptionServer.cs`):
- `SubscriptionProjectionState` (`:630`) gains `long Floor` and `Dictionary<string, long> Applied`.
- The single-row fast path in `Watch` (`:296-340`) reads the change's `Sequence` (reflection, like `Key`).
Under `_lifecycleGate` it drops the change if `Sequence <= max(Floor, Applied[key])`. Otherwise it applies
the change and records it. Removals are included.
- `Reconcile` (`:347`) and the subscribe snapshot (`:168-181`) read `seq0` before `CreateProjectedSnapshot`
(`:402`), set `Floor = seq0`, and clear `Applied`.
- This is sound: every change applied earlier was applied under the same gate, after its admission, so
its `Sequence` is at most `seq0`.
- Hop and unauthorized paths already reconcile from current state, and they set the floor too.
- **`CultNetDatabaseServer.CreateSubscription`** (`:501-513`):
- A per-subscription `(Floor, Applied)` under a per-subscription lock, held across check, record and send.
- `HandleSubscribeAsync`'s snapshot (`:362-369`) takes `seq0` first and sets the floor.
- **Verification.**
- Two threads write one key, with a blocking cache observer that forces the inverted delivery (Q3 shape).
On both servers, the peer's last state for the key equals the cache's.
- A removal overtaken by an older upsert leaves the key absent at the peer.
- The negative check: without the drop, both tests fail.
- **Blockers.** Cut 5, which provides `Sequence` on database changes.
- **Ledger.** About +45.
### Salvage from `hands/cultnet-publish-commits` (revised)
- **Keep:**
- both test files;
- the `NetworkingTests.cs` edits;
- the door table and the shape rule;
- "replicas never mint";
- log-before-publish;
- "the snapshot logs nothing";
- "loads and pulls on a primary are logged" (B1);
- the composition-doc paragraph, rewritten to name the journal.
- **Drop:**
- the reorder buffer and the drainer;
- `_predictionGate`;
- logging from the observer;
- the swallowed exception path (A1 and A3 on the branch).
## Subtraction ledger (estimate)
| Cut | Removed | Added | Other |
|---|---|---|---|
| 1 | ~15 | ~45 | none |
| 2 | ~5 | ~30 | the cache no longer uses R3 `Subject` |
| 3 | 0 | ~50 | one public method (Q-P3) |
| 4 | ~130 vs branch | ~95 | the merge brings +1109/-243, of which about 780 are tests; the floor, disk-first order, `SortedList`, snapshot order and `CultNetShardLogException` are the extra over the first estimate |
| 5 | ~10 | ~35 | the database `Subject` goes, and one public property |
| 6 | 0 | ~45 | none |
- **Source, net against main:** about +245.
- **Source against the branch:** no scheduler anywhere.
- The budget is pressure, not a metric.
## Operator questions
- Q-P1..Q-P4 are ruled (see Rulings). Their option analysis is in the map's git history.
- **Q-P5 A (ruled 2026-09-29): a primary that cannot log a committed change throws a typed
`CultNetShardLogException` naming the shard and the burned sequence, after publication, not derived from
`InvalidOperationException`.**
- The commit stands, the change is published, and replicas behind it snapshot-resync (Imagination pass 4 (a)).
- The writer hears of it. AetheriaEve `Program.cs:3722` cannot mistake it for a denial.
Recorded, not forked:
- (b): no origin on the journal. Loads and pulls are logged on primaries.
- (a)'s restart residual when the store refuses both writes.
- The replica keeps retry-by-cursor.
- A throwing getter refuses loads too.
- Reentrant writes keep main's depth-first delivery. Order is data.
- Journal I/O under the gate follows the store precedent.
## Follow-ups outside this campaign
- **`cultmesh-py` watch.** It has no isolation and no Sequence
(`node.py:1059-1065`).
- **Durable log whole-file rewrite.** `CultNetShardMutationLogStorage.cs:83-89`
rewrites the whole file per `Append`. It is a monolith, and under the gate
its cost is now every writer's.
- **Mesh database-backed handles.** `CultMesh.cs:2362` and `:3396` can drop
stale changes once `CultNetDatabaseChange.Sequence` exists.
- **Skill scar.** The Eureka SKILL.md already says "a ticket system that
serialized delivery was built, deadlocked, and was deleted". The Soul pass 3
direction re-proposed it anyway. The Soul brief template should tell Soul to
check `git log -S` for a proposed mechanism before recommending it.
- **A chained replica after a snapshot.** A replica that snapshot-resyncs has no log entries below the snapshot
sequence. It should raise its own floor to that sequence, so a replica chained on it resyncs instead of seeing
a gap (`ApplyShardSnapshotResponseAsync` `:675-736`). It predates this campaign.
- **AetheriaEve `Program.cs:3722`.** It reads a committed write's `InvalidOperationException` as a denial. Under
Q-P5 A, Cut 4's log failures cannot reach it. Observer and `OnUpdate` exceptions still can, so the Cuts 1-2
recording stands.- **Cut 4 Soul, recorded 2026-09-30 (Self), not fixed in Cut 4:**
- **File log store is not crash-safe (hard prerequisite).** `CultNetFileShardMutationLogStore.Append` and
`CompactThrough` use `File.WriteAllBytes` (truncate, rewrite, no temp+rename, no fsync). A failure mid-write
truncates the log; every later append burns a sequence and a restart cannot construct the database. It also
rewrites the whole file per append, which Cut 4 now does under the cache gate (2.2 ms per write at 100
entries, 33 ms at 950, a 2.49 s stall). An atomic, append-only store must land before any live daemon
enables durable shard logs. Today only AetheriaEve's offline tools do; the live Aetheria daemon passes
`enableDurableShardLogs: false`.
- **A slow log append blocks cache readers** (`CultCache.Get` takes the gate). Accepted with a note in the
store's XML doc.
- **Crash between the store write and the log append** leaves the log lagging the store, and nothing detects
it; a restarted primary reuses that sequence (A2's divergence reached by a plain kill). The spec called this
lag detectable; for a crash it is not.
- **Cut 4 final Soul (4d), recorded 2026-09-30 (Self), not fixed in Cut 4:**
- **F2, read cost.** `CultNetDatabase.Serves` (`:1980-1989`) runs per row in `GetAll`/`GetByName`/`GetByIndex` for every
layout except a single owns-everything shard: a reflection `GetTrackedKey`, a gate lock and a shard re-scan per row.
`database.GetAll` measured 24.8x `cache.GetAll` on 20,000 rows under a key-prefixed shard. Fix: filter by key inside
the cache enumeration (the `rowFilter` path already has keys) and hoist the owns-everything check.
- **F4, dispose drain edges.** An observer that removed its stash entry before the drain can publish after `Dispose`
returns (dropped); a throwing `_cacheJournal.Dispose()` skips the drain. Neither double-publishes or deadlocks.
- **F5, refusal naming.** A write whose schema is owned but whose key is outside the shard's prefix also answers
`unowned_schema` with details `{field:"schemaId"}`; the peer cannot tell a key refusal from a schema refusal.
- **Pre-existing, separate task.** Python emits error code `unsupported_schema_version`
(`cultnet-py/.../interop_peer.py:415`, `cultmesh-py/.../server.py:161`), which is not in the error-code enum; TS and
Rust refuse the whole message.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,520 +0,0 @@
# CultCache document variants: cut map (draft)
Date 2026-09-29. Imagination output for `docs/document-variants-target.md` (branch
`codex/document-variants`). Body read at `origin/main` `268e0ef`. This map owns means only;
the target owns ends and rulings 1-7. Nothing here is decided that the batch in section 4
asks the operator to decide.
## 1. Probe facts (run, not inferred)
Probe: scratch clone `scratchpad/variant-probe` (commit `7b6de8e`, probe files only), run on
Yggdrasil via `ygg-verify.sh` with images `dotnet`, `rust`, `python:3.12-slim`, `node:22`.
Each runtime's current reader wrote a normal store, the bytes were altered four ways, and
the store was reopened. Output in `scratchpad/probe-out/*.txt`.
| Altered store | C# | Python | TypeScript | Rust |
| --- | --- | --- | --- | --- |
| A. v1 header, record gains slot 4, payload empty | refuses, "read past end of stream" | refuses, "incomplete input" | refuses, "Offset outside DataView" | refuses (strict tuple), misleading legacy-envelope message |
| B. v1, slot 4 plus a full payload | **loads the payload silently** | **loads silently** | **loads silently** | refuses |
| C. header `cultcache.store.v2` | **ignores the header**; refused only by the empty payload | refuses, misleading "legacy envelopes" message | refuses | refuses |
| D. payload holds the delta as a sparse array | **loads `Name=''`, `Max=99`** | **loads `name=None`** | refuses | loads; `get` of that key fails |
Consequences:
- The **empty payload is the only tripwire every shipped reader trips**, pinned builds
included (Aetheria pins `caching-unity-v1.4.0`). A variant record's payload must be empty,
and the delta must live outside it.
- A payload-as-delta encoding is ruled out: C# and Python misread it silently.
- C# never checks `formatVersion` (`CultDocumentMessagePackSerialization.DeserializeSnapshot`
defaults a missing one to v1). C#, Python and TypeScript skip extra record slots silently.
These are latent defects today and become misreads when the format grows.
- Every refusal is loud but none of them names the record. The target requires named refusals,
so current readers need a small fix (C0).
Other Body facts that shape the map:
- **CultNet has no member-path grammar.** `docs/cultnet-selection-cut.md` D2: "Declared
aliases, not a field-path language". It reaches top-level declared `index`/`role` aliases
only (`CultDocumentMemberView`). Nothing exists to reuse. See section 3.
- **The schema catalog describes top-level members only.** A nested type (`BehaviorData`,
`PerformanceStat`, `HardpointData`) appears only as a CLR type name. Soft drift and hard
rejection cannot see below slot level.
- **Resolution needs a codec, and the core has none.** `GameCult.Caching` holds objects. The
MessagePack codec lives in `GameCult.Caching.MessagePack`. `CultInspectorModel` already
takes the codec as two injected functions (`CultCacheMessagePack.CreateInspectorModel`).
- **Same concrete type implies same home store.** `Home(type)` routes by type, so ruling 3
("same store") falls out of ruling 6. No separate check is needed.
- **The name index shadows silently.** `Index()` does `MapOf(_names, type)[name] = key`, so a
second record with the same name overwrites the entry. A variant that inherits its base's
name would make `GetByName` return one of the two, depending on apply order.
- **Studio edits by whole-document upsert.** `CultInspectorEdit.Commit` calls
`UpsertAsync(type, copy, key)`, and `Duplicate` clones through the codec.
- **Raw readers bypass the cache.** In CultLib: `CultMesh.ReadSingleFileDocument`
(`CultMeshSingleFileDocuments.cs:112`), plus its legacy snapshot decoder. In consumers:
AetheriaEve `AetheriaRuntimeCatalogStore` (directory-store pages).
- **Kotlin reads no store files.** It sees CultNet raw snapshots only.
## 2. Model page (step 0b)
| Kind | Named by | Over time | Decided by |
| --- | --- | --- | --- |
| Variant record | its own `CultRecordKey`, minted like any record; same `schemaId` as its type | created, re-overridden, rebased, removed; never converted to or from a plain record implicitly (Q1) | author through Studio or the variant write API; the cache validates |
| Base reference | the base's key, in the record's variant slot | changed only by an explicit rebase; a base cannot be deleted while it has variants, unless the same batch rebases or removes them | author; the cache refuses a cycle, a missing base, a different concrete type, a global type |
| Override entry | `(op, path, id, value)`; op is Set, Insert or Remove | added by editing; removed by clearing; survives base edits while its path resolves | author; the cache refuses entries a base edit strands (Q2); an entry whose member left the type soft-drifts with a warning (Q4 c) |
| Member path | a sequence of `(slot, elementId)` steps; `slot` is the MessagePack `[Key]`; `elementId` is `""` unless the step enters an object-list element | a type change can invalidate it | the type's owner (slot authority); ids by the element's creator |
| Element id | a `[CultElementId]` string member on an object-list element type; `""` means unset | minted by the cache on write when unset; copied unchanged into variants; a moved element is a new element | cache mints it; it is never retargeted |
| Resolved view | `CultStoredDocument.Document` for a variant key | recomputed inside the hold that admits the variant, or any base on its chain | the cache only; never persisted, never written back (Q1) |
| Index and name entries | as today, from the resolved view | a dependent's entries are dropped and re-added in the same `Apply` as its base change | the cache; a variant must override the `[CultName]` member (rule R6) |
| Watches | as today; each re-resolved dependent emits `Updated` with its own `Sequence` | published in the same hold as the base change | the cache |
| Compare-exchange | `(schemaId, storedAt)` of the stored delta (ruling 2) | a base change does not stamp its variants | the cache; the resolved view is not an identity |
| Persisted format version | store header `formatVersion` | `cultcache.store.v2` when the store holds at least one variant; `v1` otherwise (Q5) | the writing store, from content |
| Variant slot on the wire (CultNet) | **empty cell**: no CultNet message carries a delta today | C5 | ruling 4 decides deltas; the message shape is C5's design |
| Dictionary entries, scalar lists, arrays | **empty cell**: no stable identity exists | whole-member Set only (Q7) | Q7 |
| Template (non-item) bases | **empty cell**: parked by ruling 6 | none | not in these cuts |
## 3. Where the Body contradicts the target
1. **"A member path in CultNet's grammar"** (ruling 1 and fork 1 text) has nothing to reuse.
CultNet selection deliberately has no path language. This map specifies a minimal slot
path (section 2) owned by `GameCult.Caching`. If CultNet ever needs paths, it can adopt
this one. Nothing is shared today, and nothing is invented twice.
2. **"Overrides are subject to the same soft drift rules as full records"** conflicts with
the ends list: "Loading refuses an override naming a member that does not exist on the
type". Under soft drift, a persisted slot missing locally is ignored with a warning, so
the two rules disagree for a dropped member. Below top level, the catalog has no shape to
drift against at all. See Q4.
3. **"Deletes first"** has little to delete: the feature is additive. The honest deletions
are the silent extra-slot skips and C#'s ignored header. C0 puts them first.
## 4. Operator questions (one batch)
**Operator rulings, 2026-09-29, each asked on its own:**
- **Q1: (a)**, a plain `Upsert` at a variant's key is refused loudly.
- **Q2: (a)**, a base edit removing an overridden element is refused unless the same batch
resolves it. Operator's reading: "the very existence of that element in the child collection
must become the new override state... you can't change the parent without also changing the
child... that's the resolve part of (a)."
- **Q3: ids everywhere, not opt-in.** Operator: "Opt-in sounds nice but consider the UX. We're
planting a footgun if we don't require ids everywhere." Rollout: **required, minted on load**.
The C# registry requires an id member on object-list element types. A store written before ids
existed loads, and ids are minted once on its first write (C2's rewrite). Other runtimes carry
the id as an ordinary member and enforce the rule when they gain variants. Each consumer's type
edit is a sweep in its own repo after C2.
- **Q4: (c) soft drift everywhere. This reverses the recommendation and supersedes the target's
end "loading refuses ... an override naming a member that does not exist on the type".**
Operator: "Soft drift everywhere aligns with the vision... the rationale for adopting
migrationless changes is still valid for variants. Any member dropped in a type change is a
design change, the reason for it to apply to the parent also applies to the variant." A
dangling override, at any depth, is ignored with a warning naming the variant and the member,
and the variant inherits the base value. The other broken-variant refusals (a base cycle, a
missing base, an incompatible base type) stand.
- **Q5: (a)**, the v2 header is written only when the store holds a variant.
- **Q6: yes**, the directory store and CultNet refuse variants loudly until their own cuts.
- **Q7: yes**, object-list elements are addressable by id, and dictionaries, scalar lists and
arrays are replaced whole. The operator asked first whether design roles and quality levels
use dictionaries. They do not: `CraftedItemData.Roles` is a `List<ItemRole>` and
`FactionProduct.Roles` is a `List<ProductRole>` (Aetheria `ItemData.cs:316`,
`FactionProduct.cs:30`). A market-segment product becomes a variant overriding one
`ProductRole` by id.
*The questions as asked:*
**Q1. A plain `Upsert` of a whole document at a variant's key.** It happens when Studio's
current edit commits, when AetherDb migrations do `batch.Upsert(type, doc, key)`, and on a
CultNet remote write.
- (a) Refuse it loudly. A variant changes only through the variant API. Flattening is an
explicit, separate operation.
- (b) Diff the document against the base and store the difference. Ids make the diff
deterministic, but a value set equal to the base stops being an override without anyone
saying so.
- (c) Flatten silently (today's behaviour by default).
- **Recommend (a).** Affects C1's write path, C3's edit commit, and every AetherDb migration
(each skips variants or uses the variant API).
**Q2. A base edit that removes an element some variant overrides.**
- (a) Refuse the commit unless the same batch clears or retargets those overrides. Studio
offers keep, retarget or clear at edit time, and "keep" means the base edit is not
committed. The store never holds a conflict, and loading refuses one.
- (b) Persist the conflict. The override goes inert, readers get the resolved document
without it, and Studio lists it.
- **Recommend (a).** Option (b) makes a reader's resolved document silently differ from what
the author wrote. Affects C2's validation, C3's rebase UI, and migrations that remove
behaviours.
**Q3. Scope of "ids everywhere".**
- (a) Every object-list element in every registered document type, in all 28 consumers. The
registry refuses a type whose element types lack an id.
- (b) Only document types that opt in with `[CultVariants]`. For those, the registry requires
`[CultElementId]` on every object element type reachable through their lists, including
union bases.
- **Recommend (b).** Option (a) breaks 28 registries for a feature that one consumer uses.
Affects C2 and Aetheria's adoption cut. A `BehaviorData` id needs a base slot above the
subclasses' highest key (32), which adds about 30 nil bytes per behaviour unless Aetheria
renumbers.
**Q4. Schema drift under overrides** (contradiction 2).
- (a) Refuse loading when an override names a member missing at any depth, or holds a value
that will not decode into the member's current type. Record this as a stated exception to
soft drift in `cultcache-schema-compatibility.md`.
- (b) Top-level overrides follow catalog soft drift (ignore with a warning); nested ones are
refused.
- **Recommend (a).** It follows the target's ends and is one rule. The cost: after a type
change drops an overridden member, the catalog must be rewritten before the new build opens
it.
**Q5. Format version.**
- (a) Write `cultcache.store.v2` only when the store holds a variant. Stores without variants
stay byte-identical `v1` for every consumer.
- (b) Always write v2 after the upgrade.
- **Recommend (a).** The empty payload remains the tripwire for pinned old C#, which ignores
the header. Affects C0 and C1.
**Q6. Slice scope.** Should the directory store and CultNet refuse variants loudly until
their own cuts? The directory store's only consumer is AetheriaEve's raw reader. CultNet
must carry deltas (ruling 4), and no message can yet.
- **Recommend yes.** It is two refusals. Otherwise C1 must design CultNet messages first.
Affects C1's size and the order of C5.
**Q7. What is addressable below a member.** The proposal: object-list elements by id only.
Dictionary entries, scalar lists and arrays are replaced whole.
- **Recommend yes.** Aetheria's cases (`Hardpoints` whole; `Behaviors[id]...Max`) fit. Keyed
dictionary addressing can be added later without a format change. Affects C2.
## 5. Cuts, in order
All cuts are in `F:\Projects\CultLib`. Rebase `codex/document-variants` (docs only) onto
`origin/main`. Each cut is one Hands pass on that branch and merges to main after its Soul
pass. Probes show that a C1 writer can merge safely before other runtimes change, because
every reader refuses it. Verification uses `ygg-verify.sh /f/Projects/CultLib <rev> <image> '<cmd>'`.
The command strings below are the `<cmd>`. Keep the job's exit status last.
Overlap: BP-1 (`cultmesh/rust-content-plane`) edits `docs/runtime-parity-scope.md`, which
C0 also edits. Rebase after BP-1 merges. `codex/fix-node24-ajv-esm` has no diff against main.
### C0. Tripwires: every reader refuses what C1 will write, and names the record
Deletes:
- The silent extra-slot skip in the C# record decoder
(`CultDocumentMessagePackSerialization.ReadPersistedRecord`'s trailing `Skip` loop) and
its copy in `CultMeshSingleFileDocuments.ReadLegacyPersistedRecord`.
- The `?? "cultcache.store.v1"` defaulting of the header.
- Python `raw_record[:4]` slicing.
- The TypeScript and Python fallthrough to the legacy envelope decoder when the header is a
`cultcache.store.*` string they do not know.
Adds, one rule in each runtime: a snapshot whose header is not `v1`, or whose record has more
than 4 slots, is refused with a message that names the version or the record key, and says
the store needs a runtime that resolves variants. C# accepts `v1` only, until C1 adds `v2`.
Keeps: every v1 byte and every v1 test.
Verification (rule: "unknown header or extra record slot is refused by name"). Each runtime
gets one test, and each test's negative is fixture B from section 1 (full payload plus slot
4) loading:
- `dotnet`: `dotnet test tests/GameCult.Caching.Tests --filter FullyQualifiedName~PreCut2StoreFormat`
- `node:22`: `npm install --no-audit --no-fund && cd packages/cultcache-ts && npm test`
- `python:3.12-slim`: `pip install -q msgpack -e packages/cultcache-py && python -m unittest discover packages/cultcache-py/tests`
- `rust`: `cd packages/cultcache-rs && cargo test` (message only; Rust already refuses)
Subtraction: about −20 lines of skip and fallthrough; +4 small refusals and 4 tests. Build:
the 4 cache packages only.
### C1. C# core: variant records, top-level Set overrides, resolution in the admitting hold
Adds:
- **`CultPersistedRecord.Variant`**, record slot 4: `[baseKey, overrides[]]`. Payload slot 3
is empty for a variant. An override is `[op, path[], id, value]`.
- Owner: `GameCult.Caching`.
- Consumer: both file stores and Studio.
- Invariant: an old reader refuses rather than misreads (section 1).
- Why no existing owner serves: the payload cannot hold the delta (probe D).
- **`CultStoredDocument.Variant`** (base key and overrides; null for plain records).
`Document` stays the resolved view.
- **A codec port on `CultCache`**: the `(serialize, deserialize)` pair `CultInspectorModel`
already takes, lifted.
- Owner: `GameCult.Caching.MessagePack`, supplied by `CultCacheMessagePack.Create`.
- Consumers: resolution and the inspector model.
- Invariant: one codec per cache; clones and override values use the store's encoding.
- `CultInspectorModel`'s constructor takes the port instead of its own pair, so there is one
pair, not two.
- A cache without a codec refuses variant writes.
- **Write API** on `CultCacheBatch`, with single-call equivalents: `UpsertVariant(key, baseKey,
overrides)`, `Flatten(key)` (Q1a), `Remove` unchanged. Rebase is `UpsertVariant` with a new
base.
- **`Validate` resolves** every admitted variant and every dependent of an admitted or
evicted base, in base-first order, against the post-batch set. It refuses, naming keys:
a cycle; a missing base; a different concrete type; a global type; an evicted base with
surviving variants; a variant whose resolved `[CultName]` equals its base's (R6); a plain
`Upsert` at a variant key (Q1a). **Not refused (Q4 c, operator 2026-09-29):** an override
naming a slot the type no longer has, or holding a value that no longer decodes into the
member's type, soft-drifts exactly as the same member would on a plain record under
`cultcache-schema-compatibility.md`. It is ignored with a warning naming the variant and
the member, and the variant inherits the base value. One rule, the existing soft-drift
path, never a variant-specific one.
- **`Apply` re-resolves dependents** in the same hold. Each emits `Updated` and is re-indexed.
A base change stamps no dependent `storedAt`.
- **Header**: the store writes `v2` iff it holds a variant (Q5a) and reads `v1` and `v2`.
- **Refusals** (Q6): `DirectoryMessagePackBackingStore` push and commit of a variant;
`CultNetDocumentRegistry` row building for a variant; `CultMesh.ReadSingleFileDocument` of
a variant key.
- **Contracts**: the variant record section in `cultcache-persistence-format.md`; one
sentence in `cultcache-schema-compatibility.md` saying overrides soft-drift like members
(Q4 c, no exception); a "variants: C# resolves; others refuse" row in
`runtime-parity-scope.md`.
Authority map:
- Owner: `CultCache.Validate`/`Apply` owns the resolved view.
- Inputs: stored deltas, base records, the codec.
- Outputs: the resolved `Document`, index entries, changes.
- Demoted: the resolved `Document` is no longer a writable owner for a variant key (Q1a).
`storedAt` is the delta's identity only.
- Forbidden writers: store `Loaded` callbacks never resolve; Studio never writes a resolved
document to a variant key; CultNet never emits a variant row.
- Shared paths: a load (`Admit` with a source), a single write, a batch and a remove all pass
the same `Validate` and `Apply`. There is no second resolution path.
Verification. New `VariantTests.cs`; each rule gets a positive and a named negative:
- a load with a variant resolves;
- a base edit updates the variant's `Get`, `GetByName` and `GetByIndex`, and `Watch` sees both
changes inside one publication, with no gap in `Sequence` between them. Negative: a
subscriber observing the base change must already read the new variant.
- `Expect` on the variant survives a base change;
- each refusal case, and the refusal message names both keys;
- the header is `v1` without variants and `v2` with them;
- a read-only store resolves;
- a variant round-trips byte-identically through flush.
Cross-runtime vectors: the Caching interop peer gains `write-variant --file`. A test in
`packages/cultcache-ts/test/cult-cache.test.ts` asserts that TypeScript refuses that file by
name; Python and Rust tests read the same fixture from `contracts/cultcache/variant-store.cc`.
Command, `dotnet`:
`dotnet test tests/GameCult.Caching.Tests && dotnet test tests/GameCult.Networking.Tests --filter FullyQualifiedName~Registry`,
then the C0 commands for `node:22`, `python` and `rust` against the new fixture.
Subtraction: the inspector's own codec pair collapses into the port; otherwise net additive,
estimated +350 to 450 lines of core and +400 of tests. Build: the Caching and MessagePack
projects, Caching.Tests, the Networking test filter, the interop peer.
**C1 status, 2026-09-29 (Self).** C1 landed on `hands/variants-c1` (`136adce`). Soul (Opus) found it
not closeable, and a fix batch is in Hands:
- **F1:** CultNet's live-change paths leak variants as plain records, which breaks Q6.
- **F3:** an undecodable override soft-drifted where a plain record refuses to load.
- **F4:** invalid overrides were accepted at write.
- **F2:** an inherited unique index value shadows the base, and the winner depends on order.
**Self's readings and defaults, not new operator rulings:**
- **Q4 (c) read literally:** an override follows the plain-record outcome. A missing slot
soft-drifts; a value that no longer decodes refuses loudly, as a plain record does.
- **Invalid overrides are refused at write.** Drift covers type changes after the fact, not
bad writes.
- **F2 extends R6 to indexes.** A commit that leaves a variant sharing an indexed value
with another record is refused.
- **One override per slot.**
**Open for the operator:** plain records with duplicate unique-index values are silently
overwritten today. That predates variants. Fixing it could refuse existing stores.
**C2a status, 2026-09-30.** Hands pushed `hands/variants-c2a` at `640ad9a`.
- Element ids are 12 hex characters. Writes mint them at random. Load mints them deterministically.
Derived ids use `[CultElementId(nameof(Member))]`.
- The CDN chunk ref's id is its offset. The witness types carry ids, and the ids cross C#, TS, Python and
Rust.
Soul held it. Fixes are in Hands:
- ids are never minted inside variant overrides;
- interface unions are never minted;
- a null derived source leaves an empty id;
- load minting is culture-dependent;
- the minted-on-load list goes stale;
- a refused batch leaves minted ids behind;
- the id format is unchecked;
- late union-slot reuse passes registration.
**C2a rulings (operator, 2026-09-30):**
- **Old readers are tripwired.** A store that holds element ids carries a marker that every pre-C2a reader
refuses under C0's unknown-header rule. An old C# reader can therefore never silently strip nested ids.
MessagePack's generated formatters skip unknown slots inside elements.
- **Duplicate element ids refuse at load,** naming the record, the list and the id. This matches the
duplicate-index ruling.
- **C2b precondition (Soul F7).** Whether a pre-id element keeps its load-time id depends on the write path.
C2b must refuse an override that targets a record whose ids exist only in memory, or mint on open.
**C2a Soul pass 2 (`e85b201b`), 2026-09-30: hold.** The tripwire only works against unreleased readers.
Every published release (`cultlib-unity-v1.0.60`, `caching-unity-v1.4.0`, `cultcache-ts-v0.14.0`,
`cultcache-py-v0.3.0`) predates C0. The 1.0.60 C# reader loads a v3 store, and its next write is v1 with the ids
stripped (probe). Other findings:
- F2: the same element object twice in one list commits, then the store refuses to load.
- F3: CultMesh single-file writes bypass the marker.
- F4: TS `pushAll` writes the header without reading it first.
**C2a rulings, 2026-09-30 (operator):**
- **Rollout order closes the released-reader hole.** Ship a CultLib release that refuses v3, move every C#
reader of a store onto it, then let a C2a writer touch that store. There is no format change for this.
This supersedes the claim above that old readers are tripwired: that claim holds only for post-C0 readers.
- **Mark by content.** A store is written as v3 (v5 for directory stores) only when a record actually holds an
element id. An existing v3 header on disk stays.
- Fix batch 3 in Hands: F2, marking by content in every runtime, F3 routed through the cache's header
decision and load checks, F4.
**C2a Soul pass 3 (`9ed61dd2`), 2026-09-30: fix first.**
- A flattened record's `HoldsIds` goes stale when its base changes in the same batch.
- A conditional commit onto disk ignores content.
- Rust `push_all` clobbers headers it cannot read.
- TS keeps a stale header after the file goes away.
- Self's claim that "rollout order makes the raw-writer hole benign" was wrong. The reader that strips ids
from a v1 store is a post-C0, pre-C2a build. That is exactly the reader the v3 marker targets, and rollout
order does not keep it away.
**Ruling, 2026-09-30 (operator): C0 is released only together with C2a.** The first CultLib release that
refuses v3 also contains C2a, so no released reader strips ids. C0 and C1 are never released on their own.
Raw and cross-runtime writers that cannot see ids stay sticky-only. Fix batch 4 is in Hands.
**Operator rulings, 2026-09-30:**
- **Duplicate unique-index values do not load** ("Duplicate indices should not load"). This covers
plain records too, so R6-for-indexes becomes one general rule:
- a commit that would leave two records holding one indexed value is refused;
- a store holding duplicates refuses to load, and the refusal names both keys and the index.
- The variant-specific rule `RefuseVariantIndexSharing` collapses into this rule.
- Before it ships, every real store is scanned, so no store stops loading by surprise.
- **Scan result (Eyes, 2026-09-30):** `[CultIndex]` had been recycled as a grouping and
selection-filter declaration: `Category`, `Kind`, `HardpointType`, `ShardId`, and others in
CultMesh, Geometry and docs. AetheriaEve's `aetheria-world.cc` (taxidermy) is the only store
holding duplicates. The only single-result lookups in real code are unique:
`PlayerData.PlayerId` and `Email` in `Server.cs`. The operator: "Pretty sure indexes are
supposed to be unique, it was not my decision to recycle that mechanism for grouping
purposes" and "Grouping was a desirable feature from my old DatabaseListView that didn't
quite make it into the initial CultCache Studio port".
- **Resolution (Self, from those answers):** `[CultIndex]` means unique. Grouping belongs to
the Studio record-grouping campaign's own attribute (`docs/studio-grouping-cut.md` on
`codex/studio-grouping`, attribute-based per the 2026-09-17 ruling). CultNet selection
filters any declared member by its catalog name, so no index is needed for filtering. The
grouping-only `[CultIndex]` declarations are deleted. This is one CultLib cut, queued
behind C2a because both edit `CultCache.cs`.
- The TS/Python last-writer-wins parity gap closes by the same rule when those runtimes
adopt it.
- **CultNet invisibility is a defect** ("looks like a defect to me"): `cache.Commit` and
`cache.UpsertAsync` on a database's cache must publish to subscribers and to the mutation
log (`CultCache.Publish`'s `if (!loaded) continue;`).
- **Hop subscriptions keep refusing variants** until CultNet carries deltas (C5): "keep refusing,
I guess, CultNet will have deltas by the time we're done here anyway".
### C2. C# element identity and nested paths (Set, Insert, Remove)
Adds:
- **`[CultElementId]`** (a string member). **Ids everywhere, not opt-in (Q3, operator
2026-09-29): there is no `[CultVariants]` attribute.** The C# registry requires an id member
on every object element type reachable through a list in any registered document type,
union bases included, and refuses a type that lacks one.
- **Minting**: on write, the cache mints ids for unset ids in every type, and refuses a
duplicate id within one list. A store written before ids existed loads, and its ids are
minted on its first write (minted on load, per the operator's rollout ruling).
- **Path steps below top level.**
- **Insert** anchored after an id, where `""` means the head. An inserted run sits
immediately after its anchor and before base elements added there later.
- **Remove** by id.
- **Canonical order**: removes, then inserts in list order, then sets by path.
- **Q2a refusal**: a base commit that orphans an override is refused.
- **Q7**: whole-member Set for dictionaries, scalar lists and arrays.
- **`CultCache.MintElementIds()`**: the one-shot rewrite for existing stores (ruling 1). It
upserts every record that has an unset id through the normal path.
- **The consumer sweep (Q3 rollout).** About 28 consumers carry list-element types, and each
needs an id member before it can take the CultLib that requires one. C2 ships the
requirement. Self schedules the per-repo sweep, one small type edit per consumer, before
those consumers bump. C2 lists the element types it finds per consumer, as data for that
sweep.
Keeps: the C1 format unchanged. C2 fills path shapes that C1 refused.
Verification:
- Two look-alike elements (two of one union subtype) get different overrides, and a base
insert before them retargets nothing.
- Rebase re-applies overrides by id. A missing id is refused, and the message names the
variant, path and id.
- A Set on a removed element is refused.
- Minting is idempotent.
- A registered type with an id-less list element type is refused, naming the type and member.
- A store written before ids loads, and its ids are minted exactly once on first write
(idempotent).
- A C#-written nested-override fixture joins `contracts/cultcache/`.
Command: `dotnet test tests/GameCult.Caching.Tests`.
Estimate +300 lines and +350 of tests. Build: as C1, without Networking.
### C3. Studio: edit the delta
Adds, in `CultInspectorModel` (engine-free; the lowering stays a renderer):
- For a variant edit, each member path is marked inherited or overridden.
- `Commit` writes `UpsertVariant` with overrides equal to the prior overrides plus a
structural diff by id. It is deterministic because ids exist.
- An explicit "override" pins a value equal to its base's.
- `ClearOverride(path)`.
- `CreateVariant(record)` next to `Duplicate`; it prompts for a name (R6).
- `Rebase(record, base)` returns a conflict list for Q2a before commit.
Lowering, in `CultCacheStudioWindow`/`Drawers`: an overridden marker and a revert control per
member, a base link, and variant creation and rebase controls.
Verification:
- `CultInspectorModelTests`: the diff produces exactly the touched paths; clear removes one
entry; the pin survives an equal value; rebase conflicts are listed.
- Negative: a Studio commit never calls a plain `Upsert` at a variant key.
Only the operator can check these in Unity against a copy of `Aetheria.cc`:
- the markers read correctly;
- clearing reverts the value live;
- a hull variant replaces `Hardpoints`;
- a laser variant changes one behaviour's `PerformanceStat.Max`;
- a base edit shows in the open variant.
Then tag `caching-unity-v1.5.0`. **This is the point where Aetheria is unblocked.**
Estimate +250 model, +150 lowering, +250 tests. Build: Caching.Tests. The Unity package is
checked by the operator in the editor.
### C4. Aetheria adoption (Aetheria repo, its own cut, after the release)
- Bump the package to `v1.5.0`.
- Add `[CultElementId]` to every list-element type Aetheria's documents carry (ids everywhere, Q3). There is no per-type opt-in.
- Add `[CultElementId]` to `BehaviorData` (base) and `HardpointData`.
- Run `MintElementIds` once over `GameData/Aetheria.cc`.
- AetherDb migrations skip variants or use the variant API (Q1a).
- Re-author the families listed in section 6.
### C5. CultNet deltas (C# reference)
Adds a new raw-record message version that carries the variant slot, following selection D3:
a v1 message string, so peers not in this cut refuse through their own dispatch. The C1
refusal in `CultNetDocumentRegistry` is deleted. Scheduled when a CultNet consumer needs
variants; none does today.
### Parked: TypeScript, Python and Rust resolution
Each runtime gets its own cut when a consumer that reads a variant-bearing store exists
(ruling 5). None does. `Aetheria.cc` is read by C# only.
## 6. First payoff in `Aetheria.cc`
Scan of the live file (206 records, `v1`): records of one schema whose leaves differ by at
most 20 percent, name excluded. This is a heuristic; the operator confirms the real families.
| Type | Records | Families that collapse | Records becoming variants |
| --- | --- | --- | --- |
| geardata | 32 | Refinery / Assembly Line / Deep Ore / Surface Ore / Shipyard (1 to 3 leaves differ); Small / Medium / Large Drive; RevvITup 2.0 / deep space burnout / Talaria; Skiron / Iapyx; Arctica / cold like my heart | 10 |
| weaponitemdata | 18 | ChargeBlast SG / ChargeBlast+- (14 of 253 leaves differ); pswarm / scorched void policy; Earp / 6k Shooter; Spectra / ColdFire | 4 |
| hulldata | 5 | Longinus / LonginusX (only `Behaviors`, `Hardpoints` and two cosmetic members differ) | 1 |
That is 15 of 55 equipment records. In most of these families the difference lies inside
`Behaviors`, so element-level overrides (C2) are what pay. Top-level overrides alone would
still copy each behaviour list.
## 7. Consumers
There are 28 projects: C# 13, Rust 9, TypeScript 10, Python 1, Kotlin 0 (Explore survey;
dependency-level for most, read in detail for Aetheria and AetheriaEve).
- **See variants:** Aetheria through C# `Get`, `GetByName` and `CultRecordRef` (resolved
documents only), CultCache Studio (deltas), and AetherDb tools (must honour Q1).
- **Must stay oblivious or refuse:** every other consumer. No other project reads
`Aetheria.cc`. Raw readers refuse through the empty payload plus C0/C1: `CultMesh`
single-file reads, AetheriaEve's directory-page reader (the directory store refuses
variants, Q6), and Mimir (writer only).

View file

@ -1,153 +0,0 @@
# CultCache Document Variants: Target
Date: 2026-09-17
Status: target, before any substrate map or cut map. This document owns the ends.
A cut map, written after an Imagination pass over the Body, will own the means.
Branch: `codex/document-variants`.
## Why
A CultCache record today is always complete. To make a bigger laser from a
laser, an author copies the record and re-specifies every stat. For a ship hull
with several hardpoint layouts, every layout is a full copy of the hull. When
the base changes, each copy has to be found and edited by hand, and copies drift
apart silently.
The first consumer is Aetheria's catalog (`F:\Projects\Aetheria\GameData\Aetheria.cc`),
edited in CultCache Studio:
- **Hull variants:** one hull with several hardpoint layout options. A variant
replaces the whole `HullData.Hardpoints` member.
- **Item variants:** a bigger laser. A variant changes stats nested inside the
design's behavior list, for example one behavior's `PerformanceStat.Max`.
## Rulings (operator, 2026-09-17)
- **This is a CultCache feature, not an Aetheria mechanism.** Operator: "Yes, this
is absolutely a CultCache feature, write it up in CultLib".
- **A variant is stored as a delta from its base and resolved by the cache.** A
record stores a reference to its base plus a sparse set of overrides. The
cache resolves it; readers see complete documents. The operator chose this
over two alternatives. Authoring-only variants in Studio over flattened stored
copies leave a derived cache pretending to be data. Per-type variant records
in each consumer rebuild the feature bespoke for every type.
## Ends
- **Readers see complete documents.** `Get`, `GetAll`, lookups by name and by
index, watches and snapshots return resolved documents. No consumer outside
the cache and its authoring tools handles a delta.
- **A base change reaches its variants.** Editing a base changes every variant
that does not override the edited value. Watches, indexes and name lookups
observe that change on the variants too, in the same commit that changed the
base.
- **A variant is a record.** It has its own key and name, and references to it
behave like references to any record. In Aetheria a lot's design and a
product's design can name a variant directly.
- **Studio edits the delta.** It shows which values a variant inherits and which
it overrides, and it can clear an override so the base value applies again.
- **Loading refuses a broken variant loudly and names the records.** Cases:
- a cycle of bases;
- a base that does not exist;
- a base whose type the variant's type cannot inherit from;
- ~~an override naming a member that does not exist on the type~~ **superseded 2026-09-29
(operator, variants Q4 c): a dangling override soft-drifts like any dropped member, ignored
with a warning; see `document-variants-cut.md` section 4.**
Deleting a base that still has variants is refused.
## Invariants that must survive
- **Wire parity** for the persisted format (`src/GameCult.Caching/Contracts/cultcache-persistence-format.md`,
`docs/persisted-record-encoding.md`). Every runtime that reads a store holding
variants resolves them identically, or refuses the store loudly. Which
runtimes resolve is decided in the map, against
`docs/runtime-parity-scope.md`; none may misread silently.
- **One home store per type**, and atomic single-store commit
(`cultcache-store-composition.md`). A base change and the resulting variant
changes are one commit, not a repair that runs afterwards.
- **Schema compatibility** (`cultcache-schema-compatibility.md`): overrides are
subject to the same semantic identity, soft drift and hard rejection rules as
full records.
- **Compare-exchange** (`Expect`, `ExpectUnchanged`) keeps a single meaning for
variants. The map settles whether it tests the stored delta or the resolved
document; it must not mean both.
## Fork rulings (operator, 2026-09-17)
- **1, granularity: stable element identity, ids everywhere** (operator, 2026-09-17: "ids
everywhere").
- Every object element in a list carries a stable id, minted when the element is created.
This is the approach of sequence CRDTs (RGA, YATA/Yjs, Automerge, Fugue).
- Matching algorithms (Myers, diff3, GumTree) are heuristics. They fail on look-alike
elements such as two `EnergyDraw` behaviors, which is why identity is minted, not
inferred.
- An override addresses an element id plus a member path in CultNet's grammar. An
insertion is a new element anchored after an existing id; a removal names the
removed id. Index addressing is out.
- Rebasing is deterministic. An override whose element id exists in the new base
re-applies. One whose element is missing is an explicit conflict that Studio shows,
to keep, retarget or clear; it is never silently retargeted.
- Existing stores get ids minted once, by a rewrite.
- **2, where resolution lives: at load, kept in memory** (operator: "agreed"). Resolution
computes a variant's complete document from its base chain and its overrides.
- The cache holds both the stored delta and the resolved document.
- A base edit re-resolves its variants inside the same commit, so watches and indexes
observe the change atomically.
- Compare-exchange (`Expect`) tests the stored delta. The resolved document is derived.
- **3, cross-store bases: no.** A variant and its base live in the same store.
*Future direction (operator, 2026-09-30, from Aetheria moddable ships):* "we'll want cross-store variant
support eventually; modders should be able to add variants of existing items without modifying the
original". Ruling 3 holds for this campaign. A mod store layering variants over a shipped catalog is the
named future consumer that would reopen it. Until then, no consumer builds its own cross-store resolver:
Aetheria's compose step stays out of resolution.
- **4, CultNet: deltas.**
- **5, runtimes: C# first.** TypeScript, Python, Rust and Kotlin refuse a store
or snapshot containing variants, loudly, until each implements resolution.
- **6, type change: the same concrete type only** (operator: "agreed").
- An override can only name members the base type has, and rebasing requires the same
type.
- A variant whose type differs from its base's is refused loudly.
- Subtype variants are parked until real content needs a template base. A record
flagged as a template, not a real item, may be the smaller feature.
- **7, clearing and rebasing.**
- Clearing an override removes that member from the variant's stored delta. A member
that is not overridden is not stored.
- Rebasing (changing a variant's base) is supported. Operator: "rebasing is a nice
tool to have, things can get annoying otherwise".
## Open forks, for the Imagination pass to map and the operator to rule
1. **Override granularity.**
- Top-level members are enough for hardpoint layouts.
- A bigger laser needs overrides inside list elements. Addressing list
elements by index is fragile: an insertion into the base silently retargets
every variant's overrides.
- Candidates: stable element identity (for example a behavior's type within its
design); replacing whole list elements only; or addressing paths.
- CultNet's selection work already resolves member paths per leaf type,
inherited members included (`docs/cultnet-selection-cut.md`). A variant's
delta addressing should reuse that grammar, not invent a second one.
2. **Where resolution lives:** at load in the cache core, lazily on read, or as a
materialized view the cache maintains. This affects memory, watch cost, and
the compare-exchange meaning.
3. **Base and variant across stores.** Can a variant in one store name a base in
another (a run-store variant of a catalog record)? This interacts with
one-home-store routing and read-only stores.
4. **CultNet.** Do variants travel as deltas, or resolved? A subscriber that
receives only resolved documents needs no resolver; one that edits needs
deltas.
5. **Runtime scope.** Which of C#, TypeScript, Python, Rust and Kotlin resolve,
and which refuse a store containing variants.
6. **Type change.** May a variant's type be a subtype of its base's type,
adding members, or must the two types match?
7. **Clearing and reparenting.** What clearing an override writes, and whether
a variant can change its base.
## Not in scope
- Scaling or formula variants ("every stat times 1.5"). An override states a
value. Generated authoring can compute values.
- Any Aetheria catalog content. Aetheria adopts the feature after a release, as
its own cut.

View file

@ -1,159 +0,0 @@
# Mutation Testing
CultLib measures its C# test suites with Stryker.NET, pinned as a repo-local
tool in `dotnet-tools.json`. Coverage is added project by project; a project
with no `stryker-config.json` next to its test project has not been scoped
yet (see "Follow-ups" below for what's outstanding).
## Running it
Restore the tool once per checkout:
```powershell
dotnet tool restore
```
Run a full mutation pass from a covered test project's directory, for
example CultMath:
```powershell
cd packages/cultmath/tests/CultMath.Tests
dotnet stryker -t mtp
```
`-t mtp` selects Stryker's Microsoft.Testing.Platform runner. It is required
for test projects on `xunit.v3` (CultMath.Tests is one): Stryker's default
`vstest` runner's coverage capture does not work against xunit.v3's native
test host and the run stalls retrying test sessions indefinitely. Projects
still on `xunit` v2 with `xunit.runner.visualstudio` can use the default
`vstest` runner.
For a single cut, scope the run to what changed instead of the whole
project:
```powershell
dotnet stryker -t mtp --since:<base-commit-or-branch>
```
Use a full commit SHA for `--since`, not an abbreviation: `git rev-parse
<short-sha>` first, then pass the full result. Stryker resolves `--since`
through the same git plumbing a short SHA can resolve ambiguously (or fail to
resolve at all) depending on the checkout's object database, and a
misresolved base silently changes what "since" means instead of erroring.
In a container, also set `git config --global --add safe.directory '*'`
before running Stryker (or any other command that shells out to `git`
against this checkout) - git refuses to operate on a repository owned by a
different user by default, which is the common case for a bind-mounted
checkout in a container.
Reports land in `StrykerOutput/` next to the test project (git-ignored).
Open the `reports/mutation-report.html` file for a browsable view, or read
`reports/mutation-report.json` for a scriptable one.
## Triage rules
The mutation score is not a gate (`break` threshold is 0). Every surviving
mutant is a finding about the tests, not a build failure, and gets triaged
into one of:
- **missing behavioural test** — name the test that should exist and what
branch, edge case, or component it would exercise.
- **degenerate fixture** — the code path is exercised, but the fixture's
values make two different mutants produce the same observable result (a
zero component that erases a `+`/`-` distinction, an already-normalized
input that erases a `*`/`/` distinction, all-equal or all-same-sign inputs
that erase a boundary or `&&`/`||` distinction). Fix the fixture, don't
invent a new one that only kills that one mutant.
- **equivalent** — the mutant cannot change behaviour. Float-threshold
boundary flips (`>` to `>=`) are equivalent by default. A reasoned
equivalence claim (for example, `>>` and `>>>` on an already-unsigned
operand) needs its one-line reason recorded, not assumed.
Do not write a hand-rolled mutation suite or anchor test as a substitute.
Where the tool can't reach (native/shader/editor code), the defence is a
behavioural test at the layer the rule is decided, not a committed mutation
harness.
## Known tool caveat
Stryker 5.0.0's `mtp` runner is documented as preview. Its per-test coverage
capture was found to report false "Survived" verdicts specifically for
mutations inside `static readonly` field initializers that a test does
cover (confirmed by hand-mutating `bool3.@false` and `bool4.@true` and
running the full suite directly with `dotnet test`, both of which fail
against the mutation that Stryker reported as surviving). Treat a survivor
inside a `static readonly` field initializer as unproven until confirmed
with a direct `dotnet test` run against the same mutation; do not triage it
as a missing test without that check.
A survivor that looks like it must be covered is unproven until confirmed
with a direct `dotnet test` run against the same hand-applied mutation - but
confirm what the mutant actually is before drawing a tooling conclusion from
it. An earlier pass here reported three survivors in
`CultGeometrySurfaceNets.TryGetOtherEndpoint`'s per-axis `if (x + 1 >=
sizeX) return false;` guards and initially triaged them as a `vstest`
coverage false-survivor (the same shape as the `mtp`/`static readonly`
caveat above). That triage was wrong: the actual mutant Stryker reports
there is `return false` -> `return true` inside the guard, which is
equivalent, not a coverage miss. Returning `true` from
`TryGetOtherEndpoint` still leaves the out `other` coordinate equal to the
current coordinate (see the method: `other` is cloned from `coord` and only
one component is incremented when the method returns `true`), so the caller
compares a sample against itself, the inside/outside flags always match,
and the crossing is skipped exactly as it would have been had the method
correctly returned `false`. No test can distinguish the two branches because
there is nothing to distinguish - it is a legitimate equivalent mutant, not
evidence of a `vstest` false survivor.
`CultGeometrySurfaceNets.Extract`'s `if (inside == (OrientationSign[axis] <
0))` (the sole authority for quad winding after the geometric cross-product
fallback was deleted) has three mutants on that line: `==` -> `!=`, and `< 0`
negated to `> 0` or widened to `<= 0`. The first two, `inside != (OrientationSign[axis]
< 0)` and `OrientationSign[axis] > 0`, are both reported `Killed`. The third,
`OrientationSign[axis] <= 0`, survives, but it is an equivalent mutant, not a
coverage gap: `OrientationSign` is the fixed table `{ 1, -1, 1 }` and never
holds `0`, so `< 0` and `<= 0` agree on every value the table can actually
produce. There is nothing for a test to distinguish, the same shape as the
`TryGetOtherEndpoint` equivalent mutant above.
No `vstest` coverage-based false survivor has actually been confirmed for
GameCult.Geometry. Every survivor triaged here so far has turned out to be a
genuine equivalent mutant once hand-mutated and checked directly against
`dotnet test`; treat any survivor that looks like it must obviously be
covered as unproven until confirmed the same way, but do not cite this
project as precedent for the `vstest` caveat itself - that caveat is
`mtp`-runner-specific and documented above for `static readonly` field
initializers, and GameCult.Geometry has never actually hit it.
## Follow-ups
CultMath.Tests and GameCult.Geometry.Tests are the only projects currently
scoped. GameCult.Geometry's `stryker-config.json` has no `test-runner`
entry: the project is NUnit 4 with `NUnit3TestAdapter` and
`Microsoft.NET.Test.Sdk`, so Stryker's default `vstest` runner applies
directly (no `mtp` workaround needed, unlike CultMath.Tests's xunit.v3
suite).
GameCult.Geometry has only ever been run `--since:`-scoped to the cut that
added surface nets; a full-project baseline (running mutation against all of
`CultGeometryIsoSurface`, `CultGeometryDocuments`, and
`CultGeometryPrimitives`, not just the diff) is a recorded follow-up, not
something either cut did.
That first `--since` pass did not actually stay diff-scoped: adding
`stryker-config.json` itself in the same cut that first used it made Stryker
log "391 mutants will be tested because: Non-CSharp files in test project
were changed" and mutate the whole `mutate` glob (all four existing
GameCult.Geometry source files), not just the new surface-nets files. A cut
that changes a non-C# file inside the test project (a fixture, this config
file, anything under `Fixtures/`) loses `--since` line-level scoping for that
run; a future cut whose diff is C#-only, against a base commit that already
has `stryker-config.json`, should scope correctly.
`GameCult.Caching`, `GameCult.Mesh` (+ `.Quic`, `.Quic.Native`),
`GameCult.Networking` (+ `.WebSockets`), and the non-.NET runtimes
(`cultcache-rs/py/ts`, `cultmesh-*`, `cultnet-*`) each still need their own
`stryker-config.json` (or ecosystem-equivalent tool: `cargo-mutants` for
Rust, `StrykerJS` for TypeScript, `mutmut` for Python) and a baseline pass.
None of that is wired into CI yet.

View file

@ -1,8 +1,5 @@
# NuGet Packaging
See `docs/semver-policy.md` for what a version bump on these packages is
required to mean and how a release is checked against that before it ships.
CultLib publishes the managed dependency graph as separate packages. The leaf
package is `GameCult.Mesh`; its NuGet dependencies preserve the CultMesh,
CultNet, and CultCache ownership boundaries.
@ -22,5 +19,5 @@ closure through UPM rather than NuGet restore.
Install the Unity package from the immutable release tag:
```text
https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.57
https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.46
```

View file

@ -13,11 +13,6 @@ Cross-runtime parity has three layers:
- Wire parity: shared CultCache file/state contracts, schema-v0 MessagePack
documents, transport profiles, and RUDP packets must cross runtime boundaries
without translation folklore.
Every store reader refuses, by name, a header or record slot it does not
understand (vectors: `tests/vectors/document-variants-c0`).
Document variants: C# resolves them (`cultcache.store.v2`); every other runtime,
CultMesh single-file reads, the directory store and CultNet refuse a variant by name
(vector written by C#: `tests/vectors/document-variants-c1`).
- Feature parity: each runtime must expose the CultCache/CultNet/CultMesh
surfaces needed for its intended role, with unsupported production-server
ownership called out plainly.
@ -67,34 +62,6 @@ to speak CultNet or participate in CultMesh.
shared transport port and expand RUDP adoption beyond the core proof.
- `packages/cultmesh-kotlin/README.md` records Kotlin's runtime role and
ergonomic surface, including the explicit non-claim around C# SoA storage.
- CultNet typed selection (`cultnet.snapshot_response_raw.v1`, C#/Rust only;
docs/cultnet-selection-cut.md, R-AY): byte parity for the
`snapshot_response_raw.v1` envelope and its nested record types, verified at
populated and absent optional values, plus semantic parity over the named
field subset. `packages/cultnet-rs/tests/selection_wire_parity.rs` and
`tests/GameCult.Networking.Tests/SelectionWireParityTests.cs` carry the byte
vectors; `packages/cultnet-rs/tests/selection.rs`'s
`write_selection_vectors_for_the_reference` and
`tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs` carry the
semantic ones. A single rows-to-bytes path driving both halves in one call
remains its own cut (R-AR) and is not part of this claim.
- CultMesh content plane (`cultmesh.content_chunk_request.v1` and `.response.v1`
plus the CDN manifest, C#/Rust only): byte parity in both directions (manifest metadata keeps its wire key order and a
duplicate key is refused, as the reference refuses it), and the reference's answer
to a chunk request, including its failure spellings: every bad request is
answered `found: false`, never refused at decode.
`packages/cultnet-rs/tests/content.rs` and
`tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs` judge
`contracts/cultmesh/content-vectors.cs-written.json` and
`content-vectors.rs-written.json`. Rust carries these chunks only on the
session the client already holds (see `src/GameCult.Mesh/docs/transport-planes.md`);
it has no authenticated content connector, so it claims no TCP+TLS or QUIC
content path.
Recorded limits: a caller that passes `max_bytes = u64::MAX` to `fetch_content` gets an unbounded
body, because the reference refuses artifacts over `int.MaxValue` bytes and Rust bounds them only by the
caller's cap.
`normalize_hash` matches the `sha256:` prefix ASCII-case-insensitively while C# `OrdinalIgnoreCase` may
also match non-ASCII forms (for example `ſha256:`); unprobed, and it affects hostile input only.
## Expansion Work Outside The Current Parity Claim

View file

@ -1,213 +0,0 @@
# Semantic Versioning Policy
Operator ruling, 2026-09-17: **semantic versioning is policy** for every
package CultLib publishes, strictly. A release that removes or changes public
surface incompatibly is a major bump, full stop — not a judgment call left to
the person cutting the release.
This document says what "breaking" means for each kind of surface this repo
actually ships, and how a release is checked against it. See
`docs/nuget-packaging.md` for how the managed/Unity packaging mechanics work;
this document is the versioning contract those mechanics must satisfy.
## The scar
`org.gamecult.cultlib` 1.0.60 removed `CultInspectorAssetPath` — public API —
and shipped as a patch bump. Its own changelog entry is titled "Breaking"; the
version number said otherwise. A reviewer relying on the number alone would
have missed it. This is recorded as history, not fixed by renumbering: `1.0.60`
stays `1.0.60`. This policy, and the check below, exist so the next one is
caught before it ships instead of written up afterward.
## Packages in scope
| Package | Kind | Tag prefix | Published by |
| --- | --- | --- | --- |
| `org.gamecult.cultlib` | Unity UPM | `cultlib-unity` | `scripts/build-unity-package.ps1`, consumed by git tag |
| `org.gamecult.caching.unity` | Unity UPM | `caching-unity` | tagged directly (no build script; see "Coverage gaps") |
| `org.gamecult.cultmath` | Unity UPM | `cultmath-unity` | `packages/cultmath/scripts/build-unity-package.ps1`, consumed by git tag |
| `@gamecult/cultcache-ts` | npm | `cultcache-ts` | `.github/workflows/publish-packages.yml` |
| `cultcache-py` | PyPI | `cultcache-py` | `.github/workflows/publish-packages.yml` |
| `cultnet-py` | PyPI | `cultnet-py` | `.github/workflows/publish-packages.yml` |
| `cultmesh-py` | PyPI | `cultmesh-py` | `.github/workflows/publish-packages.yml` |
Rust crates are consumed as pinned git revisions (see `publish-packages.yml`'s
own header comment) and are out of scope for tagged-release semver policing;
a `Cargo.toml` version still exists and should follow this policy by
convention, but nothing in this repo checks it mechanically today.
Tag naming stays `<package>-v<version>` for every package above, matching the
prefixes already in use.
## What counts as breaking
Semver's ordinary rule — additive is minor or patch, anything a consumer's
existing code or data can no longer rely on is major — applies per surface as
follows.
### Managed public API (`org.gamecult.cultlib`, NuGet packages, `cultmath`)
Breaking: removing or renaming a public type/member, changing a public
member's signature or observable behavior in an incompatible way, tightening
a public contract (new required parameter, narrowed accepted input),
or moving a type across assemblies/namespaces without a compatible forwarder.
Not breaking: adding a new public member, type, or overload; widening an
accepted type; deprecating (marking obsolete) without removing.
### Unity package inspector attributes (`org.gamecult.cultlib`,
`org.gamecult.caching.unity`)
These ship as ordinary public API by the rule above, but get their own
callout because they are easy to mistake for "just editor tooling": an
attribute class, its constructor shape, and the serialized value shape it
reads/writes are all public surface a consumer's `[CultInspectorX(...)]`
usage and serialized scene/prefab data depend on. Removing or renaming an
attribute, or changing what it expects a annotated member's persisted value to
look like (as `CultInspectorAssetPath` to `CultInspectorAssetGuid` did in
1.0.60) is breaking.
### Persisted wire format / store compatibility (CultCache)
Governed by `src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md`
and `cultcache-persistence-format.md` — read those for the authoritative
rules. In semver terms: anything those documents classify as **hard
rejection** (type change, reference/value change, one-vs-many change, target
schema change, name/index lookup semantics change, a persisted schema with no
compatible local candidate) is breaking for any package that reads or writes
the format. Anything classified as **soft-migratable drift** (compatible
schema id drift, an ignorable missing slot, a defaultable missing slot) is not
breaking on its own, but changing what counts as soft-migratable — loosening
or tightening the drift rules themselves — is breaking for whichever package
owns that classification logic.
### CultNet protocol surface
Breaking: a wire-incompatible change to a CultNet message, frame, or
handshake shape; removing or repurposing a message type or field that another
runtime's implementation depends on; changing routing, authority, or trust
semantics such that a peer built against the previous protocol version
misbehaves rather than cleanly failing. Additive, backward-compatible framing
changes (a new optional field a receiver may ignore) are not breaking.
### Native bridge exported functions (`GameCult.Mesh.Quic.Native`)
Breaking: removing or changing the signature/ABI of a `CULTMESH_API`
(`extern "C"`) exported function, or changing an exported function's
behavior such that the existing managed P/Invoke caller (`GameCult.Mesh.Quic.Native`'s
C# side) or Rust caller (`cultmath-core`'s equivalent `#[no_mangle] extern "C"`
surface) no longer gets what it asked for. Swapping the underlying TLS/QUIC
implementation (as 1.0.60 did, Schannel to OpenSSL MsQuic) is not breaking
*by itself* as long as the exported v1 functions keep their signatures and
behavior — 1.0.60's actual breaking change was the inspector attribute
removal, not the bridge swap, and its changelog correctly filed the bridge
swap under "Changed" rather than "Breaking".
## Pre-1.0 packages
A package at `0.y.z` (currently `org.gamecult.cultmath`, at `0.2.x`) uses the
standard pre-1.0 convention: `MAJOR` stays `0` during initial development, so
`MINOR` carries what `MAJOR` means once the package reaches `1.0.0`. Concretely:
- a breaking change bumps `MINOR` (and resets `PATCH` to `0`);
- a non-breaking change bumps `PATCH`.
Leaving `0.y.z` for `1.0.0` is a deliberate, separate decision (the API is now
considered stable), not something the checker infers on its own.
## What a release changelog entry must contain
Every published package keeps a `CHANGELOG.md` beside its manifest
(`unity/org.gamecult.cultlib/CHANGELOG.md` and
`src/GameCult.Unity/Assets/Caching/CHANGELOG.md` are the existing precedent).
Each release adds a `## [<version>]` section containing:
- a category subsection per change (`### Added`, `### Changed`, `### Fixed`,
etc., following Keep a Changelog style);
- **`### Breaking`** — this exact heading, this exact spelling — for any
change that meets the "what counts as breaking" rules above. This heading
is not decorative: the CI check below greps for it by name.
- enough detail that a consumer can tell what to change, in the style of the
1.0.60 entry (name the removed/changed symbol and its replacement).
A version with no changelog entry at all fails the check below, the same as
a mislabelled one — an undocumented release is not a smaller sin than a
mislabelled one.
## Enforcement
`scripts/check-changelog-semver.mjs` is the mechanical check. Given a
package name, its `CHANGELOG.md` path, the version being released, and a git
tag prefix, it:
1. resolves the previous published version from the highest existing
`<prefix>-v*` tag older than the version being released (or treats the
release as a package's first if none exists);
2. requires a `## [<version>]` changelog entry to exist at all;
3. classifies the version bump (major/minor/patch) against the previous
version, refusing anything that is not exactly the next version in some
lane — a skip, a reversal, or a repeat are all refused, not just wrong
bump sizes;
4. if the changelog entry has a `### Breaking` section, requires the bump to
be at least major (or, pre-1.0, at least minor);
5. fails loudly, naming the package, the previous and new version, and (when
relevant) the changelog heading that triggered the requirement.
Run it directly: `node scripts/check-changelog-semver.mjs --package <name>
--changelog <path> --version <x.y.z> --tag-prefix <prefix>`. Its own tests
live in `scripts/check-changelog-semver.test.mjs` (`node --test
scripts/check-changelog-semver.test.mjs`).
It is wired into:
- `.github/workflows/publish-packages.yml`, right after each job's existing
"tag matches manifest version" check, for `cultcache-ts`, `cultcache-py`,
`cultnet-py`, and `cultmesh-py`;
- `scripts/build-unity-package.ps1`, for `org.gamecult.cultlib`;
- `packages/cultmath/scripts/build-unity-package.ps1`, for
`org.gamecult.cultmath`;
- `scripts/verify-caching-unity-release.ps1`, for `org.gamecult.caching.unity`.
### Coverage gaps
`publish-packages.yml` only triggers on `cultcache-ts-v*`, `cultcache-py-v*`,
`cultnet-py-v*`, and `cultmesh-py-v*` tag pushes — it does not build or gate
the Unity UPM packages at all; those are consumed directly by git tag
(`unity/org.gamecult.cultlib`, `packages/cultmath/unity/org.gamecult.cultmath`,
`src/GameCult.Unity/Assets/Caching`), so a workflow trigger on their tags
cannot be added to that file without also giving it a reason to check out and
build Unity content it currently has no job for. The check is instead wired
into the scripts that already run at Unity-package release time:
`build-unity-package.ps1` and `packages/cultmath/scripts/build-unity-package.ps1`
already verify version consistency between the built assembly and
`package.json`, so the semver check runs alongside that as one more release
gate. `org.gamecult.caching.unity` has no build script at all today — it is
pure C# source tagged directly with no compile step — so
`scripts/verify-caching-unity-release.ps1` exists solely to run this check
before that package is tagged; it is not a build script and does not become
one.
None of this is enforced by a git hook or branch protection rule: it runs
when someone remembers to run the Unity release scripts, the same way the
existing assembly-version check does. A tag pushed by hand without running
the release script bypasses both checks equally; closing that gap is a CI
trigger on tag push for the Unity packages, not something this change adds.
Rust crates (consumed as pinned git revisions, not tagged releases in the
usual sense) and the CultNet protocol surface itself (no automated wire
compatibility test currently gates a release) are policy by convention only —
described above, not mechanically checked. Extending the checker to a Rust
crate's `Cargo.toml`/`CHANGELOG.md` or to an automated CultNet interop
compatibility gate is future work, not part of this change.
`@gamecult/cultcache-ts`, `cultcache-py`, `cultnet-py`, `cultmesh-py`, and
`org.gamecult.cultmath` currently have **no `CHANGELOG.md` file at all**. This
is a real, pre-existing gap, not something introduced by this policy: their
next tagged release will fail the new check for exactly that reason ("no
changelog entry for version X") until each package's `CHANGELOG.md` exists.
That failure is intended — a release with no changelog is not a smaller
problem than a mislabelled one — but it does mean the very next release of
any of these five packages needs a `CHANGELOG.md` created first. Backfilling
changelog content for their past releases is not part of this change: it
would mean inventing release-note prose for history this pass has no
first-hand knowledge of, which is worse than leaving the gap named.

View file

@ -1,432 +0,0 @@
# Studio Record Grouping And Reference Drag-Drop Cut
First mapped 2026-09-17 (Imagination) against CultLib `122c217`. **Refreshed 2026-09-29
(Imagination) against CultLib `main` `feeced8` and Aetheria `codex/fire-control-12` `4b594e11`.**
Every `file:line` below is against those revisions unless marked. Hands re-reads an anchor
before editing it; `main` keeps moving.
**Rulings.** Q1 **B** (a class attribute on the model; argument order is nesting order;
2026-09-17). Q2 **A** (occupied values only). Q3 **A** (list only, not the ref picker).
Q4 **A** (no record search). Q5 **overtaken**: one release from `main`, together with
document-variants C3 (`docs/document-variants-cut.md`), taking whatever native-plugin drift
`main` carries. Q6 **A** (flat row by resolved value, C3's variant marker; 2026-09-30). Q7 **B** (legacy parity
minus manufacturer, plus `FactionProductData` by `Manufacturer` then `Design`, plus
`WeaponItemData` by `WeaponType`; 2026-09-30).
## Target
### Ends
1. **Declared record grouping.** `[CultInspectorGroupBy(nameof(A), nameof(B))]` on a document
class makes Studio's record list for that type a foldout tree, nested in argument order. A
declaration on a base type groups every derived type listed under it.
2. **Create in a group.** Every group node offers `Create`. The new record gets every grouped
member on the path to that node set to that node's value, so it lands in that node.
3. **Reference drag and drop.** A record row dragged from Studio's list onto any
`CultRecordRef<T>` value the inspector draws (member, list element, dictionary key or value)
is accepted exactly when the ref's picker would offer that record.
Recovered from the legacy "Database Tools" window (Aetheria `d3db1730`,
`Assets/Scripts/CultCache/Editor/DatabaseView.cs`, `AetheriaDatabaseView.cs`,
`Inspectors/DatabaseLinkInspector.cs`). Selector lambdas are rejected (operator, 2026-09-17).
**The attribute is not `[CultIndex]`.** `[CultIndex]` means unique (operator, 2026-09-30;
`CultDocumentContracts.cs:34`). Grouping is `CultInspectorGroupByAttribute`, in
`CultInspectorAttributes.cs`, read only by `CultInspectorModel`. It changes no schema id,
catalog entry, index, or store byte. The index cut's deletion of grouping-only `[CultIndex]`
declarations (CultMesh, Geometry) adds no `[CultInspectorGroupBy]` in their place: those are
service stores, not Studio-authored catalogs.
### Invariants
- **I1. The model owns grouping.** `CultInspectorModel` alone decides which members group a
listed type and in what order, which declarations are invalid (and the notice), the tree's
partition, node identity, order and labels, and the object create-in-group makes. Studio
lowers the tree; it never reads the attribute, calls `SetValue` for a preset, or computes a
group label.
- **I2. The leaves partition the candidates.** Across all leaves the tree holds exactly
`RecordCandidates(CultRecordRef<listed>, records)`, each record once, in candidate order
inside a leaf. Grouping never hides a record, including one whose grouped value is unset,
dangling, or an undefined enum value.
- **I3. The listed type owns the tree's shape.** Grouping comes from the type selected in the
type pane, never from each record's runtime type.
- **I4. Node identity is value identity, not the label.** A ref groups by key, an enum by its
underlying value, other kinds by the value's invariant string. Foldout state keys on node id.
- **I5. Create-in-group round-trips.** Upsert what `CreateInGroup(listed, node)` returns,
regroup: the new record lies in a node with the same id.
- **I6. One candidate rule.** `IsRecordCandidate(refType, record)` is the only test for "this
record may be this ref's value". `RecordCandidates` filters through it; drop acceptance calls it.
- **I7. One ref label.** The model owns `None` and `Missing <key>`; the picker and group labels
both use it.
- **I8. Invalid declarations never break the window.** They give a flat list plus the model's
notice, never a throw in `OnGUI`.
- **I9. A drop honours the picker's gates.** Refused in a disabled (read-only) scope, when the
payload came from another model, and when the key equals the current key. A dictionary-key
drop still passes `ReplaceKey` (`CultCacheStudioDrawers.cs:303-309`).
- **I10 (new). Grouping reads what readers read.** A variant record groups, labels, and is a
drop candidate by its resolved `Document` (`CultCache.cs:369-371`), never its delta.
### Non-goals
No sibling-runtime port (TS `cult-cache-inspector.ts` is a byte dumper; Rust and Python have
no inspector). No runtime CultUI panel. No search (Q4), no picker grouping (Q3), no
drop-to-append on list headers, no legacy `New <Type>` naming.
## Body findings
Kept from 2026-09-17, re-anchored:
- **F3.** Legacy grouping matched the exact type; grouping derived records under a base
declaration is new.
- **F4.** Legacy create-in-group was dead (`DatabaseView.cs:70-75` throws); legacy foldouts
keyed on the label hash.
- **F5.** `member.GetCustomAttributes(true)` ignores `inherit` for a `PropertyInfo`, so a base
`[M] abstract P` is not seen on the override. Sites: `CultInspectorModel.cs:36` (now the only
metadata site: `MetadataOf` caches it per member, `:341-351`) and `:209`
(`CultInspectorDrawerClaims.Resolve`). Fix: `Attribute.GetCustomAttributes(member, true)`.
- **F6.** Studio `Add` creates only the listed type (`CultCacheStudioWindow.cs:340-346`,
`CreateElement(type, type)`), disabled when the model cannot create it (`:141`). So
create-in-group never makes a derived type, and an abstract listed type offers no `Create`.
- **F7.** Studio search filters the type pane only (`:112-120`).
- **F8.** Foldouts are in-memory (`CultCacheStudioDrawers.cs:33`); `OnDisable` closes the store
(`:48-51`), domain reload included. The only `EditorPrefs` key is `LastPathKey` (`:13`).
- **F9.** No drawer claims `CultRecordRef<>`, so every ref reaches `DrawRecordRef`
(`CultCacheStudioDrawers.cs:254-280`).
- **F10.** The Studio editor sources compile outside Unity (netstandard2.1, C# 9, against
Unity 6000.3.24f1 `Editor\Data\Managed\UnityEngine\*.dll` without the `UnityEditor.dll`
facade, plus the built `GameCult.Caching*` and tracked `MessagePack*.dll`). The 09-17
scratch project is gone; Hands rebuilds it in its own scratch from this recipe.
- **F11.** The record row reads its click in `GUILayout.Toggle` (`:184`); a drag start must be
handled on a reserved rect before the toggle draws (IMGUI convention, not probed).
New since the map:
- **F12. Variants (C1, `6ffe17a`).** `CultInspectorModel(registry, CultCodec)`
(`CultInspectorModel.cs:302-306`); Studio still calls `CultCacheMessagePack.CreateInspectorModel(registry)`
(`CultCacheMessagePack.cs:33-34`, unchanged). `CultStoredDocument.Variant` is the delta
(`CultCache.cs:374`); `Document` is the resolved view. A top-level override is a
`Set` whose path is one step at the member's slot (`CultVariants.cs:72-73`), so "is this
grouped member overridden on this variant" is computable from the member's `Slot`.
- **F13. Every Studio commit to a variant key is refused until C3.** `CultInspectorEdit.Commit`
is a plain `UpsertAsync` (`CultInspectorModel.cs:266-282`); Q1a refuses a plain write at a
variant key (`CultCache.cs:2494-2496`). A drop into a variant's inspector therefore reports
that refusal until C3 lands. The release waits for C3, so no shipped Studio shows it, but
the operator click-through must run after C3.
- **F14. Studio `Duplicate` of a variant makes a flattened plain copy**
(`CultCacheStudioWindow.cs:349-353` clones the resolved `Document`). Not this campaign's
defect; C3's `CreateVariant` sits beside it.
- **F15. Element ids (C2a, `hands/variants-c2a`, WIP `df74f8c`).** No grouping interaction:
grouped members are top-level scalars and refs; ids live on list elements. Drops write a ref
into the edit document; ids are untouched and minted on write for new elements; C3's diff
keys overrides on them. Two consequences:
- After C2a, registration refuses a document type whose object-list element lacks a
`[CultElementId]` (`CultCache.cs` descriptor build, WIP). Cut 1's new fixtures carry no
object lists.
- C2a does not hide or lock the id member, so Studio shows it as an editable string.
Hand-editing one retargets overrides. That belongs to C3 (make `[CultElementId]` members
read-only in the model), not here; flagged for Self.
- **F16. The 1.0.60/1.4.0 release shipped** (`0155ba8`, tags on `45c2f40`) and
`codex/asset-guid-attribute` is merged. It already carried the 302,080-byte native bridge,
so the 09-17 Q5 drift is spent. Native source has moved since (`git diff --stat 45c2f40 main
-- native/`: 5 files, +840/-118), so this release rebuilds the bridge again. The v1 exports
the managed side imports (`src/GameCult.Mesh.Quic.Native/*.cs:263-276`) remain at
`cultmesh_quic_native.cpp:1405-1465`.
- **F17. Aetheria's schema moved.** `ItemData.Manufacturer` is gone (slot 3 retired,
`ItemData.cs:281-282`). The manufacturer relation now lives on `FactionProductData`
(`FactionProduct.cs:27`, `CultRecordRef<Faction> Manufacturer`; `:24`,
`CultRecordRef<CraftedItemData> Design`). Aetheria pins `cultlib-unity-v1.0.60` /
`caching-unity-v1.4.0` / `cultmath-unity-v0.2.4` (`Packages/manifest.json:54-56`),
`CultLibRevision` `45c2f40` and a separate `CultMathRevision` `6d5e209`
(`Directory.Build.props:6,10`).
- **F18. After C2a, Aetheria cannot take the new CultLib without its element-id sweep**
(`List<ItemRole>`, `List<BehaviorData>`, `List<AudioStat>`, `List<HardpointData>`,
`List<ProductRole>`, `List<LoadoutSlot>`, among others). So the Aetheria re-pin belongs to
variants C4, and this campaign's Cut 4 shrinks to annotations and tests on top of it.
## Identity and authority
| Thing | Owner | Lifecycle | Persisted |
|---|---|---|---|
| `[CultInspectorGroupBy]` | consumer source | compile time; nearest declaration wins; no arguments opts out | source only |
| `CultInspectorGrouping` (resolved) | model | cached per listed type like `_shapes` (`CultInspectorModel.cs:296`) | no |
| `CultInspectorRecordGroup` tree, node ids, labels | model | rebuilt each `OnGUI`, as `RecordCandidates` is (`CultCacheStudioWindow.cs:167`) | no |
| Group foldouts (node id → bool) | window | window memory; cleared by `CloseStore` (`:318-327`) | no |
| Drag payload `{ Model, Key }` under `"GameCult.CultCacheStudio.Record"` | Studio list (source) | one drag | no |
| Drop acceptance | `IsRecordCandidate` plus I9 gates | per `DragUpdated`/`DragPerform` | no |
| Created-in-group object | model makes; `CultCache` admits via `UpsertAsync`, as `Add` | new plain record | store, on `Save` |
## Forks
Q1-Q5 are ruled (header). Two remain.
### Q6. RULED A (2026-09-30). How does the grouped list show a variant? (blocks nothing in Cuts 1-2; shapes C3's row lowering)
Mechanical defaults, not asked: a variant groups by its resolved values (I10), so a variant
that inherits its base's `HullType` sits in the base's node and moves when the base changes;
create-in-group always makes a plain record; C3's `CreateVariant` needs no preset because the
variant inherits its base's group; a variant is a valid drag payload.
- **A. Flat by resolved value.** A variant is an ordinary row in its resolved group. C3 adds a
row marker ("variant of Longinus"). Which of its grouped values are overridden shows in the
inspector through C3's per-member marker, not in the tree.
- **B. Nested under the base.** Inside a leaf, a variant row sits indented under its base's row
when both share the leaf; otherwise it stands alone with the marker.
- **C. A's rows plus an inherited/overridden glyph on the row** for the grouped members.
- **Recommendation: A.** I2 stays one rule, the tree has one shape, and the base link already
lives in C3's inspector. B makes the tree's shape depend on two relations, and splits
whenever a variant overrides a grouped value. C duplicates C3's inspector marker.
### Q7. RULED B (2026-09-30). Which Aetheria types group, and by what?
Candidates on `codex/fire-control-12` (enums unless noted; `[Flags]` enums excluded):
| Type | Member | Anchor | Note |
|---|---|---|---|
| `SimpleCommodityData` | `Category` (`SimpleCommodityCategory`) | `ItemData.cs:310` | legacy parity |
| `CompoundCommodityData` | `Category` (`CompoundCommodityCategory`) | `ItemData.cs:338` | legacy parity |
| `GearData` | `Hardpoint` (`HardpointType`) | `ItemData.cs:454` | legacy parity; `WeaponItemData` inherits when listed under `GearData` |
| `WeaponItemData` | `WeaponType`; or `WeaponCaliber`, `WeaponRange` | `ItemData.cs:485`, `:482`, `:479` | re-declaration, applies when listing weapons |
| `HullData` | `HullType` | `ItemData.cs:519` | legacy parity |
| `FactionProductData` | `Manufacturer` (ref `Faction`), then `Design` (ref `CraftedItemData`) | `FactionProduct.cs:27`, `:24` | where "by manufacturer" now lives |
| `Loadout` | `Hull` (ref `HullData`) | `Loadout.cs:17` | optional |
`ConsumableItemData`, `CargoBayData`, `DockingBayData`, `Faction`, `PersonalityAttribute`
have no groupable member worth a level.
- **A. Legacy parity minus manufacturer:** commodities by `Category`, `GearData` by
`Hardpoint`, `HullData` by `HullType`.
- **B. A, plus `FactionProductData` by `Manufacturer` then `Design`, plus `WeaponItemData` by
`WeaponType`.**
- **C. B with `FactionProductData` by `Design` then `Manufacturer`** (the market-segment view:
who makes this design).
- **D. B plus `Loadout` by `Hull`.**
- **Recommendation: B.** It recovers everything the legacy window grouped, puts the
manufacturer view where the manufacturer now is, and gives the weapon list its own axis.
C is the better view if the operator authors products per design rather than per faction.
Product record counts are unprobed; Cut 4's Hands reports them.
## Defaults (recorded, not asked)
- **Labels:** enum `Enum.GetName`, else the number; ref `RecordLabel` of the candidate with that
key (for `Faction`, `Name`, not legacy `ShortName`); unset ref `None`; dangling `Missing <key>`;
string its value or `(empty)`; integer invariant string; bool `False`/`True`.
- **Order within a level:** enums by underlying value; refs `None` first, then label
(`OrdinalIgnoreCase`, then key), `Missing` last; strings `OrdinalIgnoreCase` then ordinal;
integers numeric; `false` before `true`.
- **Groupable kinds:** String, Integer, Bool, Enum (not `[Flags]`), RecordRef. Refused with a
notice and a flat list: any other kind (Float included), a `[Flags]` enum, `Hidden`,
`IsReadOnly`, a name that is not an inspector member of the listed type, a name given twice.
- **Global types are never grouped.**
- **Foldouts:** window memory, collapsed by default, cleared by `CloseStore`. Create-in-group,
`Add` and `Duplicate` expand the path to the new selection. No `EditorPrefs`/`SessionState`.
- **Create into a `Missing <key>` node** presets the dangling key (keeps I5).
- **A drop of the current key is rejected** (no no-op commit).
- **Variants:** see Q6's defaults and I10.
## Sequence with document variants
| Cut | Files | Overlaps grouping? |
|---|---|---|
| Variants C2a (element ids, in flight) | `CultCache.cs`, new `CultElementIds.cs`, Mesh/Geometry documents, `ElementIdTests.cs` | no |
| Index cut (`[CultIndex]` means unique; queued behind C2a) | `CultCache.cs`, Mesh/Geometry | no |
| Variants C2b (nested paths; inferred from the brief, not yet written in the variants map) | `CultCache.cs`, `CultVariants.cs` | no |
| **Grouping Cuts 1-2** | `CultInspectorModel.cs`, `CultInspectorAttributes.cs`, `CultInspectorModelTests.cs`, `CultCacheStudioWindow.cs`, `CultCacheStudioDrawers.cs`, Studio README | — |
| Variants C3 (Studio edits the delta) | `CultInspectorModel.cs` (edit, commit, diff, `CreateVariant`, `Rebase`), window (variant controls beside `Duplicate`, row marker), drawers (per-member marker and revert), `CultInspectorModelTests.cs` | **yes: all five grouping files** |
**Order:** grouping Cuts 1-2 now, in parallel with C2a, the index cut and C2b (no shared
files). Grouping merges to `main` before C3's Hands starts; C3 needs C2b anyway. C3 then
rebases onto grouping and lowers its row marker inside the grouped row drawing that Cut 2
writes. Then **one release (Cut 3)**, then one Aetheria pass (variants C4 plus Cut 4).
Where they touch: `DrawRecords` rows (`CultCacheStudioWindow.cs:180-185`; grouping rewrites,
C3 marks); the toolbar (`:142-157`; C3 adds variant buttons beside `Duplicate`, grouping
leaves it alone); `DrawRecordRef` (grouping adds the drop target; C3 marks at the member
level in `DrawValue`, not inside it); `CultInspectorModel.cs` (disjoint methods);
`CultInspectorModelTests.cs` (disjoint fixtures; name grouping fixtures `Group*`).
**One release commit:** Cut 3 below is the single release for both campaigns. The variants
map's C3 line "Then tag `caching-unity-v1.5.0`" should point here instead of owning a second
release step (proposal for Self).
## Cuts
Hands for Cuts 1-2: branch `hands/studio-grouping` in a fresh worktree off `origin/main`
(Self picks the path, e.g. `C:/wsNN-grouping`). `F:\Projects\CultLib-studio-grouping` holds the
superseded docs-only `codex/studio-grouping`; do not reuse it. Do not touch the main checkout.
Mutation testing is Stryker.NET on the cut's diff; there are no hand-applied mutations.
### Cut 1. CultLib model: declaration, tree, candidate and label authority (~150k tokens)
- **Delete and collapse first:**
- `CultInspectorModel.cs:519`: the inline `target.IsInstanceOfType(record.Document) &&
record.Key.Value.Length > 0` in `RecordCandidates` (`:514-522`) moves into
`IsRecordCandidate`; `RecordCandidates` filters through it (I6).
- `:36` and `:209`: `member.GetCustomAttributes(true)` → `Attribute.GetCustomAttributes(member, true)` (F5).
- **Add in `CultInspectorAttributes.cs`**, before `CultInspectorDrawerAttribute` (`:81-86`):
`CultInspectorGroupByAttribute(params string[] members)`, `AttributeUsage(Class,
Inherited = true, AllowMultiple = false)`, property `Members`, with a comment in the file's
style stating nesting, nearest-wins and opt-out.
- **Add in `CultInspectorModel.cs`** (or a sibling `CultInspectorGrouping.cs`, same namespace;
one file while it stays under ~800 lines; it is 645 now):
- `CultInspectorGrouping { Type ListedType; IReadOnlyList<CultInspectorMember> Members; string? Notice }`
(members empty when `Notice` is set).
- `CultInspectorRecordGroup { string Id; string Label; int Depth; IReadOnlyList<object?> Values;
IReadOnlyList<CultInspectorRecordGroup> Children; IReadOnlyList<CultStoredDocument> Records; int Count }`.
- `GroupingOf(Type listed)`, cached like `ShapeOf` (`:324-333`): names resolve against
`MembersOf(listed)` (`:335-339`); validation per Defaults.
- `GroupRecords(Type listed, IEnumerable<CultStoredDocument> records)`: partitions
`RecordCandidates(CultRecordRef<listed>)` by value identity, reading each record's
`Document` (resolved, I10).
- `CreateInGroup(Type listed, CultInspectorRecordGroup group, out string? notice)`:
`CreateElement(listed, listed, out notice)` (`:387-398`), then `SetValue` each level; refs
via `CreateRecordRef` (`:524-529`). `ArgumentException` for a node not of `listed`'s current
grouping.
- `IsRecordCandidate(Type refType, CultStoredDocument record)`.
- `RecordRefLabel(Type refType, object? value, IEnumerable<CultStoredDocument> records)`: `None`,
`RecordLabel` (`:315-319`), or `Missing <key>`.
- **Tests** in `tests/GameCult.Caching.Tests/CultInspectorModelTests.cs` (476 lines; fixtures
`:299-476`). New fixtures `GroupBase` (abstract) / `GroupLeaf : GroupBase` `[CultDocument]`,
a re-declaring type and an opting-out type; members: enum declared out of alphabetical
order, `CultRecordRef<InspectOther>`, string, float, `[Flags]` enum, hidden int, read-only int.
No object lists (F15). Behaviours, one test each:
1. levels nest in declared order, not slot order;
2. a `GroupLeaf` listed under `GroupBase` groups by the base declaration (I3);
3. nearest declaration wins; an empty declaration opts out;
4. leaves partition the candidates over unset refs, dangling refs and undefined enum values (I2);
5. two refs sharing one `[CultName]` give two nodes; `None` first; `Missing k` last (I4, I7);
6. enum nodes order by value;
7. create at depth 2, upsert into a temp single-file `CultCache`, regroup: same node id (I5);
8. each invalid declaration (unknown, float, `[Flags]`, hidden, read-only, duplicate) gives a
notice and a flat tree, no throw (I8);
9. `IsRecordCandidate` accepts a subtype, refuses a sibling and an empty key, and
`RecordCandidates` equals the records it accepts (I6);
10. a base abstract property's `[CultInspectorLabel]` reaches the override's metadata (F5);
11. **a variant groups by its resolved value**: base with `Enum=A`, variant overriding it to
`B` via `UpsertVariantAsync` and `Override`, regroup: the variant is in `B`; change the
base to `C` with the variant not overriding it, regroup: it follows to `C` (I10).
- **Verify (Yggdrasil):**
`ygg-verify.sh /f/Projects/CultLib <rev> dotnet 'dotnet test tests/GameCult.Caching.Tests -c Release'`,
then
`ygg-verify.sh /f/Projects/CultLib <rev> dotnet 'dotnet tool install -g dotnet-stryker && export PATH="$PATH:$HOME/.dotnet/tools" && cd tests/GameCult.Caching.Tests && dotnet stryker --project GameCult.Caching.csproj --since:<base-sha> > /tmp/s.log 2>&1; rc=$?; tail -60 /tmp/s.log; exit $rc'`.
Survivors triaged by name in the report.
- **Ledger:** model ~+170, attributes ~+15, tests ~+230; one predicate collapsed.
### Cut 2. CultLib Studio: lower the tree, create in group, drag source, drop target (~150k tokens)
- **Delete first:**
- `CultCacheStudioDrawers.cs:266` inline `"Missing " + key : "None"` →
`Model.RecordRefLabel(type, value, Records)` for `names[0]` when `index == 0`.
- `CultCacheStudioWindow.cs:180-185` flat `foreach` → tree drawing.
- **Window** (`CultCacheStudioWindow.cs`):
- fields (`:13-30`): `Dictionary<string,bool> _groupFoldouts` (ordinal), `const string RecordDragKey`;
`CloseStore` (`:318-327`) clears them.
- `DrawRecords` (`:135-189`): `GroupingOf(_selectedType)`; a `Notice` shows a warning
`HelpBox`. Draw `GroupRecords(_selectedType, _records)` recursively: foldout `Label (Count)`
indented by `Depth`; an expanded node draws children, leaf rows, then a `Create` mini
button under `Add`'s gate (`:141-146`). The root is not a foldout; `Add` stays root create.
- row: reserve the rect with `GUILayoutUtility.GetRect`, handle `MouseDrag` there first
(`PrepareStartDrag`, `SetGenericData(RecordDragKey, new CultCacheStudioRecordDrag(_model, key))`,
empty `objectReferences`, `StartDrag(label)`, `Use()`), then `GUI.Toggle(rect, ...)` (F11).
Keep the existing `<Type>` suffix logic (`:182-183`) in one place for C3 to extend.
- `AddInGroup(node)` mirrors `Add` (`:340-346`) through `Run` (`:369`); selects the key and
expands the node's id prefixes. `Add`/`Duplicate` reveal the new record's path (a
`_revealKey` resolved next frame is acceptable).
- **Drawers** (`CultCacheStudioDrawers.cs`): `internal sealed class CultCacheStudioRecordDrag
{ Model; Key }`. In `DrawRecordRef` (`:254-280`), capture the `HorizontalScope` rect; on
`DragUpdated`/`DragPerform` inside it with our payload: `Copy` when `GUI.enabled`, same
`Model`, key differs, record in `Records`, and `IsRecordCandidate`; else `Rejected`. Leave
foreign drags alone. On perform: `AcceptDrag`, `next = key`, `GUI.changed = true`, `Use()`;
return through the existing `:279`.
- **README** (`src/GameCult.Unity/Assets/Caching/README.md`): grouping, create in group and
drag-to-reference after the paragraph at `:30-34`; the grouping API, `IsRecordCandidate`,
`RecordRefLabel` under `## Inspection Model` (`:39`).
- **Verify:**
- Cut 1's Yggdrasil test command still passes.
- Negative greps (each empty):
`rg -n "IsInstanceOfType" src/GameCult.Unity/Assets/Caching/Editor | rg -v "assetType.IsInstanceOfType"` (I6; `:215` is the asset-GUID sub-asset check, not candidacy);
`rg -n "\"Missing \"" src/GameCult.Unity/Assets/Caching/Editor` (I7; the union drawer's
`"None"` at `:393` is not a ref label, so grep `Missing` only);
`rg -n "SetValue|GetCustomAttribute|CultInspectorGroupBy" src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioWindow.cs` (I1);
`rg -n "EditorPrefs" src/GameCult.Unity/Assets/Caching/Editor` shows only `LastPathKey`.
- **Starfire, one job, editor closed:** the F10 compile gate, `dotnet build` of a scratch
netstandard2.1 project over the worktree's `Assets/Caching/Editor/*.cs`. 0 errors.
- **Ledger:** window ~+80, drawers ~+30, README ~+20; two inline decisions deleted.
### Cut 3. Joint release: cultlib Unity 1.0.61, Studio 1.5.0 (after grouping and variants C3 merge)
- **Base:** `main` with grouping Cuts 1-2 and variants C1-C3 merged; a clean worktree.
- **Files:** `unity/org.gamecult.cultlib/package.json:4` `1.0.60` → `1.0.61`;
`src/GameCult.Unity/Assets/Caching/package.json:4` `1.4.0` → `1.5.0` and `:13` dependency
`1.0.61`; `CHANGELOG.md` entries in both packages (grouping, create in group, drag to
reference; variant editing from C3); plugins via
`powershell -File scripts\build-unity-package.ps1 -UpdateTemplate` (Windows: MSVC and the
pinned MsQuic digests; its version check is at `:168-170`).
- **Tags:** `cultlib-unity-v1.0.61`, `caching-unity-v1.5.0`; no `cultmath-unity` tag.
- **Verify (Starfire, one job at a time):**
- byte check: rerun the script; `git status --porcelain unity/` is empty;
- expected diff: `GameCult.Caching.dll/.pdb` changes, and `gamecult_mesh_quic_native.dll`
changes (F16); `msquic.dll` should not (same pinned digest). Anything else stops the release;
- API present: reflect over the tracked `GameCult.Caching.dll` for
`CultInspectorGroupByAttribute`, `GroupRecords`, `CreateInGroup`, `IsRecordCandidate`,
`RecordRefLabel`, and C3's `CreateVariant`;
- native ABI: `dumpbin /exports` lists `cultmesh_quic_open`, `_state`, `_poll`, `_error`, `_close`;
- `git ls-remote --tags origin` lists both tags.
### Cut 4. Aetheria: grouping annotations (inside variants C4's pass; blocked on Q7)
- **Where:** the Aetheria branch and worktree Self opens for variants C4. Never
`F:\Projects\Aetheria`'s working tree. C4 owns the pin bump (`Directory.Build.props:6`, and
`Packages/manifest.json:54-55` to `#caching-unity-v1.5.0` / `#cultlib-unity-v1.0.61`;
`:56` cultmath unchanged; `packages-lock.json` follows) and the element-id sweep (F18). This
cut adds only:
- Q7's `[CultInspectorGroupBy(...)]` class attributes (recommendation B: five declarations);
- `tests/Aetheria.Shared.Tests/StudioGroupingTests.cs` (xUnit): `GroupingOf` over
`CultCacheMessagePack.CreateInspectorModel(registry)` names exactly the declared members
per annotated type with no notice; `GroupingOf(typeof(WeaponItemData))` follows Q7; every
registered document type's `GroupingOf` has a null notice.
- **Verify:**
- headless (Starfire, one job; the revision check needs `CultLibRoot` clean at the release
SHA and `CultMathRoot` clean at `6d5e209`):
`dotnet test tests\Aetheria.Shared.Tests -p:CultLibRoot=<clean worktree at release SHA> -p:CultMathRoot=<clean worktree at 6d5e209>`;
- Unity 6000.3.24f1 batchmode compile, editor closed:
`-batchmode -nographics -quit -projectPath <Aetheria worktree> -logFile <scratch>`; 0 errors,
both new tags resolved.
- **Operator only, in Aetheria's Studio against a copy of `GameData/Aetheria.cc`:**
- each annotated type shows the declared levels; `GearData` includes `<WeaponItemData>` rows;
- create in a node: the record lands there and is selected;
- drag a `Faction` onto a `FactionProductData.Manufacturer`: accepted; drag a gear record onto
it: rejected;
- drag a `PersonalityAttribute` onto a `DemandProfile` key that already exists: refused
through the notice;
- read-only open: every drop rejected;
- a variant (once C4 authors one, e.g. `LonginusX`) sits in its resolved `HullType` node,
and a drop into its inspector writes an override (C3), not a refusal;
- close without edits: store bytes unchanged.
## Subtraction ledger
Added: one attribute, two model types, five model methods, ~11 CultLib and ~3 Aetheria tests,
~110 Studio lines, five annotations. Collapsed: the candidate predicate (now one method, reused
by drop), the ref label strings, the property-inheritance miss (F5). Not added: selectors,
registries, `EditorPrefs` keys, runtime ports, packages, targets, daemons, a second release.
Build footprint: `GameCult.Caching` and its tests (Yggdrasil); the F10 compile gate, the
package script (all tracked plugins including the native bridge) and Aetheria's headless and
batchmode checks (Starfire; Windows is the target for the editor plugin and win-x64 bridge).
## What the 2026-09-17 map had wrong as of `feeced8`
- Cut 4 grouped `GearData` by `Manufacturer`; `ItemData.Manufacturer` no longer exists (F17).
- Q5's native-plugin drift was spent by the 1.0.60 release (F16); the Cut 3 "rebase onto
`codex/asset-guid-attribute`" note is obsolete (merged).
- Cut 4 re-pinned Aetheria on its own; after C2a it cannot, without the id sweep (F18).
- Aetheria branch `codex/cultcache-cutover`, props `:5`, manifest `:53-55`, cultmath `v0.2.3`: now
`codex/fire-control-12`, `:6`, `:54-56`, `v0.2.4` with a separate CultMath pin.
- Negative greps for `IsInstanceOfType` and `"None"` would fail on unrelated lines (`:215`, `:393`).
- Hand-applied mutations per test are retired; Stryker.NET on the diff replaces them.
- Moved anchors: `RecordCandidates` `:512-520`→`:514-522`; `_shapes` `:298`→`:296`; `RecordLabel`
`:313-317`→`:315-319`; `CreateElement` `:385-396`→`:387-398`; drawer attribute `:80`→`:81-86`;
`DrawRecordRef` `:195-221`→`:254-280`, label `:207`→`:266`; `ReplaceKey` routing
`:243-251`→`:303-309`; test fixtures `:380-459`→`:299-476`; README `:36-39`→`:30-39`; build-script
version check `:150-155`→`:168-170`.
- Worktree `F:\Projects\CultLib-studio-grouping` is now the superseded docs branch; Hands uses a
fresh one.

File diff suppressed because it is too large Load diff

View file

@ -1,160 +0,0 @@
# TypeScript QUIC Realtime Plane Target
Date: 2026-09-16
Status: target accepted by the operator (2026-09-16), ready for Eureka. No cut
map exists yet. When the pipeline starts, Imagination maps the cut into
`docs/typescript-quic-realtime-cut.md` against `main` at `a0813c6` or later.
The TypeScript runtime gains the CultMesh QUIC realtime state plane that the C#
runtime already has: a provider that serves frames and a consumer that receives
them, byte-compatible with `GameCult.Mesh.Quic` and the native MsQuic connector
the Unity package ships. This document states the end state and the invariants
that constrain getting there.
Operator ruling, 2026-09-16, on providing QUIC for a Node service: "Option 2 all
the way, this is what CultLib is supposed to be, we're not half assing it." A
separate .NET provider process and a WebSocket shortcut were both rejected.
## Why now
The first consumer is StreamPixels. Its service (Node 24, TypeScript, on
Yggdrasil) must publish realtime state to a Unity overlay running on a
streamer's machine. The overlay is a receive-only client using the Unity
package's `CultMeshNativeQuicRealtimeTransportConnector`. Today no TypeScript
path exists:
- Node v24.15.0 has no `node:quic`, with or without `--experimental-quic`.
- `packages/cultmesh-ts` and `packages/cultnet-ts` contain no QUIC code.
- `GameCult.Mesh.Quic` (.NET 10, `System.Net.Quic`) is the only QUIC provider.
- The C# TCP control and content connectors are plaintext and restricted to
loopback (`src/GameCult.Mesh/docs/transport-planes.md`, "Security and release
gate"), so they cannot carry the overlay's remote session either.
## This touches the foundation
- **Wire parity is an invariant.** The C# codec is the reference:
`src/GameCult.Mesh/CultMeshRealtimeWireProtocol.cs`. The TypeScript runtime
must produce and accept identical bytes:
- ALPN `cultmesh-state-v1`; connection close code `0x43554c54`, stream abort
code `0x53544154`; reliable stream type `1`, latest-only stream type `2`.
- Frame: magic `0x31545343`, then a 37-byte little-endian fixed header
(delivery byte, producer epoch i64, sequence i64, channel/schema/body id
lengths u16, payload length i32, header size i32 = 37, wire version u16 =
1), then UTF-8 channel id, schema id, body id, then payload.
- Limits: payload at most 64 MiB; each identity at most 65,535 UTF-8 bytes;
decode rejects wrong magic, unknown version, out-of-range delivery, and any
length that does not sum exactly.
- **Delivery semantics are an invariant**
(`src/GameCult.Mesh.Quic/README.md`, `CultMeshRealtimeTransports.cs`):
- `ReliableOrdered`: one persistent ordered stream; broadcast is
completion-bearing.
- `LatestOnly`: independent streams; at most one pending frame per
`(channel, body)`, replaced by a newer `(producer epoch, sequence)`
generation; a keyed coalescing outbox per physical peer, so a slow or
departed consumer never backpressures the producer.
- `Unreliable`: fails closed until datagrams are exposed, matching the managed
C# adapter.
- **Trust is an invariant.** No remote session comes online without the chain
in `transport-planes.md`: an Odin-signed `CultMeshRouteCertificate` (Verse,
authority runtime, protocol set, endpoint, generation, validity, provider
P-256 key) checked against consumer-pinned Odin roots, TLS 1.3, and the
endpoint certificate bound to that route, including the
`cultmesh-state+quic://host:port?cert-sha256=<uppercase SHA-256>` pin.
Signatures are ECDSA P-256 over SHA-256 with P1363 `r || s` encoding and the
length-prefixed canonical transcript. Unsigned traffic exists only under an
explicit `LocalDevelopment` policy on loopback.
- **One implementation of TypeScript route verification.** `cultmesh-browser`
already verifies Odin route certificates and provider nonce possession with
WebCrypto (`packages/cultmesh-browser/src/index.ts`, `verifyAuthorityRoute`,
`canonicalRoute`, `canonicalSession`, `verifyP256`). The Node QUIC path must
reuse that owner, not grow a second verifier.
- **The realtime plane carries state frames only.** Schemas, commands,
receipts, manifests, and immutable content do not travel through it
(`ICultMeshRealtimeTransport` doc comment). RUDP is never installed
implicitly in its place.
- **Existing consumers survive.** The StreamPixels service already imports
`cultcache-ts`, `cultnet-ts`, and `cultmesh-ts` (`verse-state.ts`,
`idunn-rudp-health.ts`) for Odin advertisement and Idunn health over RUDP.
Imagination audits every other TypeScript consumer across `F:\Projects`
before sizing the cut.
## Target state
- `cultmesh-ts` exposes a QUIC realtime provider and a QUIC realtime consumer
with the same ergonomic shape as the C# `CultMeshQuicRealtimeServer`
(listen, broadcast with an explicit delivery mode) and
`ICultMeshRealtimeTransportConnector` (connect by advertised candidate,
receive frames), under the branded `CultMesh` entrypoint.
- QUIC comes from native MsQuic bindings owned by CultLib, not from a Node
polyfill or a third-party QUIC stack with different semantics. How the binding
is built and shipped is the means (Imagination probes it); what it must
deliver is TLS 1.3, ALPN, independent unidirectional or bidirectional streams,
certificate validation with a caller hook, and clean close and abort codes.
- Frame encoding and decoding exist once in TypeScript and are tested against
bytes produced by the C# codec.
- Application code never picks endpoints, ranks routes, or writes reconnect
loops; `CultMeshSessionManager`-equivalent route selection owns that, as in C#.
- The build produces binaries for the hosts that run them: Windows x64 for
local development, and Linux x64 for Yggdrasil, built on Linux or the deploy
path's builder, not cross-compiled on Starfire.
## Not in this migration
One foundation per pipeline. These are separate, named pipelines or consumer
work:
- **TypeScript content plane** (a provider in `cultmesh-ts`, a verifying HTTPS
reader in `cultmesh-browser`) for exported character artifacts. HTTPS content
transport and single-host trust on Yggdrasil were already ruled.
- **Authenticated remote control plane** (TLS on the TCP schema and content
connectors). See Q1.
- **Browser QUIC** (WebTransport). Browsers keep the verified WebSocket path.
- **Python, Rust, and Kotlin QUIC.** The parity scope is role-scoped; this
migration claims TypeScript and C# only.
- **StreamPixels integration** (service publication, overlay subscription, and
the catalog and scene-intent schemas) is consumer work in StreamPixels after a
released `cultmesh-ts` exists.
## Verification the cut must reach
- Golden frame bytes from the C# codec decode in TypeScript, and TypeScript
encodings decode in C#, for every delivery mode and at the length limits.
Malformed inputs are rejected identically.
- Separate processes, both directions: a TypeScript provider to the C# native
connector (the one Unity uses) and to the managed connector, and a C# managed
provider to the TypeScript consumer. Extend the existing parity harnesses
(`CULTMESH_NATIVE_EXTERNAL_ENDPOINT`, `packages/cultnet-ts/test/interop`) rather
than adding a parallel one.
- `LatestOnly` coalescing: a stalled consumer does not delay the producer, and
the consumer converges on the terminal generation.
- Trust negatives: an expired or unsigned route, the wrong Odin root, a
mismatched `cert-sha256` pin, and a replayed nonce are each refused, with a
mutation per rule.
- The Linux x64 binary runs on the Yggdrasil deploy path, verified on that host.
## Rulings (operator, 2026-09-16)
The operator accepted every recommendation below ("You got it"). Each question
is kept with its options as the record of what was decided against.
- **Q1. Which remote plane carries the overlay's non-frame data?** The realtime
plane forbids schemas, commands, and content, and the TCP planes are
loopback-only. A: publish her character, the subathon window, and scene
intents as QUIC state frames with their CultCache schema id and payload, and
ship the costume catalog as a versioned immutable artifact on the HTTPS
content plane; the overlay sends no commands, so it needs no remote control
plane. B: add TLS to the TCP schema and content connectors as its own
pipeline first. **Recommended: A.** It needs no new plane for M1, matches a
receive-only client, and leaves TLS TCP to the first consumer that sends
commands.
- **Q2. Where does shared TypeScript route verification live?** It is inside
`cultmesh-browser` today, and `cultmesh-ts` is Node-only. A: move it to a
browser-safe shared module that both import (for example beside
`cultnet-ts/contracts`). B: have `cultmesh-ts` depend on `cultmesh-browser`.
**Recommended: A**, so neither transport package owns the other's trust.
- **Q3. Which platforms must the native binding support at release?**
**Recommended: Windows x64 and Linux x64 only**, the two hosts that run it
today.
- **Q4. Are QUIC datagrams (`Unreliable`) in scope?** **Recommended: no.** Fail
closed like the managed C# adapter; no consumer needs them.

View file

@ -1,13 +0,0 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-stryker": {
"version": "5.0.0",
"commands": [
"dotnet-stryker"
],
"rollForward": false
}
}
}

View file

@ -1,118 +1,20 @@
cmake_minimum_required(VERSION 3.24)
project(gamecult_mesh_quic_native LANGUAGES CXX)
# Two platforms, one source. Windows takes MsQuic from the pinned OpenSSL NuGet
# layout (MSQUIC_ROOT); Linux takes it from the extracted libmsquic deb, whose
# headers and library land in unrelated places, so they are named separately.
if(NOT WIN32 AND NOT (CMAKE_SYSTEM_NAME STREQUAL "Linux"))
message(FATAL_ERROR "The CultMesh native QUIC bridge supports Windows x64 and Linux x64")
if(NOT WIN32)
message(FATAL_ERROR "The current CultMesh native QUIC connector supports Windows desktop only")
endif()
if(NOT DEFINED MSQUIC_ROOT)
message(FATAL_ERROR "MSQUIC_ROOT must point at the extracted Microsoft.Native.Quic.MsQuic.Schannel package build/native directory")
endif()
add_library(gamecult_mesh_quic_native SHARED cultmesh_quic_native.cpp)
target_compile_features(gamecult_mesh_quic_native PRIVATE cxx_std_20)
# include/cultmesh_quic_native.h is the host-facing contract, not an internal
# detail: the bridge includes it, and so does any consumer that can.
target_include_directories(gamecult_mesh_quic_native PUBLIC include)
if(WIN32)
if(NOT DEFINED MSQUIC_ROOT)
message(FATAL_ERROR "MSQUIC_ROOT must point at the extracted Microsoft.Native.Quic.MsQuic.OpenSSL package build/native directory")
endif()
# Static CRT. A Node or Unity host without the Visual C++ redistributable
# installed cannot load a DLL that imports vcruntime140.dll, and the failure
# arrives as "the specified module could not be found" with nothing naming the
# missing runtime. The bridge carries its own instead.
set_property(TARGET gamecult_mesh_quic_native PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded")
target_compile_definitions(gamecult_mesh_quic_native PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX)
target_include_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_ROOT}/include")
target_link_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_ROOT}/lib/x64")
# crypt32 and bcrypt belong to the v1 shim alone: CryptHashCertificate2
# compares the Unity pin. Nothing in the v2 path needs them. ntdll carries
# RtlIpv4/6StringToAddressEx, which msquic_winuser.h's inline QuicAddrFromString
# calls to parse the listener host; the Linux header parses it in-tree instead.
target_link_libraries(gamecult_mesh_quic_native PRIVATE msquic crypt32 bcrypt ntdll)
# The DLL is committed beside its source, so a rebuild must reproduce it: /Brepro drops the link timestamps.
target_compile_options(gamecult_mesh_quic_native PRIVATE /Brepro)
target_link_options(gamecult_mesh_quic_native PRIVATE /Brepro)
else()
if(NOT DEFINED MSQUIC_INCLUDE_DIR OR NOT DEFINED MSQUIC_LIB_DIR)
message(FATAL_ERROR "MSQUIC_INCLUDE_DIR and MSQUIC_LIB_DIR must point at the msquic v2.5.9 headers and the extracted libmsquic library directory")
endif()
target_include_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_INCLUDE_DIR}")
target_link_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_LIB_DIR}")
target_link_libraries(gamecult_mesh_quic_native PRIVATE msquic pthread)
# Only the CULTMESH_API exports leave the .so; everything else stays internal.
target_compile_options(gamecult_mesh_quic_native PRIVATE -fvisibility=hidden -fvisibility-inlines-hidden)
# libmsquic.so.2 ships beside the bridge rather than being installed
# system-wide, so the loader is pointed at the bridge's own directory.
set_target_properties(gamecult_mesh_quic_native PROPERTIES
BUILD_WITH_INSTALL_RPATH TRUE
INSTALL_RPATH "$ORIGIN")
endif()
target_compile_definitions(gamecult_mesh_quic_native PRIVATE GAMECULT_MESH_QUIC_NATIVE_EXPORTS WIN32_LEAN_AND_MEAN NOMINMAX)
target_include_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_ROOT}/include")
target_link_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_ROOT}/lib/x64")
target_link_libraries(gamecult_mesh_quic_native PRIVATE msquic crypt32 bcrypt)
set_target_properties(gamecult_mesh_quic_native PROPERTIES
OUTPUT_NAME "gamecult_mesh_quic_native"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
# The runtime-lifetime scenarios. Off by default: the shipped artifact is the
# library alone, and a delivery build has no business producing an executable.
# CULTMESH_QUIC_DEBUG_ASSERTS goes with them, never into a shipped binary — it
# makes the bridge state the quiesce invariant instead of leaving it to whatever
# a sanitizer happens to catch.
option(CULTMESH_QUIC_BUILD_TESTS "Build the runtime-lifetime scenario runner" OFF)
option(CULTMESH_QUIC_DEBUG_ASSERTS "Abort the bridge on a violated internal invariant" OFF)
if(CULTMESH_QUIC_DEBUG_ASSERTS)
target_compile_definitions(gamecult_mesh_quic_native PRIVATE CULTMESH_QUIC_DEBUG_ASSERTS)
endif()
if(CULTMESH_QUIC_BUILD_TESTS)
add_executable(cultmesh_quic_native_tests tests/cultmesh_quic_native_tests.cpp)
target_compile_features(cultmesh_quic_native_tests PRIVATE cxx_std_20)
# The header only. The scenarios drive the bridge as a host does, so they get
# no more of it than a host has.
target_link_libraries(cultmesh_quic_native_tests PRIVATE gamecult_mesh_quic_native)
# The scenarios see the development seam (section 7 of the header) exactly when
# the library has one, so `holdclose` compiles out of the same builds the
# bridge's own assertions compile out of.
if(CULTMESH_QUIC_DEBUG_ASSERTS)
target_compile_definitions(cultmesh_quic_native_tests PRIVATE CULTMESH_QUIC_DEBUG_ASSERTS)
endif()
set_target_properties(cultmesh_quic_native_tests PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
if(WIN32)
# gamecult_mesh_quic_native.dll imports msquic.dll, and the scenario
# runner loads it from its own directory (or PATH) at process start, not
# from MSQUIC_ROOT. build-quic-native.ps1's packaged output gets a copy
# from its own recipe; a test build has no packaging step, so without this
# the loader falls through to whatever msquic.dll happens to already be on
# PATH — silently running the scenarios against an unpinned, wrong-version
# MsQuic instead of failing to start. CMake already knows MSQUIC_ROOT, so
# it places the pinned DLL itself instead of leaving the reader to guess
# why a scenario passed against the wrong binary.
add_custom_command(TARGET cultmesh_quic_native_tests POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy
"${MSQUIC_ROOT}/bin/x64/msquic.dll"
"$<TARGET_FILE_DIR:cultmesh_quic_native_tests>/msquic.dll")
else()
target_link_libraries(cultmesh_quic_native_tests PRIVATE pthread)
# The linker resolves the bridge's own NEEDED libmsquic.so.2 while linking
# this executable, and the runtime copy only lands beside the binaries after
# the build; point it at the extracted package instead.
target_link_options(cultmesh_quic_native_tests PRIVATE "-Wl,-rpath-link,${MSQUIC_LIB_DIR}")
set_target_properties(cultmesh_quic_native_tests PROPERTIES
BUILD_WITH_INSTALL_RPATH TRUE
INSTALL_RPATH "$ORIGIN")
# Both binaries carry INSTALL_RPATH "$ORIGIN" and expect libmsquic.so.2
# beside them at load time. build-quic-native.sh's packaged output gets
# that copy from its own recipe; a test build has no packaging step, so
# the runner would otherwise die at once with "cannot open shared object
# file". CMake already knows where MSQUIC_LIB_DIR is, so it places the
# file itself instead of leaving the reader to run an extra cp by hand.
add_custom_command(TARGET cultmesh_quic_native_tests POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy
"${MSQUIC_LIB_DIR}/libmsquic.so.2"
"$<TARGET_FILE_DIR:cultmesh_quic_native_tests>/libmsquic.so.2")
endif()
endif()
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")

View file

@ -1,168 +1,10 @@
# GameCult.Mesh.Quic.Native
One MsQuic body for every CultMesh host that is not .NET: two roles (provider
and consumer), two platforms (Windows x64 and Linux x64), and no callbacks
across the host boundary.
This Windows desktop bridge keeps MsQuic's callback-heavy C API below the
managed runtime boundary. It accepts a discovery-authorized SHA-256 certificate
pin, negotiates `cultmesh-state-v1`, and emits complete CultMesh realtime frame
payloads. It owns transport only; frame meaning remains in `GameCult.Mesh`.
The bridge owns MsQuic registration, configuration, listener, connection and
stream handles, TLS credential loading, the `cultmesh-state-v1` ALPN, the
1-byte-kind plus 4-byte-LE-length QUIC stream framing, and one event queue per
runtime. It owns transport and nothing else: it never decodes a CultMesh frame,
never decides delivery semantics, never coalesces, never reconnects, and in the
v2 path never decides whether to trust a certificate. Frame meaning stays in
`GameCult.Mesh` and in the TypeScript codec; trust stays with the host.
## No host callbacks
Every crossing is the host calling in, including the one blocking wait. MsQuic's
worker threads only ever push onto the queue and wake that wait, so no host
runtime is called from a thread it does not own. That is what lets one binary
serve a Node process through FFI and a Unity player through P/Invoke without
either one's threading rules leaking into the other.
It is also why nothing here includes a Node header, a Unity header, or a
JavaScript engine: the bridge does not know what is on the other side.
## Two ABIs
**v2** is the runtime-neutral queue API, and the one new hosts should use. It is
stated once, in [`include/cultmesh_quic_native.h`](include/cultmesh_quic_native.h):
the 64-byte event struct with its field order and asserted offsets, the thirteen
exports with their signatures, the id and return-code conventions, the object
lifetime rules and the threading rules. That file is the contract. This README
does not restate it, and where the two ever disagree the header is right.
A host that can include it should. A host that cannot — koffi, Unity's
P/Invoke — declares the same thing in its own FFI and is checked against the
header by a test rather than by eye.
Two things worth reading before writing a host, because they are easy to get
wrong from the outside:
- **Return codes are negative on a bad call, on every platform.** `-1` is a bad
call, `-2` a rejected argument, `<= -1000` a MsQuic refusal. `QUIC_STATUS`
itself is not portable — a negative HRESULT on Windows, a positive errno on
POSIX — so it never reaches a host; `cultmesh_quic_last_status` hands over the
raw value for diagnostics and nothing else.
- **`cultmesh_quic_runtime_close` quiesces.** It marks the runtime closing, wakes
every blocked `cultmesh_quic_next_event`, and waits for every in-flight call to
return before it frees anything. No host call may begin after it starts.
A client connection defers certificate validation: the bridge indicates the
certificate, emits event 3 carrying the DER, and answers MsQuic only when the
host calls `cultmesh_quic_connection_certificate_complete`. If the host never
answers, MsQuic's 10-second handshake timeout closes the connection and event 4
reports it. Providers load their certificate as PKCS12; the advertised
`cert-sha256` pin is computed by the host from that same PKCS12, not here.
**v1** is the five exports the shipped Unity package imports by name
(`cultmesh_quic_open`, `_state`, `_poll`, `_error`, `_close`). They are Windows
only, matching `CultMeshNativeQuicRealtimeTransport.CanConnect`, and are
implemented on top of a private v2 runtime holding one connection. They decide
nothing the v2 path does not, with one exception: a v1 connection carries a
SHA-256 pin that is compared here, in C, because that is the contract the
managed connector was built against. A v2 connection never carries one.
## Building
Windows x64, from the repo root:
powershell -File scripts/build-quic-native.ps1
MsQuic comes from `Microsoft.Native.Quic.MsQuic.OpenSSL` 2.5.9, pinned twice: the
NuGet zip by digest and size, and the `msquic.dll` inside it by digest and size,
because the archive is what the download is checked against and the DLL is what
actually ships beside the bridge. The OpenSSL flavour rather than Schannel
because Schannel's MsQuic refuses `QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12`,
which is the only credential type a provider has on both platforms.
The build uses MSVC with `/Brepro` so that a build reproduces itself: the link
timestamps are dropped, and building the same source twice gives the same bytes.
It links the CRT statically (`/MT`), because a Node or Unity host without the
Visual C++ redistributable cannot load a DLL that imports `vcruntime140.dll`,
and the failure it reports names no missing runtime; the bridge carries its own.
The committed Unity plugin
(`unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/gamecult_mesh_quic_native.dll`)
is this build: 302,080 bytes, `/MT`, OpenSSL MsQuic, no CRT import. It replaced
an older 40,448-byte build — before `/MT` and before the move off Schannel,
still importing `VCRUNTIME140.dll` and `MSVCP140.dll` — at the CultLib 1.0.60 /
Studio 1.4.0 release.
Linux x64, on a Debian 13 host:
scripts/build-quic-native.sh
MsQuic comes from Microsoft's Debian 13 pool as a pinned `.deb`, and the POSIX
headers from the msquic source tree at the matching tag; all four downloads are
verified by SHA-256. The bridge is linked with `-fvisibility=hidden` so only the
v2 exports leave the `.so`, and with `RPATH $ORIGIN` so the `libmsquic.so.2`
shipped beside it resolves without being installed system-wide.
A Linux host still needs MsQuic's own runtime dependencies present:
`libssl3t64`, `libnuma1`, `libxdp1`, `libnl-route-3-200`.
The development loop for it is one committed image,
[`scripts/quic-native-linux-dev.Dockerfile`](../../scripts/quic-native-linux-dev.Dockerfile),
pinned to the same `debian:13` digest the release workflow builds in. It carries
the toolchain, MsQuic's runtime dependencies, and `setarch`, because a
sanitizer build of the runtime-lifetime scenarios needs `personality(ADDR_NO_RANDOMIZE)`
and a container that cannot ask for that cannot run them:
docker build -t cultlib-quic-native-dev -f scripts/quic-native-linux-dev.Dockerfile scripts
docker run --rm --security-opt seccomp=unconfined -v "${PWD}:/src" -w /src cultlib-quic-native-dev bash -lc "
scripts/build-quic-native.sh
cmake -S native/GameCult.Mesh.Quic.Native -B artifacts/quic-native-tests/linux-x64 -G Ninja \
-DCMAKE_BUILD_TYPE=Release -DCULTMESH_QUIC_BUILD_TESTS=ON -DCULTMESH_QUIC_DEBUG_ASSERTS=ON \
-DMSQUIC_INCLUDE_DIR=\$(pwd)/artifacts/dependencies/msquic-linux-2.5.9/include \
-DMSQUIC_LIB_DIR=\$(pwd)/artifacts/dependencies/msquic-linux-2.5.9/package/usr/lib/x86_64-linux-gnu
cmake --build artifacts/quic-native-tests/linux-x64
for scenario in closerace holdclose holdtimeout polltimeout pollbusy pollhammer zerotimeout payloadfit latecall waitseam; do
setarch -R artifacts/quic-native-tests/linux-x64/bin/cultmesh_quic_native_tests \"\${scenario}\" || exit 1
done
"
Both lines are run from the repository root, and the mount is that root wherever
it is: `${PWD}` in PowerShell, `$(pwd)` in a POSIX shell. `MSQUIC_INCLUDE_DIR`
and `MSQUIC_LIB_DIR` need `$(pwd)` too, even though the `cmake` invocation
itself already runs from `/src`: CMake resolves a relative path passed as a
cache variable against the CMakeLists.txt that consumes it
(`native/GameCult.Mesh.Quic.Native`), not against the directory `cmake` was
invoked from, so a bare `artifacts/...` here looks for the dependency one level
too deep and fails the compile before it reaches the loader. The test target's
own build now copies `libmsquic.so.2` from `MSQUIC_LIB_DIR` beside the built
binaries, so nothing else here needs the packaged output directory `scripts/build-quic-native.sh`
produces. `CULTMESH_QUIC_BUILD_TESTS`
is off in the shipped configuration (see the CMakeLists.txt option comment); it
turns on the scenario runner `tests/cultmesh_quic_native_tests.cpp` compiles into,
and `CULTMESH_QUIC_DEBUG_ASSERTS` turns on both the bridge's internal invariant
assertions and the four scenarios (`holdclose`, `latecall`, `holdtimeout`,
`waitseam`) that need the development seam in section 7 of the header. Each
scenario name is a positional argument to the built binary; run them individually
to bisect a failure, or all ten as shown to cover the runtime-lifetime rules.
A Windows clone made before `.gitattributes` kept shell scripts at LF still has
`scripts/build-quic-native.sh` with carriage returns, and pulling does not fix
it: the script's content did not change, so git neither rewrites it nor reports
it modified. The container then dies at once on the interpreter `bash\r`. From
the repository root, in either shell, this checks the script out again under the
current attributes:
git rm --cached -q scripts/build-quic-native.sh; git checkout HEAD -- scripts/build-quic-native.sh
`--security-opt seccomp=unconfined` is load-bearing, not caution. A
ThreadSanitizer configuration needs the process's address space where it expects
it, so a TSan run must be re-executed under `setarch -R`, which asks the kernel
for `personality(ADDR_NO_RANDOMIZE)`. Docker's default seccomp profile denies
that call. Without it ThreadSanitizer dies before `main` with "unexpected memory
mapping" — a configuration that never starts, not a scenario that passed.
A win32-x64 build of the scenario runner needs the checkout somewhere short —
near a drive root rather than under a deep temporary directory. MSVC builds the
bridge and its scenario runner through MSBuild, whose file tracker gives out on
long paths, and the build then fails outright rather than naming the path as the
cause.
Where an artifact was built is part of what it is. The Linux binary is linked
against Debian 13's glibc, so a build from anywhere else is a different artifact
whatever the file name says.
The build consumes Microsoft's pinned
`Microsoft.Native.Quic.MsQuic.Schannel` package. It does not contain
Aetheria-specific code.

File diff suppressed because it is too large Load diff

View file

@ -1,371 +0,0 @@
/* GameCult.Mesh.Quic.Native — C ABI v2, the runtime-neutral CultMesh QUIC bridge.
*
* This header is the contract. It is the single place the event layout, the
* exports, the id and return-code conventions, the object lifetime rules and the
* threading rules are stated; `cultmesh_quic_native.cpp` includes it, the README
* points at it, and a host that cannot include it (koffi, P/Invoke) declares the
* same thing in its own FFI and is checked against this file by a test.
*
* It is C. It includes nothing of MsQuic, nothing of Node, nothing of Unity, and
* nothing platform-specific: a host binds these twelve queue exports plus
* `cultmesh_quic_last_status` and needs no other build input.
*
* The v1 Unity exports (`cultmesh_quic_open`, `_state`, `_poll`, `_error`,
* `_close`) are deliberately not here. They are a Windows-only shim for a
* shipped managed connector, not a contract offered to new hosts.
*/
#ifndef CULTMESH_QUIC_NATIVE_H
#define CULTMESH_QUIC_NATIVE_H
#include <stddef.h>
#include <stdint.h>
#if defined(__cplusplus)
#define CULTMESH_QUIC_STATIC_ASSERT(condition, message) static_assert(condition, message)
#elif defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L
#define CULTMESH_QUIC_STATIC_ASSERT(condition, message) _Static_assert(condition, message)
#else
#define CULTMESH_QUIC_STATIC_ASSERT(condition, message)
#endif
/* `CULTMESH_QUIC_BUILD` is defined by the bridge's own translation unit and by
* nothing else. A consumer gets plain declarations and links by name, which is
* what every host here does: all three load the library at runtime. */
#if defined(CULTMESH_QUIC_BUILD)
# if defined(_WIN32)
# define CULTMESH_QUIC_API __declspec(dllexport)
# else
# define CULTMESH_QUIC_API __attribute__((visibility("default")))
# endif
#else
# define CULTMESH_QUIC_API
#endif
#if defined(__cplusplus)
extern "C" {
#endif
/* ---------------------------------------------------------------------------
* 1. The event
*
* Exactly 64 bytes, no implicit padding, field order below is the ABI. A host
* that reorders a field reads a different struct; the offsets are asserted here
* so a compiler that pads differently fails the build rather than the wire.
* --------------------------------------------------------------------------- */
struct cultmesh_quic_event {
uint32_t type; /* offset 0 — one of the cultmesh_quic_event_type values */
uint32_t stream_kind; /* offset 4 — 1 reliable, 2 latest-only; 0 when not a stream event */
uint64_t listener_id; /* offset 8 — 0 for an outbound connection */
uint64_t connection_id; /* offset 16 */
uint64_t stream_id; /* offset 24 */
uint64_t code; /* offset 32 — meaning depends on `type`; see below */
int32_t status; /* offset 40 — the raw platform QUIC status, diagnostics only */
int32_t payload_length; /* offset 44 — bytes written into the caller's payload buffer */
uint8_t reserved[16]; /* offset 48 — zero today; do not read meaning into it */
};
CULTMESH_QUIC_STATIC_ASSERT(sizeof(struct cultmesh_quic_event) == 64,
"the event struct is a 64-byte ABI contract");
CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, type) == 0,
"event field offsets are an ABI contract");
CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, stream_kind) == 4,
"event field offsets are an ABI contract");
CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, listener_id) == 8,
"event field offsets are an ABI contract");
CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, connection_id) == 16,
"event field offsets are an ABI contract");
CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, stream_id) == 24,
"event field offsets are an ABI contract");
CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, code) == 32,
"event field offsets are an ABI contract");
CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, status) == 40,
"event field offsets are an ABI contract");
CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, payload_length) == 44,
"event field offsets are an ABI contract");
CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, reserved) == 48,
"event field offsets are an ABI contract");
enum cultmesh_quic_event_type {
/* A listener accepted an inbound connection. `listener_id`, `connection_id`. */
CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION = 1,
/* The handshake completed. `connection_id`. */
CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED = 2,
/* Payload: the peer's DER certificate. The handshake is blocked until the
* host answers with `cultmesh_quic_connection_certificate_complete`. */
CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED = 3,
/* The connection is gone, whoever ended it, and its id no longer resolves.
* Payload: a UTF-8 reason. `code` is the QUIC application error code — the
* peer's when the peer ended it, and the code the host passed to
* `cultmesh_quic_connection_shutdown` when the host did. Emitted exactly
* once per connection id, including for the initiator of a local shutdown:
* every connection id's end is observable from the host that owns it. */
CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN = 4,
/* An inbound stream's first byte named its kind. `stream_id`, `stream_kind`. */
CULTMESH_QUIC_EVENT_STREAM_STARTED = 5,
/* Payload: one whole encoded frame, the 4-byte length prefix stripped. */
CULTMESH_QUIC_EVENT_STREAM_FRAME = 6,
/* One per `cultmesh_quic_stream_send_frame` call that returned 0, and never
* for the bridge's own kind byte. `code` is a cultmesh_quic_send_result. */
CULTMESH_QUIC_EVENT_STREAM_SEND_COMPLETE = 7,
/* The stream is gone. `stream_id`, `stream_kind`. */
CULTMESH_QUIC_EVENT_STREAM_SHUTDOWN = 8,
/* The listener stopped. `listener_id`. */
CULTMESH_QUIC_EVENT_LISTENER_STOPPED = 9
};
/* The `code` of event 7. A canceled send is a send MsQuic released without
* putting on the wire — the stream was aborted or the connection ended under it.
* It is reported, not silently dropped. */
enum cultmesh_quic_send_result {
CULTMESH_QUIC_SEND_COMPLETED = 0,
CULTMESH_QUIC_SEND_CANCELED = 1
};
/* The first byte of every CultMesh QUIC stream. */
enum cultmesh_quic_stream_kind {
CULTMESH_QUIC_STREAM_RELIABLE = 1,
CULTMESH_QUIC_STREAM_LATEST_ONLY = 2
};
/* ---------------------------------------------------------------------------
* 2. Ids
*
* Listeners, connections and streams are named by `uint64_t` ids drawn from one
* per-runtime counter that starts at 1 and never repeats. 0 is never a valid id
* and is what an out parameter holds when a call fails. An id is the only thing
* a host ever holds: there is no host-visible pointer to a connection or stream,
* so a host cannot outlive an object it is naming. An id whose object is gone is
* a bad call (-1), not undefined behaviour.
* --------------------------------------------------------------------------- */
/* ---------------------------------------------------------------------------
* 3. Return codes — negative on a bad call, on every platform
*
* Every int32_t-returning export returns:
*
* 0 the call succeeded
* 1, 2 `cultmesh_quic_next_event` only; see its comment
* -1 a bad call: a null handle, a null out parameter,
* or an id whose object is gone
* -2 an argument the bridge rejects on its own terms:
* an unknown stream kind, an oversized frame, a
* listener host that is not an IP literal
* <= -1000 MsQuic refused. The bridge code is
* -(1000 + (raw_status & 0xffff)); the raw
* platform status is available from
* `cultmesh_quic_last_status` and a sentence about
* it from `cultmesh_quic_last_error`.
*
* This mapping exists because `QUIC_STATUS` itself is not portable: it is a
* negative HRESULT on Windows and a positive errno on POSIX, so a host that
* checked for a negative value saw every Linux failure as success.
*
* What the mapping makes portable is the sign, and only the sign. A failure is
* negative everywhere, and that is what a host may branch on. The magnitude of a
* <= -1000 code is diagnostic and platform-specific: the same MsQuic failure
* carries different numbers on different platforms (a TLS error is -12032 on
* win32-x64 and -1126 on linux-x64; an ALPN already in use is -1009 against
* -1091), and the 16-bit mask can give two distinct statuses the same code
* (`QUIC_STATUS_USER_CANCELED` and `QUIC_STATUS_FILE_NOT_FOUND` are both -1002
* on Windows). Switching on a particular code is therefore not portable and not
* a reliable identification of the failure. Report it, log it, and read
* `cultmesh_quic_last_status` and `cultmesh_quic_last_error` beside it.
* --------------------------------------------------------------------------- */
#define CULTMESH_QUIC_RESULT_BAD_CALL (-1)
#define CULTMESH_QUIC_RESULT_BAD_ARGUMENT (-2)
#define CULTMESH_QUIC_RESULT_MSQUIC_BASE (-1000)
/* ---------------------------------------------------------------------------
* 4. Object lifetime — resolved from an id, kept alive across the call
*
* The bridge's objects are shared-owned. The runtime's maps hold one reference
* each; an export that names an id takes its own reference under the lock and
* holds it for the whole MsQuic call, so an object cannot be freed under a host
* thread that is using it. MsQuic's worker, on shutdown-complete, removes the
* map entry and drops the map's reference only — it never frees an object a host
* call is inside.
*
* The MsQuic handle follows the object: it is closed exactly once, by whichever
* holder drops the last reference (or by `cultmesh_quic_runtime_close`), under a
* one-shot exchange guard. A handle is therefore valid for as long as any holder
* has a reference to its object, which is the property the host thread needs. A
* stream holds a reference to its connection, so stream handles are always
* closed before the connection handle they belong to.
*
* `cultmesh_quic_runtime_close` quiesces before it tears anything down. It marks
* the runtime closing, wakes every blocked `cultmesh_quic_next_event`, and waits
* for every in-flight export to return before it closes a handle or frees a
* byte. No host call may begin after `cultmesh_quic_runtime_close` starts. That
* ordering is the host's, and it is a prohibition rather than a race the bridge
* arbitrates: once the close returns, the handle names freed memory, and a call
* made with it has nothing left to refuse it. A call that races the start of the
* close, while it is still running, is refused with -1 and touches nothing; that
* narrows one mis-ordering and does not make the prohibition optional.
* --------------------------------------------------------------------------- */
/* ---------------------------------------------------------------------------
* 5. Threading
*
* There are no host callbacks. Every crossing is the host calling in.
*
* - MsQuic's worker threads run inside this library only. They push events onto
* the runtime's queue and wake the one blocking wait. No host code is ever
* called from a thread the host does not own.
* - `cultmesh_quic_next_event` is called from host threads, one or many. It is
* the only blocking export. Concurrent callers each get a distinct event.
* - Every other export may be called from any host thread at any time, except
* during or after `cultmesh_quic_runtime_close`.
* - `cultmesh_quic_runtime_close` is called once, from one thread, and
* quiesces as described above.
* --------------------------------------------------------------------------- */
/* ---------------------------------------------------------------------------
* 6. The exports
* --------------------------------------------------------------------------- */
/* Opens a runtime: one MsQuic registration, one event queue. `app_name` may be
* null. `*out_runtime` is the opaque handle every other export takes. */
CULTMESH_QUIC_API int32_t cultmesh_quic_runtime_open(
const char* app_name, void** out_runtime);
/* Quiesces, tears down and frees the runtime. Safe to call with null. After it
* returns, the handle is dead; see section 4 for the ordering rule. */
CULTMESH_QUIC_API void cultmesh_quic_runtime_close(void* runtime);
/* Opens a provider listener on `host`:`port` with a PKCS12 credential. `host`
* may be null or empty for any address, otherwise it must be an IP literal.
* `port` 0 binds an ephemeral port, which `*out_bound_port` reports. A failure
* leaves no listener behind and no id allocated to the host. */
CULTMESH_QUIC_API int32_t cultmesh_quic_listener_open(
void* runtime, const char* host, uint16_t port,
const uint8_t* pkcs12, int32_t pkcs12_length, const char* password,
uint64_t* out_listener_id, uint16_t* out_bound_port);
/* Stops and closes a listener. Its accepted connections are not affected.
* Emits event 9. Unknown ids are ignored. */
CULTMESH_QUIC_API void cultmesh_quic_listener_close(
void* runtime, uint64_t listener_id);
/* Starts an outbound connection. Certificate validation is deferred: the host
* gets event 3 and must answer. */
CULTMESH_QUIC_API int32_t cultmesh_quic_connection_open(
void* runtime, const char* host, uint16_t port, uint64_t* out_connection_id);
/* Answers event 3. `accept` non-zero continues the handshake; zero fails it with
* a bad-certificate alert. */
CULTMESH_QUIC_API int32_t cultmesh_quic_connection_certificate_complete(
void* runtime, uint64_t connection_id, int32_t accept);
/* Ends a connection with a QUIC application error code. The initiator gets its
* own event 4 carrying `code`, as the peer does. Unknown ids are ignored. */
CULTMESH_QUIC_API void cultmesh_quic_connection_shutdown(
void* runtime, uint64_t connection_id, uint64_t code);
/* Opens an outbound unidirectional stream and puts `kind` on the wire as its
* first byte. That byte is the bridge's own and never produces an event 7. */
CULTMESH_QUIC_API int32_t cultmesh_quic_stream_open(
void* runtime, uint64_t connection_id, uint8_t kind, uint64_t* out_stream_id);
/* Queues one whole encoded frame, length-prefixed by the bridge. A call that
* returns 0 produces exactly one event 7 later, completed or canceled. */
CULTMESH_QUIC_API int32_t cultmesh_quic_stream_send_frame(
void* runtime, uint64_t stream_id,
const uint8_t* encoded_frame, int32_t length, int32_t fin);
/* Aborts a stream with a QUIC application error code. Unknown ids are ignored. */
CULTMESH_QUIC_API void cultmesh_quic_stream_shutdown(
void* runtime, uint64_t stream_id, uint64_t code);
/* The one blocking crossing.
*
* 0 nothing within `timeout_ms`. A poller already blocked here when
* `cultmesh_quic_runtime_close` starts is woken and returns 0. A poll that
* begins after the close starts is forbidden by section 4, and is not a
* case this return code covers.
* 1 `*out_event` filled and up to `payload_capacity` payload bytes copied;
* `*out_required` is the payload size
* 2 `payload_capacity` is too small. Nothing is consumed and nothing is
* written to `*out_event`; `*out_required` is the size to allocate. Ask
* again with a big enough buffer and the same event is delivered.
* -1 a bad call, including a null `payload` with a non-zero
* `payload_capacity`, which is a lie about the buffer and is refused
* rather than treated as a discard.
*
* `payload` may be null when `payload_capacity` is 0: that is the two-phase
* poll's first ask. */
CULTMESH_QUIC_API int32_t cultmesh_quic_next_event(
void* runtime, int32_t timeout_ms, struct cultmesh_quic_event* out_event,
uint8_t* payload, int32_t payload_capacity, int32_t* out_required);
/* The last error sentence, NUL-terminated, truncated to `capacity`. Returns the
* number of bytes written, or -1 on a bad call. */
CULTMESH_QUIC_API int32_t cultmesh_quic_last_error(
void* runtime, char* destination, int32_t capacity);
/* The raw platform `QUIC_STATUS` behind the last <= -1000 return, as its own
* bits: a negative HRESULT on Windows, a positive errno on POSIX. Diagnostics
* only; what is portable about the return code is its sign, not its value. 0
* when nothing has failed. */
CULTMESH_QUIC_API int32_t cultmesh_quic_last_status(void* runtime);
/* ---------------------------------------------------------------------------
* 7. The development seam
*
* Present only in a library configured with `CULTMESH_QUIC_DEBUG_ASSERTS`, which
* a shipped build never is. It exists for the runtime-lifetime scenarios: the
* quiesce is a promise about host calls that are inside the library, and the
* wait scenarios need the same kind of promise about the exact duration the
* bridge hands its own condition wait. A scenario that can only sleep and hope
* cannot tell a bridge that counts and waits correctly from one that does not.
*
* It is process-wide, not per-runtime, because the numbers outlive the runtime
* they describe: `cultmesh_quic_runtime_close` frees the runtime, and the count
* it waited on is read afterwards. The scenarios open one runtime at a time.
* --------------------------------------------------------------------------- */
#if defined(CULTMESH_QUIC_DEBUG_ASSERTS)
/* Non-zero arms the hold and resets both counters: every call that reaches the
* blocking wait in `cultmesh_quic_next_event` parks inside the library instead
* of returning. Zero releases every held call and disarms it. A held call
* returns 0 and touches the runtime no further. */
CULTMESH_QUIC_API void cultmesh_quic_debug_hold_calls(int32_t hold);
/* The high-water mark of host calls counted inside the library since the hold
* was armed. Zero means no call was ever counted. */
CULTMESH_QUIC_API int32_t cultmesh_quic_debug_peak_calls(void);
/* How many host calls were counted inside the library when the last
* `cultmesh_quic_runtime_close` began its wait, or -1 if no close has begun one
* since the hold was armed. This is the number the wait exists for. */
CULTMESH_QUIC_API int32_t cultmesh_quic_debug_calls_at_close(void);
/* The whole duration, in milliseconds, most recently handed to the condition
* wait inside `cultmesh_quic_next_event`, recorded at the point the wait is
* entered rather than observed from outside. A poll whose queue is not empty,
* or whose `timeout_ms` is not greater than zero, never enters that wait and
* leaves this unchanged. -1 until the first such wait, or after
* `cultmesh_quic_debug_reset_last_wait_ms` — tracked by a separate internal
* flag, not by -1 being stored here, so a wait that ever recorded exactly -1
* itself could not be confused with nothing having been recorded.
*
* This exists because a wall-clock probe cannot tell the host's timeout from
* any function of it that is the identity at the probe's own value — a floor,
* a round, a clamp, a later-poll fault, there is always another one that
* matches. This reads what the bridge actually handed its wait instead of
* inferring it from how long a call took. */
CULTMESH_QUIC_API int32_t cultmesh_quic_debug_last_wait_ms(void);
/* Resets the value `cultmesh_quic_debug_last_wait_ms` reports to -1, without
* touching the hold or the two call counters above. */
CULTMESH_QUIC_API void cultmesh_quic_debug_reset_last_wait_ms(void);
#endif /* CULTMESH_QUIC_DEBUG_ASSERTS */
#if defined(__cplusplus)
}
#endif
#endif /* CULTMESH_QUIC_NATIVE_H */

355
package-lock.json generated
View file

@ -489,326 +489,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@koromix/koffi-android-arm64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-android-arm64/-/koffi-android-arm64-3.3.0.tgz",
"integrity": "sha512-lHXE2mq+uG6CVeX1iepn2ATcAWMZPagr+J0ICYge7wTMDYc5pPRQMq0Rr/16775i6QlUqQqpk8c5Gu66+u3gGA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-android-x64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-android-x64/-/koffi-android-x64-3.3.0.tgz",
"integrity": "sha512-7EeVK/KS9XNbOsUfy0B3uuSjPQZenjJ35y9QSi6R6eK4Fv0oGvREQaBzIJRJwonxp7sJfGObnwnQTnKTVK2DWA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-darwin-arm64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.3.0.tgz",
"integrity": "sha512-2R9bihYmYFyRP98wRG2m5WQVKMONf7DoPJI8RkGCOUB2afOXJEVXFepiSKxBZGPml8i1USwAxMUwkzbJ5uMG1A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-darwin-x64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.3.0.tgz",
"integrity": "sha512-OecYCQfzhUx+XyAtatIHlk0jIz87wKNjtYvwlwgMwgaGQwO/TxFeReNo+s3167Rh1a9c8rUIOjq1jfzAPBxbKA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-freebsd-arm64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.3.0.tgz",
"integrity": "sha512-6ytHzV1DuyxTP5LtFmQ3imBk71j/F/Nb0Hcwhjcn35esnknm5rQbgRl6tNRldrkGnM4GIAJpsagooUBh1xAzYg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-freebsd-ia32": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.3.0.tgz",
"integrity": "sha512-hINo5z1Iz2u3Rx3oHfzcmi5A03gYAoYf4nCJJYaY0bb5IuGdIC7noQTyyI9YxfyTJgoqw3wKFWWYtvsAmEo9Hw==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-freebsd-x64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.3.0.tgz",
"integrity": "sha512-7XQYAZh828nW6+y3IdyUNdo2VbVHL5ZC8Qb+yqHrlDjTjVXSyGYZJHAyJZ5c7eSTTKBhtIMTX238rIsdexxrog==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-arm": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm/-/koffi-linux-arm-3.3.0.tgz",
"integrity": "sha512-NbahRKKKFkHoFN/ZpLY+w4F6eYSqV4KMN40PmjZ1xQjG/XjYb6PIYgE41c7/acAPxOYqujrcaI3B4BAgWYm5/A==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-arm64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.3.0.tgz",
"integrity": "sha512-Eo/+wGeawGICzJkUlkzi/KerAJ6U1L/Hf7cnJKH7aGgCOY5RRnjUqXs/eL+UDpRcFHzGeUn+Fq+8q2VxYxvAfw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-ia32": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.3.0.tgz",
"integrity": "sha512-u9tKuj42RFWJU8ZgZqcBhkFCkIZE/SE6oR1ljpukttJUhOVuaf/lmsWxyOxiVu3U8T1gtD658FlLgrbMVIn6aQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-loong64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.3.0.tgz",
"integrity": "sha512-2hKlNZ6OXkTUp6fcueZcP0sQ6yhpdfANg9/mWxDKzT1o8qIj4qJD7UcZLCcRIC8JXiP8JQwZBtlPuyXIqofDQg==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-ppc64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ppc64/-/koffi-linux-ppc64-3.3.0.tgz",
"integrity": "sha512-bIVAoHgzyRN7xs529BK7rF3BbZUTJgIr0t/wvBvAgaIpreKsdZ/QWxhedKRbTN7ljlDHkPZFmu5XcCjb/vqUag==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-riscv64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.3.0.tgz",
"integrity": "sha512-2niYAe+TBXbvf7yog/aiHiDUrKQn+RkMSH5x3ZYo65OQyII1CY3Jk2kP3Pe735gq0LzIYJsDuGMW+02ighSadQ==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-x64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.3.0.tgz",
"integrity": "sha512-apU8ZMOrOh1eqMP/Pp0sDZ/WY6A4r6JWph26h63VM9nZ+5KCTYuPgojfoLPNvrfI/bn1PY3+bpXKRhZHI3gxbQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-openbsd-arm64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-arm64/-/koffi-openbsd-arm64-3.3.0.tgz",
"integrity": "sha512-iTyHMCSSs+X579sWJv7XVFMp9gOfROI8IUEwp09YxJpGvmiSoipI0J7kytFuFq6dyuwO0Ymy4JdlmKnCM0qkxg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-openbsd-ia32": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.3.0.tgz",
"integrity": "sha512-xCvMV+4VNHjTxgVPEsBzU0k8dlA7aoFO5QjRF9lVU/NzGhUedlYvFgaF6IWsGOoWEaNt9hgP4KK/wxMbm9v8EQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-openbsd-x64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.3.0.tgz",
"integrity": "sha512-xItIwx1elM8k1pTudEfuV/1U2gQ06+rFfxCVXrZkyWYYGTzmA5To00f8garJFR+E9gbjQJbn8QDFvWMI+i2sMA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-win32-arm64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.3.0.tgz",
"integrity": "sha512-fD41LgKBx5i1YlJDuds4tRHAaG5vV55tvagzJwKFDR/5BDeaFvH9QFMUMDoNl2LkmVyeug0dIElHr/oxejh3IA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-win32-ia32": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.3.0.tgz",
"integrity": "sha512-BfDbDVEuQwPZOvWXawc3JT/7i9cN7QHYotgo014jjXb9pNv46Ykhb0RRglkcHg+SdytW/7pxYn18YwcQP6LLvQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-win32-x64": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.3.0.tgz",
"integrity": "sha512-D+ORp73rTmvGVfq01+4uBmgVZqrPqU9N9xCZCfexaCDlVk0WQsaqid1dGsi6eScJuHt4Cp8s1i5/4CsecxFTKQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@msgpack/msgpack": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz",
@ -1035,38 +715,6 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/koffi": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/koffi/-/koffi-3.3.0.tgz",
"integrity": "sha512-UeyQtptuPeCP0BqKD+Q8ftTQhIUBHO7bXyK9DVIaW9hJSg0BrICm4K5N8Qtn7zKk20sb5vpnES9KBqVksM2kpQ==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"url": "https://liberapay.com/Koromix"
},
"optionalDependencies": {
"@koromix/koffi-android-arm64": "3.3.0",
"@koromix/koffi-android-x64": "3.3.0",
"@koromix/koffi-darwin-arm64": "3.3.0",
"@koromix/koffi-darwin-x64": "3.3.0",
"@koromix/koffi-freebsd-arm64": "3.3.0",
"@koromix/koffi-freebsd-ia32": "3.3.0",
"@koromix/koffi-freebsd-x64": "3.3.0",
"@koromix/koffi-linux-arm": "3.3.0",
"@koromix/koffi-linux-arm64": "3.3.0",
"@koromix/koffi-linux-ia32": "3.3.0",
"@koromix/koffi-linux-loong64": "3.3.0",
"@koromix/koffi-linux-ppc64": "3.3.0",
"@koromix/koffi-linux-riscv64": "3.3.0",
"@koromix/koffi-linux-x64": "3.3.0",
"@koromix/koffi-openbsd-arm64": "3.3.0",
"@koromix/koffi-openbsd-ia32": "3.3.0",
"@koromix/koffi-openbsd-x64": "3.3.0",
"@koromix/koffi-win32-arm64": "3.3.0",
"@koromix/koffi-win32-ia32": "3.3.0",
"@koromix/koffi-win32-x64": "3.3.0"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@ -1219,8 +867,7 @@
"dependencies": {
"@gamecult/cultcache-ts": "^0.14.0",
"@msgpack/msgpack": "^3.1.2",
"cultnet-ts": "^0.2.0",
"koffi": "3.3.0"
"cultnet-ts": "^0.2.0"
},
"devDependencies": {
"@types/node": "^24.0.0",

View file

@ -14,8 +14,6 @@ from .backing_store import (
)
STORE_FORMAT_VERSION = "cultcache.store.v1"
_STORE_FORMAT_PREFIX = "cultcache.store."
_PERSISTED_RECORD_SLOTS = 4
class JsonLinesBackingStore:
@ -155,14 +153,7 @@ def _encode_v1_snapshot(envelopes: list[CultCacheEnvelope]) -> list[Any]:
def _decode_v1_snapshot(decoded: Any) -> list[CultCacheEnvelope] | None:
if not isinstance(decoded, list) or not decoded:
return None
if isinstance(decoded[0], str) and decoded[0].startswith(_STORE_FORMAT_PREFIX) and decoded[0] != STORE_FORMAT_VERSION:
raise ValueError(
f"CultCache store format {decoded[0]!r} is not readable; this runtime reads {STORE_FORMAT_VERSION!r} only. "
"The store needs a runtime that resolves document variants."
)
if decoded[0] != STORE_FORMAT_VERSION:
if not isinstance(decoded, list) or not decoded or decoded[0] != STORE_FORMAT_VERSION:
return None
if len(decoded) < 3 or not isinstance(decoded[1], list) or not isinstance(decoded[2], list):
raise ValueError("CultCache v1 snapshot must contain a schema catalog and record array")
@ -178,12 +169,7 @@ def _decode_v1_snapshot(decoded: Any) -> list[CultCacheEnvelope] | None:
for raw_record in decoded[2]:
if not isinstance(raw_record, list) or len(raw_record) < 4:
raise ValueError("CultCache persisted records must be MessagePack arrays")
if len(raw_record) > _PERSISTED_RECORD_SLOTS:
raise ValueError(
f"CultCache record {raw_record[0]!r} (schema {raw_record[1]!r}) has {len(raw_record)} slots; "
f"this runtime reads {_PERSISTED_RECORD_SLOTS}. The store needs a runtime that resolves document variants."
)
key, schema_id, stored_at, payload = raw_record
key, schema_id, stored_at, payload = raw_record[:4]
if not isinstance(key, str) or not key:
raise ValueError("CultCache persisted records must declare a key")
if not isinstance(schema_id, str) or not schema_id:

View file

@ -802,45 +802,5 @@ class CultCacheTests(unittest.TestCase):
self.assertEqual(cache.get_required_envelope(document, "item:one").key, "item:one")
# Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests.
_VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "document-variants-c0"
_ITEM_SCHEMA_ID = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788"
def _pull_vector(self, name: str) -> list[CultCacheEnvelope]:
with tempfile.TemporaryDirectory() as tmp:
store_path = Path(tmp) / "store.msgpack"
store_path.write_bytes((self._VECTORS / name).read_bytes())
return SingleFileMessagePackBackingStore(store_path).pull_all()
def test_single_file_refuses_unknown_header_by_name(self) -> None:
with self.assertRaises(ValueError) as caught:
self._pull_vector("unknown-header.msgpack")
self.assertIn("cultcache.store.v9", str(caught.exception))
def test_single_file_refuses_extra_record_slot_naming_the_record(self) -> None:
with self.assertRaises(ValueError) as caught:
self._pull_vector("extra-slot-full-payload.msgpack")
self.assertIn("item:anvil", str(caught.exception))
self.assertIn(self._ITEM_SCHEMA_ID, str(caught.exception))
def test_single_file_refuses_variant_store_by_version_or_record(self) -> None:
with self.assertRaises(ValueError) as caught:
self._pull_vector("variant-v2.msgpack")
message = str(caught.exception)
self.assertTrue("cultcache.store.v2" in message or "item:anvil-big" in message, message)
def test_single_file_refuses_the_csharp_written_variant_store_by_version_or_record(self) -> None:
with self.assertRaises(ValueError) as caught:
self._pull_vector("../document-variants-c1/variant-store.msgpack")
message = str(caught.exception)
self.assertTrue("cultcache.store.v2" in message or "laser-big" in message, message)
def test_v1_store_written_at_the_base_commit_still_reads_byte_for_byte(self) -> None:
envelopes = self._pull_vector("v1-base.msgpack")
self.assertEqual([(e.key, e.type) for e in envelopes], [("alpha", "vectors.item"), ("beta", "vectors.item")])
self.assertEqual(envelopes[0].payload, b"\x92\xa5alpha\x01")
self.assertEqual(envelopes[1].payload, b"\x92\xa4beta\x02")
if __name__ == "__main__":
unittest.main()

View file

@ -271,7 +271,7 @@ struct PersistedSchemaCatalogMember(
Option<String>,
);
#[derive(Clone, Debug, serde::Serialize)]
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
struct PersistedRecord(
String,
String,
@ -279,67 +279,6 @@ struct PersistedRecord(
#[serde(with = "serde_bytes")] Vec<u8>,
);
impl<'de> serde::Deserialize<'de> for PersistedRecord {
/// Reads the four v1 slots and refuses a fifth, naming the record. A record with more
/// slots was written by a runtime that resolves document variants.
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
use serde::de::{Error, IgnoredAny, SeqAccess, Visitor};
struct RecordVisitor;
impl<'de> Visitor<'de> for RecordVisitor {
type Value = PersistedRecord;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a CultCache persisted record of 4 slots")
}
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> std::result::Result<PersistedRecord, A::Error> {
let key: String = seq.next_element()?.ok_or_else(|| A::Error::invalid_length(0, &self))?;
let schema_id: String = seq.next_element()?.ok_or_else(|| A::Error::invalid_length(1, &self))?;
let stored_at: String = seq.next_element()?.ok_or_else(|| A::Error::invalid_length(2, &self))?;
let payload: serde_bytes::ByteBuf =
seq.next_element()?.ok_or_else(|| A::Error::invalid_length(3, &self))?;
if seq.next_element::<IgnoredAny>()?.is_some() {
return Err(A::Error::custom(format!(
"CultCache record {key:?} (schema {schema_id:?}) has more than 4 slots; this runtime reads 4. The store needs a runtime that resolves document variants."
)));
}
Ok(PersistedRecord(key, schema_id, stored_at, payload.into_vec()))
}
}
deserializer.deserialize_seq(RecordVisitor)
}
}
/// The store header string, or `None` when the bytes are not a snapshot-shaped array
/// (the legacy envelope array starts with a map).
fn store_header(bytes: &[u8]) -> Option<String> {
struct Header(String);
impl<'de> serde::Deserialize<'de> for Header {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
struct HeaderVisitor;
impl<'de> serde::de::Visitor<'de> for HeaderVisitor {
type Value = Header;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("an array whose first slot is the store header")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> std::result::Result<Header, A::Error> {
let header: String = seq
.next_element()?
.ok_or_else(|| <A::Error as serde::de::Error>::invalid_length(0, &self))?;
while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
Ok(Header(header))
}
}
deserializer.deserialize_seq(HeaderVisitor)
}
}
rmp_serde::from_slice::<Header>(bytes).ok().map(|header| header.0)
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct PushAllOptions {
pub soft: bool,
@ -719,11 +658,9 @@ impl SingleFileMessagePackBackingStore {
if bytes.is_empty() {
return Ok(Vec::new());
}
match store_header(&bytes) {
Some(header) if header.starts_with("cultcache.store.") => decode_store_snapshot(&bytes),
_ => rmp_serde::from_slice(&bytes).map_err(anyhow::Error::from),
}
.map_err(|error| anyhow!("failed to decode MessagePack {}: {error:#}", self.path.display()))
decode_store_snapshot(&bytes)
.or_else(|_| rmp_serde::from_slice(&bytes))
.with_context(|| format!("failed to decode MessagePack {}", self.path.display()))
}
/// Reads one filesystem snapshot without creating or opening the sibling
@ -2581,15 +2518,11 @@ fn encode_store_snapshot(entries: &[CultCacheEnvelope]) -> Result<PersistedStore
}
fn decode_store_snapshot(bytes: &[u8]) -> Result<Vec<CultCacheEnvelope>> {
if let Some(header) = store_header(bytes) {
if header != "cultcache.store.v1" {
return Err(anyhow!(
"CultCache store format {header:?} is not readable; this runtime reads \"cultcache.store.v1\" only. The store needs a runtime that resolves document variants."
));
}
}
let snapshot: PersistedStoreSnapshot =
rmp_serde::from_slice(bytes).context("failed to decode CultCache v1 snapshot")?;
if snapshot.0 != "cultcache.store.v1" {
return Err(anyhow!("unsupported CultCache snapshot {}", snapshot.0));
}
let catalog = snapshot
.1
@ -4640,62 +4573,4 @@ mod tests {
assert_eq!(store.pull_all_read_only_snapshot()?, vec![admitted]);
Ok(())
}
// Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests.
const ITEM_SCHEMA_ID: &str = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788";
fn pull_vector(name: &str) -> Result<Vec<CultCacheEnvelope>> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.msgpack");
let vector = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/vectors/document-variants-c0")
.join(name);
std::fs::copy(vector, &path)?;
SingleFileMessagePackBackingStore::new(&path).pull_all()
}
fn refusal(name: &str) -> String {
format!("{:#}", pull_vector(name).unwrap_err())
}
#[test]
fn single_file_refuses_unknown_header_by_name() {
assert!(refusal("unknown-header.msgpack").contains("cultcache.store.v9"));
}
#[test]
fn single_file_refuses_extra_record_slot_naming_the_record() {
let message = refusal("extra-slot-full-payload.msgpack");
assert!(message.contains("item:anvil"), "{message}");
assert!(message.contains(ITEM_SCHEMA_ID), "{message}");
}
#[test]
fn single_file_refuses_variant_store_by_version_or_record() {
let message = refusal("variant-v2.msgpack");
assert!(
message.contains("cultcache.store.v2") || message.contains("item:anvil-big"),
"{message}"
);
}
// The store the C# runtime wrote through its own variant API (tests/vectors/document-variants-c1).
#[test]
fn single_file_refuses_the_csharp_written_variant_store_by_version_or_record() {
let message = refusal("../document-variants-c1/variant-store.msgpack");
assert!(
message.contains("cultcache.store.v2") || message.contains("laser-big"),
"{message}"
);
}
#[test]
fn v1_store_written_at_the_base_commit_still_reads_byte_for_byte() -> Result<()> {
let envelopes = pull_vector("v1-base.msgpack")?;
let rows: Vec<_> = envelopes.iter().map(|e| (e.key.as_str(), e.r#type.as_str())).collect();
assert_eq!(rows, vec![("alpha", "vectors.item"), ("beta", "vectors.item")]);
assert_eq!(envelopes[0].payload, b"\x92\xa5alpha\x01");
assert_eq!(envelopes[1].payload, b"\x92\xa4beta\x02");
Ok(())
}
}

View file

@ -45,7 +45,7 @@
},
"homepage": "https://github.com/GameCult/CultLib/tree/main/packages/cultcache-ts#readme",
"engines": {
"node": ">=20"
"node": ">=18"
},
"dependencies": {
"@msgpack/msgpack": "^3.1.3",

View file

@ -1,7 +1,5 @@
import { decode } from "@msgpack/msgpack";
import { STORE_FORMAT_VERSION, isV1Snapshot, requireV1RecordSlots } from "./store-format";
export interface CultCacheInspection {
filePath: string;
fileSizeBytes: number;
@ -41,6 +39,8 @@ export interface InspectedRecord {
payloadDecodeError?: string;
}
const STORE_FORMAT_VERSION = "cultcache.store.v1";
export function inspectCultCacheBytes(
filePath: string,
bytes: Uint8Array,
@ -197,7 +197,6 @@ function decodeV1Record(value: unknown, catalogBySchemaId: Map<string, Inspected
throw new Error("CultCache persisted records must be MessagePack arrays.");
}
requireV1RecordSlots(value);
const [key = "", schemaId = "", storedAt = "", payload = new Uint8Array()] = value;
if (!isNonEmptyString(key) || !isNonEmptyString(schemaId) || !isNonEmptyString(storedAt)) {
throw new Error("CultCache persisted records must declare key, schemaId, and storedAt.");
@ -350,6 +349,10 @@ function toJsonSafe(value: unknown): unknown {
return value;
}
function isV1Snapshot(value: unknown): value is unknown[] {
return Array.isArray(value) && value[0] === STORE_FORMAT_VERSION;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

View file

@ -4,7 +4,6 @@ import { dirname, resolve } from "node:path";
import { decode, encode } from "@msgpack/msgpack";
import { z } from "zod";
import { STORE_FORMAT_VERSION, isV1Snapshot, requireV1RecordSlots } from "./store-format";
import type {
CacheBackingStore,
CultCacheEnvelope,
@ -13,6 +12,8 @@ import type {
PushAllOptions,
} from "./types";
const STORE_FORMAT_VERSION = "cultcache.store.v1";
const envelopeSchema = z.object({
key: z.string().min(1),
type: z.string().min(1),
@ -284,7 +285,7 @@ function encodeCatalogMember(member: CultCacheSchemaCatalogMember): unknown[] {
}
function decodeSnapshot(decoded: unknown): DecodedSnapshot | undefined {
if (!isV1Snapshot(decoded)) {
if (!Array.isArray(decoded) || decoded.length === 0 || decoded[0] !== STORE_FORMAT_VERSION) {
return undefined;
}
@ -435,7 +436,6 @@ function decodeRecord(value: unknown): PersistedRecord {
throw new Error("CultCache persisted records must be MessagePack arrays.");
}
requireV1RecordSlots(value);
const [key = "", schemaId = "", storedAt = "", payload = new Uint8Array()] = value;
if (!isNonEmptyString(key) || !isNonEmptyString(schemaId) || !isNonEmptyString(storedAt)) {
throw new Error("CultCache persisted records must declare key, schemaId, and storedAt.");

View file

@ -1,33 +0,0 @@
export const STORE_FORMAT_VERSION = "cultcache.store.v1";
const STORE_FORMAT_PREFIX = "cultcache.store.";
const PERSISTED_RECORD_SLOTS = 4;
/**
* True for a v1 snapshot, false for anything that is not a CultCache store header (the legacy
* envelope array). Any other `cultcache.store.*` header is refused by name.
*/
export function isV1Snapshot(decoded: unknown): decoded is unknown[] {
if (!Array.isArray(decoded) || typeof decoded[0] !== "string" || !decoded[0].startsWith(STORE_FORMAT_PREFIX)) {
return false;
}
if (decoded[0] !== STORE_FORMAT_VERSION) {
throw new Error(
`CultCache store format "${decoded[0]}" is not readable; this runtime reads "${STORE_FORMAT_VERSION}" only. ` +
"The store needs a runtime that resolves document variants.",
);
}
return true;
}
/** Refuses a persisted record with more slots than v1 defines, naming its key and schema id. */
export function requireV1RecordSlots(record: unknown[]): void {
if (record.length > PERSISTED_RECORD_SLOTS) {
throw new Error(
`CultCache record "${String(record[0])}" (schema "${String(record[1])}") has ${record.length} slots; ` +
`this runtime reads ${PERSISTED_RECORD_SLOTS}. The store needs a runtime that resolves document variants.`,
);
}
}

View file

@ -1413,55 +1413,3 @@ async function runJsonCommand(
return JSON.parse(trimmed.split(/\r?\n/).at(-1) as string);
}
// Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests.
const variantVectors = resolve(cultLibRoot, "tests", "vectors", "document-variants-c0");
const itemSchemaId = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788";
async function pullVector(name: string): Promise<CultCacheEnvelope[]> {
const dir = await mkdtemp(join(tmpdir(), "cultcache-vector-"));
const file = join(dir, "store.msgpack");
await writeFile(file, await readFile(join(variantVectors, name)));
return new SingleFileMessagePackBackingStore(file).pullAll();
}
test("SingleFileMessagePackBackingStore refuses an unknown store header by name", async () => {
await assert.rejects(() => pullVector("unknown-header.msgpack"), /cultcache\.store\.v9/u);
});
test("SingleFileMessagePackBackingStore refuses an extra record slot naming the record", async () => {
await assert.rejects(
() => pullVector("extra-slot-full-payload.msgpack"),
(error: Error) => error.message.includes("item:anvil") && error.message.includes(itemSchemaId),
);
});
test("SingleFileMessagePackBackingStore refuses a variant store by version or record", async () => {
await assert.rejects(
() => pullVector("variant-v2.msgpack"),
(error: Error) => error.message.includes("cultcache.store.v2") || error.message.includes("item:anvil-big"),
);
});
test("SingleFileMessagePackBackingStore refuses the C#-written variant store by version or record", async () => {
await assert.rejects(
() => pullVector("../document-variants-c1/variant-store.msgpack"),
(error: Error) => error.message.includes("cultcache.store.v2") || error.message.includes("laser-big"),
);
});
test("CultCache inspector refuses the same vectors by name", async () => {
const inspect = async (name: string) => inspectCultCacheBytes(name, await readFile(join(variantVectors, name)));
await assert.rejects(() => inspect("unknown-header.msgpack"), /cultcache\.store\.v9/u);
await assert.rejects(
() => inspect("extra-slot-full-payload.msgpack"),
(error: Error) => error.message.includes("item:anvil") && error.message.includes(itemSchemaId),
);
});
test("a v1 store written at the base commit still reads byte for byte", async () => {
const envelopes = await pullVector("v1-base.msgpack");
assert.deepEqual(envelopes.map((entry) => [entry.key, entry.type]), [["alpha", "vectors.item"], ["beta", "vectors.item"]]);
assert.deepEqual([...envelopes[0]!.payload], [0x92, 0xa5, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x01]);
assert.deepEqual([...envelopes[1]!.payload], [0x92, 0xa4, 0x62, 0x65, 0x74, 0x61, 0x02]);
});

View file

@ -26,15 +26,13 @@ shader semantics.
acceleration and semi-implicit Euler integration
- `math` intrinsics: `radians`, `degrees`, `abs`, `floor`, `ceil`, `frac`,
`min`, `max`, `clamp`, `saturate`, `lerp`, `step`, `smoothstep`, `dot`,
`cross`, `length`, `distance`, `normalize`, `reflect`, `csum`, `erf`,
`erfinv` (scalar only), exponential
`cross`, `length`, `distance`, `normalize`, `reflect`, `csum`, exponential
`decay`/`damp`, intercept/segment helpers, Catmull-Rom, Bezier curves, and
deterministic value-noise primitives
- `shaders/CultMath.hlsl`, a canonical HLSL mirror include for shader-side
parity. HLSL already owns `float2`, `float3`, and `float4`; the include
exposes `cultmath_*` functions for shared semantics HLSL does not provide.
It includes `shaders/CultMath.Phacelle.hlsl` (MPL-2.0) from the same
directory, so ship both files.
exposes `cultmath_*` functions for shared semantics, including CultMath's
safe `normalize` contract.
- `Voronoi.SampleTones`, a C# batch surface that calls the Rust
`cultmath-core` native kernel when `cultmath_core` is available and falls back
to the managed parity path otherwise.
@ -61,7 +59,7 @@ float grain = math.value_noise_bicubic(math.float2(12.0f, 3.5f));
```hlsl
#include "CultMath.hlsl"
float3 normal = normalize(float3(0.25, 1.0, -0.1));
float3 normal = cultmath_normalize(float3(0.25, 1.0, -0.1));
float rim = cultmath_saturate(1.0 - dot(normal, float3(0.0, 0.0, -1.0)));
float glow = cultmath_smoothstep(0.2, 1.0, rim);
float grain = cultmath_value_noise_bicubic(float2(12.0, 3.5));

View file

@ -6,36 +6,6 @@
simplex-noise functions by Ian McEwan and Ashima Arts, as distributed by Stefan
Gustavson's `webgl-noise` project and Unity Mathematics.
`math.snoise(float3)` and `cultmath_snoise(float3)` follow `src/noise3D.glsl` and
`math.snoise(float2)` follows `src/noise2D.glsl` of stegu/webgl-noise at commit
`22434e04d7753f7e949e8d724ab3da2864c17a0f` (the simplex-boundary fix is
`21d9fe23d7`, scale `ff3b5d34ea`, permutation `a3e6d57095`).
`math.snoise_grad`/`cultmath_snoise_grad` (and the `fbm_grad`/`ridged_grad`
octave sums built on them) follow the analytic-gradient differentiation from
the same project's `src/noise3Dgrad.glsl`, with the same kernel radius,
permutation and scale as `snoise(float3)`.
- Copyright 2011 Ashima Arts.
- Upstream: <https://github.com/ashima/webgl-noise>
- License: MIT.
## Phacelle Noise
`math.phacelle` and `cultmath_phacelle` follow Rune Skovbo Johansen's Phacelle
noise (Shadertoy `t3dyWl`; erosion filter `wXcfWn`; blog, March 2026), read
from the C# port `PhacelleNoise` in `lpmitchell/AdvancedTerrainErosion`
(`AdvancedTerrainErosion.cs`, converted to burstable C# by Luke Mitchell,
2026). The cell weight, the phase blend and the normalization follow it. The
3D-cell generalisation, the caller-supplied stripe wave vector, the exact
gradient and the exact pruning are new here.
- Copyright (c) 2025 Rune Skovbo Johansen.
- Upstream: <https://github.com/lpmitchell/AdvancedTerrainErosion>
- License: MPL-2.0 (<https://mozilla.org/MPL/2.0/>). MPL-2.0 is file-level, so it
covers exactly the files that carry the port and nothing else:
`src/CultMath/math.Phacelle.cs`, `src/CultMath/CultPhasor.cs`,
`shaders/CultMath.Phacelle.hlsl` and the Unity copy of that include. Each
carries the MPL-2.0 header and this attribution. The rest of CultMath is MIT,
so the `GameCult.Math` NuGet package declares `MIT AND MPL-2.0` and ships this
file. The Unity package as a whole is MPL-2.0.

View file

@ -52,26 +52,7 @@ That makes these HLSL rules, not options:
operand returns the other (so `saturate(NaN)` is 0), `clamp(x, a, b)` is
`min(max(x, a), b)`, and `saturate(x)` is `min(1, max(0, x))`, which maps -0
to +0; `any`/`all` accept numeric vectors (a component is true when it is
not 0); `min`, `max`, and `abs` on int vectors return int vectors; int
`clamp` is `IMin(IMax(x, a), b)`, so inverted bounds return `b`.
- Same-size vector conversions (`intN(floatN)`, `floatN(intN)`, `boolN(floatN)`,
`boolN(intN)`, `floatN(boolN)`, `intN(boolN)`) exist as constructors and
`math` functions and follow dxc: float to int is `fptosi`, truncating toward
zero; numeric to bool is `!= 0` (`fcmp une`, so NaN is true); bool to numeric
is 0 or 1. DXIL leaves float-to-int undefined for NaN and out-of-range values,
and so does C#; CultMath does not pin them.
- `normalize(x)` is `x * Rsqrt(Dot(x, x))`, which is how dxc lowers it, and
DXIL.rst defines `Rsqrt` as `1 / sqrt(src)`. A zero vector therefore
normalizes to NaN (`0 * inf`), and a NaN component makes every component NaN.
(The special-value table under `Rsqrt` in DXIL.rst is a copy of the `Round`
table and contradicts that definition, so CultMath follows the definition.)
`length` and `distance` are `Sqrt` of the summed squares, so a zero vector
gives 0. Callers that can meet zero-length vectors guard them before calling
`normalize`.
- dxc demotes double `frac`, `exp`, `lerp`, and `floor` to float. CultMath's
double overloads keep double precision instead: `frac(double)` is
`x - floor(x)` in double. That is a deliberate divergence for CPU simulation
time, which shaders do not carry.
not 0); `min`, `max`, and `abs` on int vectors return int vectors.
dxc marks float arithmetic and compares `fast` (no NaNs) unless a value is
`precise`, so drivers may optimize NaN handling away and NaN results on real
@ -83,7 +64,9 @@ GPU. Because `min`, `max`, and `saturate` return the non-NaN operand, some
functions turn NaN or infinity into numbers: `smoothstep(a, a, a)` is 0;
infinite inputs to `smoothstep` and the intercept helpers give numbers; the
bezier, `smoothstep01`, and `smootherstep` functions return finite values for
NaN input. Callers that must detect bad data should check `isnan` before calling these
NaN input; and `normalize((NaN, 2.41))` is `(NaN, 2.41e20)`, because the NaN
length is clamped to the epsilon and the other component is divided by it.
Callers that must detect bad data should check `isnan` before calling these
functions.
Deferred until a consumer needs them: `float4x4`, `transpose`, `determinant`,
@ -100,10 +83,7 @@ needs anything else, the test fails:
no CultMath analog.
3. File-scope `static const` becomes `const`.
4. Floating literals gain `f` (`0.5` becomes `0.5f`).
5. Struct fields gain `public`. HLSL struct members have no access-modifier
concept; a C# struct's fields default to `private`, which would hide them
from the mirror test's reflection-based field comparison.
6. The file body is wrapped in a class, since C# has no free functions; HLSL
5. The file body is wrapped in a class, since C# has no free functions; HLSL
functions become private instance methods.
HLSL constructs that stay outside the target and must not appear in the mirror:
@ -123,216 +103,9 @@ compiled under `using static CultMath.math;`, so its intrinsics are C# `math`
itself: the test proves the composition in the text, not the intrinsic rules,
which only `HlslSemanticsTests` pins.
The comparison also allows exactly one struct return shape: a mirror function
whose HLSL return type is a plain struct of `float`/`floatN`/`int`/`intN`
fields is compared to its C# counterpart field by field, recursing into each
field's own components, down to bit-for-bit scalars, matching each leaf's
declaration path (field names, not just position) and declared type as well
as its value. `CultCellular` (`math.cs`, `cellular`) and `CultPhasor`
(`math.Phacelle.cs`, `phacelle`) are its consumers. A struct return with a mismatched, reordered-by-name, or
retyped field does not get a second comparison path or a shape of its own;
it fails the same walk that already handles a bare vector return.
## Invariant 8: Value-and-Gradient Primitives
A primitive tagged invariant 8 returns its value and analytic gradient
together, laid out as `float4(gradient.xyz, value.w)`, with no value-only twin
(a value-only form would be a second path with no consumer). `smin_grad` and
`cellular` are the first primitives in this family:
- `smin_grad(float4 a, float4 b, float k)` is Inigo Quilez's quadratic-polynomial
smooth minimum ("smooth minimum",
<https://iquilezles.org/articles/smin/>), carried to value-and-gradient
form. Its blend factor `h` is affine in `b.w - a.w`, so differentiating the
value with respect to position, the terms carrying `dh/dp` cancel exactly;
the surviving gradient is `lerp(∇b, ∇a, h)`, the same `h` the value uses.
This is the analytic gradient, not an approximation, and it is continuous
across the `|a.w - b.w| = k` seam where `h` saturates to 0 or 1.
- `cellular(float3 p)` is Worley's cellular texture basis function ("A
Cellular Texture Basis Function", SIGGRAPH 1996), searched over the
jittered 5×5×5 neighbourhood of feature points selected by `pcg3d` (never
the sin-based `hash`), with cells whose position-only lower bound already
exceeds the running F2 skipped before their hash is even computed. It
returns `CultCellular { nearest, edge, id }`: `nearest` is `(∇F1, F1)`,
`edge` is `(∇F2 - ∇F1, F2 - F1)`, and `id` is the nearest cell's `pcg4d`
hash (over the cell coordinate alone, never the `pcg3d` hash that produced
its jitter) mapped to `[0, 1)`. `∇F1` and `∇F2` are each undefined exactly
at their own feature point (`F1 = 0` or `F2 = 0`); `cellular` follows the
repo's existing degenerate-normal convention
(`GameCult.Geometry.CultGeometryIsoSurface.EmitOrientedTriangle`, which
guards a zero-length normal and returns the zero vector instead of the NaN
a bare `normalize` gives there) and returns the zero vector rather than NaN
for either. `F1 = 0` is ordinary (`p` sits on a feature point); `F2 = 0`
needs two distinct cells' `pcg3d`-jittered feature points to land on the
same float32 value in every component, which is unreachable at ordinary
magnitudes but confirmed reachable (see "Precision domain" below). The
identity of the nearest and second-nearest feature point changes
discontinuously across the `F1 = F2` set (a genuine kink, not a numerical
artifact), so `∇F1` and `∇F2` are only continuous away from that set.
The 5×5×5 radius is exact, not merely safer than the classic 3×3×3 radius:
for a query at position `u` inside its own cell (`u` in `[0, 1)^3` per
component) and a candidate feature at integer cell offset `d` with jitter
`j` in `[0, 1)^3`, the per-axis displacement `(u_i - j_i) - d_i` is always
strictly greater in magnitude than `max(0, |d_i| - 1)`, because
`u_i - j_i` is always strictly inside `(-1, 1)`. So any cell with `|d_i| >=
3` on some axis is strictly farther than 2 from the query. Two real,
always-present candidates bound `F1` and `F2`: the query's own cell, whose
distance is always strictly less than `sqrt(3)` (every axis strictly under
1); and the near-side neighbour on whichever single axis has `u_i` closest
to a cell boundary. Let `m = min(u_i, 1 - u_i)` on that chosen axis, so
`m` is in `[0, 1/2]` by construction. Stepping one cell towards that
nearer boundary puts the query within `1 + m` of the far face on the
offset axis and within `1 - m` of the near face on each of the other two
axes, so that neighbour's squared distance is at most
`(1 + m)^2 + 2*(1 - m)^2 = 3 - 2m + 3m^2 <= 3` for every `m` in `[0, 1/2]`
(3 at `m = 0`, dipping to a minimum of `8/3 ~= 2.667` at `m = 1/3`, then
rising back to 2.75 at `m = 1/2`), and strictly below 3 because
the jitter never reaches exactly 0 or 1. `F2` is at most the larger of
these two real candidates (any two real candidates upper-bound the
2nd-smallest value over the full infinite candidate set), so `F1 <
sqrt(3)` and `F2 < sqrt(3)` always, comfortably under the radius-2
exclusion distance of 2, with margin `2 - sqrt(3) ~= 0.268` (Soul's
adversarial check, cut 2a-i: the worst realizable in-box `F2` is exactly
`sqrt(3)`, reached only in the unattainable limit at a cell corner). 3×3×3's
matching argument only reaches radius 1, whose excluded cells start at
distance 1, short of the `sqrt(3)` ceiling — the gap its measured failures
(9 of 400,000 points in `[-50, 50]^3`, worst error 0.088) live in.
An earlier version of this proof picked the near-side neighbour on a fixed
axis instead of whichever axis sits closest to its own boundary, and that
version is wrong (Soul, cut 2a-i): `u = (0.5, 0, 0)` with the x-neighbour's
jittered feature at `(-1, 1 - 2^-24, 1 - 2^-24)` gives a distance of about
2.06, outside the radius-2 exclusion the proof needs — `u.x = 0.5` is the
point on the cell farthest from any boundary, so `x` was the wrong axis to
fix there. Choosing the axis by proximity to its own boundary is what keeps
`m <= 1/2` and the bound at `sqrt(3)`.
`CellularAndSminGradTests` pins both the prune's bit-identical equivalence
to an unpruned 5×5×5 reference and the search's exact agreement with an
independent 7×7×7 brute-force oracle.
Precision domain: `cellular`'s exactness above is a statement about the
search radius, not about float32. Past `|p|` of roughly `2^23`, adjacent
integer cells stop being distinguishable from their neighbours at float32
precision in some directions, and distinct cells' jittered features can
round onto the same value, making `F1 = F2` ties (and, per-component,
`F2 = 0`) increasingly common from about `1e6` and confirmed at `2^24`
(measured: 4096 of 4096 sampled integer points at `2^24` hit this before
the guard above). `cellular` stays finite there; it does not stay accurate
in the sense of matching the idealized real-valued Worley field.
The lower-bound prune's bit-identity to the unpruned 5×5×5 search also has
a domain: it holds for `|p| < 2^25`. Above that, `cell + d` (an integer
cell coordinate plus a small integer offset) starts to round in float32,
so a pruned cell's own recomputed offset can land on a different neighbour
than the unpruned loop visits at that same nominal `(dx, dy, dz)`, and the
two search paths diverge (Soul, cut 2a-i: measured 180 of 1,100,000 points
differ at `|p| ~= 3.4e7`). This is a float32-precision limit on the prune's
equivalence proof, not a bug in the prune itself: below `2^25` the two
paths are proven bit-identical (`PrunedSearchIsBitIdenticalToTheUnprunedFiveCubedSearch`,
which samples `[-50, 50]^3`, is comfortably inside that domain).
- `snoise(float3)` follows webgl-noise `src/noise3D.glsl` as of
stegu/webgl-noise `22434e04d7`: kernel radius squared `0.5`, output scale
`105`, permutation `mod289((34x+10)x)`. Upstream's `21d9fe23d7` (2020-10-14,
"an age-old bug that caused slight discontinuities along simplex
boundaries") moved the radius from `0.6` to `0.5`; at `0.6` a lattice vertex
outside the four summed corners can lie inside the kernel, leaving a value
jump at the cell boundary (3.1e-3 over the committed probe's 400 lines). `ff3b5d34ea` set the scale
to `105` and `a3e6d57095` (2021-06-30) the permutation; the 2D `snoise` shares
that permutation helper, so its values changed too, while its own kernel
(`0.5`) and scale (`130`) already matched upstream.
`NoiseGradTests.SnoiseAndItsGradientAreContinuousAcrossSimplexCellBoundaries`
pins the continuity.
- `snoise_grad(float3 p)` carries `snoise(float3)` to value-and-gradient form,
following the differentiation in webgl-noise `src/noise3Dgrad.glsl` (Ashima
Arts / Ian McEwan, MIT), which uses the same radius, permutation and scale as
`snoise(float3)`: per corner, `m0 = max(0.5 - dot(x,x), 0)`, so
`d(m0^4 * dot(p,x))/dx = -8*m0^3*dot(p,x)*x + m0^4*p`, summed over the four
corners and scaled by the same constant as the value, so `.w` is exactly
`snoise(float3)`. `fbm_grad` and
`ridged_grad` are octave sums of `snoise_grad`: each octave samples at
`p * frequency`, so by the chain rule its gradient scales by that same
frequency, and both amplitude (`gain`) and frequency (`lacunarity`) compound
per octave rather than applying once to the whole sum. `ridged_grad` folds
each octave about zero (`Σ aᵢ(1 − |nᵢ|)`, gradient `−Σ aᵢfᵢ sign(nᵢ)∇nᵢ`,
Musgrave's ridged multifractal, *Texturing and Modeling* ch. 16); the crease
at `nᵢ = 0` is deliberate (the operator's dune term, 2026-09-25), not a bug.
Both clamp `octaves` to `[0, 16]`: `HlslSourceCompatibilityTests`'s bit-parity
oracle drives every mirrored `int` parameter across the full int32 range,
which is the right domain for `pcg3d`/`pcg4d`'s O(1) hash inputs but would
turn an unclamped octave count into a multi-billion-iteration loop for these
two mirrors.
- `phacelle(float3 p, float3 side, float offset, float normalization)` is Rune
Skovbo Johansen's Phacelle noise ("phase + cell": a stripe pattern built by
blending one cosine/sine wave per jittered cell), generalised to 3D cells. It
returns `CultPhasor { cos, sin }`, each `(∇, value)`. `side` is the stripe
wave vector, whose length is 2π times the stripe frequency; in 3D the
perpendicular to the flow is not unique, so the caller chooses it (on a unit
sphere, `cross(p̂, flow)`). `offset` is the phase in cycles. It visits 4×4×4
cells with `pcg3d`-hashed jitter in `[-0.5, 0.5)`, weights each wave by
`max(0, exp(-2d²) - 0.01111)`, and normalizes the blended phasor as upstream
does (length 1 where the raw length is at least `1 - normalization`, scaled
by `1 / (1 - normalization)` below). The gradient is exact, weight
derivatives and normalization included, rather than upstream's
`∇cos ≈ -sin·side`, which ignores the weight gradient. It is discontinuous
where the raw length crosses the normalization threshold and where a cell's
weight leaves its support (d² = 2.24995, distance 1.49998); the value is
continuous at both. At the support edge `∇w` jumps from `-4·0.01111·v` to 0,
a per-cell jump of length `0.0444·|v|` (0.0667 at `|v| = 1.5`); measured over
random points, the output gradient jumps by a median 1.5% of its length and
at most about 27%. `normalization` is meant to lie in `[0, 1]`: 1 or more
saturates (every output is stretched to length 1), a negative value scales
every output down by `1 / (1 - normalization)`, and the normalized gradient
scales as `1 / |I|` for the blended phasor `I`, so it grows where the blend
nearly cancels. The total weight `W` is positive for every `pcg3d` input (the
smallest found by hill-climbing is 0.056); `W = 0`, and a NaN output, needs
adversarial jitter no hash yields.
Cells whose per-axis lower bound `Σ max(0, |local - g| - 0.5)²` is at least
2.25 lie outside the support and have weight exactly 0, so they are skipped,
which changes no output bit. Cells whose bound sits just under 2.25 can still
carry a nonzero weight, so the cut cannot be lowered; `PhacelleTests` pins
the cut with points next to such cells, against an internal overload with the
prune disabled, and the HLSL mirror test pins it on the shader side. The prune
keeps about 45 of the 64 cells on average (a saving of about 30%); about 14
have a nonzero weight. The port sits in its own MPL-2.0 files
(`math.Phacelle.cs`, `CultPhasor.cs`, `CultMath.Phacelle.hlsl`); see
`THIRD-PARTY-NOTICES.md`. No 2D variant exists; nothing consumes one.
Integer hashing uses the PCG hashes from Jarzynski and Olano, "Hash Functions
for GPU Rendering" (JCGT 9(3), 2020): `pcg(uint)` is O'Neill's RXS-M-XS 32/32
permutation over one LCG step, and `pcg3d`/`pcg4d` are the paper's (3 → 3) and
(4 → 4) hashes. They use only uint multiply, add, xor, and shifts. dxc wraps
uint multiply and add modulo 2^32 exactly as C# unchecked arithmetic does, so
they are integer-exact in HLSL and on the CPU, GPU included, unlike the
`sin`-based `hash`. CultMath has no uint vectors, so `pcg3d(int3)` and
`pcg4d(int4)` carry uint bit patterns in int components. The `float2`/`float3`/`float4`
overloads hash the IEEE-754 bits (`asuint`), so -0 and 0 hash differently; they
exist for callers that already hold a hash key as float bits, and `pcg3d(float2)`
holds z at 0, which is the paper's route for (2 → N) inputs. Seeds and other
scalar uses take one output component.
`cellular`'s jitter and id always hash the integer cell coordinate directly
(`pcg3d(int3(neighbor))`, `pcg4d(int4(int3(cellId), 0))`), never a cell
coordinate's float32 bit pattern. Hashing the bit pattern of an
integer-valued float is not the same permutation as hashing the integer: two
adjacent cells' bit patterns share more structure (both have the same
exponent and a mantissa that differs by one increment) than two adjacent
integers do going into `pcg3d`'s own mixing, and that residual structure
survived into the jitter. Hashing the integer cell coordinate directly is
exact and runtime-independent, and it is the HLSL mirror's own hash, so C#
and shader agree bit-for-bit. What pins this is the oracle and mirror tests,
not a uniformity measurement: reverting to hashing the float bits (or the
sin-based hash) is caught by `ProductionSearchMatchesTheBruteForceOracleExactly`,
`EveryMirrorFunctionMatchesCSharpMath`, `F1NeverExceedsF2`, and the other
tests that key off the integer-hash fixtures (cut 2a-i). `CellularAndSminGradTests`
(`JitterAndIdPassAChiSquareUniformityTest`) checks the uniformity of the hash
as `cellular` uses it: the jitter marginals and the id pass a chi-square test
at a stated significance against `pcg3d(int3(...))`.
Where HLSL is silent, CultMath keeps its own decisions and does not defer to
Unity.Mathematics: `hash` returns float, and `Random` is CultMath's own xorshift32. Engine-shaped
Unity.Mathematics: `normalize` is safe (divides by `max(length, 1e-20)`),
`hash` returns float, and `Random` is CultMath's own xorshift32. Engine-shaped
helpers that HLSL lacks (`float2x2.Rotate`, `float3x3.Euler`, `quaternion`,
`snoise`) enter only when a consumer needs them, with their semantics stated in
code.

View file

@ -27,25 +27,6 @@ if (Test-Path -LiteralPath $outputRoot) {
Remove-Item -LiteralPath $outputRoot -Recurse -Force
}
# docs/semver-policy.md (CultLib root, two levels up from this script):
# refuse to build a release whose CHANGELOG.md entry claims a breaking change
# under too small a bump, or whose version skips or reverses the previous
# published tag. Runs before the build below so a bad version number fails
# fast. cultmath is pre-1.0 (0.y.z): a breaking change must be at least a
# minor bump. $repoRoot above is this package's own root (packages\cultmath),
# not CultLib's; the shared checker script lives one level above that.
$cultLibRoot = Split-Path -Parent (Split-Path -Parent $repoRoot)
$cultmathVersion = (Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json).version
& node (Join-Path $cultLibRoot "scripts\check-changelog-semver.mjs") `
--package "org.gamecult.cultmath" `
--changelog (Join-Path $templateRoot "CHANGELOG.md") `
--version $cultmathVersion `
--tag-prefix "cultmath-unity" `
--cwd $cultLibRoot
if ($LASTEXITCODE -ne 0) {
throw "CultMath Unity package failed the semver policy check (see docs/semver-policy.md)."
}
# The tracked DLL and pdb are committed beside their source, so neither may name a commit: Source Link
# writes the commit SHA into the pdb, and the DLL carries that pdb's content id.
dotnet build $projectPath -c $Configuration -f netstandard2.1 -o $buildRoot --no-incremental `
@ -78,9 +59,7 @@ $requiredTemplateFiles = @(
"Runtime\UnityBridge\UnityConversions.cs",
"Runtime\UnityBridge\UnityConversions.cs.meta",
"Shaders\CultMath.hlsl",
"Shaders\CultMath.hlsl.meta",
"Shaders\CultMath.Phacelle.hlsl",
"Shaders\CultMath.Phacelle.hlsl.meta"
"Shaders\CultMath.hlsl.meta"
)
foreach ($relativePath in $requiredTemplateFiles) {
if (-not (Test-Path -LiteralPath (Join-Path $templateRoot $relativePath) -PathType Leaf)) {

View file

@ -1,99 +0,0 @@
#ifndef CULTMATH_PHACELLE_HLSL
#define CULTMATH_PHACELLE_HLSL
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
// the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
//
// Phacelle noise. Copyright (c) 2025 Rune Skovbo Johansen (Shadertoy t3dyWl; MPL-2.0), read from the
// C# port PhacelleNoise in lpmitchell/AdvancedTerrainErosion, converted to burstable C# by Luke
// Mitchell (2026). Modified by GameCult: 3D cells, a caller-supplied stripe wave vector, an exact
// gradient and exact pruning. This file is an MPL-2.0 file; CultMath.hlsl, which includes it after
// its own definitions of CULTMATH_TAU and cultmath_pcg3d, is MIT (see THIRD-PARTY-NOTICES.md).
// Invariant 8's struct return shape for cultmath_phacelle, compared field by field and bit for bit
// against CultMath.CultPhasor, same field order.
struct CultPhasor
{
float4 cos;
float4 sin;
};
// Johansen's Phacelle noise on 3D cells. See math.phacelle's comment (math.Phacelle.cs) for the stripe wave
// vector, the weight, the normalization, the exact gradient and why the prune is exact. Mirrors
// the C# body operation for operation; the C#-only internal overload that switches the prune off
// has no shader twin.
CultPhasor cultmath_phacelle(float3 p, float3 side, float offset, float normalization)
{
float3 cell = floor(p);
float3 local = p - cell;
float phaseOffset = offset * CULTMATH_TAU;
float sumW = 0.0;
float sumCos = 0.0;
float sumSin = 0.0;
float3 gradW = float3(0.0, 0.0, 0.0);
float3 gradCos = float3(0.0, 0.0, 0.0);
float3 gradSin = float3(0.0, 0.0, 0.0);
for (int dz = -1; dz <= 2; dz++)
{
float gz = max(0.0, abs(local.z - (float)dz) - 0.5);
for (int dy = -1; dy <= 2; dy++)
{
float gy = max(0.0, abs(local.y - (float)dy) - 0.5);
for (int dx = -1; dx <= 2; dx++)
{
float gx = max(0.0, abs(local.x - (float)dx) - 0.5);
if (gx * gx + gy * gy + gz * gz >= 2.25)
continue;
float3 gridStep = float3(dx, dy, dz);
int3 hash = cultmath_pcg3d(int3(cell + gridStep));
float3 jitter = float3(
((uint)hash.x >> 8) * (1.0 / 16777216.0) - 0.5,
((uint)hash.y >> 8) * (1.0 / 16777216.0) - 0.5,
((uint)hash.z >> 8) * (1.0 / 16777216.0) - 0.5);
float3 v = local - gridStep - jitter;
float falloff = exp(-2.0 * dot(v, v));
float w = max(0.0, falloff - 0.01111);
float3 dw = w > 0.0 ? (-4.0 * falloff) * v : float3(0.0, 0.0, 0.0);
float phase = dot(v, side) + phaseOffset;
float c = cos(phase);
float s = sin(phase);
sumW += w;
sumCos += w * c;
sumSin += w * s;
gradW += dw;
gradCos += dw * c - (w * s) * side;
gradSin += dw * s + (w * c) * side;
}
}
}
float invW = 1.0 / sumW;
float rawCos = sumCos * invW;
float rawSin = sumSin * invW;
float3 gradRawCos = (gradCos - rawCos * gradW) * invW;
float3 gradRawSin = (gradSin - rawSin * gradW) * invW;
float rawLength = sqrt(rawCos * rawCos + rawSin * rawSin);
float floorLength = 1.0 - normalization;
CultPhasor result;
if (rawLength > floorLength)
{
float outCos = rawCos / rawLength;
float outSin = rawSin / rawLength;
float3 gradLength = (rawCos * gradRawCos + rawSin * gradRawSin) / rawLength;
result.cos = float4((gradRawCos - outCos * gradLength) / rawLength, outCos);
result.sin = float4((gradRawSin - outSin * gradLength) / rawLength, outSin);
}
else
{
result.cos = float4(gradRawCos / floorLength, rawCos / floorLength);
result.sin = float4(gradRawSin / floorLength, rawSin / floorLength);
}
return result;
}
#endif

View file

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

View file

@ -11,6 +11,7 @@
static const float CULTMATH_PI = 3.14159265358979323846;
static const float CULTMATH_TAU = 6.28318530717958647692;
static const float CULTMATH_HALF_PI = 1.57079632679489661923;
static const float CULTMATH_NORMALIZE_EPSILON = 1.0e-20;
float cultmath_radians(float degrees_value) { return degrees_value * (CULTMATH_PI / 180.0); }
float cultmath_degrees(float radians_value) { return radians_value * (180.0 / CULTMATH_PI); }
@ -76,8 +77,8 @@ float cultmath_smootherstep(float value)
float2 cultmath_snoise_mod289(float2 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; }
float3 cultmath_snoise_mod289(float3 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; }
float4 cultmath_snoise_mod289(float4 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; }
float3 cultmath_snoise_permute(float3 value) { return cultmath_snoise_mod289(((value * 34.0) + 10.0) * value); }
float4 cultmath_snoise_permute(float4 value) { return cultmath_snoise_mod289(((value * 34.0) + 10.0) * value); }
float3 cultmath_snoise_permute(float3 value) { return cultmath_snoise_mod289(((value * 34.0) + 1.0) * value); }
float4 cultmath_snoise_permute(float4 value) { return cultmath_snoise_mod289(((value * 34.0) + 1.0) * value); }
// Ashima Arts / Ian McEwan 3D simplex noise (MIT), same float32 evaluation
// order as the C# math.snoise(float3) mirror.
@ -125,9 +126,9 @@ float cultmath_snoise(float3 value)
p2 *= norm.z;
p3 *= norm.w;
float4 m = max(0.5 - float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
float4 m = max(0.6 - float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
m = m * m;
return 105.0 * dot(m * m, float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
return 42.0 * dot(m * m, float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
}
float cultmath_snoise(float2 value)
@ -162,114 +163,6 @@ float cultmath_snoise(float2 value)
return 130.0 * dot(m, g);
}
// Analytic gradient of cultmath_snoise(float3) (invariant 8), following webgl-noise
// src/noise3Dgrad.glsl (Ashima Arts / McEwan, MIT); same kernel, permutation and scale as
// cultmath_snoise(float3) (stegu/webgl-noise 22434e04d7). See the C# math.snoise_grad comment for the
// derivation. Every local up through p0..p3 and x0..x3 is cultmath_snoise(float3)'s own derivation
// verbatim.
float4 cultmath_snoise_grad(float3 value)
{
float2 c = float2(1.0 / 6.0, 1.0 / 3.0);
float3 i = floor(value + dot(value, c.yyy));
float3 x0 = value - i + dot(i, c.xxx);
float3 g = step(x0.yzx, x0.xyz);
float3 l = 1.0 - g;
float3 i1 = min(g.xyz, l.zxy);
float3 i2 = max(g.xyz, l.zxy);
float3 x1 = x0 - i1 + c.xxx;
float3 x2 = x0 - i2 + c.yyy;
float3 x3 = x0 - 0.5;
i = cultmath_snoise_mod289(i);
float4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute(
i.z + float4(0.0, i1.z, i2.z, 1.0)) + i.y + float4(0.0, i1.y, i2.y, 1.0)) + i.x + float4(0.0, i1.x, i2.x, 1.0));
const float n_ = 0.142857142857;
float3 ns = float3(2.0 * n_, 0.5 * n_ - 1.0, n_);
float4 j = p - 49.0 * floor(p * ns.z * ns.z);
float4 x_ = floor(j * ns.z);
float4 y_ = floor(j - 7.0 * x_);
float4 x = x_ * ns.x + ns.yyyy;
float4 y = y_ * ns.x + ns.yyyy;
float4 h = 1.0 - abs(x) - abs(y);
float4 b0 = float4(x.xy, y.xy);
float4 b1 = float4(x.zw, y.zw);
float4 s0 = floor(b0) * 2.0 + 1.0;
float4 s1 = floor(b1) * 2.0 + 1.0;
float4 sh = -step(h, 0.0);
float4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
float4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
float3 p0 = float3(a0.xy, h.x);
float3 p1 = float3(a0.zw, h.y);
float3 p2 = float3(a1.xy, h.z);
float3 p3 = float3(a1.zw, h.w);
float4 norm = 1.79284291400159 - 0.85373472095314 * float4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3));
p0 *= norm.x;
p1 *= norm.y;
p2 *= norm.z;
p3 *= norm.w;
float4 m0 = max(0.5 - float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
float4 m2 = m0 * m0;
float4 m3 = m2 * m0;
float4 m4 = m2 * m2;
float4 px = float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3));
float value2 = 105.0 * dot(m4, px);
float3 grad = -8.0 * m3.x * x0 * px.x + m4.x * p0
+ -8.0 * m3.y * x1 * px.y + m4.y * p1
+ -8.0 * m3.z * x2 * px.z + m4.z * p2
+ -8.0 * m3.w * x3 * px.w + m4.w * p3;
grad *= 105.0;
return float4(grad, value2);
}
// Octave sum of cultmath_snoise_grad (invariant 8); see the C# math.fbm_grad comment for the
// per-octave frequency/amplitude derivation and why octaves is clamped.
float4 cultmath_fbm_grad(float3 p, int octaves, float lacunarity, float gain)
{
octaves = clamp(octaves, 0, 16);
float amplitude = 1.0;
float frequency = 1.0;
float value = 0.0;
float3 gradient = float3(0.0, 0.0, 0.0);
for (int i = 0; i < octaves; i++)
{
float4 n = cultmath_snoise_grad(p * frequency);
value += amplitude * n.w;
gradient += amplitude * frequency * n.xyz;
frequency *= lacunarity;
amplitude *= gain;
}
return float4(gradient, value);
}
// Ridged multifractal noise (Musgrave, Texturing and Modeling, ch. 16); see the C# math.ridged_grad
// comment for the fold/sign derivation, the deliberate crease at n = 0, and why octaves is clamped.
float4 cultmath_ridged_grad(float3 p, int octaves, float lacunarity, float gain)
{
octaves = clamp(octaves, 0, 16);
float amplitude = 1.0;
float frequency = 1.0;
float value = 0.0;
float3 gradient = float3(0.0, 0.0, 0.0);
for (int i = 0; i < octaves; i++)
{
float4 n = cultmath_snoise_grad(p * frequency);
float s = sign(n.w);
value += amplitude * (1.0 - abs(n.w));
gradient += -amplitude * frequency * s * n.xyz;
frequency *= lacunarity;
amplitude *= gain;
}
return float4(gradient, value);
}
float cultmath_lengthsq(float2 value) { return dot(value, value); }
float cultmath_lengthsq(float3 value) { return dot(value, value); }
float cultmath_lengthsq(float4 value) { return dot(value, value); }
@ -278,6 +171,10 @@ float cultmath_distance(float2 left, float2 right) { return length(left - right)
float cultmath_distance(float3 left, float3 right) { return length(left - right); }
float cultmath_distance(float4 left, float4 right) { return length(left - right); }
float2 cultmath_normalize(float2 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); }
float3 cultmath_normalize(float3 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); }
float4 cultmath_normalize(float4 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); }
float2 cultmath_reflect(float2 incident, float2 normal_value)
{
return incident - 2.0 * dot(normal_value, incident) * normal_value;
@ -389,122 +286,6 @@ float cultmath_hash(float value) { return cultmath_frac(sin(value) * 43758.5453)
float cultmath_hash(float2 value) { return cultmath_hash(dot(value, float2(127.1, 311.7))); }
float cultmath_hash(float3 value) { return cultmath_hash(dot(value, float3(127.1, 311.7, 74.7))); }
// PCG hashes (Jarzynski and Olano, JCGT 2020), written over scalar uints so CultMath's int3/int4 carry the bits.
uint cultmath_pcg(uint value)
{
uint state = value * 747796405u + 2891336453u;
uint word = ((state >> (int)((state >> 28) + 4)) ^ state) * 277803737u;
return (word >> 22) ^ word;
}
int3 cultmath_pcg3d(int3 value)
{
uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u, z = (uint)value.z * 1664525u + 1013904223u;
x += y * z; y += z * x; z += x * y;
x ^= x >> 16; y ^= y >> 16; z ^= z >> 16;
x += y * z; y += z * x; z += x * y;
return int3((int)x, (int)y, (int)z);
}
int3 cultmath_pcg3d(float2 value) { return cultmath_pcg3d(float3(value, 0.0)); }
int3 cultmath_pcg3d(float3 value) { return cultmath_pcg3d(int3((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z))); }
int4 cultmath_pcg4d(int4 value)
{
uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u;
uint z = (uint)value.z * 1664525u + 1013904223u, w = (uint)value.w * 1664525u + 1013904223u;
x += y * w; y += z * x; z += x * y; w += y * z;
x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; w ^= w >> 16;
x += y * w; y += z * x; z += x * y; w += y * z;
return int4((int)x, (int)y, (int)z, (int)w);
}
int4 cultmath_pcg4d(float4 value) { return cultmath_pcg4d(int4((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z), (int)asuint(value.w))); }
// Inigo Quilez, "smooth minimum": quadratic-polynomial smin carried to value-and-gradient form. See
// math.smin_grad's comment (math.cs) for why the gradient blend is exact, not an approximation.
float4 cultmath_smin_grad(float4 a, float4 b, float k)
{
float h = saturate(0.5 + 0.5 * (b.w - a.w) / k);
float value = lerp(b.w, a.w, h) - k * h * (1.0 - h);
float3 gradient = lerp(b.xyz, a.xyz, h);
return float4(gradient, value);
}
// Invariant 8's one struct return shape (design.md, "HLSL Target: Source Transformations"):
// compared field by field and bit for bit against CultMath.CultCellular, same field order.
struct CultCellular
{
float4 nearest;
float4 edge;
float id;
};
// Worley, "A Cellular Texture Basis Function" (SIGGRAPH 1996). See math.cellular's comment
// (math.cs) for the hashing, gradient, search-radius exactness proof, id source and
// degenerate-point conventions this mirrors exactly, including the 5x5x5 lower-bound prune.
CultCellular cultmath_cellular(float3 p)
{
float3 cell = floor(p);
float f1 = 1.0e30;
float f2 = 1.0e30;
float3 c1 = float3(0.0, 0.0, 0.0);
float3 c2 = float3(0.0, 0.0, 0.0);
float3 cellId = float3(0.0, 0.0, 0.0);
for (int dz = -2; dz <= 2; dz++)
{
float lz = max(0.0, abs((float)dz) - 1.0);
for (int dy = -2; dy <= 2; dy++)
{
float ly = max(0.0, abs((float)dy) - 1.0);
for (int dx = -2; dx <= 2; dx++)
{
float lx = max(0.0, abs((float)dx) - 1.0);
float lowerBound = sqrt(lx * lx + ly * ly + lz * lz);
if (lowerBound >= f2)
continue;
float3 neighbor = cell + float3(dx, dy, dz);
int3 hash = cultmath_pcg3d(int3(neighbor));
float3 jitter = float3(
((uint)hash.x >> 8) * (1.0 / 16777216.0),
((uint)hash.y >> 8) * (1.0 / 16777216.0),
((uint)hash.z >> 8) * (1.0 / 16777216.0));
float3 feature = neighbor + jitter;
float d = cultmath_distance(p, feature);
if (d < f1)
{
f2 = f1; c2 = c1;
f1 = d; c1 = feature; cellId = neighbor;
}
else if (d < f2)
{
f2 = d; c2 = feature;
}
}
}
}
// F1 = 0 and F2 = 0 share the same degenerate-gradient guard; see math.cellular's comment
// (math.cs) for why F2 = 0 is reachable past roughly |p| = 2^23.
float3 grad1 = f1 > 0.0 ? (p - c1) / f1 : float3(0.0, 0.0, 0.0);
float3 grad2 = f2 > 0.0 ? (p - c2) / f2 : float3(0.0, 0.0, 0.0);
// id comes from pcg4d over the winning cell's integer coordinate alone (same int-hashing rule
// as the jitter above), never from the pcg3d hash that produced its jitter (F4).
int4 idHash = cultmath_pcg4d(int4(int3(cellId), 0));
CultCellular result;
result.nearest = float4(grad1, f1);
result.edge = float4(grad2 - grad1, f2 - f1);
result.id = ((uint)idHash.w >> 8) * (1.0 / 16777216.0);
return result;
}
#include "CultMath.Phacelle.hlsl"
float cultmath_value_noise(float2 position)
{
float2 cell = floor(position);

View file

@ -1,347 +0,0 @@
using System.Runtime.InteropServices;
namespace CultMath;
/// <summary>Outcome of <see cref="BoundedLeastSquares.Solve"/>.</summary>
public enum BoundedLeastSquaresStatus
{
/// <summary>The KKT conditions hold: every free variable has zero gradient and every bound has a gradient pointing into it.</summary>
Converged,
/// <summary>The iteration cap was reached. <c>x</c> holds the last feasible iterate, which is never worse than the clamped start.</summary>
IterationLimit,
/// <summary>The input broke the contract; nothing was solved and <c>x</c> is exactly as passed.</summary>
InvalidInput,
}
/// <summary>
/// Dense bounded least squares: <c>min ||A x - b||^2</c> subject to <c>lo &lt;= x &lt;= hi</c>, for small problems
/// (tens of rows and columns). C#-only, like the intercept helpers in <see cref="math"/>; there is no HLSL mirror.
/// </summary>
/// <remarks>
/// Primal active set. Each iteration solves the free variables by Cholesky on the normal equations, takes the
/// step to the first bound it would cross, and, once a full step lands, releases the bound with the most
/// violated gradient sign. Free columns that are linearly dependent on earlier free columns (a zero column, a
/// duplicate) get a zero step instead of a failed pivot, so a singular free set never throws.
/// <para>
/// Precision. A, b and <c>x</c> are float at the API; everything else, including the working iterate, the
/// gradient and the KKT test, is double, carved from the caller's workspace. <c>x</c> is converted in at the start
/// and back out at the end, so a returned float <c>x</c> is the nearest float to the double solution and always
/// inside the box.
/// </para>
/// <para>
/// Stopping. KKT termination uses a tolerance of 1e-6 times the problem's gradient scale (the largest gradient
/// component at the box point nearest the origin), so scaling A, b or the bounds, and the choice of warm start, do
/// not change it. The tolerance is only a resolution: once a stationarity miss or a
/// bound release has been followed by a pass that lowers the cost by no more than rounding, the solver reports
/// <see cref="BoundedLeastSquaresStatus.Converged"/>, so an optimal point whose double gradient sits above the
/// tolerance costs one confirming iteration rather than the iteration cap.
/// </para>
/// <para>
/// Near-singular problems. The normal equations square the condition number, so the cost gap grows with cond(A)
/// (measured: below 1e-6 of |b|^2 through cond(A) 3e5, about 2e-5 at 1e6; the gap is relative to |b|^2), independent of the absolute scale of A.
/// The solver owns optimality up to that range. Columns whose pivot falls below 1e-12 of their own diagonal (cond(A)
/// beyond roughly 1e6) are treated as dependent and not moved, so the answer there is one optimum among many, not the
/// minimum-norm one. Callers beyond that range, or that need the minimum-norm answer, add a small ridge row to
/// <c>A</c> and <c>b</c>; that stays the caller's policy.
/// </para>
/// The solver allocates nothing and is deterministic.
/// <para>
/// Input contract: <c>a</c> and <c>b</c> must be finite; <c>lo</c> and <c>hi</c> must not be NaN and need
/// <c>lo &lt;= hi</c>. Infinite bounds are allowed and mean unbounded on that side (<c>lo = -Inf</c>,
/// <c>hi = +Inf</c>); <c>lo = +Inf</c> or <c>hi = -Inf</c> is invalid. Any violation returns
/// <see cref="BoundedLeastSquaresStatus.InvalidInput"/> without throwing, with <c>iterations</c> 0 and
/// <c>x</c> exactly as passed. A NaN or infinite warm start is legal: it is clamped into the box (an infinite
/// result on an unbounded side becomes 0 clamped to the box). Spans shorter than the stated dimensions are a
/// caller bug and throw <see cref="ArgumentException"/>.
/// </para>
/// </remarks>
public static class BoundedLeastSquares
{
/// <summary>Default iteration cap for <see cref="Solve"/>.</summary>
public const int DefaultMaxIterations = 100;
private const double PivotRelativeTolerance = 1e-12;
private const double KktRelativeTolerance = 1e-6;
private const double StallRelativeTolerance = 1e-15;
/// <summary>Floats of caller-supplied workspace needed for <paramref name="n"/> columns.</summary>
public static int WorkspaceLength(int n) => 2 * (2 * n * n + 6 * n) + 3 * n;
/// <summary>
/// Minimises <c>||A x - b||^2</c> subject to <c>lo &lt;= x &lt;= hi</c>. <paramref name="x"/> is the warm start
/// on entry (clamped to the box first) and the result on exit.
/// </summary>
/// <param name="m">Rows of <paramref name="a"/>.</param>
/// <param name="n">Columns of <paramref name="a"/>, and length of <paramref name="lo"/>, <paramref name="hi"/> and <paramref name="x"/>.</param>
/// <param name="a">Row-major <c>m * n</c> matrix.</param>
/// <param name="b">Length <c>m</c> target.</param>
/// <param name="lo">Lower bounds; each must be <c>&lt;= hi</c>; <c>-Inf</c> means unbounded below.</param>
/// <param name="hi">Upper bounds; <c>+Inf</c> means unbounded above.</param>
/// <param name="x">Warm start in, solution out.</param>
/// <param name="workspace">At least <see cref="WorkspaceLength"/> floats; contents on entry are ignored.</param>
/// <param name="iterations">Active-set iterations used.</param>
/// <param name="maxIterations">Iteration cap.</param>
public static BoundedLeastSquaresStatus Solve(
int m, int n,
ReadOnlySpan<float> a, ReadOnlySpan<float> b,
ReadOnlySpan<float> lo, ReadOnlySpan<float> hi,
Span<float> x, Span<float> workspace,
out int iterations,
int maxIterations = DefaultMaxIterations)
{
if (m < 0 || n < 0 || a.Length < m * n || b.Length < m || lo.Length < n || hi.Length < n
|| x.Length < n || workspace.Length < WorkspaceLength(n))
throw new ArgumentException("BoundedLeastSquares: span shorter than the stated dimensions.");
// Double-precision vectors and matrices first, then the integer flags, all carved from the caller's floats.
var doubleCount = 2 * n * n + 6 * n;
var dbl = MemoryMarshal.Cast<float, double>(workspace.Slice(0, 2 * doubleCount));
var ata = Take(ref dbl, n * n);
var chol = Take(ref dbl, n * n);
var atb = Take(ref dbl, n);
var g = Take(ref dbl, n);
var p = Take(ref dbl, n);
var y = Take(ref dbl, n);
var rhs = Take(ref dbl, n);
var xd = Take(ref dbl, n);
// One flag per column: 0 free, -1 at lo, +1 at hi. Free-list and dependency flags follow.
var ints = MemoryMarshal.Cast<float, int>(workspace.Slice(2 * doubleCount, 3 * n));
var state = Take(ref ints, n);
var freeIndex = Take(ref ints, n);
var dependent = Take(ref ints, n);
iterations = 0;
for (var j = 0; j < n; j++)
if (!(lo[j] <= hi[j]) || float.IsPositiveInfinity(lo[j]) || float.IsNegativeInfinity(hi[j]))
return BoundedLeastSquaresStatus.InvalidInput;
// A float is never large enough to overflow a double product or a sum of tens of them, so a non-finite
// A^T b entry is exactly a non-finite entry of A or b (NaN and infinity never cancel to a finite value).
for (var j = 0; j < n; j++)
{
var sb = 0.0;
for (var r = 0; r < m; r++)
sb += (double)a[r * n + j] * b[r];
if (!double.IsFinite(sb))
return BoundedLeastSquaresStatus.InvalidInput;
atb[j] = sb;
for (var k = 0; k <= j; k++)
{
var sa = 0.0;
for (var r = 0; r < m; r++)
sa += (double)a[r * n + j] * a[r * n + k];
ata[j * n + k] = sa;
ata[k * n + j] = sa;
}
}
// From here x is written. A warm start that is NaN is moved to the lower bound, and an infinite result
// (an unbounded side) restarts from 0 clamped to the box. The working iterate lives in double; x is only
// the API's float view of it.
for (var j = 0; j < n; j++)
{
var l = lo[j];
var h = hi[j];
var v = float.IsNaN(x[j]) ? l : Math.Clamp(x[j], l, h);
if (float.IsInfinity(v))
v = Math.Clamp(0f, l, h);
x[j] = v;
xd[j] = v;
state[j] = v == l ? -1 : (v == h ? 1 : 0);
}
// The tolerance's scale is the gradient magnitude at the box point nearest the origin (the origin itself
// when it is inside the box, where the gradient is -A^T b). It depends on A, b and the bounds, never on the
// warm start, so a far start cannot be judged converged by its own steepness, and b = 0 with a box that
// excludes the origin still gets a real scale. It is zero only when that point is already stationary, and
// then the progress rule below does the stopping.
var gScale = 0.0;
for (var j = 0; j < n; j++)
{
var s = -atb[j];
for (var k = 0; k < n; k++)
s += ata[j * n + k] * Math.Clamp(0.0, lo[k], hi[k]);
gScale = Math.Max(gScale, Math.Abs(s));
}
var kktTol = KktRelativeTolerance * gScale;
// Cost at the last point from which the solver chose to continue past a stationarity miss or a
// release. A pass that ends without lowering it has nothing left to gain: the tolerance is a
// resolution, not the stopping rule, and a noise floor above it must not spin to the cap.
var fMark = double.PositiveInfinity;
var status = BoundedLeastSquaresStatus.IterationLimit;
while (true)
{
Gradient(ata, atb, xd, g, n);
var freeCount = 0;
for (var j = 0; j < n; j++)
if (state[j] == 0)
freeIndex[freeCount++] = j;
SolveFreeSet(ata, chol, g, p, y, rhs, freeIndex.Slice(0, freeCount), dependent, n);
var t = 1.0;
var block = -1;
for (var i = 0; i < freeCount; i++)
{
var j = freeIndex[i];
var pj = p[j];
if (pj == 0.0) continue;
var tj = pj > 0.0 ? (hi[j] - xd[j]) / pj : (lo[j] - xd[j]) / pj;
if (tj < t)
{
t = tj;
block = j;
}
}
if (block >= 0)
{
if (iterations >= maxIterations) { status = BoundedLeastSquaresStatus.IterationLimit; break; }
iterations++;
for (var i = 0; i < freeCount; i++)
{
var j = freeIndex[i];
xd[j] = Math.Clamp(xd[j] + t * p[j], lo[j], hi[j]);
}
if (p[block] > 0.0) { xd[block] = hi[block]; state[block] = 1; }
else { xd[block] = lo[block]; state[block] = -1; }
continue;
}
for (var i = 0; i < freeCount; i++)
{
var j = freeIndex[i];
xd[j] = Math.Clamp(xd[j] + p[j], lo[j], hi[j]);
}
Gradient(ata, atb, xd, g, n);
// Stationarity of the free set is part of KKT: a Newton step on a near-singular free set can leave a residual gradient.
var freeViolated = false;
for (var i = 0; i < freeCount; i++)
if (Math.Abs(g[freeIndex[i]]) > kktTol)
freeViolated = true;
var release = -1;
var worst = kktTol;
for (var j = 0; j < n; j++)
{
if (lo[j] == hi[j]) continue;
// A bound at lo wants a positive gradient and one at hi a negative one; a free column has state 0.
var violation = state[j] * g[j];
if (violation > worst)
{
worst = violation;
release = j;
}
}
if (release < 0 && !freeViolated) { status = BoundedLeastSquaresStatus.Converged; break; }
var f = Objective(xd, g, atb, n, out var fScale);
if (f >= fMark - StallRelativeTolerance * fScale) { status = BoundedLeastSquaresStatus.Converged; break; }
fMark = f;
if (iterations >= maxIterations) { status = BoundedLeastSquaresStatus.IterationLimit; break; }
iterations++;
// A stationarity miss re-solves the same free set from the improved point.
if (release >= 0) state[release] = 0;
}
for (var j = 0; j < n; j++)
x[j] = (float)xd[j];
return status;
}
private static Span<T> Take<T>(ref Span<T> pool, int length)
{
var head = pool.Slice(0, length);
pool = pool.Slice(length);
return head;
}
// 0.5 x^T A^T A x - x^T A^T b (the cost up to a constant), from the gradient at x; scale is the magnitude of
// the terms summed, which is what the rounding error of the sum is proportional to.
private static double Objective(ReadOnlySpan<double> x, ReadOnlySpan<double> g, ReadOnlySpan<double> atb, int n, out double scale)
{
var f = 0.0;
scale = 0.0;
for (var j = 0; j < n; j++)
{
f += 0.5 * x[j] * (g[j] - atb[j]);
scale += 0.5 * Math.Abs(x[j]) * (Math.Abs(g[j]) + Math.Abs(atb[j]));
}
return f;
}
private static void Gradient(ReadOnlySpan<double> ata, ReadOnlySpan<double> atb, ReadOnlySpan<double> x, Span<double> g, int n)
{
for (var j = 0; j < n; j++)
{
var s = -atb[j];
for (var k = 0; k < n; k++)
s += ata[j * n + k] * x[k];
g[j] = s;
}
}
// Newton step over the free set: solves (A^T A)_FF p_F = -g_F by Cholesky. A free column whose pivot collapses
// (dependent on earlier free columns) is dropped from the factor and gets p = 0. No refinement step: Cholesky is
// backward stable, so the residual, which is the gradient the KKT test reads, is already at rounding level, and
// refining in the same precision does not improve the forward error.
private static void SolveFreeSet(
ReadOnlySpan<double> ata, Span<double> l, ReadOnlySpan<double> g, Span<double> p, Span<double> y,
Span<double> rhs, ReadOnlySpan<int> free, Span<int> dependent, int n)
{
var k = free.Length;
for (var i = 0; i < k; i++)
{
var fi = free[i];
var d = ata[fi * n + fi];
for (var c = 0; c < i; c++)
d -= l[i * n + c] * l[i * n + c];
if (d <= PivotRelativeTolerance * ata[fi * n + fi])
{
dependent[i] = 1;
for (var c = 0; c < i; c++) l[i * n + c] = 0.0;
l[i * n + i] = 1.0;
for (var r = i + 1; r < k; r++) l[r * n + i] = 0.0;
continue;
}
dependent[i] = 0;
var lii = Math.Sqrt(d);
l[i * n + i] = lii;
for (var r = i + 1; r < k; r++)
{
var s = ata[free[r] * n + fi];
for (var c = 0; c < i; c++)
s -= l[r * n + c] * l[i * n + c];
l[r * n + i] = s / lii;
}
}
for (var i = 0; i < k; i++)
rhs[i] = dependent[i] != 0 ? 0.0 : -g[free[i]];
Substitute(l, rhs, y, p, free, n);
}
// Solves L L^T out = rhs; rhs is indexed by free-list position, out by column.
private static void Substitute(ReadOnlySpan<double> l, ReadOnlySpan<double> rhs, Span<double> y, Span<double> outByColumn, ReadOnlySpan<int> free, int n)
{
var k = free.Length;
for (var i = 0; i < k; i++)
{
var s = rhs[i];
for (var c = 0; c < i; c++)
s -= l[i * n + c] * y[c];
y[i] = s / l[i * n + i];
}
for (var i = k - 1; i >= 0; i--)
{
var s = y[i];
for (var r = i + 1; r < k; r++)
s -= l[r * n + i] * outByColumn[free[r]];
outByColumn[free[i]] = s / l[i * n + i];
}
}
}

View file

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 4ba991e1b6c54e099bc7ea16541734c5

View file

@ -1,26 +0,0 @@
namespace CultMath;
/// <summary>
/// The value-and-gradient result of <see cref="math.cellular(float3)"/> (invariant 8's one struct
/// return shape). Every field follows the same <c>float4(gradient.xyz, value.w)</c> layout as a
/// bare value-and-gradient return.
/// </summary>
public struct CultCellular
{
/// <summary>(&#8711;F1, F1): the gradient and distance to the nearest feature point.</summary>
public float4 nearest;
/// <summary>(&#8711;F2 - &#8711;F1, F2 - F1): for lineae along cell borders.</summary>
public float4 edge;
/// <summary>The nearest cell's pcg4d hash, mapped to [0, 1); independent of the pcg3d hash that
/// produced that cell's jitter.</summary>
public float id;
public CultCellular(float4 nearest, float4 edge, float id)
{
this.nearest = nearest;
this.edge = edge;
this.id = id;
}
}

View file

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 76be9498b63e410891715b3ec965caf8

View file

@ -8,9 +8,7 @@
<RootNamespace>CultMath</RootNamespace>
<AssemblyName>CultMath</AssemblyName>
<PackageId>GameCult.Math</PackageId>
<!-- MIT overall; math.Phacelle.cs and CultPhasor.cs (and the two Phacelle HLSL files) are MPL-2.0. -->
<PackageLicenseExpression>MIT AND MPL-2.0</PackageLicenseExpression>
<Version>0.2.4</Version>
<Version>0.2.0</Version>
<Description>Clean-room C# math shaped for HLSL-style renderer code.</Description>
<Authors>GameCult</Authors>
<PackageReadmeFile>README.md</PackageReadmeFile>
@ -23,8 +21,6 @@
<ItemGroup>
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
<None Include="..\..\shaders\CultMath.hlsl" Pack="true" PackagePath="contentFiles\any\any\shaders\CultMath.hlsl" CopyToOutputDirectory="PreserveNewest" />
<None Include="..\..\shaders\CultMath.Phacelle.hlsl" Pack="true" PackagePath="contentFiles\any\any\shaders\CultMath.Phacelle.hlsl" CopyToOutputDirectory="PreserveNewest" />
<None Include="..\..\THIRD-PARTY-NOTICES.md" Pack="true" PackagePath="\" />
</ItemGroup>
</Project>

View file

@ -1,27 +0,0 @@
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
// the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
//
// The result type of math.phacelle (math.Phacelle.cs), Rune Skovbo Johansen's Phacelle noise
// (Copyright (c) 2025, MPL-2.0; see THIRD-PARTY-NOTICES.md).
namespace CultMath;
/// <summary>
/// The value-and-gradient result of <see cref="math.phacelle(float3, float3, float, float)"/>
/// (invariant 8's struct return shape). Both fields use the <c>float4(gradient.xyz, value.w)</c>
/// layout of a bare value-and-gradient return.
/// </summary>
public struct CultPhasor
{
/// <summary>(&#8711;cos, cos): the normalized cosine wave and its gradient.</summary>
public float4 cos;
/// <summary>(&#8711;sin, sin): the normalized sine wave and its gradient.</summary>
public float4 sin;
public CultPhasor(float4 cos, float4 sin)
{
this.cos = cos;
this.sin = sin;
}
}

View file

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 807ae2c8a758cf31896da12f1e5047bd

View file

@ -7,8 +7,6 @@ namespace CultMath;
public partial struct float2
{
public float2(float2 xy) : this(xy.x, xy.y) { }
public float2(int2 value) : this(value.x, value.y) { }
public float2(bool2 value) : this(value.x ? 1.0f : 0.0f, value.y ? 1.0f : 0.0f) { }
public float r { readonly get => x; set => x = value; }
public float g { readonly get => y; set => y = value; }
public readonly float2 xx => new(x, x);
@ -74,8 +72,6 @@ public partial struct float3
public float3(float x, float2 yz) : this(x, yz.x, yz.y) { }
public float3(float2 xy, float z) : this(xy.x, xy.y, z) { }
public float3(float3 xyz) : this(xyz.x, xyz.y, xyz.z) { }
public float3(int3 value) : this(value.x, value.y, value.z) { }
public float3(bool3 value) : this(value.x ? 1.0f : 0.0f, value.y ? 1.0f : 0.0f, value.z ? 1.0f : 0.0f) { }
public float r { readonly get => x; set => x = value; }
public float g { readonly get => y; set => y = value; }
public float b { readonly get => z; set => z = value; }
@ -324,8 +320,6 @@ public partial struct float4
public float4(float2 xy, float2 zw) : this(xy.x, xy.y, zw.x, zw.y) { }
public float4(float3 xyz, float w) : this(xyz.x, xyz.y, xyz.z, w) { }
public float4(float4 xyzw) : this(xyzw.x, xyzw.y, xyzw.z, xyzw.w) { }
public float4(int4 value) : this(value.x, value.y, value.z, value.w) { }
public float4(bool4 value) : this(value.x ? 1.0f : 0.0f, value.y ? 1.0f : 0.0f, value.z ? 1.0f : 0.0f, value.w ? 1.0f : 0.0f) { }
public float r { readonly get => x; set => x = value; }
public float g { readonly get => y; set => y = value; }
public float b { readonly get => z; set => z = value; }
@ -1007,8 +1001,6 @@ public partial struct float4
public partial struct int2
{
public int2(int2 xy) : this(xy.x, xy.y) { }
public int2(float2 value) : this((int)value.x, (int)value.y) { }
public int2(bool2 value) : this(value.x ? 1 : 0, value.y ? 1 : 0) { }
public int r { readonly get => x; set => x = value; }
public int g { readonly get => y; set => y = value; }
public readonly int2 xx => new(x, x);
@ -1074,8 +1066,6 @@ public partial struct int3
public int3(int x, int2 yz) : this(x, yz.x, yz.y) { }
public int3(int2 xy, int z) : this(xy.x, xy.y, z) { }
public int3(int3 xyz) : this(xyz.x, xyz.y, xyz.z) { }
public int3(float3 value) : this((int)value.x, (int)value.y, (int)value.z) { }
public int3(bool3 value) : this(value.x ? 1 : 0, value.y ? 1 : 0, value.z ? 1 : 0) { }
public int r { readonly get => x; set => x = value; }
public int g { readonly get => y; set => y = value; }
public int b { readonly get => z; set => z = value; }
@ -1324,8 +1314,6 @@ public partial struct int4
public int4(int2 xy, int2 zw) : this(xy.x, xy.y, zw.x, zw.y) { }
public int4(int3 xyz, int w) : this(xyz.x, xyz.y, xyz.z, w) { }
public int4(int4 xyzw) : this(xyzw.x, xyzw.y, xyzw.z, xyzw.w) { }
public int4(float4 value) : this((int)value.x, (int)value.y, (int)value.z, (int)value.w) { }
public int4(bool4 value) : this(value.x ? 1 : 0, value.y ? 1 : 0, value.z ? 1 : 0, value.w ? 1 : 0) { }
public int r { readonly get => x; set => x = value; }
public int g { readonly get => y; set => y = value; }
public int b { readonly get => z; set => z = value; }
@ -2007,8 +1995,6 @@ public partial struct int4
public partial struct bool2
{
public bool2(bool2 xy) : this(xy.x, xy.y) { }
public bool2(float2 value) : this(value.x != 0.0f, value.y != 0.0f) { }
public bool2(int2 value) : this(value.x != 0, value.y != 0) { }
public bool r { readonly get => x; set => x = value; }
public bool g { readonly get => y; set => y = value; }
public readonly bool2 xx => new(x, x);
@ -2074,8 +2060,6 @@ public partial struct bool3
public bool3(bool x, bool2 yz) : this(x, yz.x, yz.y) { }
public bool3(bool2 xy, bool z) : this(xy.x, xy.y, z) { }
public bool3(bool3 xyz) : this(xyz.x, xyz.y, xyz.z) { }
public bool3(float3 value) : this(value.x != 0.0f, value.y != 0.0f, value.z != 0.0f) { }
public bool3(int3 value) : this(value.x != 0, value.y != 0, value.z != 0) { }
public bool r { readonly get => x; set => x = value; }
public bool g { readonly get => y; set => y = value; }
public bool b { readonly get => z; set => z = value; }
@ -2324,8 +2308,6 @@ public partial struct bool4
public bool4(bool2 xy, bool2 zw) : this(xy.x, xy.y, zw.x, zw.y) { }
public bool4(bool3 xyz, bool w) : this(xyz.x, xyz.y, xyz.z, w) { }
public bool4(bool4 xyzw) : this(xyzw.x, xyzw.y, xyzw.z, xyzw.w) { }
public bool4(float4 value) : this(value.x != 0.0f, value.y != 0.0f, value.z != 0.0f, value.w != 0.0f) { }
public bool4(int4 value) : this(value.x != 0, value.y != 0, value.z != 0, value.w != 0) { }
public bool r { readonly get => x; set => x = value; }
public bool g { readonly get => y; set => y = value; }
public bool b { readonly get => z; set => z = value; }
@ -3120,15 +3102,9 @@ public partial struct double3
public static partial class math
{
public static float2 float2(float2 xy) => new(xy);
public static float2 float2(int2 value) => new(value);
public static float2 float2(bool2 value) => new(value);
public static float2 float2(int value) => new(value, value);
public static float3 float3(float x, float2 yz) => new(x, yz);
public static float3 float3(float2 xy, float z) => new(xy, z);
public static float3 float3(float3 xyz) => new(xyz);
public static float3 float3(int3 value) => new(value);
public static float3 float3(bool3 value) => new(value);
public static float3 float3(int value) => new(value, value, value);
public static float4 float4(float x, float y, float2 zw) => new(x, y, zw);
public static float4 float4(float x, float2 yz, float w) => new(x, yz, w);
public static float4 float4(float x, float3 yzw) => new(x, yzw);
@ -3136,17 +3112,10 @@ public static partial class math
public static float4 float4(float2 xy, float2 zw) => new(xy, zw);
public static float4 float4(float3 xyz, float w) => new(xyz, w);
public static float4 float4(float4 xyzw) => new(xyzw);
public static float4 float4(int4 value) => new(value);
public static float4 float4(bool4 value) => new(value);
public static float4 float4(int value) => new(value, value, value, value);
public static int2 int2(int2 xy) => new(xy);
public static int2 int2(float2 value) => new(value);
public static int2 int2(bool2 value) => new(value);
public static int3 int3(int x, int2 yz) => new(x, yz);
public static int3 int3(int2 xy, int z) => new(xy, z);
public static int3 int3(int3 xyz) => new(xyz);
public static int3 int3(float3 value) => new(value);
public static int3 int3(bool3 value) => new(value);
public static int4 int4(int x, int y, int2 zw) => new(x, y, zw);
public static int4 int4(int x, int2 yz, int w) => new(x, yz, w);
public static int4 int4(int x, int3 yzw) => new(x, yzw);
@ -3154,16 +3123,10 @@ public static partial class math
public static int4 int4(int2 xy, int2 zw) => new(xy, zw);
public static int4 int4(int3 xyz, int w) => new(xyz, w);
public static int4 int4(int4 xyzw) => new(xyzw);
public static int4 int4(float4 value) => new(value);
public static int4 int4(bool4 value) => new(value);
public static bool2 bool2(bool2 xy) => new(xy);
public static bool2 bool2(float2 value) => new(value);
public static bool2 bool2(int2 value) => new(value);
public static bool3 bool3(bool x, bool2 yz) => new(x, yz);
public static bool3 bool3(bool2 xy, bool z) => new(xy, z);
public static bool3 bool3(bool3 xyz) => new(xyz);
public static bool3 bool3(float3 value) => new(value);
public static bool3 bool3(int3 value) => new(value);
public static bool4 bool4(bool x, bool y, bool2 zw) => new(x, y, zw);
public static bool4 bool4(bool x, bool2 yz, bool w) => new(x, yz, w);
public static bool4 bool4(bool x, bool3 yzw) => new(x, yzw);
@ -3171,6 +3134,4 @@ public static partial class math
public static bool4 bool4(bool2 xy, bool2 zw) => new(xy, zw);
public static bool4 bool4(bool3 xyz, bool w) => new(xyz, w);
public static bool4 bool4(bool4 xyzw) => new(xyzw);
public static bool4 bool4(float4 value) => new(value);
public static bool4 bool4(int4 value) => new(value);
}

View file

@ -1,127 +0,0 @@
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
// the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
//
// Phacelle noise. Copyright (c) 2025 Rune Skovbo Johansen (Shadertoy t3dyWl; MPL-2.0), read from the
// C# port PhacelleNoise in lpmitchell/AdvancedTerrainErosion, converted to burstable C# by Luke
// Mitchell (2026). Modified by GameCult: 3D cells, a caller-supplied stripe wave vector, an exact
// gradient and exact pruning. This file and CultPhasor.cs are the MPL-2.0 files of CultMath; the
// rest of the package is MIT (see THIRD-PARTY-NOTICES.md).
namespace CultMath;
public static partial class math
{
// Johansen's Phacelle noise (phase + cell), generalised to 3D cells with a caller-supplied
// stripe wave vector. Each of the 4x4x4 cells around p carries one jittered feature point
// (jitter in [-0.5, 0.5) per axis, from pcg3d over the integer cell coordinate, the same
// integer-hashing rule as cellular) and one cosine/sine wave that runs along `side` (the wave
// vector: its length is 2*pi times the stripe frequency, and the caller chooses which
// perpendicular it is; on a unit sphere that is cross(p_hat, flow)). The waves are blended with
// the bell weight max(0, exp(-2 d^2) - 0.01111) (exactly 0 at distance 1.5, the nearest a
// feature outside the 4x4x4 block can be in 2D, which is what keeps cell-grid lines out), and
// the blended phasor is normalized: its length is stretched to 1 wherever the raw length is at
// least 1 - normalization, and scaled by 1 / (1 - normalization) below that. `offset` is the
// phase in cycles (1 is a full turn). Value and normalization follow Johansen's
// PhacelleNoise as ported in lpmitchell/AdvancedTerrainErosion.
//
// `normalization` is meant to lie in [0, 1]. At 0 the output is the raw blend (a phasor of
// length at most 1); at 1 the floor is 0 and every output is stretched to length 1. Values of
// 1 or more saturate at that (the floor is never above the raw length), and a negative value
// scales every output down by 1 / (1 - normalization) < 1. The normalized gradient scales as
// 1 / |I| where I is the blended phasor (below), so it grows wherever the blend nearly
// cancels; a normalization near 1 lets that happen at every point.
//
// The gradient is exact: it carries the weight derivative (grad w = -4 v exp(-2 |v|^2) where
// w > 0) and the normalization, not upstream's convention grad(cos) ~ -sin * side, which
// ignores the weight gradient (Asura probe: 16.8 deg off at the median, 97.8 at the 95th
// percentile; the exact form is 0.00 and 0.03). With P = sum(w * (cos, sin)) and W = sum(w),
// I = P / W has grad I = (grad P - I * grad W) / W; above the threshold the output is I / |I|
// with gradient (grad I - out * grad|I|) / |I|, below it grad I / (1 - normalization). The
// value is continuous at both seams below; the gradient is not.
// * Where |I| crosses the threshold, the gradient jumps between the two branches.
// * Where a cell's weight leaves its support (d^2 = ln(1 / 0.01111) / 2 = 2.24995, distance
// 1.49998), grad w jumps from -4 * 0.01111 * v to 0: a per-cell jump of length
// 0.0444 * |v|, 0.0667 at |v| = 1.5. Measured over random points, the resulting jump in
// the output gradient is a median 1.5% of its length and at most about 27%.
//
// W is the sum of 64 weights and is positive for every input pcg3d produces (the smallest
// W found by hill-climbing the hash-driven cell contents is 0.056). It is 0, and the output
// NaN, only if all 64 weights vanish, which takes adversarial jitter no hash yields.
//
// Pruning is exact: per axis, |local - g| - 0.5 (floored at 0) lower-bounds |v| for any jitter,
// so a cell whose summed square is at least 2.25 lies outside the support (2.24995 < 2.25) and
// has weight exactly 0: it contributes nothing to any sum. Cells whose bound sits just under
// 2.25 can still have a nonzero weight, so the cut cannot be lowered. On average the prune
// keeps about 45 of the 64 cells (a saving of about 30%); only about 14 have a nonzero weight.
// Only the internal overload can switch the prune off; PhacelleTests uses it to pin bit equality.
public static CultPhasor phacelle(float3 p, float3 side, float offset, float normalization) =>
phacelle(p, side, offset, normalization, true);
internal static CultPhasor phacelle(float3 p, float3 side, float offset, float normalization, bool prune)
{
var cell = floor(p);
var local = p - cell;
var phaseOffset = offset * TAU;
var sumW = 0.0f;
var sumCos = 0.0f;
var sumSin = 0.0f;
var gradW = new float3(0.0f, 0.0f, 0.0f);
var gradCos = new float3(0.0f, 0.0f, 0.0f);
var gradSin = new float3(0.0f, 0.0f, 0.0f);
for (var dz = -1; dz <= 2; dz++)
{
var gz = max(0.0f, abs(local.z - (float)dz) - 0.5f);
for (var dy = -1; dy <= 2; dy++)
{
var gy = max(0.0f, abs(local.y - (float)dy) - 0.5f);
for (var dx = -1; dx <= 2; dx++)
{
var gx = max(0.0f, abs(local.x - (float)dx) - 0.5f);
if (prune && gx * gx + gy * gy + gz * gz >= 2.25f)
continue;
var gridStep = new float3(dx, dy, dz);
var hash = pcg3d(int3(cell + gridStep));
var jitter = new float3(cellular_unit(hash.x) - 0.5f, cellular_unit(hash.y) - 0.5f, cellular_unit(hash.z) - 0.5f);
var v = local - gridStep - jitter;
var falloff = exp(-2.0f * dot(v, v));
var w = max(0.0f, falloff - 0.01111f);
var dw = w > 0.0f ? (-4.0f * falloff) * v : new float3(0.0f, 0.0f, 0.0f);
var phase = dot(v, side) + phaseOffset;
var c = cos(phase);
var s = sin(phase);
sumW += w;
sumCos += w * c;
sumSin += w * s;
gradW += dw;
gradCos += dw * c - (w * s) * side;
gradSin += dw * s + (w * c) * side;
}
}
}
var invW = 1.0f / sumW;
var rawCos = sumCos * invW;
var rawSin = sumSin * invW;
var gradRawCos = (gradCos - rawCos * gradW) * invW;
var gradRawSin = (gradSin - rawSin * gradW) * invW;
var rawLength = sqrt(rawCos * rawCos + rawSin * rawSin);
var floorLength = 1.0f - normalization;
if (rawLength > floorLength)
{
var outCos = rawCos / rawLength;
var outSin = rawSin / rawLength;
var gradLength = (rawCos * gradRawCos + rawSin * gradRawSin) / rawLength;
return new CultPhasor(
new float4((gradRawCos - outCos * gradLength) / rawLength, outCos),
new float4((gradRawSin - outSin * gradLength) / rawLength, outSin));
}
return new CultPhasor(
new float4(gradRawCos / floorLength, rawCos / floorLength),
new float4(gradRawSin / floorLength, rawSin / floorLength));
}
}

View file

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

View file

@ -1,10 +1,5 @@
using System;
// Cut 2a-i fix batch 1: cellular_unit's [0,1) mapping bug (F7) is only reachable from cellular's
// private call sites; this lets CultMath.Tests probe it directly instead of hunting for a hash
// input that happens to land on the bit pattern under test.
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("CultMath.Tests")]
namespace CultMath;
public static partial class math
@ -110,9 +105,6 @@ public static partial class math
public static float2 frac(float2 value) => value - floor(value);
public static float3 frac(float3 value) => value - floor(value);
public static float4 frac(float4 value) => value - floor(value);
// dxc has no double Frc: it demotes to float, as it does for exp and lerp. CultMath's double
// overloads keep double precision instead, so frac(double) is x - floor(x) in double.
public static double frac(double value) => value - Math.Floor(value);
// dxc lowers float min/max/clamp to DXIL FMin/FMax (saturate to Saturate, defined as FMin(1, FMax(0, x))).
// DXIL.rst: FMin(a, b) is a < b ? a : b, FMax(a, b) is a >= b ? a : b, and a NaN operand returns the other.
@ -138,11 +130,6 @@ public static partial class math
public static float2 clamp(float2 value, float2 minimum, float2 maximum) => min(max(value, minimum), maximum);
public static float3 clamp(float3 value, float3 minimum, float3 maximum) => min(max(value, minimum), maximum);
public static float4 clamp(float4 value, float4 minimum, float4 maximum) => min(max(value, minimum), maximum);
// dxc lowers int clamp to IMin(IMax(x, a), b): inverted bounds return b rather than throwing like Math.Clamp.
public static int clamp(int value, int minimum, int maximum) => min(max(value, minimum), maximum);
public static int2 clamp(int2 value, int2 minimum, int2 maximum) => min(max(value, minimum), maximum);
public static int3 clamp(int3 value, int3 minimum, int3 maximum) => min(max(value, minimum), maximum);
public static int4 clamp(int4 value, int4 minimum, int4 maximum) => min(max(value, minimum), maximum);
// DXIL Saturate is FMin(1, FMax(0, x)); that operand order, unlike clamp(x, 0, 1), maps -0 to +0.
public static float saturate(float value) => min(1.0f, max(0.0f, value));
@ -218,10 +205,9 @@ public static partial class math
public static float distance(float3 left, float3 right) => length(left - right);
public static float distance(float4 left, float4 right) => length(left - right);
// dxc lowers normalize(x) to x * Rsqrt(Dot(x, x)), and DXIL Rsqrt is 1 / sqrt(src): a zero vector is 0 * inf = NaN.
public static float2 normalize(float2 value) => value * (1.0f / MathF.Sqrt(dot(value, value)));
public static float3 normalize(float3 value) => value * (1.0f / MathF.Sqrt(dot(value, value)));
public static float4 normalize(float4 value) => value * (1.0f / MathF.Sqrt(dot(value, value)));
public static float2 normalize(float2 value) => value / max(length(value), 1.0e-20f);
public static float3 normalize(float3 value) => value / max(length(value), 1.0e-20f);
public static float4 normalize(float4 value) => value / max(length(value), 1.0e-20f);
public static quaternion normalize(quaternion value)
{
var length = MathF.Sqrt(value.x * value.x + value.y * value.y + value.z * value.z + value.w * value.w);
@ -387,43 +373,6 @@ public static partial class math
public static float hash(float2 value) => hash(dot(value, new float2(127.1f, 311.7f)));
public static float hash(float3 value) => hash(dot(value, new float3(127.1f, 311.7f, 74.7f)));
public static uint asuint(float value) => (uint)BitConverter.SingleToInt32Bits(value);
// PCG hashes from Jarzynski and Olano, "Hash Functions for GPU Rendering" (JCGT 9(3), 2020). Integer-only, so
// dxc and C# agree bit for bit. pcg3d/pcg4d carry uint bit patterns in int3/int4; float overloads hash IEEE bits.
public static uint pcg(uint value)
{
var state = value * 747796405u + 2891336453u;
var word = ((state >> (int)((state >> 28) + 4)) ^ state) * 277803737u;
return (word >> 22) ^ word;
}
public static int3 pcg3d(int3 value)
{
uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u, z = (uint)value.z * 1664525u + 1013904223u;
x += y * z; y += z * x; z += x * y;
x ^= x >> 16; y ^= y >> 16; z ^= z >> 16;
x += y * z; y += z * x; z += x * y;
return new int3((int)x, (int)y, (int)z);
}
// The paper hashes 2 -> N inputs through pcg3d with the unused component held constant.
public static int3 pcg3d(float2 value) => pcg3d(new float3(value, 0.0f));
public static int3 pcg3d(float3 value) => pcg3d(new int3((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z)));
public static int4 pcg4d(int4 value)
{
uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u;
uint z = (uint)value.z * 1664525u + 1013904223u, w = (uint)value.w * 1664525u + 1013904223u;
x += y * w; y += z * x; z += x * y; w += y * z;
x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; w ^= w >> 16;
x += y * w; y += z * x; z += x * y; w += y * z;
return new int4((int)x, (int)y, (int)z, (int)w);
}
public static int4 pcg4d(float4 value) =>
pcg4d(new int4((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z), (int)asuint(value.w)));
public static float log(float value) => MathF.Log(value);
public static float2 log(float2 value) => new(log(value.x), log(value.y));
public static float3 log(float3 value) => new(log(value.x), log(value.y), log(value.z));
@ -526,79 +475,8 @@ public static partial class math
a._m20 * b._m01 + a._m21 * b._m11 + a._m22 * b._m21,
a._m20 * b._m02 + a._m21 * b._m12 + a._m22 * b._m22);
// Abramowitz & Stegun 7.1.26, a single-precision rational/exponential fit with a stated
// max absolute error of 1.5e-7. erf is odd, so only x >= 0 is fit and the sign is restored.
// Measured against an independent reference: worst absolute error 6.621e-7, inside the
// 2e-6 bar this package's tests pin.
public static float erf(float value)
{
const float p = 0.3275911f;
const float a1 = 0.254829592f;
const float a2 = -0.284496736f;
const float a3 = 1.421413741f;
const float a4 = -1.453152027f;
const float a5 = 1.061405429f;
var s = sign(value);
var x = abs(value);
var t = 1.0f / (1.0f + p * x);
var poly = ((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t;
var result = 1.0f - poly * exp(-x * x);
return s * result;
}
// Giles, "Approximating the erfinv function" (GPU Computing Gems, 2010): a single-precision
// minimax polynomial in two ranges of w = -ln((1-x)(1+x)), central and tail.
// Domain is [-1, 1]. Outside it, including +-infinity, the result is NaN (there is no real
// inverse). At the edges the mathematical limit is infinite: erfinv(1) = +infinity and
// erfinv(-1) = -infinity. The unguarded polynomial hits log(0) = -infinity at those edges and
// the tail branch's leading coefficient is negative, so it delivers the wrong sign; guard the
// edges explicitly instead of trusting the fit through the singularity.
// Measured against an independent reference: worst absolute error 5.066e-7, and the
// erf(erfinv(y)) round trip 6.109e-7 worst case, both inside the 2e-6 bar this package's
// tests pin.
public static float erfinv(float value)
{
if (value >= 1.0f) return value > 1.0f ? float.NaN : float.PositiveInfinity;
if (value <= -1.0f) return value < -1.0f ? float.NaN : float.NegativeInfinity;
var w = -log((1.0f - value) * (1.0f + value));
float p;
if (w < 5.0f)
{
w -= 2.5f;
p = 2.81022636e-08f;
p = 3.43273939e-07f + p * w;
p = -3.5233877e-06f + p * w;
p = -4.39150654e-06f + p * w;
p = 0.00021858087f + p * w;
p = -0.00125372503f + p * w;
p = -0.00417768164f + p * w;
p = 0.246640727f + p * w;
p = 1.50140941f + p * w;
}
else
{
w = sqrt(w) - 3.0f;
p = -0.000200214257f;
p = 0.000100950558f + p * w;
p = 0.00134934322f + p * w;
p = -0.00367342844f + p * w;
p = 0.00573950773f + p * w;
p = -0.0076224613f + p * w;
p = 0.00943887047f + p * w;
p = 1.00167406f + p * w;
p = 2.83297682f + p * w;
}
return p * value;
}
// Ashima Arts / Ian McEwan 3D simplex noise (MIT), mirrored by cultmath_snoise(float3)
// in shaders/CultMath.hlsl with the same float32 evaluation order. Follows stegu/webgl-noise
// src/noise3D.glsl at 22434e04d7 (kernel radius^2 0.5, scale 105, permute (34x+10)x); see
// THIRD-PARTY-NOTICES.md. Continuous across simplex-cell boundaries
// (NoiseGradTests.SnoiseAndItsGradientAreContinuousAcrossSimplexCellBoundaries).
// in shaders/CultMath.hlsl with the same float32 evaluation order.
public static float snoise(float3 value)
{
const float cx = 1.0f / 6.0f;
@ -645,9 +523,9 @@ public static partial class math
p2 *= norm.z;
p3 *= norm.w;
var m = max(0.5f - new float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0f);
var m = max(0.6f - new float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0f);
m = m * m;
return 105.0f * dot(m * m, new float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
return 42.0f * dot(m * m, new float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
}
// Ashima Arts / Ian McEwan 2D simplex noise, kept component-explicit so
@ -693,280 +571,8 @@ public static partial class math
private static float2 snoise_mod289(float2 value) => value - floor(value * (1.0f / 289.0f)) * 289.0f;
private static float3 snoise_mod289(float3 value) => value - floor(value * (1.0f / 289.0f)) * 289.0f;
private static float4 snoise_mod289(float4 value) => value - floor(value * (1.0f / 289.0f)) * 289.0f;
private static float3 snoise_permute(float3 value) => snoise_mod289(((value * 34.0f) + 10.0f) * value);
private static float4 snoise_permute(float4 value) => snoise_mod289(((value * 34.0f) + 10.0f) * value);
// Analytic gradient of snoise(float3) (invariant 8), following the differentiation in
// webgl-noise src/noise3Dgrad.glsl (Ashima Arts / McEwan, MIT), which uses the same kernel
// radius^2 0.5, permutation (34x+10)x and scale 105 as snoise(float3) (stegu/webgl-noise
// 22434e04d7). Every local up through p0..p3 and x0..x3 below is snoise(float3)'s own derivation
// verbatim, so .w is that same field (NoiseGradTests.SnoiseGradValueIsBitEqualToSnoise pins the
// two bit-equal over a random sweep). Per corner, m0 = max(0.5 - dot(x,x), 0), so dm0/dx = -2x,
// and d(m0^4 * dot(p,x))/dx = 4*m0^3*(-2x)*dot(p,x) + m0^4*p = -8*m0^3*dot(p,x)*x + m0^4*p (p is
// that corner's own gradient constant, independent of x); summed over the four corners and
// scaled by the same 105 as the value.
public static float4 snoise_grad(float3 value)
{
const float cx = 1.0f / 6.0f;
const float cy = 1.0f / 3.0f;
var i = floor(value + dot(value, new float3(cy, cy, cy)));
var x0 = value - i + dot(i, new float3(cx, cx, cx));
var g = step(new float3(x0.y, x0.z, x0.x), x0);
var l = 1.0f - g;
var lzxy = new float3(l.z, l.x, l.y);
var i1 = min(g, lzxy);
var i2 = max(g, lzxy);
var x1 = x0 - i1 + cx;
var x2 = x0 - i2 + cy;
var x3 = x0 - 0.5f;
i = snoise_mod289(i);
var p = snoise_permute(snoise_permute(snoise_permute(
i.z + new float4(0.0f, i1.z, i2.z, 1.0f)) + i.y + new float4(0.0f, i1.y, i2.y, 1.0f)) + i.x + new float4(0.0f, i1.x, i2.x, 1.0f));
const float n = 0.142857142857f;
var ns = new float3(2.0f * n, 0.5f * n - 1.0f, n);
var j = p - 49.0f * floor(p * ns.z * ns.z);
var xs = floor(j * ns.z);
var ys = floor(j - 7.0f * xs);
var x = xs * ns.x + ns.y;
var y = ys * ns.x + ns.y;
var h = 1.0f - abs(x) - abs(y);
var b0 = new float4(x.x, x.y, y.x, y.y);
var b1 = new float4(x.z, x.w, y.z, y.w);
var s0 = floor(b0) * 2.0f + 1.0f;
var s1 = floor(b1) * 2.0f + 1.0f;
var sh = -step(h, 0.0f);
var a0 = new float4(b0.x, b0.z, b0.y, b0.w) + new float4(s0.x, s0.z, s0.y, s0.w) * new float4(sh.x, sh.x, sh.y, sh.y);
var a1 = new float4(b1.x, b1.z, b1.y, b1.w) + new float4(s1.x, s1.z, s1.y, s1.w) * new float4(sh.z, sh.z, sh.w, sh.w);
var p0 = new float3(a0.x, a0.y, h.x);
var p1 = new float3(a0.z, a0.w, h.y);
var p2 = new float3(a1.x, a1.y, h.z);
var p3 = new float3(a1.z, a1.w, h.w);
var norm = 1.79284291400159f - 0.85373472095314f * new float4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3));
p0 *= norm.x;
p1 *= norm.y;
p2 *= norm.z;
p3 *= norm.w;
var m0 = max(0.5f - new float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0f);
var m2 = m0 * m0;
var m3 = m2 * m0;
var m4 = m2 * m2;
var px = new float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3));
var value2 = 105.0f * dot(m4, px);
var grad = -8.0f * m3.x * x0 * px.x + m4.x * p0
+ -8.0f * m3.y * x1 * px.y + m4.y * p1
+ -8.0f * m3.z * x2 * px.z + m4.z * p2
+ -8.0f * m3.w * x3 * px.w + m4.w * p3;
grad *= 105.0f;
return new float4(grad, value2);
}
// Octave sum of snoise_grad (invariant 8). Each octave samples snoise_grad at p scaled by the
// running frequency, so by the chain rule its gradient scales by that same frequency
// (d/dp[n(f*p)] = f * grad_n(f*p)); amplitude and frequency both compound per octave (gain,
// lacunarity respectively), not applied once for the whole sum, so with 1 octave this is exactly
// snoise_grad (NoiseGradTests.FbmGradWithOneOctaveEqualsSnoiseGrad). octaves is clamped to
// [0, 16]: HlslSourceCompatibilityTests' bit-parity oracle drives every mirrored int parameter
// across the full int32 range, which is the right domain for pcg3d/pcg4d's O(1) hash inputs but
// would turn an unclamped octave count into a multi-billion-iteration loop for this mirror.
public static float4 fbm_grad(float3 p, int octaves, float lacunarity, float gain)
{
octaves = clamp(octaves, 0, 16);
var amplitude = 1.0f;
var frequency = 1.0f;
var value = 0.0f;
var gradient = new float3(0.0f, 0.0f, 0.0f);
for (var i = 0; i < octaves; i++)
{
var n = snoise_grad(p * frequency);
value += amplitude * n.w;
gradient += amplitude * frequency * new float3(n.x, n.y, n.z);
frequency *= lacunarity;
amplitude *= gain;
}
return new float4(gradient, value);
}
// Ridged multifractal noise (Musgrave, Texturing and Modeling, ch. 16; the operator's dune term,
// 2026-09-25): each octave folds snoise_grad about zero, value = Sum a_i*(1 - |n_i|), gradient =
// -Sum a_i*f_i*sign(n_i)*grad(n_i), the same per-octave frequency scaling as fbm_grad's chain
// rule plus the fold's sign flip (d|n|/dp = sign(n)*dn/dp). The crease at n_i = 0 is real and
// deliberate, not a bug: NoiseGradTests excludes a band around it, the same way cellular excludes
// its F1 = F2 seam. sign follows this codebase's HLSL-matching convention (design.md: sign
// returns int, 0 at exactly 0), so the crease itself contributes no gradient rather than an
// arbitrary one. Same octave clamp as fbm_grad, for the same bit-parity-oracle reason.
public static float4 ridged_grad(float3 p, int octaves, float lacunarity, float gain)
{
octaves = clamp(octaves, 0, 16);
var amplitude = 1.0f;
var frequency = 1.0f;
var value = 0.0f;
var gradient = new float3(0.0f, 0.0f, 0.0f);
for (var i = 0; i < octaves; i++)
{
var n = snoise_grad(p * frequency);
var s = (float)sign(n.w);
value += amplitude * (1.0f - abs(n.w));
gradient += -amplitude * frequency * s * new float3(n.x, n.y, n.z);
frequency *= lacunarity;
amplitude *= gain;
}
return new float4(gradient, value);
}
// Inigo Quilez, "smooth minimum" (https://iquilezles.org/articles/smin/): the quadratic-polynomial
// smin, carried to value-and-gradient form (invariant 8). h is affine in (b.w - a.w) inside the
// smoothing band, so differentiating value = lerp(b.w, a.w, h) - k*h*(1-h) with respect to
// position, the terms carrying dh/dp cancel exactly (dh/dp * [(a.w-b.w) - k + 2*k*h] and the
// bracket is identically zero given how h is built from a.w-b.w). What is left is exactly
// lerp(∇b, ∇a, h): the gradient blend is not an approximation, it is the analytic gradient.
// Outside the band (|a.w - b.w| >= k), h saturates to 0 or 1 and this is exactly min(a, b) with
// that input's own gradient, continuously (h and the correction term both reach the boundary at
// the same value from the smooth side, so there is no kink to exclude here the way cellular's
// F1 = F2 seam needs one).
public static float4 smin_grad(float4 a, float4 b, float k)
{
var h = saturate(0.5f + 0.5f * (b.w - a.w) / k);
var value = lerp(b.w, a.w, h) - k * h * (1.0f - h);
var gradient = lerp(new float3(b.x, b.y, b.z), new float3(a.x, a.y, a.z), h);
return new float4(gradient, value);
}
// Worley, "A Cellular Texture Basis Function" (SIGGRAPH 1996): F1/F2 and their analytic
// gradients, searched over the jittered neighbourhood of feature points. Feature points are
// selected with pcg3d over each neighbour's integer cell coordinate (design.md, "Integer
// hashing"), never the float32 bit pattern of that coordinate and never the sin-based hash.
// Hashing the integer cell coordinate directly is exact and runtime-independent, and it is what
// the HLSL mirror does, so C# and shader stay bit-for-bit aligned. What pins this is the oracle
// and mirror tests, not a uniformity measurement: reverting to hashing the float32 bit pattern
// (or the sin-based hash) is caught by ProductionSearchMatchesTheBruteForceOracleExactly,
// EveryMirrorFunctionMatchesCSharpMath, F1NeverExceedsF2, and the other tests that key off the
// integer-hash fixtures. The
// gradient of a distance field, del|p - c|, is the unit vector (p - c)/|p - c|; that is undefined exactly at
// a feature point (F1 = 0 or F2 = 0), so cellular follows the repo's existing degenerate-normal
// convention (GameCult.Geometry.CultGeometryIsoSurface.EmitOrientedTriangle: guard the zero-length
// case and return the zero vector instead of the NaN a bare normalize would produce there).
//
// The search radius is 2 cells on every axis (5x5x5), not the classic 1-cell (3x3x3) radius,
// because 3x3x3 is not exact: measured 9 of 400,000 points in [-50,50]^3 land on the wrong F2
// (worst error 0.088), and past |p| of about 2^23 the float32 jitter can put two distinct cells'
// features on the same value, which 3x3x3 has no chance of noticing either way. 5x5x5 is exact,
// not merely safer, by the following argument:
//
// Let u = p - cell be the query's position inside its own cell (u in [0,1)^3 per component), and
// let a candidate feature at integer cell offset d = (dx, dy, dz) sit at d + j for some jitter j
// in [0,1)^3. Per axis, the displacement from p to that feature is (u_i - j_i) - d_i; since
// u_i - j_i is always strictly inside (-1, 1) regardless of the actual u_i and j_i values,
// |displacement_i| is always strictly greater than max(0, |d_i| - 1). So every cell with |d_i| >=
// 3 on some axis is strictly farther than 2 from p (that axis alone already gives max(0, 3-1) =
// 2). Now bound F1 and F2 from above using two real, always-present candidates: the query's own
// cell (d = 0), whose distance is always strictly less than sqrt(3) (every axis strictly under
// 1); and the near-side neighbour on whichever single axis's u_i sits closest to a cell
// boundary. Let m = min(u_i, 1 - u_i) on that chosen axis (m is in [0, 1/2] by construction,
// since it is the smaller of the two). Stepping one cell towards that nearer boundary puts the
// query within (1 + m) of the far face on the offset axis and within (1 - m) of the near face on
// each of the other two axes, so that neighbour's squared distance is at most
// (1 + m)^2 + 2*(1 - m)^2 = 3 - 2m + 3m^2, which is <= 3 for every m in [0, 1/2] (3 at m = 0,
// dipping to a minimum of 8/3 ~= 2.667 at m = 1/3, then rising back to 2.75 at m = 1/2), and
// strictly below 3 because the jitter never reaches exactly 0 or
// 1 (u_i - j_i stays strictly inside (-1, 1), so this bound, like the own-cell one, is never
// tight). F2 is at most the larger of these two real candidates (adding candidates to a set can
// only lower or hold its 2nd-smallest value), so F1 < sqrt(3) and F2 < sqrt(3) always: roughly
// 1.732 < 2, margin 2 - sqrt(3) ~= 0.268 (Soul's adversarial check, cut 2a-i: the worst
// realizable in-box F2 is exactly sqrt(3), reached only in the unattainable limit at a cell
// corner). Every cell outside the 5x5x5 block is farther than 2, hence farther than either F1 or
// F2, so the true global F1 and F2 are always inside it. (The matching argument for 3x3x3 only
// reaches radius 1, whose excluded cells start at distance > 1, which is less than the 1.732
// ceiling — the gap 3x3x3's failures live in.)
//
// An earlier version of this proof picked the near-side neighbour on a fixed axis rather than
// whichever axis sits closest to its boundary, and that version is wrong (Soul, cut 2a-i):
// u = (0.5, 0, 0) with the x-neighbour's jittered feature at (-1, 1 - 2^-24, 1 - 2^-24) gives a
// distance of about 2.06, outside the radius-2 exclusion the proof needs. Choosing the axis by
// proximity to its own boundary is what keeps m <= 1/2 and the bound at sqrt(3); x was the wrong
// axis to fix there because u.x = 0.5 is the point on the whole cube farthest from any boundary.
//
// The loop below still visits every one of the 125 cells in a fixed order, but skips the
// pcg3d/jitter/distance work for a cell whose distance cannot possibly beat the current f2: that
// per-cell lower bound (the max(0, |d_i| - 1) term above, combined across axes) depends only on
// the integer offset, never on p or the hash, so pruning a cell the unpruned loop would also
// have rejected changes no output bit (CellularExactSearchTests pins the pruned/unpruned
// equivalence and the exactness against a 7x7x7 brute-force oracle).
public static CultCellular cellular(float3 p)
{
var cell = floor(p);
var f1 = 1.0e30f;
var f2 = 1.0e30f;
var c1 = new float3(0.0f, 0.0f, 0.0f);
var c2 = new float3(0.0f, 0.0f, 0.0f);
var cellId = new float3(0.0f, 0.0f, 0.0f);
for (var dz = -2; dz <= 2; dz++)
{
var lz = max(0.0f, abs((float)dz) - 1.0f);
for (var dy = -2; dy <= 2; dy++)
{
var ly = max(0.0f, abs((float)dy) - 1.0f);
for (var dx = -2; dx <= 2; dx++)
{
var lx = max(0.0f, abs((float)dx) - 1.0f);
var lowerBound = sqrt(lx * lx + ly * ly + lz * lz);
if (lowerBound >= f2)
continue;
var neighbor = cell + new float3(dx, dy, dz);
var hash = pcg3d(int3(neighbor));
var jitter = new float3(cellular_unit(hash.x), cellular_unit(hash.y), cellular_unit(hash.z));
var feature = neighbor + jitter;
var d = distance(p, feature);
if (d < f1)
{
f2 = f1; c2 = c1;
f1 = d; c1 = feature; cellId = neighbor;
}
else if (d < f2)
{
f2 = d; c2 = feature;
}
}
}
}
// F1 = 0 is reachable (p sits exactly on a feature point). F2 = 0 needs two distinct cells'
// pcg3d-jittered features to land on the same float32 value in every component: unreachable
// at ordinary magnitudes, but confirmed reachable past roughly |p| = 2^23 (design.md,
// "Precision domain"), so both share the same degenerate-gradient guard.
var grad1 = f1 > 0.0f ? (p - c1) / f1 : new float3(0.0f, 0.0f, 0.0f);
var grad2 = f2 > 0.0f ? (p - c2) / f2 : new float3(0.0f, 0.0f, 0.0f);
// id comes from pcg4d over the winning cell's integer coordinate alone (same int-hashing
// rule as the jitter above), never from the pcg3d hash that produced its jitter: sharing a
// hash between id and jitter would make id a deterministic function of the jitter it is
// supposed to be independent from, correlated across every cell (F4).
var idHash = pcg4d(new int4(int3(cellId), 0));
return new CultCellular(
new float4(grad1, f1),
new float4(grad2 - grad1, f2 - f1),
cellular_unit(idHash.w));
}
// Maps a hash component's uint bit pattern to [0, 1) using the top 24 bits, so the result is
// always strictly less than 1: a plain divide-by-2^32 rounds 0xFFFFFF80..0xFFFFFFFF up to
// exactly 1.0f in float32, outside the documented range. (uint >> 8) has at most 24 significant
// bits, which float32's 24-bit mantissa represents exactly, and the division is by an exact power
// of two, so both the shift-and-convert and the divide are the same IEEE-754 round-to-nearest on
// dxc and C#, bit-exact on both sides.
internal static float cellular_unit(int bits) => ((uint)bits >> 8) * (1.0f / 16777216.0f);
private static float3 snoise_permute(float3 value) => snoise_mod289(((value * 34.0f) + 1.0f) * value);
private static float4 snoise_permute(float4 value) => snoise_mod289(((value * 34.0f) + 1.0f) * value);
public static float value_noise(float2 position)
{

View file

@ -1,427 +0,0 @@
using System;
using CultMath;
using Xunit;
namespace CultMath.Tests;
// Stryker's mtp baseline on hands/adopt-stryker found BatchMath undefended in three ways:
// both existing tests used 2-3 particles while LaneCount >= 4, so the vector body never ran
// and only the scalar tail executed; both held y at 0 for every particle, so every y-axis
// mutation was invisible; and the guard branches (early return, negative deltaTime, length
// mismatch) were never exercised. These tests read LaneCount at runtime, use
// LaneCount * 3 + 1 particles (a non-empty vector body AND a non-empty remainder), give every
// particle distinct nonzero x and y, and pin results against a scalar reference computed here
// rather than by calling the code under test.
public sealed class BatchMathTests
{
private const float Tolerance = 1e-4f;
[Fact]
public void RadialFalloffMatchesScalarReferenceAcrossVectorBodyAndRemainder()
{
var lanes = BatchMath.LaneCount;
var count = lanes * 3 + 1;
Assert.True(count > lanes, "fixture must exceed one lane to exercise the vector body");
Assert.True((count % lanes) != 0, "fixture must leave a non-empty scalar remainder");
var px = new float[count];
var py = new float[count];
var ax = new float[count];
var ay = new float[count];
// Distinct, asymmetric per-particle values: x and y follow different formulas so an
// x/y swap mutation is observable, and no particle sits at the falloff center.
for (var i = 0; i < count; i++)
{
px[i] = i * 1.3f - 4.0f;
py[i] = i * -0.7f + 2.5f;
}
const float centerX = 3.0f;
const float centerY = -1.0f;
const float strength = 5.0f;
// Chosen so some particles fall inside the radius and some fall outside it, exercising
// both the ConditionalSelect(inRange, ...) vector path and the scalar "continue".
const float radius = 6.0f;
// The scalar remainder is exactly one particle (index count-1). The generator above puts
// it far outside the radius, which would leave any y-axis mutation in the scalar tail's
// "in range" arithmetic unobserved (the only remainder particle never reaches it). Pin it
// inside the radius with a nonzero, non-degenerate y offset instead.
px[count - 1] = centerX + 2.0f;
py[count - 1] = centerY + 1.5f;
BatchMath.AddRadialFalloffAcceleration2D(px, py, centerX, centerY, strength, radius, ax, ay);
var insideCount = 0;
var outsideCount = 0;
for (var i = 0; i < count; i++)
{
var dx = centerX - px[i];
var dy = centerY - py[i];
var distanceSquared = Math.Max(dx * dx + dy * dy, 1.0e-8f);
var distance = MathF.Sqrt(distanceSquared);
if (distance > radius)
{
outsideCount++;
Assert.Equal(0.0f, ax[i], Tolerance);
Assert.Equal(0.0f, ay[i], Tolerance);
continue;
}
insideCount++;
var falloff = Math.Max(0.0f, 1.0f - distance / radius);
var magnitude = strength * falloff;
var expectedAx = dx / distance * magnitude;
var expectedAy = dy / distance * magnitude;
Assert.Equal(expectedAx, ax[i], Tolerance);
Assert.Equal(expectedAy, ay[i], Tolerance);
}
Assert.True(insideCount > 0, "fixture must include particles inside the radius");
Assert.True(outsideCount > 0, "fixture must include particles outside the radius");
}
[Fact]
public void RadialFalloffAccumulatesOntoExistingAcceleration()
{
// Guards against a mutation that assigns instead of accumulating: seed a nonzero
// acceleration and confirm the falloff contribution is added to it, not overwritten.
var px = new[] { 0.0f };
var py = new[] { 0.0f };
var ax = new[] { 10.0f };
var ay = new[] { -20.0f };
BatchMath.AddRadialFalloffAcceleration2D(px, py, 1.0f, 0.0f, 4.0f, 8.0f, ax, ay);
var dx = 1.0f - 0.0f;
var dy = 0.0f - 0.0f;
var distance = MathF.Sqrt(dx * dx + dy * dy);
var falloff = Math.Max(0.0f, 1.0f - distance / 8.0f);
var magnitude = 4.0f * falloff;
Assert.Equal(10.0f + dx / distance * magnitude, ax[0], Tolerance);
Assert.Equal(-20.0f + dy / distance * magnitude, ay[0], Tolerance);
}
[Fact]
public void RadialFalloffValidatesEqualSpanLengths()
{
var px = new float[3];
var py = new float[2];
var ax = new float[3];
var ay = new float[3];
Assert.Throws<ArgumentException>(() =>
BatchMath.AddRadialFalloffAcceleration2D(px, py, 0.0f, 0.0f, 1.0f, 1.0f, ax, ay));
}
[Fact]
public void RadialFalloffIsNoOpWhenRadiusIsNonPositive()
{
var px = new[] { 1.0f, 2.0f };
var py = new[] { 3.0f, -4.0f };
var ax = new[] { 5.0f, 6.0f };
var ay = new[] { 7.0f, 8.0f };
BatchMath.AddRadialFalloffAcceleration2D(px, py, 0.0f, 0.0f, 9.0f, 0.0f, ax, ay);
Assert.Equal(new[] { 5.0f, 6.0f }, ax);
Assert.Equal(new[] { 7.0f, 8.0f }, ay);
}
[Fact]
public void RadialFalloffIsNoOpWhenStrengthIsZero()
{
var px = new[] { 1.0f, 2.0f };
var py = new[] { 3.0f, -4.0f };
var ax = new[] { 5.0f, 6.0f };
var ay = new[] { 7.0f, 8.0f };
BatchMath.AddRadialFalloffAcceleration2D(px, py, 0.0f, 0.0f, 0.0f, 10.0f, ax, ay);
Assert.Equal(new[] { 5.0f, 6.0f }, ax);
Assert.Equal(new[] { 7.0f, 8.0f }, ay);
}
[Fact]
public void RadialFalloffIsNoOpWhenSpansAreEmpty()
{
var px = Array.Empty<float>();
var py = Array.Empty<float>();
var ax = Array.Empty<float>();
var ay = Array.Empty<float>();
// Must not throw for a valid but degenerate zero-length batch.
BatchMath.AddRadialFalloffAcceleration2D(px, py, 0.0f, 0.0f, 1.0f, 1.0f, ax, ay);
Assert.Empty(ax);
Assert.Empty(ay);
}
[Fact]
public void EulerIntegrationMatchesScalarReferenceAcrossVectorBodyAndRemainder()
{
var lanes = BatchMath.LaneCount;
var count = lanes * 3 + 1;
Assert.True((count % lanes) != 0, "fixture must leave a non-empty scalar remainder");
const float deltaTime = 0.25f;
var dynamicMask = new float[count];
var px = new float[count];
var py = new float[count];
var vx = new float[count];
var vy = new float[count];
var ax = new float[count];
var ay = new float[count];
for (var i = 0; i < count; i++)
{
// Every third particle is frozen (mask 0) so the mask branch is exercised across
// both the vector body and the remainder, not only at the array's edges.
dynamicMask[i] = i % 3 == 0 ? 0.0f : 1.0f;
px[i] = i * 0.4f - 1.0f;
py[i] = i * -0.6f + 3.0f;
vx[i] = i * 0.2f + 1.0f;
vy[i] = i * -0.3f - 2.0f;
ax[i] = i * 0.5f - 2.5f;
ay[i] = i * -0.9f + 1.5f;
}
// The scalar remainder is exactly one particle (index count-1). The mask pattern above
// would leave it frozen for lane counts divisible by 3, which masks every y-axis (and
// x-axis) mutation in the scalar tail's arithmetic to zero regardless of the mutation.
// Force it dynamic so the remainder actually exercises the scalar tail's math.
dynamicMask[count - 1] = 1.0f;
// Independent copies for the scalar reference; the arrays under test are mutated in place.
var expectedPx = (float[])px.Clone();
var expectedPy = (float[])py.Clone();
var expectedVx = (float[])vx.Clone();
var expectedVy = (float[])vy.Clone();
BatchMath.IntegrateSemiImplicitEuler2D(deltaTime, dynamicMask, px, py, vx, vy, ax, ay);
var anyFrozen = false;
var anyMoving = false;
for (var i = 0; i < count; i++)
{
var mask = dynamicMask[i];
expectedVx[i] += ax[i] * deltaTime * mask;
expectedVy[i] += ay[i] * deltaTime * mask;
expectedPx[i] += expectedVx[i] * deltaTime * mask;
expectedPy[i] += expectedVy[i] * deltaTime * mask;
if (mask == 0.0f) anyFrozen = true; else anyMoving = true;
Assert.Equal(expectedVx[i], vx[i], Tolerance);
Assert.Equal(expectedVy[i], vy[i], Tolerance);
Assert.Equal(expectedPx[i], px[i], Tolerance);
Assert.Equal(expectedPy[i], py[i], Tolerance);
}
Assert.True(anyFrozen, "fixture must include masked-off (frozen) particles");
Assert.True(anyMoving, "fixture must include dynamic particles");
}
[Fact]
public void EulerIntegrationRejectsNonPositiveDeltaTime()
{
var dynamicMask = new[] { 1.0f };
var px = new[] { 0.0f };
var py = new[] { 0.0f };
var vx = new[] { 0.0f };
var vy = new[] { 0.0f };
var ax = new[] { 0.0f };
var ay = new[] { 0.0f };
Assert.Throws<ArgumentOutOfRangeException>(() =>
BatchMath.IntegrateSemiImplicitEuler2D(0.0f, dynamicMask, px, py, vx, vy, ax, ay));
Assert.Throws<ArgumentOutOfRangeException>(() =>
BatchMath.IntegrateSemiImplicitEuler2D(-1.0f, dynamicMask, px, py, vx, vy, ax, ay));
}
[Fact]
public void EulerIntegrationValidatesEqualSpanLengths()
{
var dynamicMask = new float[3];
var px = new float[3];
var py = new float[3];
var vx = new float[2];
var vy = new float[3];
var ax = new float[3];
var ay = new float[3];
Assert.Throws<ArgumentException>(() =>
BatchMath.IntegrateSemiImplicitEuler2D(0.5f, dynamicMask, px, py, vx, vy, ax, ay));
}
[Fact]
public void Float2RadialFalloffMatchesScalarReferenceWithNonzeroYAndOutOfRangeParticles()
{
var count = 5;
var positions = new float2[count];
for (var i = 0; i < count; i++)
{
positions[i] = new float2(i * 1.1f - 2.0f, i * -0.8f + 1.0f);
}
var center = new float2(1.5f, -0.5f);
const float strength = 3.0f;
const float radius = 3.0f;
var acceleration = new float2[count];
BatchMath.AddRadialFalloffAcceleration2D(positions, center, strength, radius, acceleration);
var insideCount = 0;
var outsideCount = 0;
for (var i = 0; i < count; i++)
{
var delta = center - positions[i];
var distanceSquared = Math.Max(delta.x * delta.x + delta.y * delta.y, 1.0e-8f);
var distance = MathF.Sqrt(distanceSquared);
if (distance > radius)
{
outsideCount++;
Assert.Equal(0.0f, acceleration[i].x, Tolerance);
Assert.Equal(0.0f, acceleration[i].y, Tolerance);
continue;
}
insideCount++;
var falloff = Math.Max(0.0f, 1.0f - distance / radius);
var expected = delta / distance * (strength * falloff);
Assert.Equal(expected.x, acceleration[i].x, Tolerance);
Assert.Equal(expected.y, acceleration[i].y, Tolerance);
}
Assert.True(insideCount > 0, "fixture must include particles inside the radius");
Assert.True(outsideCount > 0, "fixture must include particles outside the radius");
}
[Fact]
public void Float2RadialFalloffAccumulatesOntoExistingAcceleration()
{
// Guards against a mutation that assigns instead of accumulating (BatchMath.cs:115):
// seed a nonzero, per-element-distinct acceleration and confirm the falloff
// contribution is added to it, not overwritten. An out-of-range particle must be left
// exactly at its seed value, not reset to zero.
var count = 5;
var positions = new float2[count];
var seedAcceleration = new float2[count];
for (var i = 0; i < count; i++)
{
positions[i] = new float2(i * 1.1f - 2.0f, i * -0.8f + 1.0f);
seedAcceleration[i] = new float2(i * 2.3f + 4.0f, i * -1.7f - 6.0f);
}
var center = new float2(1.5f, -0.5f);
const float strength = 3.0f;
const float radius = 3.0f;
var acceleration = (float2[])seedAcceleration.Clone();
BatchMath.AddRadialFalloffAcceleration2D(positions, center, strength, radius, acceleration);
var insideCount = 0;
var outsideCount = 0;
for (var i = 0; i < count; i++)
{
var delta = center - positions[i];
var distanceSquared = Math.Max(delta.x * delta.x + delta.y * delta.y, 1.0e-8f);
var distance = MathF.Sqrt(distanceSquared);
if (distance > radius)
{
outsideCount++;
Assert.Equal(seedAcceleration[i].x, acceleration[i].x, Tolerance);
Assert.Equal(seedAcceleration[i].y, acceleration[i].y, Tolerance);
continue;
}
insideCount++;
var falloff = Math.Max(0.0f, 1.0f - distance / radius);
var contribution = delta / distance * (strength * falloff);
Assert.Equal(seedAcceleration[i].x + contribution.x, acceleration[i].x, Tolerance);
Assert.Equal(seedAcceleration[i].y + contribution.y, acceleration[i].y, Tolerance);
}
Assert.True(insideCount > 0, "fixture must include particles inside the radius");
Assert.True(outsideCount > 0, "fixture must include particles outside the radius");
}
[Fact]
public void Float2EulerIntegrationMatchesScalarReferenceWithNonzeroYAndMask()
{
const float deltaTime = 0.2f;
var dynamicMask = new[] { 1.0f, 0.0f, 1.0f };
var position = new[] { new float2(0.0f, 0.0f), new float2(1.0f, 1.0f), new float2(-2.0f, 3.0f) };
var velocity = new[] { new float2(1.0f, -1.0f), new float2(2.0f, 2.0f), new float2(-1.0f, 0.5f) };
var acceleration = new[] { new float2(0.5f, 1.5f), new float2(3.0f, -3.0f), new float2(2.0f, -0.5f) };
var expectedPosition = (float2[])position.Clone();
var expectedVelocity = (float2[])velocity.Clone();
BatchMath.IntegrateSemiImplicitEuler2D(deltaTime, dynamicMask, position, velocity, acceleration);
for (var i = 0; i < position.Length; i++)
{
var mask = dynamicMask[i];
expectedVelocity[i] += acceleration[i] * deltaTime * mask;
expectedPosition[i] += expectedVelocity[i] * deltaTime * mask;
Assert.Equal(expectedVelocity[i].x, velocity[i].x, Tolerance);
Assert.Equal(expectedVelocity[i].y, velocity[i].y, Tolerance);
Assert.Equal(expectedPosition[i].x, position[i].x, Tolerance);
Assert.Equal(expectedPosition[i].y, position[i].y, Tolerance);
}
}
[Fact]
public void Float2EulerIntegrationRejectsNonPositiveDeltaTime()
{
var dynamicMask = new[] { 1.0f };
var position = new[] { float2.zero };
var velocity = new[] { float2.zero };
var acceleration = new[] { float2.zero };
Assert.Throws<ArgumentOutOfRangeException>(() =>
BatchMath.IntegrateSemiImplicitEuler2D(0.0f, dynamicMask, position, velocity, acceleration));
}
[Fact]
public void Float2EulerIntegrationValidatesEqualSpanLengths()
{
var dynamicMask = new float[3];
var position = new float2[3];
var velocity = new float2[2];
var acceleration = new float2[3];
Assert.Throws<ArgumentException>(() =>
BatchMath.IntegrateSemiImplicitEuler2D(0.5f, dynamicMask, position, velocity, acceleration));
}
[Fact]
public void Float2RadialFalloffValidatesEqualSpanLengths()
{
var positions = new float2[3];
var acceleration = new float2[2];
Assert.Throws<ArgumentException>(() =>
BatchMath.AddRadialFalloffAcceleration2D(positions, float2.zero, 1.0f, 1.0f, acceleration));
}
[Fact]
public void Float2RadialFalloffIsNoOpWhenRadiusIsNonPositiveOrStrengthIsZero()
{
var positions = new[] { new float2(1.0f, 3.0f), new float2(2.0f, -4.0f) };
var acceleration = new[] { new float2(5.0f, 7.0f), new float2(6.0f, 8.0f) };
BatchMath.AddRadialFalloffAcceleration2D(positions, float2.zero, 9.0f, 0.0f, acceleration);
Assert.Equal(new float2(5.0f, 7.0f), acceleration[0]);
Assert.Equal(new float2(6.0f, 8.0f), acceleration[1]);
BatchMath.AddRadialFalloffAcceleration2D(positions, float2.zero, 0.0f, 10.0f, acceleration);
Assert.Equal(new float2(5.0f, 7.0f), acceleration[0]);
Assert.Equal(new float2(6.0f, 8.0f), acceleration[1]);
}
}

View file

@ -1,686 +0,0 @@
using System;
using CultMath;
using Xunit;
namespace CultMath.Tests;
public sealed class CellularAndSminGradTests
{
// Central-difference tolerances for smin_grad and cellular (F5), sized separately: the two
// functions have different curvature, so the same eps does not carry the same truncation
// error, and an earlier shared 3e-4 tolerance was fragile against both (Soul, cut 2a-i: measured
// max error smin 2.74e-4, cellular nearest 1.25e-4, cellular edge 1.88e-4 on a small sample —
// and re-measured here at 5.3e-4, 2.0e-4, 2.6e-4 on a larger one — against that 3e-4 tolerance,
// sometimes under 1.1x headroom, not the "~6e-5 noise" an earlier version of this comment
// claimed). Each tolerance below is set from its own measured worst case with real headroom.
// smin_grad's value is exactly quadratic in position within a smoothing band (math.cs comment on
// smin_grad: h is affine in b.w - a.w, which is itself affine in position, so value(q) has zero
// third derivative there): central differences have no truncation error from curvature, only
// float32 rounding noise, so a larger eps directly dilutes that noise with no accuracy cost.
// Measured max error at eps=0.01f over 10,000 samples: 4.37e-5 (up from eps=0.001f's 5.3e-4,
// confirming the noise is eps-limited, not curvature-limited); this tolerance keeps about 5.7x
// headroom over that. A uniform-scale mutant on the returned gradient (a 1.01x M9/M16-style
// mutant applied here) still dies down to a 1.0005x scale (0.05%); it survives at 1.0002x.
private const float SminGradientTolerance = 2.5e-4f;
// cellular's F1/F2 are Euclidean distance fields: real curvature away from a feature point means
// central differences DO carry O(eps^2) truncation error, so (unlike smin_grad) a larger eps
// makes things worse, not better; a sweep at eps in {0.0002f, 0.0003f, 0.0005f, 0.0007f, 0.001f}
// over 8,000 samples found the committed eps=0.0005f already close to the sweet spot. Measured
// max error at that eps: nearest 2.04e-4, edge 2.57e-4; this tolerance keeps just over 5x
// headroom over the larger of the two. M9 (grad1 * 1.01) and M16 (grad2 * 1.01) both still die
// down to a 1.002x scale (0.2%); both survive at 1.001x.
private const float CellularGradientTolerance = 1.3e-3f;
// ---- smin_grad ----
[Fact]
public void SminValueNeverExceedsTheOrdinaryMinimum()
{
var random = new System.Random(0xACE1);
for (var i = 0; i < 256; i++)
{
var a = new float4(RandomUnit(random), RandomScalar(random));
var b = new float4(RandomUnit(random), RandomScalar(random));
var k = 0.05f + random.NextSingle() * 5.0f;
var result = math.smin_grad(a, b, k);
Assert.True(result.w <= math.min(a.w, b.w) + 1.0e-4f, $"a.w={a.w} b.w={b.w} k={k} smin={result.w}");
}
}
[Fact]
public void SminMatchesTheHardMinimumAndItsGradientOutsideTheBand()
{
var random = new System.Random(0xACE2);
for (var i = 0; i < 64; i++)
{
var gradA = RandomUnit(random);
var gradB = RandomUnit(random);
var k = 0.1f + random.NextSingle() * 2.0f;
// Push b comfortably above a, past the smoothing band, so a is the ordinary minimum.
var aw = random.NextSingle() * 10.0f - 5.0f;
var bw = aw + k * (1.5f + random.NextSingle());
var a = new float4(gradA, aw);
var b = new float4(gradB, bw);
var lower = math.smin_grad(a, b, k);
Assert.Equal(aw, lower.w, precision: 4);
Assert.Equal(gradA.x, lower.x, precision: 4);
Assert.Equal(gradA.y, lower.y, precision: 4);
Assert.Equal(gradA.z, lower.z, precision: 4);
// Argument order must not matter: b below a is symmetric.
var higher = math.smin_grad(b, a, k);
Assert.Equal(aw, higher.w, precision: 4);
Assert.Equal(gradA.x, higher.x, precision: 4);
Assert.Equal(gradA.y, higher.y, precision: 4);
Assert.Equal(gradA.z, higher.z, precision: 4);
}
}
[Fact]
public void SminGradientMatchesCentralDifferencesOfItsOwnValue()
{
var random = new System.Random(0xACE3);
var tested = 0;
while (tested < 200)
{
var cf = RandomUnit(random);
var cg = RandomUnit(random);
var offF = random.NextSingle() * 4.0f - 2.0f;
var offG = random.NextSingle() * 4.0f - 2.0f;
var k = 0.2f + random.NextSingle() * 2.0f;
var p = new float3(random.NextSingle() * 4.0f - 2.0f, random.NextSingle() * 4.0f - 2.0f, random.NextSingle() * 4.0f - 2.0f);
float F(float3 q) => math.dot(q, cf) + offF;
float G(float3 q) => math.dot(q, cg) + offG;
float Smin(float3 q) => math.smin_grad(new float4(cf, F(q)), new float4(cg, G(q)), k).w;
// Stay clear of the |a.w - b.w| = k seam: central differences straddling a kink would
// disagree with either side's analytic gradient (the same reason cellular excludes a
// band around its own F1 = F2 set). The band has an absolute floor, not just a
// fraction of k: eps grew to 0.01f below (SminGradientTolerance's comment), and a
// fraction-of-k-only band could be narrower than the diff can shift by eps*|cf-cg|
// (up to about 0.035 here) when k is small, letting p +/- eps step across the seam even
// though p itself was clear of it.
var diff = F(p) - G(p);
if (Math.Abs(Math.Abs(diff) - k) < Math.Max(0.05f * k, 0.05f))
continue;
tested++;
var analytic = math.smin_grad(new float4(cf, F(p)), new float4(cg, G(p)), k);
// eps=0.01f, not 0.001f: smin is exactly quadratic within a band (no truncation error
// from curvature; see SminGradientTolerance's comment), so the larger eps only dilutes
// float32 rounding noise, at no accuracy cost, while still comfortably inside the band
// exclusion above.
const float eps = 0.01f;
var numeric = new float3(
(Smin(p + new float3(eps, 0.0f, 0.0f)) - Smin(p - new float3(eps, 0.0f, 0.0f))) / (2.0f * eps),
(Smin(p + new float3(0.0f, eps, 0.0f)) - Smin(p - new float3(0.0f, eps, 0.0f))) / (2.0f * eps),
(Smin(p + new float3(0.0f, 0.0f, eps)) - Smin(p - new float3(0.0f, 0.0f, eps))) / (2.0f * eps));
AssertWithinTolerance(numeric.x, analytic.x, SminGradientTolerance);
AssertWithinTolerance(numeric.y, analytic.y, SminGradientTolerance);
AssertWithinTolerance(numeric.z, analytic.z, SminGradientTolerance);
}
}
[Fact]
public void SminGradientIsNotAConstantBlendOrTheSwappedFactor()
{
// Falsifies two named mutants directly: blending the gradient with a constant 0.5 instead of
// h, or with (1 - h) instead of h. Here a is comfortably the minimum (h saturates to 1 in the
// correct formula), so the correct gradient is exactly a's, unmixed. A constant-0.5 blend or a
// swapped (1 - h) blend both pull in b's gradient, which differs from a's here.
var a = new float4(1.0f, 0.0f, 0.0f, -3.0f);
var b = new float4(0.0f, 1.0f, 0.0f, 5.0f);
var result = math.smin_grad(a, b, 0.25f);
Assert.Equal(1.0f, result.x, precision: 5);
Assert.Equal(0.0f, result.y, precision: 5);
Assert.Equal(0.0f, result.z, precision: 5);
Assert.Equal(-3.0f, result.w, precision: 5);
}
// ---- cellular ----
[Fact]
public void F1NeverExceedsF2()
{
var random = new System.Random(0xCE11);
for (var i = 0; i < 256; i++)
{
var p = new float3(random.NextSingle() * 20.0f - 10.0f, random.NextSingle() * 20.0f - 10.0f, random.NextSingle() * 20.0f - 10.0f);
var result = math.cellular(p);
Assert.True(result.edge.w >= -1.0e-4f, $"F2 - F1 = {result.edge.w} at {p.x},{p.y},{p.z}");
// Both returned .w values, checked against the independent 7x7x7 brute-force oracle (F1,
// F6), not a value the test derived some other way.
var (f1, f2) = FindF1F2(p);
Assert.Equal(f1, result.nearest.w, precision: 3);
Assert.Equal(f2 - f1, result.edge.w, precision: 3);
}
}
[Fact]
public void NearestFeatureWinsEvenWhenItIsInTheLastVisitedNeighbourCell()
{
// math.cellular's own search order visits neighbour offsets with dz outermost and dx
// innermost over a 5x5x5 block (F6), and it also prunes cells whose position-only lower
// bound already exceeds the running F2. Offsets with any |component| = 2 are not provably
// unreachable as F1: their minimum possible distance is 1 (e.g. (2, 0, 0), whose lower bound
// per the exactness proof in math.cs is max(0, 2-1) = 1), well inside the query's own cell's
// sqrt(3) ceiling, so a |component| = 2 offset winning F1 is rare rather than impossible
// (Soul, cut 2a-i: measured 3 F1 wins in 2,000,000 samples). It is still rare enough that the
// 3x3x3 corners remain the practically-reachable "last" cases for this test. (dx, dy, dz)
// = (1, 1, 1) is the last of those the fixed iteration order visits, and if pruning or search
// order were broken, this is the offset most likely to be skipped. Rather than hand-picking a
// hash value, search random points for one whose true nearest feature (by independent brute
// force over the same 5x5x5 block) happens to live in exactly that cell, at a comfortable
// margin from the F1 = F2 seam, then check math.cellular agrees.
var random = new System.Random(0xCE14);
var lastOffset = new float3(1.0f, 1.0f, 1.0f);
for (var attempt = 0; attempt < 5000; attempt++)
{
var p = new float3(random.NextSingle() * 8.0f - 4.0f, random.NextSingle() * 8.0f - 4.0f, random.NextSingle() * 8.0f - 4.0f);
var (offset, f1, f2) = FindNearestOffset(p);
if (!offset.Equals(lastOffset) || f1 < 0.05f || f2 - f1 < 0.05f)
continue;
var cell = new float3(MathF.Floor(p.x), MathF.Floor(p.y), MathF.Floor(p.z));
var feature = FeaturePoint(cell + lastOffset);
var expectedGradient = (p - feature) / f1;
var result = math.cellular(p);
Assert.Equal(f1, result.nearest.w, precision: 4);
Assert.Equal(expectedGradient.x, result.nearest.x, precision: 4);
Assert.Equal(expectedGradient.y, result.nearest.y, precision: 4);
Assert.Equal(expectedGradient.z, result.nearest.z, precision: 4);
return;
}
Assert.Fail("did not find a random point whose nearest feature lives in the last-visited neighbour cell");
}
[Fact]
public void NearestGradientIsFiniteZeroAtAFeaturePointInsteadOfNaN()
{
var feature = FeaturePoint(new float3(4.0f, -2.0f, 9.0f));
var result = math.cellular(feature);
Assert.Equal(0.0f, result.nearest.w, precision: 4);
Assert.True(float.IsFinite(result.nearest.x) && float.IsFinite(result.nearest.y) && float.IsFinite(result.nearest.z));
Assert.Equal(0.0f, result.nearest.x, precision: 5);
Assert.Equal(0.0f, result.nearest.y, precision: 5);
Assert.Equal(0.0f, result.nearest.z, precision: 5);
}
[Fact]
public void GradientMatchesCentralDifferencesAwayFromTheF1EqualsF2Seam()
{
var random = new System.Random(0xCE12);
var tested = 0;
var attempts = 0;
while (tested < 120 && attempts < 20000)
{
attempts++;
var p = new float3(random.NextSingle() * 6.0f - 3.0f, random.NextSingle() * 6.0f - 3.0f, random.NextSingle() * 6.0f - 3.0f);
var (f1, f2, f3) = FindF1F2F3(p);
// Exclude a band around the F1 = F2 set: the identity of the nearest and second-nearest
// feature swap there, a genuine kink in F1 and F2 alike, not something central differences
// can approximate. Also exclude a band around F2 = F3: edge (F2 - F1) kinks there too,
// since the second-nearest feature's identity swaps (Soul, cut 2a-i: an unfiltered sample
// hit a 0.78 edge error this way). Also skip the exact feature point itself (F1 = 0),
// covered separately.
if (f2 - f1 < 0.05f || f1 < 0.01f || f3 - f2 < 0.05f)
continue;
tested++;
var result = math.cellular(p);
// eps=0.0005f: unlike smin_grad, cellular's F1/F2 are Euclidean distance fields with real
// curvature, so central differences carry O(eps^2) truncation error and a larger eps is
// worse, not better (see CellularGradientTolerance's comment for the measured sweep).
const float eps = 0.0005f;
// Differentiate cellular's own returned .w values (F1): calling cellular again at p +/-
// eps and reading result.nearest.w / result.edge.w, not a duplicate FindF1F2 search. A
// mutant that corrupts only the returned gradient, leaving the returned value alone,
// would survive a check built on an independent value oracle; it cannot survive this one.
float NearestValueAt(float3 q) => math.cellular(q).nearest.w;
float EdgeValueAt(float3 q) => math.cellular(q).edge.w;
AssertGradientMatchesCentralDifference(NearestValueAt, p, eps, result.nearest, CellularGradientTolerance);
AssertGradientMatchesCentralDifference(EdgeValueAt, p, eps, result.edge, CellularGradientTolerance);
}
Assert.True(tested >= 100, $"only {tested} of {attempts} random points landed away from the F1=F2 band");
}
[Fact]
public void IdIsConstantAcrossAFeaturesWholeRegionAndVariesBetweenCells()
{
var random = new System.Random(0xCE13);
var baseCell = new float3(2.0f, 2.0f, 2.0f);
var feature = FeaturePoint(baseCell);
var idAtFeature = math.cellular(feature).id;
for (var i = 0; i < 32; i++)
{
// Small offsets that stay inside the same Voronoi region (well short of any neighbour).
var jitter = new float3(random.NextSingle() * 0.2f - 0.1f, random.NextSingle() * 0.2f - 0.1f, random.NextSingle() * 0.2f - 0.1f);
var nearby = feature + jitter;
if (!FindNearestCell(nearby).Equals(baseCell))
continue;
Assert.Equal(idAtFeature, math.cellular(nearby).id, precision: 6);
}
var otherCellId = math.cellular(FeaturePoint(baseCell + new float3(5.0f, 0.0f, 0.0f))).id;
Assert.NotEqual(idAtFeature, otherCellId);
}
[Fact]
public void IdIsNotCorrelatedWithAnyJitterComponent()
{
// F4: id must come from hash bits the jitter never uses. A mutant that takes id from
// hash.x (or hash.y) makes id literally equal to that jitter component, which this catches
// as a correlation near 1.0, far past the 0.05 threshold. n = 20000 keeps sampling noise
// (measured under 0.01 at this n) well clear of that threshold.
const int n = 20000;
var random = new System.Random(0xCE15);
var ids = new double[n];
var jitterX = new double[n];
var jitterY = new double[n];
var jitterZ = new double[n];
for (var i = 0; i < n; i++)
{
var cell = new float3(random.Next(-2000, 2000), random.Next(-2000, 2000), random.Next(-2000, 2000));
var hash = math.pcg3d(math.int3(cell));
var jitter = new float3(Unit(hash.x), Unit(hash.y), Unit(hash.z));
ids[i] = math.cellular(cell + jitter).id;
jitterX[i] = jitter.x;
jitterY[i] = jitter.y;
jitterZ[i] = jitter.z;
}
Assert.True(Math.Abs(Correlation(ids, jitterX)) < 0.05, "id correlates with jitter.x");
Assert.True(Math.Abs(Correlation(ids, jitterY)) < 0.05, "id correlates with jitter.y");
Assert.True(Math.Abs(Correlation(ids, jitterZ)) < 0.05, "id correlates with jitter.z");
}
[Fact]
public void JitterAndIdPassAChiSquareUniformityTest()
{
// F4/jitter uniformity: cellular hashes the winning cell's INTEGER coordinate (design.md,
// "Integer hashing"), never that coordinate's float32 bit pattern. Hashing the bit pattern of
// an integer-valued float shares more structure between adjacent cells (same exponent, a
// mantissa one increment apart) than the integers themselves do going into pcg3d's own
// mixing, and that structure survived into the jitter: Soul measured jitter.x's marginal
// chi-square statistic at 203.8 over 1,000,000 cells this way (15 degrees of freedom;
// critical value 37.7 at alpha = 0.001), soundly rejecting uniformity. 16 equal-width bins
// over [0, 1) give those same 15 degrees of freedom for jitter.x/y/z and id alike.
const int n = 200_000;
const int bins = 16;
const double criticalValue = 37.7; // chi-square, 15 df, alpha = 0.001
var jitterXCounts = new int[bins];
var jitterYCounts = new int[bins];
var jitterZCounts = new int[bins];
var idCounts = new int[bins];
var random = new System.Random(0xCE19);
for (var i = 0; i < n; i++)
{
var cell = new float3(random.Next(-100000, 100000), random.Next(-100000, 100000), random.Next(-100000, 100000));
var hash = math.pcg3d(math.int3(cell));
jitterXCounts[Bin(Unit(hash.x), bins)]++;
jitterYCounts[Bin(Unit(hash.y), bins)]++;
jitterZCounts[Bin(Unit(hash.z), bins)]++;
var idHash = math.pcg4d(new int4(math.int3(cell), 0));
idCounts[Bin(Unit(idHash.w), bins)]++;
}
var expected = (double)n / bins;
AssertUniform(jitterXCounts, expected, criticalValue, "jitter.x");
AssertUniform(jitterYCounts, expected, criticalValue, "jitter.y");
AssertUniform(jitterZCounts, expected, criticalValue, "jitter.z");
AssertUniform(idCounts, expected, criticalValue, "id");
}
private static int Bin(float value, int binCount) => Math.Clamp((int)(value * binCount), 0, binCount - 1);
private static void AssertUniform(int[] counts, double expected, double criticalValue, string name)
{
var chiSquare = 0.0;
foreach (var count in counts)
{
var delta = count - expected;
chiSquare += delta * delta / expected;
}
Assert.True(chiSquare < criticalValue, $"{name} chi-square {chiSquare:G6} exceeds critical value {criticalValue} ({counts.Length - 1} df)");
}
[Fact]
public void NoNaNAtIntegerPointsNear2To24()
{
// F3: past |p| of about 2^23, distinct cells' jittered features can round to the same
// float32 value, making F2 = 0 reachable. Soul measured 4096 of 4096 integer points at 2^24
// producing NaN before the guard; this pins the fix over the same sample.
const float scale = 16777216.0f; // 2^24
for (var ix = 0; ix < 16; ix++)
for (var iy = 0; iy < 16; iy++)
for (var iz = 0; iz < 16; iz++)
{
var p = new float3(scale + ix, scale + iy, scale + iz);
var result = math.cellular(p);
Assert.True(
float.IsFinite(result.nearest.x) && float.IsFinite(result.nearest.y) &&
float.IsFinite(result.nearest.z) && float.IsFinite(result.nearest.w),
$"nearest not finite at {p.x},{p.y},{p.z}: {result.nearest.x},{result.nearest.y},{result.nearest.z},{result.nearest.w}");
Assert.True(
float.IsFinite(result.edge.x) && float.IsFinite(result.edge.y) &&
float.IsFinite(result.edge.z) && float.IsFinite(result.edge.w),
$"edge not finite at {p.x},{p.y},{p.z}: {result.edge.x},{result.edge.y},{result.edge.z},{result.edge.w}");
Assert.True(float.IsFinite(result.id), $"id not finite at {p.x},{p.y},{p.z}");
}
}
[Fact]
public void CellularUnitMapsAllOnesBitsBelowOne()
{
// F7: cellular_unit(0xFFFFFFFF) must land in [0, 1), not round up to exactly 1.0f.
var mapped = math.cellular_unit(-1); // -1's bit pattern is 0xFFFFFFFF.
Assert.True(mapped >= 0.0f && mapped < 1.0f, $"cellular_unit(0xFFFFFFFF) = {mapped}");
}
[Fact]
public void ProductionSearchMatchesTheBruteForceOracleExactly()
{
// F6, part 2: the production 5x5x5 search must equal the independent 7x7x7 oracle exactly
// (to the oracle's own float32 precision), over a large seeded sweep in the domain Soul
// measured ([-50, 50]^3, where 3x3x3 missed 9 of 400,000 points).
var random = new System.Random(0xCE16);
for (var i = 0; i < 20000; i++)
{
var p = new float3(random.NextSingle() * 100.0f - 50.0f, random.NextSingle() * 100.0f - 50.0f, random.NextSingle() * 100.0f - 50.0f);
var result = math.cellular(p);
var (f1, f2) = FindF1F2(p);
Assert.Equal(f1, result.nearest.w, precision: 4);
Assert.Equal(f2 - f1, result.edge.w, precision: 4);
}
}
[Fact]
public void SearchReachesEveryAxisAlignedRadiusTwoSlice()
{
// F6, part 2 (Soul finding): a single-slice loop-bound mutant on one axis (e.g. dz < 2
// instead of dz <= 2, silently dropping the +2 layer on that axis alone) still passed the
// random 20,000-point sweeps above. That is not those sweeps being weak; it is the
// exactness proof's own margin (F1, F2 always < sqrt(3), a radius-2 cell's lower bound is
// max(0, 2-1) = 1, not "exactly 2" as an earlier version of this comment claimed) making a
// genuine win from any single |offset component| = 2 cell rare over uniformly random points
// (Soul, cut 2a-i: measured about 1 hit in 28,000 samples, not the "zero in 3,000,000" an
// earlier version of this comment claimed).
// Hunting for one by choosing candidate query POINTS is the wrong end of the search; this
// instead scans candidate integer CELLS and, for each, builds the query point most likely to
// realize that cell's neighbour as the winner: the closest point of the query cell's own unit
// cube to the target neighbour's actual jittered feature. That construction finds a real
// example for every one of the six axis-aligned radius-2 offsets within a couple thousand
// candidate cells, deterministically (pcg3d has no randomness to get lucky or unlucky with).
foreach (var offset in AxisAlignedRadiusTwoOffsets)
{
var p = FindPointRealizingOffset(offset);
var (f1, f2) = FindF1F2(p); // independent 7x7x7 oracle
var result = math.cellular(p);
Assert.Equal(f1, result.nearest.w, precision: 3);
Assert.Equal(f2 - f1, result.edge.w, precision: 3);
}
}
// The six axis-aligned radius-2 offsets (F6, part 2): shared by
// SearchReachesEveryAxisAlignedRadiusTwoSlice and, via FindPointRealizingOffset, by
// HlslSourceCompatibilityTests' mirror-comparison inputs, so both pin the same construction.
internal static readonly float3[] AxisAlignedRadiusTwoOffsets =
{
new(2.0f, 0.0f, 0.0f), new(-2.0f, 0.0f, 0.0f),
new(0.0f, 2.0f, 0.0f), new(0.0f, -2.0f, 0.0f),
new(0.0f, 0.0f, 2.0f), new(0.0f, 0.0f, -2.0f),
};
// Hunting for a query point where a radius-2 offset wins F1 or F2 by choosing candidate query
// POINTS is the wrong end of the search (Soul, cut 2a-i: about 1 hit in 28,000 uniformly random
// points); this instead scans candidate integer CELLS and, for each, builds the query point most
// likely to realize that cell's neighbour as the winner: the closest point of the query cell's
// own unit cube to the target neighbour's actual jittered feature. That construction finds a
// real example for every one of the six axis-aligned radius-2 offsets within a couple thousand
// candidate cells, deterministically (pcg3d has no randomness to get lucky or unlucky with).
internal static float3 FindPointRealizingOffset(float3 offset)
{
for (var qz = -80; qz <= 80; qz++)
for (var qy = -80; qy <= 80; qy++)
for (var qx = -80; qx <= 80; qx++)
{
var q = new float3(qx, qy, qz);
var targetFeature = FeaturePoint(q + offset);
var p = new float3(
Math.Clamp(targetFeature.x, q.x, q.x + 0.999f),
Math.Clamp(targetFeature.y, q.y, q.y + 0.999f),
Math.Clamp(targetFeature.z, q.z, q.z + 0.999f));
var (f1, f2) = FindF1F2(p); // independent 7x7x7 oracle
var d = math.distance(p, targetFeature);
if (MathF.Abs(d - f1) > 1.0e-4f && MathF.Abs(d - f2) > 1.0e-4f)
continue; // this cell didn't realize the target offset as F1 or F2; try another.
return p;
}
throw new InvalidOperationException($"could not construct a query point where offset {offset.x},{offset.y},{offset.z} ever wins F1 or F2");
}
[Fact]
public void PrunedSearchIsBitIdenticalToTheUnprunedFiveCubedSearchBelowTwoToTheTwentyFifth()
{
// F6, part 2: the lower-bound prune inside math.cellular's loop must never change the
// result. Compares against an independent, unpruned 5x5x5 reference that visits every cell
// in the same fixed order, over a large seeded sweep in [-50, 50]^3.
//
// This bit-identity has a domain: it holds for |p| < 2^25 (design.md, "Precision domain").
// Above that, cell + offset starts to round in float32, so the pruned loop's recomputed
// neighbour and the unpruned loop's neighbour at the same nominal (dx, dy, dz) can land on
// different actual cells, and the two searches diverge (Soul, cut 2a-i: 180 of 1,100,000
// points differ at |p| ~= 3.4e7). [-50, 50]^3 is comfortably inside the proven domain.
var random = new System.Random(0xCE18);
for (var i = 0; i < 20000; i++)
{
var p = new float3(random.NextSingle() * 100.0f - 50.0f, random.NextSingle() * 100.0f - 50.0f, random.NextSingle() * 100.0f - 50.0f);
var pruned = math.cellular(p);
var unpruned = UnprunedCellular5x5x5(p);
Assert.Equal(unpruned.nearest.x, pruned.nearest.x);
Assert.Equal(unpruned.nearest.y, pruned.nearest.y);
Assert.Equal(unpruned.nearest.z, pruned.nearest.z);
Assert.Equal(unpruned.nearest.w, pruned.nearest.w);
Assert.Equal(unpruned.edge.x, pruned.edge.x);
Assert.Equal(unpruned.edge.y, pruned.edge.y);
Assert.Equal(unpruned.edge.z, pruned.edge.z);
Assert.Equal(unpruned.edge.w, pruned.edge.w);
Assert.Equal(unpruned.id, pruned.id);
}
}
// Independent of math.cellular's own pruning: visits every cell of the 5x5x5 block, in the same
// fixed order, unconditionally.
private static CultCellular UnprunedCellular5x5x5(float3 p)
{
var cell = new float3(MathF.Floor(p.x), MathF.Floor(p.y), MathF.Floor(p.z));
var f1 = 1.0e30f;
var f2 = 1.0e30f;
var c1 = float3.zero;
var c2 = float3.zero;
var cellId = float3.zero;
for (var dz = -2; dz <= 2; dz++)
for (var dy = -2; dy <= 2; dy++)
for (var dx = -2; dx <= 2; dx++)
{
var neighbor = cell + new float3(dx, dy, dz);
var feature = FeaturePoint(neighbor);
var d = math.distance(p, feature);
if (d < f1) { f2 = f1; c2 = c1; f1 = d; c1 = feature; cellId = neighbor; }
else if (d < f2) { f2 = d; c2 = feature; }
}
var grad1 = f1 > 0.0f ? (p - c1) / f1 : float3.zero;
var grad2 = f2 > 0.0f ? (p - c2) / f2 : float3.zero;
var idHash = math.pcg4d(new int4(math.int3(cellId), 0));
return new CultCellular(new float4(grad1, f1), new float4(grad2 - grad1, f2 - f1), Unit(idHash.w));
}
private static double Correlation(double[] a, double[] b)
{
var n = a.Length;
var meanA = 0.0;
var meanB = 0.0;
for (var i = 0; i < n; i++) { meanA += a[i]; meanB += b[i]; }
meanA /= n;
meanB /= n;
var cov = 0.0;
var varA = 0.0;
var varB = 0.0;
for (var i = 0; i < n; i++)
{
var da = a[i] - meanA;
var db = b[i] - meanB;
cov += da * db;
varA += da * da;
varB += db * db;
}
return cov / Math.Sqrt(varA * varB);
}
// ---- shared fixtures: an independent brute-force Worley search over the same public pcg3d
// hash cellular itself is documented to use (design.md, "Integer hashing"), so these tests do
// not anchor to cellular's own control flow or iteration order. FindF1F2 searches 7x7x7 (radius
// 3), strictly wider than production's own proven-sufficient 5x5x5 (F6), so it is a true
// independent oracle rather than a second copy of the algorithm under test. ----
internal static float3 FeaturePoint(float3 cell)
{
var hash = math.pcg3d(math.int3(cell));
var jitter = new float3(Unit(hash.x), Unit(hash.y), Unit(hash.z));
return cell + jitter;
}
// Mirrors math.cellular_unit's top-24-bit [0, 1) mapping (F7) exactly, so this fixture never
// drifts from production's own jitter/id mapping.
private static float Unit(int bits) => ((uint)bits >> 8) * (1.0f / 16777216.0f);
private static (float f1, float f2) FindF1F2(float3 p)
{
var (f1, f2, _) = FindF1F2F3(p);
return (f1, f2);
}
// Same independent 7x7x7 oracle as FindF1F2, plus the third-nearest distance. Central-difference
// tests need this: excluding only the F1=F2 gap is not enough, because a small F2=F3 gap is also
// a kink (the identity of the second-nearest feature swaps there), and hitting one produces a
// large, curvature-driven finite-difference error unrelated to eps or float noise (Soul, cut
// 2a-i: an unfiltered large sample hit an edge error of 0.78 this way, versus ~2e-4 once F2=F3
// is also excluded).
private static (float f1, float f2, float f3) FindF1F2F3(float3 p)
{
var cell = new float3(MathF.Floor(p.x), MathF.Floor(p.y), MathF.Floor(p.z));
var f1 = float.PositiveInfinity;
var f2 = float.PositiveInfinity;
var f3 = float.PositiveInfinity;
for (var dz = -3; dz <= 3; dz++)
for (var dy = -3; dy <= 3; dy++)
for (var dx = -3; dx <= 3; dx++)
{
var d = math.distance(p, FeaturePoint(cell + new float3(dx, dy, dz)));
if (d < f1) { f3 = f2; f2 = f1; f1 = d; }
else if (d < f2) { f3 = f2; f2 = d; }
else if (d < f3) { f3 = d; }
}
return (f1, f2, f3);
}
private static (float3 offset, float f1, float f2) FindNearestOffset(float3 p)
{
var cell = new float3(MathF.Floor(p.x), MathF.Floor(p.y), MathF.Floor(p.z));
var f1 = float.PositiveInfinity;
var f2 = float.PositiveInfinity;
var bestOffset = float3.zero;
for (var dz = -2; dz <= 2; dz++)
for (var dy = -2; dy <= 2; dy++)
for (var dx = -2; dx <= 2; dx++)
{
var offset = new float3(dx, dy, dz);
var d = math.distance(p, FeaturePoint(cell + offset));
if (d < f1) { f2 = f1; f1 = d; bestOffset = offset; }
else if (d < f2) { f2 = d; }
}
return (bestOffset, f1, f2);
}
private static float3 FindNearestCell(float3 p)
{
var cell = new float3(MathF.Floor(p.x), MathF.Floor(p.y), MathF.Floor(p.z));
var best = float.PositiveInfinity;
var bestCell = cell;
for (var dz = -1; dz <= 1; dz++)
for (var dy = -1; dy <= 1; dy++)
for (var dx = -1; dx <= 1; dx++)
{
var neighbour = cell + new float3(dx, dy, dz);
var d = math.distance(p, FeaturePoint(neighbour));
if (d < best) { best = d; bestCell = neighbour; }
}
return bestCell;
}
private static void AssertGradientMatchesCentralDifference(Func<float3, float> scalarField, float3 p, float eps, float4 valueAndGradient, float tolerance)
{
var numeric = new float3(
(scalarField(p + new float3(eps, 0.0f, 0.0f)) - scalarField(p - new float3(eps, 0.0f, 0.0f))) / (2.0f * eps),
(scalarField(p + new float3(0.0f, eps, 0.0f)) - scalarField(p - new float3(0.0f, eps, 0.0f))) / (2.0f * eps),
(scalarField(p + new float3(0.0f, 0.0f, eps)) - scalarField(p - new float3(0.0f, 0.0f, eps))) / (2.0f * eps));
AssertWithinTolerance(numeric.x, valueAndGradient.x, tolerance);
AssertWithinTolerance(numeric.y, valueAndGradient.y, tolerance);
AssertWithinTolerance(numeric.z, valueAndGradient.z, tolerance);
}
// A fixed decimal-place Assert.Equal snaps two numbers that agree to within a few times 1e-5 to
// different rounded digits whenever their true difference straddles a rounding boundary (seen:
// 0.824987829 vs 0.825006127, agreeing to 2e-5, rounding to 0.82 and 0.83). An absolute-tolerance
// check states the actual claim (central differences agree with the analytic gradient within the
// finite-difference step's own truncation error) without that artifact.
private static void AssertWithinTolerance(float expected, float actual, float tolerance) =>
Assert.True(MathF.Abs(expected - actual) <= tolerance, $"expected {expected}, actual {actual}, tolerance {tolerance}");
private static float3 RandomUnit(System.Random random) =>
new(random.NextSingle() * 2.0f - 1.0f, random.NextSingle() * 2.0f - 1.0f, random.NextSingle() * 2.0f - 1.0f);
private static float RandomScalar(System.Random random) => random.NextSingle() * 20.0f - 10.0f;
}

View file

@ -1,182 +0,0 @@
using CultMath;
using Xunit;
namespace CultMath.Tests;
// math.first_order_intercept_time / first_order_intercept had one happy-path test before this
// (MathTests.FirstOrderInterceptSolvesSimpleClosingTarget). Stryker's baseline on
// hands/adopt-stryker found L264 (zero relative velocity), L270 (the a-approx-zero linear
// fallback), L280/L283 (the quadratic determinant and its roots) and L285 (the t1>0 branch)
// all undefended by fixtures that never varied relative velocity, never went linear, never
// lacked a solution, and never used off-axis/off-origin shooter and target state.
//
// For the closing cases (a genuine positive root exists), the assertion is behavioural: the
// returned aim point, fired at shotSpeed from a moving shooter, and the target's own motion
// must put the shot and the target at the same place at the returned time. That is verified by
// computing both positions independently in the test from math.first_order_intercept's own
// contract (targetPosition + time * targetRelativeVelocity is the aim point; the shot's actual
// world position folds in the shooter's velocity), not by re-deriving a second copy of the
// production formula.
//
// For the no-solution branches, there is no physical coincidence to check (the fallback is not
// a root of the intercept equation), so the assertion instead pins the contract read directly
// from the source at math.cs:264-298: velocitySquared < 0.001 => 0; determinant < 0 => 0;
// determinant > 0 with both roots <= 0 => 0 (via max(t2, 0)).
public sealed class FirstOrderInterceptTests
{
private const int Precision = 4;
[Fact]
public void ZeroRelativeVelocityReturnsZeroTime()
{
// Target and shooter share a velocity: targetRelativeVelocity is exactly zero, well
// under the 0.001 lengthsq guard, regardless of either party's absolute speed.
var time = math.first_order_intercept_time(
shotSpeed: 50.0f,
targetRelativePosition: new float3(12.0f, -7.0f, 3.0f),
targetRelativeVelocity: float3.zero);
Assert.Equal(0.0f, time);
var aimPoint = math.first_order_intercept(
shooterPosition: new float3(1.0f, 2.0f, 3.0f),
shooterVelocity: new float3(4.0f, 4.0f, 4.0f),
shotSpeed: 50.0f,
targetPosition: new float3(13.0f, -5.0f, 6.0f),
targetVelocity: new float3(4.0f, 4.0f, 4.0f));
// Contract: with time 0, the aim point is exactly the target's current position.
Assert.Equal(13.0f, aimPoint.x, Precision);
Assert.Equal(-5.0f, aimPoint.y, Precision);
Assert.Equal(6.0f, aimPoint.z, Precision);
}
[Fact]
public void LinearFallbackWhenRelativeSpeedMatchesShotSpeedProducesPhysicalIntercept()
{
// velocitySquared == shotSpeed*shotSpeed makes a == 0 (|a| < 0.001), forcing the
// linear branch: time = -lengthsq(pos) / (2 * dot(vel, pos)), clamped to >= 0.
var shooterPosition = new float3(2.0f, -3.0f, 1.0f);
var shooterVelocity = new float3(1.0f, 0.5f, -0.5f);
var targetRelativePosition = new float3(10.0f, 0.0f, 0.0f);
var targetRelativeVelocity = new float3(-2.0f, 0.0f, 0.0f);
const float shotSpeed = 2.0f; // |targetRelativeVelocity| == 2 == shotSpeed => a == 0
var time = math.first_order_intercept_time(shotSpeed, targetRelativePosition, targetRelativeVelocity);
// -100 / (2 * -20) = 2.5, computed independently of the production formula's code path.
Assert.Equal(2.5f, time, Precision);
var targetPosition = shooterPosition + targetRelativePosition;
var targetVelocity = shooterVelocity + targetRelativeVelocity;
var aimPoint = math.first_order_intercept(shooterPosition, shooterVelocity, shotSpeed, targetPosition, targetVelocity);
AssertShotAndTargetCoincideAtTime(shooterPosition, shooterVelocity, shotSpeed, aimPoint, targetPosition, targetVelocity, time);
}
[Fact]
public void NegativeDeterminantReturnsZeroWhenTargetOutrunsTheProjectile()
{
// Target moving fast and mostly sideways, with a slow shot, so no real root of the
// intercept quadratic exists. b = 2*dot(vel, pos) is deliberately nonzero (-40): if it
// were 0, a mutant that drops the "determinant < 0" branch entirely and falls through to
// `max(-b / (2a), 0)` would coincidentally also compute 0 and this test would not
// distinguish the two.
var time = math.first_order_intercept_time(
shotSpeed: 1.0f,
targetRelativePosition: new float3(10.0f, 0.0f, 0.0f),
targetRelativeVelocity: new float3(-2.0f, 30.0f, 0.0f));
Assert.Equal(0.0f, time);
}
[Fact]
public void BothQuadraticRootsPositiveReturnsTheEarlierOne()
{
// velocitySquared > shotSpeed*shotSpeed (a > 0): the target is relatively faster than
// the shot, but closing almost head-on, so both quadratic roots are real and positive
// (lengthsq(pos) >= 0 forces same-signed roots whenever a > 0). This is the only shape
// that reaches the `t1 > 0` branch's `t2 > 0 ? min(t1, t2) : t1` line at all; every other
// fixture in this file keeps t1 <= 0.
var shooterPosition = new float3(4.0f, -2.0f, 1.0f);
var shooterVelocity = new float3(0.5f, -0.5f, 0.0f);
var shotSpeed = 5.0f;
var targetRelativePosition = new float3(10.0f, 0.0f, 0.0f);
var targetRelativeVelocity = new float3(-20.0f, 0.0f, 0.0f);
var time = math.first_order_intercept_time(shotSpeed, targetRelativePosition, targetRelativeVelocity);
// a = 400 - 25 = 375, b = -400, c = 100, determinant = 10000, root = 100:
// t1 = (400 + 100) / 750 = 0.6667, t2 = (400 - 100) / 750 = 0.4. Both positive; the
// earlier one (0.4) is the physically correct intercept time.
Assert.Equal(0.4f, time, Precision);
var targetPosition = shooterPosition + targetRelativePosition;
var targetVelocity = shooterVelocity + targetRelativeVelocity;
var aimPoint = math.first_order_intercept(shooterPosition, shooterVelocity, shotSpeed, targetPosition, targetVelocity);
AssertShotAndTargetCoincideAtTime(shooterPosition, shooterVelocity, shotSpeed, aimPoint, targetPosition, targetVelocity, time);
}
[Fact]
public void RecedingTargetWithBothRootsNonPositiveReturnsZero()
{
// Target moving directly away from the shooter, faster than a slow shot: a real
// determinant exists but both quadratic roots are negative, so t1 > 0 is false and the
// function falls through to max(t2, 0) == 0.
var time = math.first_order_intercept_time(
shotSpeed: 1.0f,
targetRelativePosition: new float3(10.0f, 0.0f, 0.0f),
targetRelativeVelocity: new float3(5.0f, 0.0f, 0.0f));
Assert.Equal(0.0f, time);
}
[Fact]
public void ClosingCaseWithOffOriginOffAxisPositionsProducesPhysicallyConsistentIntercept()
{
// Neither shooter nor target sits at the origin, no vector is axis-aligned, and both
// parties are moving. This is the fixture shape ("degenerate fixtures are how these
// survived") the earlier single test never exercised.
var shooterPosition = new float3(2.0f, 3.0f, -1.0f);
var shooterVelocity = new float3(1.0f, -1.0f, 0.5f);
var shotSpeed = 10.0f;
var targetPosition = new float3(15.0f, 8.0f, 4.0f);
var targetVelocity = new float3(-3.0f, 2.0f, 1.0f);
var targetRelativePosition = targetPosition - shooterPosition;
var targetRelativeVelocity = targetVelocity - shooterVelocity;
var time = math.first_order_intercept_time(shotSpeed, targetRelativePosition, targetRelativeVelocity);
Assert.True(time > 0.0f, "fixture must produce a genuine closing solution");
var aimPoint = math.first_order_intercept(shooterPosition, shooterVelocity, shotSpeed, targetPosition, targetVelocity);
AssertShotAndTargetCoincideAtTime(shooterPosition, shooterVelocity, shotSpeed, aimPoint, targetPosition, targetVelocity, time);
}
/// <summary>
/// Fires from <paramref name="shooterPosition"/>, inheriting <paramref name="shooterVelocity"/>
/// and adding <paramref name="shotSpeed"/> toward <paramref name="aimPoint"/>, and asserts the
/// shot's world position at <paramref name="time"/> coincides with the target's own actual
/// motion (<paramref name="targetPosition"/> + time * <paramref name="targetVelocity"/>).
/// This is the behavioural contract first_order_intercept exists to serve: aim here, and the
/// target will be there when the shot arrives.
/// </summary>
private static void AssertShotAndTargetCoincideAtTime(
float3 shooterPosition,
float3 shooterVelocity,
float shotSpeed,
float3 aimPoint,
float3 targetPosition,
float3 targetVelocity,
float time)
{
var direction = math.normalize(aimPoint - shooterPosition);
var shotVelocity = shooterVelocity + shotSpeed * direction;
var shotPositionAtTime = shooterPosition + time * shotVelocity;
var targetPositionAtTime = targetPosition + time * targetVelocity;
Assert.Equal(targetPositionAtTime.x, shotPositionAtTime.x, Precision);
Assert.Equal(targetPositionAtTime.y, shotPositionAtTime.y, Precision);
Assert.Equal(targetPositionAtTime.z, shotPositionAtTime.z, Precision);
}
}

View file

@ -7,7 +7,7 @@ public sealed class HlslMirrorTests
[Fact]
public void HlslMirrorDoesNotPublishGeometryKernels()
{
var source = HlslSourceCompatibilityTests.ReadShaderSource(GetCultMathRoot());
var source = File.ReadAllText(Path.Combine(GetCultMathRoot(), "shaders", "CultMath.hlsl"));
Assert.DoesNotContain("AdvancedErosionFilter.hlsl", source);
Assert.DoesNotContain("Planetary.hlsl", source);
Assert.DoesNotContain("spherical_erosion", source);
@ -17,7 +17,7 @@ public sealed class HlslMirrorTests
[Fact]
public void HlslMirrorPublishesCultMathPrimitives()
{
var include = HlslSourceCompatibilityTests.ReadShaderSource(GetCultMathRoot());
var include = File.ReadAllText(Path.Combine(GetCultMathRoot(), "shaders", "CultMath.hlsl"));
var requiredSymbols = new[]
{
"cultmath_radians",
@ -31,6 +31,7 @@ public sealed class HlslMirrorTests
"cultmath_smootherstep",
"cultmath_lengthsq",
"cultmath_distance",
"cultmath_normalize",
"cultmath_reflect",
"cultmath_rotate",
"cultmath_csum",
@ -40,17 +41,12 @@ public sealed class HlslMirrorTests
"cultmath_quadratic_bezier",
"cultmath_cubic_bezier",
"cultmath_hash",
"cultmath_pcg(uint",
"cultmath_pcg3d(int3",
"cultmath_pcg4d(int4",
"cultmath_value_noise",
"cultmath_snoise(float2",
"cultmath_snoise(float3",
"cultmath_value_noise_bicubic",
"cultmath_value_noise_texture",
"cultmath_value_noise_texture_bicubic",
"cultmath_smin_grad",
"cultmath_cellular",
};
foreach (var symbol in requiredSymbols)
@ -58,19 +54,8 @@ public sealed class HlslMirrorTests
Assert.Contains(symbol, include);
}
// HLSL's normalize intrinsic is the contract; a mirror copy would only restate it.
Assert.DoesNotContain("cultmath_normalize", include);
}
[Theory]
[InlineData("CultMath.hlsl")]
[InlineData("CultMath.Phacelle.hlsl")]
public void UnityPackageShaderIsIdenticalToTheCanonicalShader(string name)
{
var root = GetCultMathRoot();
Assert.Equal(
File.ReadAllText(Path.Combine(root, "shaders", name)).ReplaceLineEndings("\n"),
File.ReadAllText(Path.Combine(root, "unity", "org.gamecult.cultmath", "Shaders", name)).ReplaceLineEndings("\n"));
Assert.Contains("CULTMATH_NORMALIZE_EPSILON", include);
Assert.Contains("max(length(value), CULTMATH_NORMALIZE_EPSILON)", include);
}
private static string GetCultMathRoot()

View file

@ -187,33 +187,6 @@ public sealed class HlslSemanticsTests
Assert.Equal(DoubleBits(-0.0), DoubleBits(math.max(-0.0, 0.0)));
Assert.Equal(DoubleBits(0.0), DoubleBits(math.min(-0.0, 0.0)));
// dxc: normalize(x) is x * Rsqrt(Dot(x, x)); length and distance are Sqrt of the summed squares.
static bool AllNaN(float4 value) =>
float.IsNaN(value.x) && float.IsNaN(value.y) && float.IsNaN(value.z) && float.IsNaN(value.w);
var n2 = math.normalize(float2.zero);
Assert.True(float.IsNaN(n2.x) && float.IsNaN(n2.y));
var n3 = math.normalize(float3.zero);
Assert.True(float.IsNaN(n3.x) && float.IsNaN(n3.y) && float.IsNaN(n3.z));
Assert.True(AllNaN(math.normalize(float4.zero)));
var nanX = math.normalize(new float2(float.NaN, 2.41f));
Assert.True(float.IsNaN(nanX.x) && float.IsNaN(nanX.y));
Assert.True(AllNaN(math.normalize(new float4(1.0f, float.NaN, 2.41f, 1.0f))));
var skew = new float3(0.25f, 1.0f, -0.1f);
var inverseLength = 1.0f / MathF.Sqrt(skew.x * skew.x + skew.y * skew.y + skew.z * skew.z);
var normalized = math.normalize(skew);
Assert.Equal(Bits(skew.x * inverseLength), Bits(normalized.x));
Assert.Equal(Bits(skew.y * inverseLength), Bits(normalized.y));
Assert.Equal(Bits(skew.z * inverseLength), Bits(normalized.z));
Assert.Equal(Bits(0.0f), Bits(math.length(float2.zero)));
Assert.Equal(Bits(0.0f), Bits(math.length(float3.zero)));
Assert.Equal(Bits(0.0f), Bits(math.length(float4.zero)));
Assert.Equal(Bits(0.0f), Bits(math.distance(new float3(1.0f, 2.0f, 3.0f), new float3(1.0f, 2.0f, 3.0f))));
// asuint(float) reinterprets the IEEE-754 bits.
Assert.Equal(0x80000000u, math.asuint(-0.0f));
Assert.Equal(0x3FC00000u, math.asuint(1.5f));
Assert.Equal(0x7FC00000u, math.asuint(BitConverter.Int32BitsToSingle(0x7FC00000)));
Assert.Equal(0, math.sign(float.NaN));
Assert.Equal(-1, math.sign(-0.5f));
Assert.Equal(new int3(-1, 0, 1), math.sign(new float3(-2.0f, 0.0f, 4.0f)));
@ -338,43 +311,6 @@ public sealed class HlslSemanticsTests
Assert.Equal(new int4(5, 5, 5, 5), math.int4(5));
}
[Fact]
public void VectorConversionsFollowDxc()
{
// fptosi truncates toward zero; NaN and out-of-range inputs are undefined in DXIL and not pinned.
Assert.Equal(new int2(-1, 2), math.int2(new float2(-1.7f, 2.9f)));
Assert.Equal(new int3(0, 0, 3), new int3(new float3(-0.5f, 0.5f, 3.99f)));
Assert.Equal(new float4(1.0f, -2.0f, 3.0f, 0.0f), math.float4(new int4(1, -2, 3, 0)));
// Numeric to bool is `!= 0` (fcmp une for float, so NaN is true); bool to numeric is 0 or 1.
Assert.Equal(new bool2(true, false), math.bool2(new float2(float.NaN, -0.0f)));
Assert.Equal(new bool3(false, true, true), math.bool3(new int3(0, 5, -1)));
Assert.Equal(new float2(1.0f, 0.0f), math.float2(new bool2(true, false)));
Assert.Equal(new int4(0, 1, 1, 0), math.int4(new bool4(false, true, true, false)));
// intN(intN) is exact; it must not promote through float and lose 2^24 + 1.
Assert.Equal(new int2(16777217, -16777217), math.int2(new int2(16777217, -16777217)));
float2 splat = math.float2(0);
Assert.Equal(float2.zero, splat);
}
[Fact]
public void FracAndIntegerClampFollowHlsl()
{
// frac(double) keeps double precision; a float frac of 1e9 + 0.25 would return 0.
Assert.Equal(0.25, math.frac(1.0e9 + 0.25));
Assert.Equal(0.75, math.frac(-0.25));
Assert.Equal(0.5f, math.frac(2.5f));
// IMin(IMax(x, a), b): the result stays int, and inverted bounds return b.
int clamped = math.clamp(7, -1, 3);
Assert.Equal(3, clamped);
Assert.Equal(-1, math.clamp(-5, -1, 3));
Assert.Equal(0, math.clamp(1, 3, 0));
Assert.Equal(new int2(0, 3), math.clamp(new int2(-4, 9), 0, 3));
Assert.Equal(new int3(1, 2, 3), math.clamp(new int3(1, 2, 3), new int3(0, 0, 0), new int3(5, 5, 5)));
Assert.Equal(new int4(2, 2, 4, 4), math.clamp(new int4(1, 2, 5, 4), new int4(2), new int4(4)));
Assert.Equal(1.0f, math.clamp(2.0f, -1, 1));
}
[Fact]
public void MatrixMulFollowsHlslRowMajorConvention()
{
@ -444,11 +380,11 @@ public sealed class HlslSemanticsTests
}
[Theory]
[InlineData(0.0f, 0.0f, 0.0f, -0.435872972f)]
[InlineData(0.25f, -0.5f, 1.75f, 0.0492948629f)]
[InlineData(12.25f, -4.5f, 3.125f, 0.443114728f)]
[InlineData(-7.3f, 2.9f, 101.4f, -0.10221792f)]
public void Snoise3MatchesGoldenValues(float x, float y, float z, float expected)
[InlineData(0.0f, 0.0f, 0.0f, -0.41219875f)]
[InlineData(0.25f, -0.5f, 1.75f, 0.5250269f)]
[InlineData(12.25f, -4.5f, 3.125f, 0.008141451f)]
[InlineData(-7.3f, 2.9f, 101.4f, 0.578015f)]
public void Snoise3MatchesUnityMathematicsReference(float x, float y, float z, float expected)
{
Assert.Equal(expected, math.snoise(new float3(x, y, z)), precision: 5);
}

View file

@ -25,12 +25,6 @@ public sealed class HlslSourceCompatibilityTests
Assert.NotNull(ShaderFunction(shader, "cultmath_snoise", typeof(float3)));
Assert.NotNull(ShaderFunction(shader, "cultmath_snoise", typeof(float2)));
Assert.NotNull(ShaderFunction(shader, "cultmath_value_noise_bicubic", typeof(float2)));
Assert.NotNull(ShaderFunction(shader, "cultmath_smin_grad", typeof(float4), typeof(float4), typeof(float)));
Assert.NotNull(ShaderFunction(shader, "cultmath_cellular", typeof(float3)));
Assert.NotNull(ShaderFunction(shader, "cultmath_snoise_grad", typeof(float3)));
Assert.NotNull(ShaderFunction(shader, "cultmath_fbm_grad", typeof(float3), typeof(int), typeof(float), typeof(float)));
Assert.NotNull(ShaderFunction(shader, "cultmath_ridged_grad", typeof(float3), typeof(int), typeof(float), typeof(float)));
Assert.NotNull(ShaderFunction(shader, "cultmath_phacelle", typeof(float3), typeof(float3), typeof(float), typeof(float)));
}
// HLSL functions carry no access modifier, so they compile as private instance methods.
@ -68,41 +62,6 @@ public sealed class HlslSourceCompatibilityTests
for (var k = 0; k < 64; k++) { var values = Enumerable.Range(0, 32).Select(_ => Next()).ToArray(); cases.Add(i => values[i]); }
for (var k = 0; k < 16; k++) { var values = Enumerable.Range(0, 32).Select(_ => Next()).ToArray(); cases.Add(i => values[i & ~1]); }
// Points aimed squarely at cellular's HLSL mirror (F6/F3 Soul findings): the generic random
// and special-value cases above pass ~92 inputs through cultmath_cellular and never happen to
// hit a point where a radius-2 cell wins (about 3.5e-5 of points, Soul measured) or an
// integer point at or past 2^23, so an HLSL mutant of the search radius, the prune, or the
// removed F2=0 guard can survive this test even though it changes cellular's real output.
void AddPointCase(float3 point)
{
var values = new[] { point.x, point.y, point.z };
cases.Add(i => i < values.Length ? values[i] : 0.0f);
}
// The six constructed points from CellularAndSminGradTests.SearchReachesEveryAxisAlignedRadiusTwoSlice:
// each one realizes a different axis-aligned radius-2 offset as F1 or F2, which is exactly
// the case an `if (lowerBound >= f2)` prune written as `>= f1`, or a search radius narrowed on
// one axis (`dz < 2`, `dx` starting at -1), changes.
foreach (var offset in CellularAndSminGradTests.AxisAlignedRadiusTwoOffsets)
AddPointCase(CellularAndSminGradTests.FindPointRealizingOffset(offset));
// Integer points at 2^24, several, both signs: past |p| ~= 2^23 distinct cells' jittered
// features can round onto the same float32 value (design.md, "Precision domain"), making
// F2 = 0 reachable and exercising the grad2 = f2 > 0 ? ... : zero guard this test would
// otherwise never touch.
const float twoTo24 = 16777216.0f;
AddPointCase(new float3(twoTo24, twoTo24, twoTo24));
AddPointCase(new float3(-twoTo24, -twoTo24, -twoTo24));
AddPointCase(new float3(twoTo24 + 3.0f, -twoTo24 + 5.0f, twoTo24 - 7.0f));
AddPointCase(new float3(-twoTo24 + 11.0f, twoTo24 - 13.0f, -twoTo24 + 17.0f));
// One exact F1 = F2 tie point: the midpoint of two adjacent cells' feature points is
// bit-exact-equidistant from both by construction (negating a vector does not change its
// length, and p - a = -(p - b) here by symmetry), without needing to search for one.
var tieMidpoint = (CellularAndSminGradTests.FeaturePoint(new float3(0.0f, 0.0f, 0.0f))
+ CellularAndSminGradTests.FeaturePoint(new float3(1.0f, 0.0f, 0.0f))) * 0.5f;
AddPointCase(tieMidpoint);
var mismatches = new List<string>();
var compared = new HashSet<string>();
foreach (var mirror in shaderType.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic).Where(m => m.Name.StartsWith("cultmath_")))
@ -119,208 +78,26 @@ public sealed class HlslSourceCompatibilityTests
foreach (var input in cases)
{
var slot = 0;
var args = types.Select(t => Scalars.Contains(t) ? Scalar(t, input(slot++))
: Activator.CreateInstance(t, Components(t).Select(f => Scalar(f.FieldType, input(slot++))).ToArray())!).ToArray();
var expected = Values(counterpart.Invoke(null, args)!);
var actual = Values(mirror.Invoke(shader, args)!);
if (!BitwiseEqual(expected, actual))
var args = types.Select(t => t == typeof(float) ? (object)input(slot++)
: Activator.CreateInstance(t, Enumerable.Range(0, Components(t)).Select(_ => (object)input(slot++)).ToArray())!).ToArray();
var expected = Floats(counterpart.Invoke(null, args)!);
var actual = Floats(mirror.Invoke(shader, args)!);
if (expected.Length != actual.Length || expected.Zip(actual).Any(p => !(float.IsNaN(p.First) && float.IsNaN(p.Second))
&& BitConverter.SingleToInt32Bits(p.First) != BitConverter.SingleToInt32Bits(p.Second)))
{
mismatches.Add($"{mirror.Name}({string.Join(", ", args.Select(a => string.Join(" ", Values(a).Select(v => v.Value))))}): C# {string.Join(" ", expected.Select(v => v.Value))}, mirror {string.Join(" ", actual.Select(v => v.Value))}");
mismatches.Add($"{mirror.Name}({string.Join(", ", args.Select(a => string.Join(" ", Floats(a))))}): C# {string.Join(" ", expected)}, mirror {string.Join(" ", actual)}");
}
}
}
Assert.True(mismatches.Count == 0, $"{mismatches.Count} mismatches:{Environment.NewLine}{string.Join(Environment.NewLine, mismatches.Take(12))}");
Assert.Equal(34, compared.Count);
Assert.Equal(26, compared.Count);
}
/// <summary>
/// The generic comparison above passes about a hundred inputs through <c>cultmath_phacelle</c>, and
/// its prune only changes an output for the rare cell whose weight is nonzero but whose lower
/// bound sits just under the 2.25 cut (Soul's F6 lesson for cellular): a mirror whose cut is 2.0
/// survives a hundred inputs. A dense sweep of realistic arguments closes that gap.
/// </summary>
[Fact]
public void PhacelleMirrorMatchesCSharpBitForBitAcrossADenseSweep()
{
var (assembly, errors) = CompileShaderMirror();
Assert.True(assembly is not null, string.Join(Environment.NewLine, errors));
var shaderType = assembly!.GetType("CultMathHlsl.HlslShader")!;
var shader = Activator.CreateInstance(shaderType);
var mirror = ShaderFunction(shaderType, "cultmath_phacelle", typeof(float3), typeof(float3), typeof(float), typeof(float))!;
private static int Components(Type t) => t.GetFields().Count(f => f.FieldType == typeof(float) && !f.IsStatic);
var random = new System.Random(0x5EED2);
float Next(float extent) => (random.NextSingle() * 2.0f - 1.0f) * extent;
for (var i = 0; i < 20000; i++)
{
var p = new float3(Next(50.0f), Next(50.0f), Next(50.0f));
var side = new float3(Next(9.0f), Next(9.0f), Next(9.0f));
var offset = random.NextSingle();
var normalization = random.NextSingle();
var expected = Values(phacelle(p, side, offset, normalization));
var actual = Values(mirror.Invoke(shader, new object[] { p, side, offset, normalization })!);
Assert.True(BitwiseEqual(expected, actual), $"mirror differs from C# at p = {p}, side = {side}");
}
}
/// <summary>
/// The dense sweep almost never meets a cell whose box bound is just under the 2.25 cut and whose
/// weight is still nonzero: a mirror cut of 2.245, 2.24 or 2.2 survives it. These points sit next
/// to such cells (<see cref="PhacelleTests.PruneCutPoints"/>), so the mirror must match C# bit for
/// bit, and must match the unpruned C# sum (the shader has no prune switch, so that is the
/// independent statement that the cut is exact).
/// </summary>
[Fact]
public void PhacelleMirrorMatchesCSharpBitForBitNextToCellsJustInsideTheCut()
{
var (assembly, errors) = CompileShaderMirror();
Assert.True(assembly is not null, string.Join(Environment.NewLine, errors));
var shaderType = assembly!.GetType("CultMathHlsl.HlslShader")!;
var shader = Activator.CreateInstance(shaderType);
var mirror = ShaderFunction(shaderType, "cultmath_phacelle", typeof(float3), typeof(float3), typeof(float), typeof(float))!;
foreach (var p in PhacelleTests.PruneCutPoints)
{
Assert.True(PhacelleTests.HasCellJustInsideTheCut(p), $"no cell just inside the cut at {p}");
var actual = Values(mirror.Invoke(shader, new object[] { p, PhacelleTests.PruneCutSide, PhacelleTests.PruneCutOffset, PhacelleTests.PruneCutNormalization })!);
var pruned = Values(phacelle(p, PhacelleTests.PruneCutSide, PhacelleTests.PruneCutOffset, PhacelleTests.PruneCutNormalization));
var unpruned = Values(phacelle(p, PhacelleTests.PruneCutSide, PhacelleTests.PruneCutOffset, PhacelleTests.PruneCutNormalization, false));
Assert.True(BitwiseEqual(pruned, actual), $"mirror differs from C# at p = {p}");
Assert.True(BitwiseEqual(unpruned, actual), $"mirror differs from the unpruned C# sum at p = {p}");
}
}
/// <summary>
/// A hand-built pair of structs standing in for a mirror struct return and its C# counterpart
/// (invariant 8's one struct return shape, e.g. <see cref="CultCellular"/>): same field shape,
/// different concrete type, exactly like the real mirror comparison. Proves the field-by-field,
/// bit-for-bit walk actually rejects a struct whose field differs; without this, the walk could
/// have been comparing lengths only, or skipping the nested vector's own components.
/// </summary>
private struct StructMirrorProbeExpected { public float4 first; public float second; }
private struct StructMirrorProbeActual { public float4 first; public float second; }
[Fact]
public void StructReturnComparisonRejectsAMismatchedField()
{
var expected = new StructMirrorProbeExpected { first = new float4(1.0f, 2.0f, 3.0f, 4.0f), second = 5.0f };
var matching = new StructMirrorProbeActual { first = new float4(1.0f, 2.0f, 3.0f, 4.0f), second = 5.0f };
var mismatchedLeaf = new StructMirrorProbeActual { first = new float4(1.0f, 2.0f, 3.0f, 4.0f), second = 5.5f };
var mismatchedNestedComponent = new StructMirrorProbeActual { first = new float4(1.0f, 2.0f, -3.0f, 4.0f), second = 5.0f };
Assert.True(BitwiseEqual(Values(expected), Values(matching)));
Assert.False(BitwiseEqual(Values(expected), Values(mismatchedLeaf)));
Assert.False(BitwiseEqual(Values(expected), Values(mismatchedNestedComponent)));
}
/// <summary>
/// Struct shapes standing in for CultCellular's own field order (<c>nearest; edge; id</c>), used
/// to falsify two named mutants (F2): H2 declares the mirror struct with its first two fields
/// swapped (<c>edge; nearest; id</c>) and swaps the assignments to match, so every leaf VALUE
/// still lands in the same POSITION as the correct struct; only the field NAME at each position
/// differs. H3 renames and retypes every field (id becomes an int carrying the same bits) while
/// keeping the original position order, so only NAME and TYPE differ, never position.
/// </summary>
private struct NearestEdgeIdExpected { public float4 nearest; public float4 edge; public float id; }
private struct EdgeNearestIdSwappedNames { public float4 edge; public float4 nearest; public float id; }
private struct RenamedAndRetypedFields { public float4 a; public float4 b; public int c; }
[Fact]
public void StructReturnComparisonRejectsFieldsSwappedByNameEvenWhenValuesAlign()
{
// H2: the mutant struct's field ORDER is (edge, nearest, id), and its assignments are
// swapped to match, so declaration-position values are identical to the correct struct's.
// Only checking names (not just position) can tell these apart.
var expected = new NearestEdgeIdExpected
{
nearest = new float4(1.0f, 2.0f, 3.0f, 4.0f),
edge = new float4(5.0f, 6.0f, 7.0f, 8.0f),
id = 9.0f,
};
var swapped = new EdgeNearestIdSwappedNames
{
edge = new float4(1.0f, 2.0f, 3.0f, 4.0f), // holds what should be "nearest"'s value.
nearest = new float4(5.0f, 6.0f, 7.0f, 8.0f), // holds what should be "edge"'s value.
id = 9.0f,
};
Assert.False(BitwiseEqual(Values(expected), Values(swapped)));
}
[Fact]
public void StructReturnComparisonRejectsRenamedAndRetypedFields()
{
// H3: field order matches the correct struct exactly, but every field is renamed, and id is
// retyped from float to int carrying the same bit pattern the correct id would round to.
var expected = new NearestEdgeIdExpected
{
nearest = new float4(1.0f, 2.0f, 3.0f, 4.0f),
edge = new float4(5.0f, 6.0f, 7.0f, 8.0f),
id = 9.0f,
};
var renamed = new RenamedAndRetypedFields
{
a = new float4(1.0f, 2.0f, 3.0f, 4.0f),
b = new float4(5.0f, 6.0f, 7.0f, 8.0f),
c = BitConverter.SingleToInt32Bits(9.0f),
};
Assert.False(BitwiseEqual(Values(expected), Values(renamed)));
}
// Integer arguments take the bit pattern of the float input, so specials become 0, 0x80000000, NaN bits, ...
private static readonly Type[] Scalars = { typeof(float), typeof(int), typeof(uint) };
private static object Scalar(Type t, float f) => t == typeof(float) ? f
: t == typeof(int) ? BitConverter.SingleToInt32Bits(f) : (object)(uint)BitConverter.SingleToInt32Bits(f);
// Scoped to scalar-typed fields: this only ever builds function ARGUMENTS (float2/3/4, int2/3/4, ...),
// never a struct-of-struct return, so it must not recurse.
private static FieldInfo[] Components(Type t) => t.GetFields().Where(f => !f.IsStatic && Scalars.Contains(f.FieldType)).ToArray();
/// <summary>
/// One flattened scalar leaf of a (possibly nested) return value: its declaration path (field
/// names joined by '.'), its declared type, and its value. Carrying path and type, not just the
/// value, is what lets the struct-return comparison reject a field that was renamed or retyped
/// but landed in the same position (F2): a positional-only comparison cannot tell "edge" holding
/// nearest's value apart from "nearest" holding it.
/// </summary>
private readonly record struct Leaf(string Path, Type Type, object Value);
/// <summary>
/// Flattens a return value to its scalar leaves, recursing through struct fields of any type
/// (invariant 8's one struct return shape, e.g. <see cref="CultCellular"/>'s float4/float4/float
/// fields) so a struct return is compared field by field, down to bit-for-bit scalars, exactly
/// like a bare vector return already is. Each leaf keeps the field name (and its ancestors') and
/// declared type it was read from.
/// </summary>
private static Leaf[] Values(object value, string path = "") => Scalars.Contains(value.GetType())
? new[] { new Leaf(path, value.GetType(), value) }
: value.GetType().GetFields().Where(f => !f.IsStatic)
.SelectMany(f => Values(f.GetValue(value)!, path.Length == 0 ? f.Name : path + "." + f.Name))
.ToArray();
// Compares leaves by NAME and TYPE as well as value, in declaration order, so a mirror struct
// whose fields are reordered, renamed, or retyped relative to its C# counterpart fails here even
// when the flattened values happen to line up positionally (F2's H2/H3 mutants).
private static bool BitwiseEqual(Leaf[] expected, Leaf[] actual) =>
expected.Length == actual.Length && expected.Zip(actual).All(p =>
p.First.Path == p.Second.Path && p.First.Type == p.Second.Type &&
(p.First.Value is float a && p.Second.Value is float b
? (float.IsNaN(a) && float.IsNaN(b)) || BitConverter.SingleToInt32Bits(a) == BitConverter.SingleToInt32Bits(b)
: p.First.Value.Equals(p.Second.Value)));
/// <summary>
/// The text of <c>CultMath.hlsl</c> with each <c>#include "name"</c> replaced in place by the
/// included file from the same directory, as the shader compiler would resolve it. Only the
/// package's own includes exist, so a missing file is an error rather than a skip.
/// </summary>
internal static string ReadShaderSource(string cultMathRoot)
{
var shaders = Path.Combine(cultMathRoot, "shaders");
return Regex.Replace(File.ReadAllText(Path.Combine(shaders, "CultMath.hlsl")), @"(?m)^#include ""([^""]+)""[ \t]*\r?$",
match => File.ReadAllText(Path.Combine(shaders, match.Groups[1].Value)));
}
private static float[] Floats(object value) => value is float f ? new[] { f }
: value.GetType().GetFields().Where(field => field.FieldType == typeof(float) && !field.IsStatic).Select(field => (float)field.GetValue(value)!).ToArray();
/// <summary>The documented HLSL-to-C# transformations, and nothing else.</summary>
internal static string TransformHlslToCSharp(string hlsl)
@ -338,19 +115,13 @@ public sealed class HlslSourceCompatibilityTests
// 4. Floating literals gain the `f` suffix; an unsuffixed C# literal is a double.
source = Regex.Replace(source, @"(?<![\w.])(\d+\.\d*(?:[eE][+-]?\d+)?|\d+[eE][+-]?\d+)(?![\w.])", "$1f");
// 5. Struct fields gain `public`; HLSL has no field access-modifier concept, and a C# struct's
// fields default to private, which would hide them from the mirror test's reflection-based
// field comparison (invariant 8's one struct return shape, e.g. CultCellular).
source = Regex.Replace(source, @"(?ms)(struct\s+\w+\s*\{)(.*?)(\};)", m =>
m.Groups[1].Value + Regex.Replace(m.Groups[2].Value, @"(?m)^(\s*)(\w+\s+\w+;)", "$1public $2") + m.Groups[3].Value);
// 6. The file body is wrapped in a class; C# has no free functions.
// 5. The file body is wrapped in a class; C# has no free functions.
return "using CultMath;\nusing static CultMath.math;\nnamespace CultMathHlsl;\npublic class HlslShader\n{\n" + source + "\n}\n";
}
private static (Assembly? Assembly, IReadOnlyList<string> Errors) CompileShaderMirror()
{
var hlsl = ReadShaderSource(FindCultMathRoot());
var hlsl = File.ReadAllText(Path.Combine(FindCultMathRoot(), "shaders", "CultMath.hlsl"));
var tree = CSharpSyntaxTree.ParseText(TransformHlslToCSharp(hlsl));
var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!)
.Split(Path.PathSeparator)

View file

@ -12,41 +12,6 @@ public sealed class MathTests
Assert.Equal(0.75f, math.frac(-1.25f));
}
// Expected values come from a Python transcription of the paper's pcg3d/pcg4d (JCGT 9(3) 2020, p. 32) and
// O'Neill's pcg_output_rxs_m_xs_32_32 with PCG_DEFAULT_MULTIPLIER_32/INCREMENT_32, independent of this code.
[Fact]
public void PcgHashesMatchReferenceTranscription()
{
static int3 U3(uint x, uint y, uint z) => new((int)x, (int)y, (int)z);
static int4 U4(uint x, uint y, uint z, uint w) => new((int)x, (int)y, (int)z, (int)w);
Assert.Equal(0x07BB2FE2u, math.pcg(0u));
Assert.Equal(0xA8BEEA3Cu, math.pcg(1u));
Assert.Equal(0x995312E1u, math.pcg(0x12345678u));
Assert.Equal(0xE62A4902u, math.pcg(0xFFFFFFFFu));
Assert.Equal(U3(0x9BAFD7C6u, 0xA8E88A6Bu, 0x3F15482Cu), math.pcg3d(new int3(0, 0, 0)));
Assert.Equal(U3(0xFA9F79A6u, 0x48F2F44Cu, 0x596F5AB1u), math.pcg3d(new int3(1, 2, 3)));
Assert.Equal(U3(0x67DCF282u, 0x49E87E17u, 0x10018917u), math.pcg3d(U3(0xFFFFFFFFu, 0x80000000u, 0x7FFFFFFFu)));
Assert.Equal(U4(0x0F02F829u, 0x2D568769u, 0x32B0C43Bu, 0xD32548EAu), math.pcg4d(new int4(0, 0, 0, 0)));
Assert.Equal(U4(0x3622CD16u, 0xF11471D8u, 0xE1109B3Fu, 0x02B94C2Fu), math.pcg4d(new int4(1, 2, 3, 4)));
Assert.Equal(U4(0x72180037u, 0x81D493E6u, 0xD9BA7226u, 0x14C9ABA7u), math.pcg4d(U4(0xFFFFFFFFu, 0x80000000u, 0x7FFFFFFFu, 0xDEADBEEFu)));
Assert.Equal(U3(0xA7B40FEEu, 0x9B70DFC0u, 0x3106C2C5u), math.pcg3d(new float3(1.5f, -2.25f, 1e7f)));
Assert.Equal(U3(0x5A09E5C6u, 0x53EC0DABu, 0x4BE4A1ECu), math.pcg3d(new float2(3.0f, 4.0f)));
Assert.Equal(U4(0x1849E380u, 0xF199DEE0u, 0x6C6B4934u, 0x34AF16DFu),
math.pcg4d(new float4(-0.0f, BitConverter.Int32BitsToSingle(0x7FC00000), 0.5f, 1e-7f)));
}
[Fact]
public void Pcg3dSeparatesAPositionGrid()
{
// A single 32-bit component is subject to birthday collisions (x has one here); the full output is distinct.
var hashes = new HashSet<int3>();
for (var i = 0; i < 100; i++)
for (var j = 0; j < 100; j++)
hashes.Add(math.pcg3d(new float2(i * 0.5f - 25.0f, j * 0.5f - 25.0f)));
Assert.Equal(10000, hashes.Count);
}
[Fact]
public void VectorOperationsAreComponentWise()
{
@ -307,10 +272,10 @@ public sealed class MathTests
[Theory]
[InlineData(0.0f, 0.0f, 0.0f)]
[InlineData(0.25f, -0.5f, 0.225074813f)]
[InlineData(12.25f, -4.5f, -0.388599217f)]
[InlineData(536.5106f, 536.5106f, 0.538390875f)]
public void SimplexNoiseMatchesGoldenValues(float x, float y, float expected)
[InlineData(0.25f, -0.5f, -0.425866783f)]
[InlineData(12.25f, -4.5f, 0.258312f)]
[InlineData(536.5106f, 536.5106f, -0.6901103f)]
public void SimplexNoiseMatchesUnityMathematics(float x, float y, float expected)
{
Assert.Equal(expected, math.snoise(new float2(x, y)), precision: 6);
}
@ -367,160 +332,4 @@ public sealed class MathTests
Assert.Equal(0.0f, px[1], precision: 5);
Assert.Equal(0.0f, py[1], precision: 5);
}
// Tabulated to 16 significant figures (Abramowitz & Stegun table 7.1 lineage / standard erf tables).
[Theory]
[InlineData(0.0f, 0.0)]
[InlineData(0.5f, 0.5204998778130465)]
[InlineData(-0.5f, -0.5204998778130465)]
[InlineData(1.0f, 0.8427007929497149)]
[InlineData(-1.0f, -0.8427007929497149)]
[InlineData(2.0f, 0.9953222650189527)]
[InlineData(-2.0f, -0.9953222650189527)]
[InlineData(3.0f, 0.9999779095030014)]
[InlineData(-3.0f, -0.9999779095030014)]
public void ErfMatchesReferenceValues(float x, double expected)
{
Assert.True(
Math.Abs(math.erf(x) - expected) <= 2e-6,
$"erf({x}) = {math.erf(x)}, expected {expected}");
}
[Fact]
public void ErfIsOdd()
{
for (var i = -30; i <= 30; i++)
{
var x = i / 10.0f;
Assert.Equal(-math.erf(x), math.erf(-x));
}
}
[Fact]
public void ErfApproachesItsLimitsOfPlusMinusOne()
{
Assert.Equal(1.0f, math.erf(6.0f));
Assert.Equal(1.0f, math.erf(20.0f));
Assert.Equal(-1.0f, math.erf(-6.0f));
Assert.Equal(-1.0f, math.erf(-20.0f));
}
[Fact]
public void ErfinvInvertsErf()
{
for (var i = -999; i <= 999; i++)
{
var y = i / 1000.0f;
var x = math.erfinv(y);
var roundTrip = math.erf(x);
Assert.True(
MathF.Abs(roundTrip - y) < 1e-5f,
$"erf(erfinv({y})) = {roundTrip}");
}
}
// Reference values from bisecting the platform's double-precision erf (Python's math.erf,
// glibc-backed), not this package's own erf, to 200 iterations over [-6, 6] -- effectively
// exact at float precision. This is what ErfinvInvertsErf cannot be: a check against this
// package's own erf only proves the two functions agree with each other, not that either is
// numerically correct. Soul measured the round-trip test alone to be blind to a coefficient
// change that degraded erfinv's worst error 300-400x (5.0e-7 to 2.0e-4).
[Theory]
// Expected values are computed for the actual float32-rounded input (e.g. the literal
// 0.999f is really 0.9990000128746033 as a double), not the decimal literal, because
// erfinv's derivative diverges near +-1: at y = 0.999f that rounding alone moves the
// true answer by ~2.6e-6, larger than the test's own error bar, if ignored.
[InlineData(0.0f, 0.0)]
[InlineData(0.1f, 0.08885599182530649)]
[InlineData(-0.1f, -0.08885599182530649)]
[InlineData(0.3f, 0.2724627261055238)]
[InlineData(-0.3f, -0.2724627261055238)]
[InlineData(0.5f, 0.4769362762044699)]
[InlineData(-0.5f, -0.4769362762044699)]
[InlineData(0.7f, 0.7328690598827587)]
[InlineData(-0.7f, -0.732869059882759)]
[InlineData(0.9f, 1.1630870719457724)]
[InlineData(-0.9f, -1.1630870719457729)]
[InlineData(0.95f, 1.3859037522360764)]
[InlineData(-0.95f, -1.3859037522360773)]
[InlineData(0.99f, 1.8213866009002766)]
[InlineData(-0.99f, -1.8213866009002793)]
[InlineData(0.999f, 2.3267563267961435)]
[InlineData(-0.999f, -2.3267563267961657)]
[InlineData(0.9999f, 2.751035437903192)]
[InlineData(-0.9999f, -2.751035437903383)]
public void ErfinvMatchesReferenceValues(float y, double expected)
{
Assert.True(
Math.Abs(math.erfinv(y) - expected) <= 2e-6,
$"erfinv({y}) = {math.erfinv(y)}, expected {expected}");
}
[Fact]
public void ErfinvAtDomainEdgesAndBeyond()
{
// Domain is [-1, 1]. The edges are the correctly-signed infinite limits; anything
// outside, including +-infinity itself, has no real inverse and is NaN.
Assert.Equal(float.PositiveInfinity, math.erfinv(1.0f));
Assert.Equal(float.NegativeInfinity, math.erfinv(-1.0f));
Assert.True(float.IsNaN(math.erfinv(1.0001f)));
Assert.True(float.IsNaN(math.erfinv(-1.0001f)));
Assert.True(float.IsNaN(math.erfinv(2.0f)));
Assert.True(float.IsNaN(math.erfinv(-2.0f)));
Assert.True(float.IsNaN(math.erfinv(float.PositiveInfinity)));
Assert.True(float.IsNaN(math.erfinv(float.NegativeInfinity)));
Assert.True(float.IsNaN(math.erfinv(float.NaN)));
}
// The deleted ErfinvIsMonotonicallyIncreasing asserted a property erfinv does not have:
// Soul measured 9662 backward steps scanning every representable float32 in [0, 0.999999],
// first at y = 0.00022214651, worst drop 5.960464e-8 (one ULP at that scale) -- invisible
// only because that fixture stepped by 0.001. erf has the same shape: 100 backward steps
// over [0, 4.5], worst drop 1.192093e-7. Pin what is actually true: monotone to within a
// tolerance comfortably above that measured noise floor.
[Fact]
public void ErfinvIsMonotonicWithinFloatingPointTolerance()
{
const float tolerance = 1e-6f;
var previous = float.NegativeInfinity;
for (var i = -999; i <= 999; i++)
{
var y = i / 1000.0f;
var x = math.erfinv(y);
Assert.True(
x >= previous - tolerance,
$"erfinv({y}) = {x} dropped more than {tolerance} below previous value {previous}");
previous = x;
}
}
// One property the consumer needs, not the whole of correctness: fire control turns
// adjacent erf values into a cell's probability mass, so a backward step in erf would
// show up as a negative mass. An erf that returned a constant would pass this and is
// caught by ErfMatchesReferenceValues instead.
// Soul checked these geometries clean: 16/64/256/1024/4096 cells over
// +-4 sigma, plus a tight 1 sigma spread and a very tight 0.25 sigma spread.
[Theory]
[InlineData(16, 4f)]
[InlineData(64, 4f)]
[InlineData(256, 4f)]
[InlineData(1024, 4f)]
[InlineData(4096, 4f)]
[InlineData(256, 1f)]
[InlineData(64, 0.25f)]
public void ErfCellMassesStayNonNegativeAcrossRealisticGeometries(int cells, float sigmaRange)
{
var lo = -sigmaRange;
var hi = sigmaRange;
var step = (hi - lo) / cells;
var previous = math.erf(lo / MathF.Sqrt(2f));
for (var i = 1; i <= cells; i++)
{
var edge = lo + step * i;
var current = math.erf(edge / MathF.Sqrt(2f));
var mass = current - previous;
Assert.True(mass >= 0f, $"cell {i} of {cells} over +-{sigmaRange} sigma has negative mass {mass}");
previous = current;
}
}
}

View file

@ -1,380 +0,0 @@
using System;
using CultMath;
using Xunit;
namespace CultMath.Tests;
public sealed class NoiseGradTests
{
// ---- Invariant-8 property harness (shared by snoise_grad, fbm_grad, ridged_grad) ----
//
// Each test compares a function's analytic gradient with central differences of its own .w at
// random points in [-3, 3]^3. eps trades curvature error (eps too large) against float32
// rounding noise (eps too small). snoise is continuous across simplex-cell boundaries (the
// continuity test below), so no point needs excluding for a seam; ridged_grad excludes a band
// around its own deliberate crease. Each tolerance is at least 5x the largest error the harness
// itself produces at its seed.
private const float PropertyEps = 0.0001f;
private const float SnoiseGradTolerance = 0.03f;
private const float FbmGradTolerance = 0.05f;
private const float RidgedGradTolerance = 0.05f;
[Fact]
public void SnoiseGradGradientMatchesCentralDifferencesOfItsOwnValue()
{
var random = new System.Random(0xA11CE);
var tested = 0;
var attempts = 0;
while (tested < 200 && attempts < 20000)
{
attempts++;
var p = RandomPoint(random);
var g = math.snoise_grad(p);
float F(float3 q) => math.snoise_grad(q).w;
var fine = CentralDifference(F, p, PropertyEps);
tested++;
AssertGradientWithinTolerance(fine, g, SnoiseGradTolerance);
}
Assert.True(tested >= 150, $"only {tested} of {attempts} points were usable");
}
[Fact]
public void FbmGradGradientMatchesCentralDifferencesOfItsOwnValue()
{
const int octaves = 4;
const float lacunarity = 2.0f;
const float gain = 0.5f;
var random = new System.Random(0xA11CF);
var tested = 0;
var attempts = 0;
while (tested < 200 && attempts < 40000)
{
attempts++;
var p = RandomPoint(random);
var g = math.fbm_grad(p, octaves, lacunarity, gain);
float F(float3 q) => math.fbm_grad(q, octaves, lacunarity, gain).w;
var fine = CentralDifference(F, p, PropertyEps);
tested++;
AssertGradientWithinTolerance(fine, g, FbmGradTolerance);
}
Assert.True(tested >= 150, $"only {tested} of {attempts} points were usable");
}
[Fact]
public void RidgedGradGradientMatchesCentralDifferencesExcludingAnyOctavesZeroBand()
{
// ridged_grad's own crease (any octave's n_i = 0) is a real, deliberate discontinuity
// (design.md, "the operator's dune term"), not a numerical artifact: exclude a band around it
// for every octave, the same way cellular excludes its F1 = F2 seam.
const float zeroBand = 0.1f;
const int octaves = 3;
const float lacunarity = 2.0f;
const float gain = 0.5f;
var random = new System.Random(0xA11D0);
var tested = 0;
var attempts = 0;
while (tested < 200 && attempts < 80000)
{
attempts++;
var p = RandomPoint(random);
if (AnyOctaveNearZero(p, octaves, lacunarity, zeroBand))
continue;
var g = math.ridged_grad(p, octaves, lacunarity, gain);
float F(float3 q) => math.ridged_grad(q, octaves, lacunarity, gain).w;
var fine = CentralDifference(F, p, PropertyEps);
tested++;
AssertGradientWithinTolerance(fine, g, RidgedGradTolerance);
}
Assert.True(tested >= 150, $"only {tested} of {attempts} points were outside the zero band");
}
private static bool AnyOctaveNearZero(float3 p, int octaves, float lacunarity, float band)
{
var frequency = 1.0f;
for (var i = 0; i < octaves; i++)
{
if (MathF.Abs(math.snoise_grad(p * frequency).w) < band)
return true;
frequency *= lacunarity;
}
return false;
}
private static float3 RandomPoint(System.Random random) => new(
random.NextSingle() * 6.0f - 3.0f, random.NextSingle() * 6.0f - 3.0f, random.NextSingle() * 6.0f - 3.0f);
private static float3 CentralDifference(Func<float3, float> f, float3 p, float eps) => new(
(f(p + new float3(eps, 0.0f, 0.0f)) - f(p - new float3(eps, 0.0f, 0.0f))) / (2.0f * eps),
(f(p + new float3(0.0f, eps, 0.0f)) - f(p - new float3(0.0f, eps, 0.0f))) / (2.0f * eps),
(f(p + new float3(0.0f, 0.0f, eps)) - f(p - new float3(0.0f, 0.0f, eps))) / (2.0f * eps));
private static void AssertGradientWithinTolerance(float3 numeric, float4 analytic, float tolerance)
{
AssertWithinTolerance(numeric.x, analytic.x, tolerance);
AssertWithinTolerance(numeric.y, analytic.y, tolerance);
AssertWithinTolerance(numeric.z, analytic.z, tolerance);
}
private static void AssertWithinTolerance(float expected, float actual, float tolerance) =>
Assert.True(MathF.Abs(expected - actual) <= tolerance, $"expected {expected}, actual {actual}, tolerance {tolerance}");
// ---- snoise is continuous across simplex-cell boundaries ----
// The simplex corner set (i, i1, i2) summed by snoise(float3), derived the same way snoise does.
private static (float3 i, float3 i1, float3 i2) SimplexCell(float3 p)
{
var i = math.floor(p + math.dot(p, new float3(1.0f / 3.0f, 1.0f / 3.0f, 1.0f / 3.0f)));
var x0 = p - i + math.dot(i, new float3(1.0f / 6.0f, 1.0f / 6.0f, 1.0f / 6.0f));
var g = math.step(new float3(x0.y, x0.z, x0.x), x0);
var l = 1.0f - g;
var lzxy = new float3(l.z, l.x, l.y);
return (i, math.min(g, lzxy), math.max(g, lzxy));
}
[Fact]
public void SnoiseAndItsGradientAreContinuousAcrossSimplexCellBoundaries()
{
// March random lines in steps of 1e-3 until the corner set changes, bisect the crossing to
// float precision, and compare the two sides. With a kernel radius^2 above 0.5 a lattice
// vertex outside the four summed corners can lie inside the kernel, which leaves a value
// jump at the boundary (stegu/webgl-noise fixed it in 21d9fe23d7); at 0.5 the jump is float
// noise. A value jump below 1e-5 and a gradient jump below 1e-4 is float noise on both sides.
const int lines = 400;
const int maxSteps = 20000;
const float dt = 1.0e-3f;
var random = new System.Random(0x5EA4);
var crossings = 0;
var maxValueJump = 0.0f;
var maxGradientJump = 0.0f;
for (var line = 0; line < lines; line++)
{
var origin = RandomPoint(random);
var direction = math.normalize(new float3(
random.NextSingle() - 0.5f, random.NextSingle() - 0.5f, random.NextSingle() - 0.5f));
var start = SimplexCell(origin);
var t = 0.0f;
var found = false;
for (var step = 0; step < maxSteps && !found; step++)
{
t += dt;
found = !SimplexCell(origin + direction * t).Equals(start);
}
Assert.True(found, $"line {line} never left its simplex cell in {maxSteps} steps");
double lo = t - dt;
double hi = t;
for (var bisect = 0; bisect < 60; bisect++)
{
var mid = 0.5 * (lo + hi);
if (SimplexCell(origin + direction * (float)mid).Equals(start)) lo = mid; else hi = mid;
}
var a = math.snoise_grad(origin + direction * (float)lo);
var b = math.snoise_grad(origin + direction * (float)hi);
crossings++;
maxValueJump = MathF.Max(maxValueJump, MathF.Abs(a.w - b.w));
maxGradientJump = MathF.Max(maxGradientJump, MathF.Max(MathF.Abs(a.x - b.x), MathF.Max(MathF.Abs(a.y - b.y), MathF.Abs(a.z - b.z))));
}
Assert.Equal(lines, crossings);
Assert.True(maxValueJump < 1.0e-5f, $"value jump {maxValueJump} across a simplex boundary");
Assert.True(maxGradientJump < 1.0e-4f, $"gradient jump {maxGradientJump} across a simplex boundary");
}
// ---- snoise_grad.w vs snoise ----
[Fact]
public void SnoiseGradValueIsBitEqualToSnoise()
{
// Both compute 105 * dot(m4, px) via the exact same operation order (math.cs comment on
// snoise_grad), so this asserts bit equality over 5,000 random points in [-100, 100]^3.
var random = new System.Random(0x50F2);
for (var i = 0; i < 5000; i++)
{
var p = new float3(
random.NextSingle() * 200.0f - 100.0f, random.NextSingle() * 200.0f - 100.0f, random.NextSingle() * 200.0f - 100.0f);
var expected = math.snoise(p);
var actual = math.snoise_grad(p).w;
Assert.Equal(BitConverter.SingleToInt32Bits(expected), BitConverter.SingleToInt32Bits(actual));
}
}
// ---- fbm_grad / ridged_grad: per-octave gain and lacunarity ----
[Fact]
public void FbmGradWithOneOctaveEqualsSnoiseGrad()
{
var random = new System.Random(0xF6B10);
for (var i = 0; i < 64; i++)
{
var p = RandomPoint(random);
var lacunarity = 1.0f + random.NextSingle() * 3.0f;
var gain = 0.1f + random.NextSingle() * 0.8f;
var expected = math.snoise_grad(p);
var actual = math.fbm_grad(p, 1, lacunarity, gain);
Assert.Equal(expected.x, actual.x);
Assert.Equal(expected.y, actual.y);
Assert.Equal(expected.z, actual.z);
Assert.Equal(expected.w, actual.w);
}
}
[Fact]
public void FbmGradCompoundsGainAndLacunarityPerOctaveNotOnce()
{
// Falsifies two mutant shapes at once: applying gain/lacunarity once for the whole sum
// instead of compounding per octave, and swapping which argument drives amplitude versus
// frequency. lacunarity != gain and both != 1 so a swap changes the result, and 3 octaves
// gives amplitude/frequency three distinct compounded values (1, gain, gain^2 and
// 1, lacunarity, lacunarity^2) so "applied once" mutants cannot coincidentally match.
// The expected value is built by the same incremental multiply chain fbm_grad itself uses
// (frequency *= lacunarity, amplitude *= gain, one step at a time, not lacunarity*lacunarity
// or MathF.Pow), so this asserts bit-exact equality rather than needing a tolerance.
const float lacunarity = 2.5f;
const float gain = 0.6f;
var random = new System.Random(0xF6B11);
for (var i = 0; i < 32; i++)
{
var p = RandomPoint(random);
var frequency = 1.0f;
var amplitude = 1.0f;
var n0 = math.snoise_grad(p * frequency);
var expectedValue = amplitude * n0.w;
var expectedGradient = amplitude * frequency * new float3(n0.x, n0.y, n0.z);
frequency *= lacunarity;
amplitude *= gain;
var n1 = math.snoise_grad(p * frequency);
expectedValue += amplitude * n1.w;
expectedGradient += amplitude * frequency * new float3(n1.x, n1.y, n1.z);
frequency *= lacunarity;
amplitude *= gain;
var n2 = math.snoise_grad(p * frequency);
expectedValue += amplitude * n2.w;
expectedGradient += amplitude * frequency * new float3(n2.x, n2.y, n2.z);
var actual = math.fbm_grad(p, 3, lacunarity, gain);
Assert.Equal(expectedValue, actual.w);
Assert.Equal(expectedGradient.x, actual.x);
Assert.Equal(expectedGradient.y, actual.y);
Assert.Equal(expectedGradient.z, actual.z);
}
}
[Fact]
public void RidgedGradCompoundsGainAndLacunarityPerOctaveNotOnce()
{
// Same construction as FbmGradCompoundsGainAndLacunarityPerOctaveNotOnce, folded: value is
// Sum a_i*(1-|n_i|), gradient is -Sum a_i*f_i*sign(n_i)*grad(n_i) (math.cs comment on
// ridged_grad). p is offset away from the origin and re-tried on a near-zero octave so no
// octave's n_i lands in the fold's own zero band, which would make the closed form's sign()
// ambiguous between runs.
const float lacunarity = 2.5f;
const float gain = 0.6f;
var random = new System.Random(0xF6B12);
var tested = 0;
var attempts = 0;
while (tested < 32 && attempts < 4000)
{
attempts++;
var p = RandomPoint(random);
if (AnyOctaveNearZero(p, 3, lacunarity, 0.1f))
continue;
tested++;
var frequency = 1.0f;
var amplitude = 1.0f;
var n0 = math.snoise_grad(p * frequency);
var s0 = (float)MathF.Sign(n0.w);
var expectedValue = amplitude * (1.0f - MathF.Abs(n0.w));
var expectedGradient = -amplitude * frequency * s0 * new float3(n0.x, n0.y, n0.z);
frequency *= lacunarity;
amplitude *= gain;
var n1 = math.snoise_grad(p * frequency);
var s1 = (float)MathF.Sign(n1.w);
expectedValue += amplitude * (1.0f - MathF.Abs(n1.w));
expectedGradient += -amplitude * frequency * s1 * new float3(n1.x, n1.y, n1.z);
frequency *= lacunarity;
amplitude *= gain;
var n2 = math.snoise_grad(p * frequency);
var s2 = (float)MathF.Sign(n2.w);
expectedValue += amplitude * (1.0f - MathF.Abs(n2.w));
expectedGradient += -amplitude * frequency * s2 * new float3(n2.x, n2.y, n2.z);
var actual = math.ridged_grad(p, 3, lacunarity, gain);
Assert.Equal(expectedValue, actual.w);
Assert.Equal(expectedGradient.x, actual.x);
Assert.Equal(expectedGradient.y, actual.y);
Assert.Equal(expectedGradient.z, actual.z);
}
Assert.True(tested == 32, $"only {tested} of {attempts} points were outside the zero band");
}
// ---- ridged_grad's deliberate crease at n = 0 ----
[Fact]
public void RidgedGradOneSidedGradientsAcrossAZeroCrossingDifferInSign()
{
// 1 octave: ridged_grad(p) = 1 - |snoise(p)|, gradient = -sign(snoise(p)) * grad(snoise)(p).
// Bisect along a fixed direction from a random start point to find a tight straddle of a
// snoise zero crossing, then check the two ridged_grad gradients just either side of it point
// in near-opposite directions (their dot product is negative): the crease is real, not a
// numerical accident (design.md, "the operator's dune term").
var random = new System.Random(0xC2E55);
for (var attempt = 0; attempt < 200; attempt++)
{
var origin = RandomPoint(random);
var direction = new float3(random.NextSingle() - 0.5f, random.NextSingle() - 0.5f, random.NextSingle() - 0.5f);
if (math.length(direction) < 1.0e-3f)
continue;
float NoiseAt(float t) => math.snoise(origin + direction * t);
var lo = -3.0f;
var hi = 3.0f;
if (Math.Sign(NoiseAt(lo)) == Math.Sign(NoiseAt(hi)))
continue;
for (var step = 0; step < 40; step++)
{
var mid = (lo + hi) * 0.5f;
if (Math.Sign(NoiseAt(lo)) == Math.Sign(NoiseAt(mid))) lo = mid; else hi = mid;
}
// lo and hi now straddle the crossing within about 3 * 2^-40 of parameter t; step off by
// a small margin either side so each sample is cleanly on one side, not exactly on it.
const float margin = 1.0e-4f;
var minusSide = origin + direction * (lo - margin);
var plusSide = origin + direction * (hi + margin);
if (MathF.Sign(NoiseAt(lo - margin)) == MathF.Sign(NoiseAt(hi + margin)))
continue; // margin stepped back across the crossing; try another direction.
var gradMinus = math.ridged_grad(minusSide, 1, 2.0f, 0.5f);
var gradPlus = math.ridged_grad(plusSide, 1, 2.0f, 0.5f);
var dot = gradMinus.x * gradPlus.x + gradMinus.y * gradPlus.y + gradMinus.z * gradPlus.z;
Assert.True(dot < 0.0f, $"one-sided gradients did not differ in sign: minus=({gradMinus.x},{gradMinus.y},{gradMinus.z}) plus=({gradPlus.x},{gradPlus.y},{gradPlus.z}) dot={dot}");
return;
}
Assert.Fail("did not find a snoise zero crossing to straddle");
}
}

View file

@ -1,340 +0,0 @@
using System;
using CultMath;
using Xunit;
namespace CultMath.Tests;
public sealed class PhacelleTests
{
// Every tolerance below is at least 5x the largest error the same test measures at its seed.
private const float GradientEps = 1.0e-3f;
private const float GradientTolerance = 5.0e-3f;
private const double ValueTolerance = 1.6e-5;
private const double ThresholdBand = 0.02;
private static float3 RandomPoint(System.Random random, float extent = 3.0f) => new(
(random.NextSingle() * 2.0f - 1.0f) * extent, (random.NextSingle() * 2.0f - 1.0f) * extent, (random.NextSingle() * 2.0f - 1.0f) * extent);
// A wave vector of length 2*pi times a stripe frequency in [0.5, 1.5], in a random direction.
private static float3 RandomSide(System.Random random)
{
float3 direction;
do
{
direction = new float3(random.NextSingle() - 0.5f, random.NextSingle() - 0.5f, random.NextSingle() - 0.5f);
}
while (math.length(direction) < 0.1f);
return math.normalize(direction) * (math.TAU * (0.5f + random.NextSingle()));
}
// ---- An independent double-precision evaluation: all 64 cells, no prune, no gradient. ----
//
// It shares only the hash (pcg3d over the integer cell coordinate, top 24 bits) and the constants
// the function is specified by: weight max(0, exp(-2 d^2) - 0.01111), jitter in [-0.5, 0.5).
private readonly record struct Reference(double Cos, double Sin, double RawLength, double SupportEdgeGap);
private static Reference Evaluate(float3 p, float3 side, float offset, float normalization)
{
double fx = Math.Floor(p.x), fy = Math.Floor(p.y), fz = Math.Floor(p.z);
double lx = p.x - fx, ly = p.y - fy, lz = p.z - fz;
double sumW = 0.0, sumCos = 0.0, sumSin = 0.0, edgeGap = double.MaxValue;
var supportRadius = Math.Sqrt(Math.Log(1.0 / 0.01111) / 2.0);
for (var dz = -1; dz <= 2; dz++)
for (var dy = -1; dy <= 2; dy++)
for (var dx = -1; dx <= 2; dx++)
{
var hash = math.pcg3d(new int3((int)fx + dx, (int)fy + dy, (int)fz + dz));
double vx = lx - dx - (math.cellular_unit(hash.x) - 0.5);
double vy = ly - dy - (math.cellular_unit(hash.y) - 0.5);
double vz = lz - dz - (math.cellular_unit(hash.z) - 0.5);
var d2 = vx * vx + vy * vy + vz * vz;
edgeGap = Math.Min(edgeGap, Math.Abs(Math.Sqrt(d2) - supportRadius));
var w = Math.Max(0.0, Math.Exp(-2.0 * d2) - 0.01111);
var phase = vx * side.x + vy * side.y + vz * side.z + (double)offset * math.TAU;
sumW += w;
sumCos += w * Math.Cos(phase);
sumSin += w * Math.Sin(phase);
}
double rawCos = sumCos / sumW, rawSin = sumSin / sumW;
var raw = Math.Sqrt(rawCos * rawCos + rawSin * rawSin);
var length = Math.Max(1.0 - normalization, raw);
return new Reference(rawCos / length, rawSin / length, raw, edgeGap);
}
private static float3 CentralDifference(Func<float3, float> f, float3 p, float eps) => new(
(f(p + new float3(eps, 0.0f, 0.0f)) - f(p - new float3(eps, 0.0f, 0.0f))) / (2.0f * eps),
(f(p + new float3(0.0f, eps, 0.0f)) - f(p - new float3(0.0f, eps, 0.0f))) / (2.0f * eps),
(f(p + new float3(0.0f, 0.0f, eps)) - f(p - new float3(0.0f, 0.0f, eps))) / (2.0f * eps));
private static float MaxComponentError(float3 numeric, float4 analytic) => MathF.Max(
MathF.Abs(numeric.x - analytic.x), MathF.Max(MathF.Abs(numeric.y - analytic.y), MathF.Abs(numeric.z - analytic.z)));
// ---- Value: the function is Johansen's, on 3D cells ----
[Theory]
[InlineData(0.0f)]
[InlineData(0.5f)]
[InlineData(0.9f)]
[InlineData(1.0f)]
public void ValueMatchesTheIndependentReferenceAndNormalizesAboveTheThreshold(float normalization)
{
var random = new System.Random(0x7ACE1 + (int)(normalization * 10.0f));
var maxError = 0.0;
var above = 0;
var below = 0;
for (var i = 0; i < 3000; i++)
{
var p = RandomPoint(random, 40.0f);
var side = RandomSide(random);
var offset = random.NextSingle();
var actual = math.phacelle(p, side, offset, normalization);
var expected = Evaluate(p, side, offset, normalization);
maxError = Math.Max(maxError, Math.Max(Math.Abs(actual.cos.w - expected.Cos), Math.Abs(actual.sin.w - expected.Sin)));
if (expected.RawLength >= 1.0 - normalization + ThresholdBand)
{
// The output magnitude is 1 wherever the raw magnitude reaches 1 - normalization.
above++;
var magnitude = Math.Sqrt((double)actual.cos.w * actual.cos.w + (double)actual.sin.w * actual.sin.w);
Assert.True(Math.Abs(magnitude - 1.0) < ValueTolerance, $"magnitude {magnitude} above the threshold at {p}");
}
else if (expected.RawLength <= 1.0 - normalization - ThresholdBand)
{
below++;
}
}
Assert.True(maxError < ValueTolerance, $"max value error against the reference {maxError}");
if (normalization > 0.0f)
Assert.True(above >= 500, $"only {above} points above the threshold");
if (normalization is > 0.0f and < 1.0f)
Assert.True(below >= 50, $"only {below} points below the threshold");
}
[Fact]
public void ValueIsPeriodicInOffsetAndAQuarterCycleRotatesCosIntoSin()
{
// offset is in cycles: a quarter turn maps cos to -sin and sin to cos, and a whole turn is the
// identity. The weights and the normalization do not depend on the phase, so this is exact
// up to float rounding.
var random = new System.Random(0x7ACE2);
for (var i = 0; i < 200; i++)
{
var p = RandomPoint(random);
var side = RandomSide(random);
var offset = random.NextSingle();
var basePhasor = math.phacelle(p, side, offset, 0.5f);
var quarter = math.phacelle(p, side, offset + 0.25f, 0.5f);
var whole = math.phacelle(p, side, offset + 1.0f, 0.5f);
Assert.True(MathF.Abs(quarter.cos.w + basePhasor.sin.w) < 2.0e-5f);
Assert.True(MathF.Abs(quarter.sin.w - basePhasor.cos.w) < 2.0e-5f);
Assert.True(MathF.Abs(whole.cos.w - basePhasor.cos.w) < 2.0e-5f);
Assert.True(MathF.Abs(whole.sin.w - basePhasor.sin.w) < 2.0e-5f);
}
}
// ---- Invariant 8: the gradient is exact ----
[Fact]
public void GradientOfCosAndSinMatchesCentralDifferencesOnBothSidesOfTheNormalizationThreshold()
{
// Above the threshold the output is I / |I|; below it, I / (1 - normalization). The gradient
// is discontinuous where |I| crosses the threshold, and at the edge of each cell's weight support
// (distance 1.49998, where max(0, ...) starts: a jump of 4 d 0.01111 in grad w), so a band
// around each is excluded (by the
// reference's raw length, since the output alone cannot say how close it is).
const float normalization = 0.5f;
var random = new System.Random(0x7ACE3);
var above = 0;
var below = 0;
var maxError = 0.0f;
for (var attempts = 0; attempts < 20000 && (above < 150 || below < 150); attempts++)
{
var p = RandomPoint(random);
var side = RandomSide(random);
var offset = random.NextSingle();
var reference = Evaluate(p, side, offset, normalization);
var distance = reference.RawLength - (1.0 - normalization);
if (Math.Abs(distance) < ThresholdBand || reference.SupportEdgeGap < 3.0 * GradientEps)
continue;
if (distance > 0.0 ? above >= 150 : below >= 150)
continue;
if (distance > 0.0) above++; else below++;
var analytic = math.phacelle(p, side, offset, normalization);
var numericCos = CentralDifference(q => math.phacelle(q, side, offset, normalization).cos.w, p, GradientEps);
var numericSin = CentralDifference(q => math.phacelle(q, side, offset, normalization).sin.w, p, GradientEps);
maxError = MathF.Max(maxError, MathF.Max(MaxComponentError(numericCos, analytic.cos), MaxComponentError(numericSin, analytic.sin)));
}
Assert.True(above >= 150 && below >= 150, $"{above} points above and {below} below the threshold");
Assert.True(maxError < GradientTolerance, $"max gradient error against central differences {maxError}");
}
// ---- Stripes run along the flow ----
[Fact]
public void OnTheUnitSphereStripesRunAlongTheFlow()
{
// side = cross(p_hat, flow) * 2*pi is tangent to the sphere and perpendicular to the flow, so the
// cosine should change across the stripes (along side) and hardly at all along them (along
// flow). Asura's probe measured 0.212 for this ratio, against 0.202 for the planar upstream.
const float radiusInCells = 10.0f;
var random = new System.Random(0x7ACE4);
var reference = math.normalize(new float3(0.3f, 0.5f, 0.8f));
var alongFlow = 0.0;
var acrossFlow = 0.0;
var used = 0;
while (used < 2000)
{
var hat = math.normalize(new float3(random.NextSingle() - 0.5f, random.NextSingle() - 0.5f, random.NextSingle() - 0.5f));
var tangent = reference - hat * math.dot(hat, reference);
if (math.length(tangent) < 0.2f)
continue;
var flow = math.normalize(tangent);
var sideDirection = math.cross(hat, flow);
var phasor = math.phacelle(hat * radiusInCells, sideDirection * math.TAU, 0.25f, 0.5f);
var gradient = new float3(phasor.cos.x, phasor.cos.y, phasor.cos.z);
alongFlow += Math.Abs(math.dot(gradient, flow));
acrossFlow += Math.Abs(math.dot(gradient, sideDirection));
used++;
}
var ratio = alongFlow / acrossFlow;
Assert.True(ratio <= 0.25, $"mean|d cos/d flow| / mean|d cos/d side| = {ratio}");
}
// ---- Continuity ----
[Fact]
public void ValueStepDifferenceShrinksTenfoldPerTenfoldStepIncludingAcrossCellPlanes()
{
// Pairs straddle an integer plane on each axis (where the 4x4x4 window moves by one cell and a
// weight that did not vanish at the window edge would jump) and also start at random points.
// Lipschitz continuity means the largest difference over a step scales with the step.
var random = new System.Random(0x7ACE5);
var steps = new[] { 1.0e-3f, 1.0e-4f, 1.0e-5f };
var maxDifference = new float[steps.Length];
for (var i = 0; i < 3000; i++)
{
var p = RandomPoint(random);
var side = RandomSide(random);
var offset = random.NextSingle();
var axis = i % 3;
if (i % 2 == 0)
p = SetAxis(p, axis, MathF.Round(GetAxis(p, axis)));
for (var k = 0; k < steps.Length; k++)
{
var low = math.phacelle(SetAxis(p, axis, GetAxis(p, axis) - steps[k] * 0.5f), side, offset, 0.5f);
var high = math.phacelle(SetAxis(p, axis, GetAxis(p, axis) + steps[k] * 0.5f), side, offset, 0.5f);
maxDifference[k] = MathF.Max(maxDifference[k], MathF.Max(MathF.Abs(low.cos.w - high.cos.w), MathF.Abs(low.sin.w - high.sin.w)));
}
}
Assert.True(maxDifference[1] * 8.0f <= maxDifference[0], $"1e-3: {maxDifference[0]}, 1e-4: {maxDifference[1]}");
Assert.True(maxDifference[2] * 8.0f <= maxDifference[1], $"1e-4: {maxDifference[1]}, 1e-5: {maxDifference[2]}");
}
private static float GetAxis(float3 v, int axis) => axis == 0 ? v.x : axis == 1 ? v.y : v.z;
private static float3 SetAxis(float3 v, int axis, float value) =>
axis == 0 ? new float3(value, v.y, v.z) : axis == 1 ? new float3(v.x, value, v.z) : new float3(v.x, v.y, value);
// ---- Exact pruning ----
[Fact]
public void PrunedOutputIsBitEqualToTheUnprunedSum()
{
var random = new System.Random(0x7ACE6);
for (var i = 0; i < 20000; i++)
{
var p = RandomPoint(random, 50.0f);
var side = RandomSide(random);
var offset = random.NextSingle();
var normalization = random.NextSingle();
var pruned = math.phacelle(p, side, offset, normalization);
var unpruned = math.phacelle(p, side, offset, normalization, false);
AssertBitEqual(unpruned.cos, pruned.cos);
AssertBitEqual(unpruned.sin, pruned.sin);
}
}
// Points next to a cell whose jitter sits at the far corner of its cube: the cell's box bound is
// just under the 2.25 cut (2.2451 to 2.25) while its real distance is still under the support
// radius (d^2 < 2.24995), so its weight is small but nonzero and a prune that cuts even slightly
// low (2.245, 2.24, 2.2) drops a contribution and changes output bits. Random points meet such
// a cell about once in 10^4 evaluations, which is why the random sweep above let those cuts
// through. Each was found once by an offline search (cells K in [-12, 12]^3 whose jitter on one
// axis lies within 0.0008 of the cube face; p is the feature moved 1.4996 out along that axis,
// so |p - feature| = 1.4996 and the bound is about 1.4988). The witness check below re-derives
// that property from the hash, so a change to pcg3d fails loudly instead of degenerating.
// Shared with the HLSL mirror test.
internal static readonly float3[] PruneCutPoints =
{
new(1.77850008f, -2.16020727f, -0.9991166f),
new(1.09346282f, -2.37882328f, -3.00082445f),
new(-6.39144945f, -3.99953747f, -0.610023975f),
new(-0.99940908f, 4.16302729f, 4.07277536f),
new(3.99912024f, 7.21890545f, 1.09053993f),
new(5.43959713f, 1.99937367f, -6.14929914f),
};
internal static readonly float3 PruneCutSide = new(2.1f, -3.7f, 1.3f);
internal const float PruneCutOffset = 0.3f;
internal const float PruneCutNormalization = 0.5f;
// True when some cell of the 4x4x4 window has a box bound in [2.2451, 2.25) and a positive weight.
internal static bool HasCellJustInsideTheCut(float3 p)
{
double fx = Math.Floor(p.x), fy = Math.Floor(p.y), fz = Math.Floor(p.z);
double lx = p.x - fx, ly = p.y - fy, lz = p.z - fz;
for (var dz = -1; dz <= 2; dz++)
for (var dy = -1; dy <= 2; dy++)
for (var dx = -1; dx <= 2; dx++)
{
var gx = Math.Max(0.0, Math.Abs(lx - dx) - 0.5);
var gy = Math.Max(0.0, Math.Abs(ly - dy) - 0.5);
var gz = Math.Max(0.0, Math.Abs(lz - dz) - 0.5);
var bound = gx * gx + gy * gy + gz * gz;
if (bound < 2.2451 || bound >= 2.25)
continue;
var hash = math.pcg3d(new int3((int)fx + dx, (int)fy + dy, (int)fz + dz));
double vx = lx - dx - (math.cellular_unit(hash.x) - 0.5);
double vy = ly - dy - (math.cellular_unit(hash.y) - 0.5);
double vz = lz - dz - (math.cellular_unit(hash.z) - 0.5);
if (Math.Exp(-2.0 * (vx * vx + vy * vy + vz * vz)) > 0.01111)
return true;
}
return false;
}
[Fact]
public void PrunedOutputIsBitEqualToTheUnprunedSumNextToCellsJustInsideTheCut()
{
foreach (var p in PruneCutPoints)
{
Assert.True(HasCellJustInsideTheCut(p), $"no cell just inside the cut at {p}: the fixture no longer exercises the prune");
var pruned = math.phacelle(p, PruneCutSide, PruneCutOffset, PruneCutNormalization);
var unpruned = math.phacelle(p, PruneCutSide, PruneCutOffset, PruneCutNormalization, false);
AssertBitEqual(unpruned.cos, pruned.cos);
AssertBitEqual(unpruned.sin, pruned.sin);
}
}
private static void AssertBitEqual(float4 expected, float4 actual)
{
Assert.Equal(BitConverter.SingleToInt32Bits(expected.x), BitConverter.SingleToInt32Bits(actual.x));
Assert.Equal(BitConverter.SingleToInt32Bits(expected.y), BitConverter.SingleToInt32Bits(actual.y));
Assert.Equal(BitConverter.SingleToInt32Bits(expected.z), BitConverter.SingleToInt32Bits(actual.z));
Assert.Equal(BitConverter.SingleToInt32Bits(expected.w), BitConverter.SingleToInt32Bits(actual.w));
}
}

View file

@ -1,583 +0,0 @@
using System;
using CultMath;
using Xunit;
namespace CultMath.Tests;
// Hand-verified on hands/adopt-stryker: weakening float3.Equals from `&&` to `||` passes the
// whole existing suite. The same shape of Equals/GetHashCode, ==/!=, and </>/<=/>= exists (with
// no shared base type) across float2/3/4, double2/3, int2/3/4, bool2/3/4, quaternion,
// float2x2 and float3x3. These tests are data-driven per component position rather than one
// method per type: each type supplies a base value and, per component index, a value that
// differs at exactly that index and agrees everywhere else, plus (for the four ordered numeric
// families) a left/right pair whose relational result differs per component. bool2/3/4 and
// quaternion have no ordering operators and are not included in the comparison-operator cases.
// float2x2/float3x3 have no == / != operators (Equals and GetHashCode only) and are covered
// separately, at row granularity.
public sealed class VectorEqualityTests
{
// ---- shared, type-agnostic assertions -------------------------------------------------
private static void AssertEqualsAndHashCodeContract<T>(T a, T b, bool expectedEqual, string label)
where T : IEquatable<T>
{
Assert.True(a.Equals(b) == expectedEqual, $"{label}: a.Equals(b) expected {expectedEqual}");
Assert.True(b.Equals(a) == expectedEqual, $"{label}: b.Equals(a) expected {expectedEqual} (symmetry)");
if (expectedEqual)
{
Assert.True(a.GetHashCode() == b.GetHashCode(), $"{label}: equal values must share a hash code");
}
}
private static void AssertElementwiseEqualityOperators<TLeft, TRight, TBool>(
TLeft a,
TRight b,
int count,
Func<int, bool> expectedEqualAt,
Func<TLeft, TRight, TBool> eq,
Func<TLeft, TRight, TBool> neq,
Func<TBool, int, bool> at,
string label)
{
var eqResult = eq(a, b);
var neqResult = neq(a, b);
for (var i = 0; i < count; i++)
{
var expectedEqual = expectedEqualAt(i);
Assert.True(at(eqResult, i) == expectedEqual, $"{label}: == component {i} expected {expectedEqual}");
Assert.True(at(neqResult, i) == !expectedEqual, $"{label}: != component {i} expected {!expectedEqual}");
}
}
private static void AssertElementwiseComparisonOperators<TLeft, TRight, TBool>(
TLeft left,
TRight right,
double[] leftVals,
double[] rightVals,
Func<TLeft, TRight, TBool> lt,
Func<TLeft, TRight, TBool> gt,
Func<TLeft, TRight, TBool> le,
Func<TLeft, TRight, TBool> ge,
Func<TBool, int, bool> at,
string label)
{
var ltResult = lt(left, right);
var gtResult = gt(left, right);
var leResult = le(left, right);
var geResult = ge(left, right);
for (var i = 0; i < leftVals.Length; i++)
{
var less = leftVals[i] < rightVals[i];
var greater = leftVals[i] > rightVals[i];
Assert.True(at(ltResult, i) == less, $"{label}: < component {i} (left={leftVals[i]}, right={rightVals[i]})");
Assert.True(at(gtResult, i) == greater, $"{label}: > component {i} (left={leftVals[i]}, right={rightVals[i]})");
Assert.True(at(leResult, i) == !greater, $"{label}: <= component {i} (left={leftVals[i]}, right={rightVals[i]})");
Assert.True(at(geResult, i) == !less, $"{label}: >= component {i} (left={leftVals[i]}, right={rightVals[i]})");
}
}
// ---- float2 -----------------------------------------------------------------------------
[Theory]
[InlineData(0)]
[InlineData(1)]
public void Float2EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index)
{
var a = new float2(3.0f, -7.0f);
var b = a;
b[index] += 5.0f;
AssertEqualsAndHashCodeContract(a, a, true, "float2 self");
AssertEqualsAndHashCodeContract(a, b, false, $"float2 differing at {index}");
AssertElementwiseEqualityOperators<float2, float2, bool2>(
a, b, 2, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float2 vector==vector");
// Scalar overloads: a scalar equal to exactly one component, on both operand sides.
var scalar = a[index];
AssertElementwiseEqualityOperators<float2, float, bool2>(
a, scalar, 2, i => a[i] == scalar, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float2 vector==scalar");
AssertElementwiseEqualityOperators<float, float2, bool2>(
scalar, a, 2, i => a[i] == scalar, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float2 scalar==vector");
}
[Fact]
public void Float2ComparisonOperatorsDifferPerComponent()
{
var left = new float2(1.0f, 5.0f);
var right = new float2(2.0f, 3.0f);
var leftVals = new double[] { 1.0, 5.0 };
var rightVals = new double[] { 2.0, 3.0 };
AssertElementwiseComparisonOperators<float2, float2, bool2>(
left, right, leftVals, rightVals,
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float2 vector-vector");
const float scalar = 2.0f;
AssertElementwiseComparisonOperators<float2, float, bool2>(
left, scalar, leftVals, new double[] { scalar, scalar },
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float2 vector-scalar");
AssertElementwiseComparisonOperators<float, float2, bool2>(
scalar, left, new double[] { scalar, scalar }, leftVals,
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float2 scalar-vector");
// A value compared against an exact copy of itself: every component ties. This is the
// complementary fixture to the no-tie one above. A tie is where < and <= (or > and >=)
// actually disagree, so it catches a strict/non-strict boundary swap (e.g. < mutated to
// <=) that a fixture with no equal components cannot: unlike a threshold compared against
// an arbitrary float, an exact self-tie is neither rare nor equivalent here, since these
// are general-purpose comparison operators (bounds/containment checks compare against
// literal endpoints routinely, as CultMath's own touching-edges rect test does).
AssertElementwiseComparisonOperators<float2, float2, bool2>(
left, left, leftVals, leftVals,
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float2 tied vector-vector");
// The scalar overloads need their own tie: a uniform vector against a scalar equal to
// every component, so every component of the vector-scalar and scalar-vector overloads
// ties too (the vector-vector tie above does not cover them).
var uniform = new float2(4.0f, 4.0f);
var uniformVals = new double[] { 4.0, 4.0 };
AssertElementwiseComparisonOperators<float2, float, bool2>(
uniform, 4.0f, uniformVals, uniformVals,
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float2 tied vector-scalar");
AssertElementwiseComparisonOperators<float, float2, bool2>(
4.0f, uniform, uniformVals, uniformVals,
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float2 tied scalar-vector");
}
// ---- float3 -----------------------------------------------------------------------------
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
public void Float3EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index)
{
var a = new float3(3.0f, -7.0f, 4.5f);
var b = a;
b[index] += 5.0f;
AssertEqualsAndHashCodeContract(a, a, true, "float3 self");
AssertEqualsAndHashCodeContract(a, b, false, $"float3 differing at {index}");
AssertElementwiseEqualityOperators<float3, float3, bool3>(
a, b, 3, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float3 vector==vector");
var scalar = a[index];
AssertElementwiseEqualityOperators<float3, float, bool3>(
a, scalar, 3, i => a[i] == scalar, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float3 vector==scalar");
AssertElementwiseEqualityOperators<float, float3, bool3>(
scalar, a, 3, i => a[i] == scalar, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float3 scalar==vector");
}
[Fact]
public void Float3ComparisonOperatorsDifferPerComponent()
{
// No component is tied: a tie makes strict < and strict > agree (both false), which
// would hide a direction-flip mutation (< swapped for >) at that component.
var left = new float3(1.0f, 5.0f, 7.0f);
var right = new float3(2.0f, 3.0f, 4.0f);
var leftVals = new double[] { 1.0, 5.0, 7.0 };
var rightVals = new double[] { 2.0, 3.0, 4.0 };
AssertElementwiseComparisonOperators<float3, float3, bool3>(
left, right, leftVals, rightVals,
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float3 vector-vector");
const float scalar = 4.0f;
AssertElementwiseComparisonOperators<float3, float, bool3>(
left, scalar, leftVals, new double[] { scalar, scalar, scalar },
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float3 vector-scalar");
AssertElementwiseComparisonOperators<float, float3, bool3>(
scalar, left, new double[] { scalar, scalar, scalar }, leftVals,
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float3 scalar-vector");
// Tied against itself (see the float2 tied-fixture comment above).
AssertElementwiseComparisonOperators<float3, float3, bool3>(
left, left, leftVals, leftVals,
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float3 tied vector-vector");
// The scalar overloads need their own tie (see the float2 uniform-vector comment above).
var uniform = new float3(4.0f, 4.0f, 4.0f);
var uniformVals = new double[] { 4.0, 4.0, 4.0 };
AssertElementwiseComparisonOperators<float3, float, bool3>(
uniform, 4.0f, uniformVals, uniformVals,
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float3 tied vector-scalar");
AssertElementwiseComparisonOperators<float, float3, bool3>(
4.0f, uniform, uniformVals, uniformVals,
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float3 tied scalar-vector");
}
// ---- float4 -----------------------------------------------------------------------------
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
public void Float4EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index)
{
var a = new float4(3.0f, -7.0f, 4.5f, 9.0f);
var b = a;
b[index] += 5.0f;
AssertEqualsAndHashCodeContract(a, a, true, "float4 self");
AssertEqualsAndHashCodeContract(a, b, false, $"float4 differing at {index}");
AssertElementwiseEqualityOperators<float4, float4, bool4>(
a, b, 4, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float4 vector==vector");
var scalar = a[index];
AssertElementwiseEqualityOperators<float4, float, bool4>(
a, scalar, 4, i => a[i] == scalar, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float4 vector==scalar");
AssertElementwiseEqualityOperators<float, float4, bool4>(
scalar, a, 4, i => a[i] == scalar, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float4 scalar==vector");
}
[Fact]
public void Float4ComparisonOperatorsDifferPerComponent()
{
// No component is tied (see the float3 comment above for why a tie hides a
// direction-flip mutation at that component).
var left = new float4(1.0f, 5.0f, 7.0f, 2.0f);
var right = new float4(2.0f, 3.0f, 4.0f, 9.0f);
var leftVals = new double[] { 1.0, 5.0, 7.0, 2.0 };
var rightVals = new double[] { 2.0, 3.0, 4.0, 9.0 };
AssertElementwiseComparisonOperators<float4, float4, bool4>(
left, right, leftVals, rightVals,
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float4 vector-vector");
const float scalar = 4.0f;
AssertElementwiseComparisonOperators<float4, float, bool4>(
left, scalar, leftVals, new double[] { scalar, scalar, scalar, scalar },
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float4 vector-scalar");
AssertElementwiseComparisonOperators<float, float4, bool4>(
scalar, left, new double[] { scalar, scalar, scalar, scalar }, leftVals,
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float4 scalar-vector");
// Tied against itself (see the float2 tied-fixture comment above).
AssertElementwiseComparisonOperators<float4, float4, bool4>(
left, left, leftVals, leftVals,
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float4 tied vector-vector");
// The scalar overloads need their own tie (see the float2 uniform-vector comment above).
var uniform = new float4(4.0f, 4.0f, 4.0f, 4.0f);
var uniformVals = new double[] { 4.0, 4.0, 4.0, 4.0 };
AssertElementwiseComparisonOperators<float4, float, bool4>(
uniform, 4.0f, uniformVals, uniformVals,
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float4 tied vector-scalar");
AssertElementwiseComparisonOperators<float, float4, bool4>(
4.0f, uniform, uniformVals, uniformVals,
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float4 tied scalar-vector");
}
// ---- double2 ----------------------------------------------------------------------------
[Theory]
[InlineData(0)]
[InlineData(1)]
public void Double2EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index)
{
var a = new double2(3.0, -7.0);
var b = a;
b[index] += 5.0;
AssertEqualsAndHashCodeContract(a, a, true, "double2 self");
AssertEqualsAndHashCodeContract(a, b, false, $"double2 differing at {index}");
AssertElementwiseEqualityOperators<double2, double2, bool2>(
a, b, 2, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "double2 ==");
}
[Fact]
public void Double2ComparisonOperatorsDifferPerComponent()
{
var left = new double2(1.0, 5.0);
var right = new double2(2.0, 3.0);
var leftVals = new double[] { 1.0, 5.0 };
var rightVals = new double[] { 2.0, 3.0 };
AssertElementwiseComparisonOperators<double2, double2, bool2>(
left, right, leftVals, rightVals,
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "double2 vector-vector");
// Tied against itself (see the float2 tied-fixture comment above).
AssertElementwiseComparisonOperators<double2, double2, bool2>(
left, left, leftVals, leftVals,
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "double2 tied vector-vector");
}
// ---- double3 ----------------------------------------------------------------------------
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
public void Double3EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index)
{
var a = new double3(3.0, -7.0, 4.5);
var b = a;
b[index] += 5.0;
AssertEqualsAndHashCodeContract(a, a, true, "double3 self");
AssertEqualsAndHashCodeContract(a, b, false, $"double3 differing at {index}");
AssertElementwiseEqualityOperators<double3, double3, bool3>(
a, b, 3, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "double3 ==");
}
[Fact]
public void Double3ComparisonOperatorsDifferPerComponent()
{
// No component is tied (see the float3 comment above).
var left = new double3(1.0, 5.0, 7.0);
var right = new double3(2.0, 3.0, 4.0);
var leftVals = new double[] { 1.0, 5.0, 7.0 };
var rightVals = new double[] { 2.0, 3.0, 4.0 };
AssertElementwiseComparisonOperators<double3, double3, bool3>(
left, right, leftVals, rightVals,
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "double3 vector-vector");
// Tied against itself (see the float2 tied-fixture comment above).
AssertElementwiseComparisonOperators<double3, double3, bool3>(
left, left, leftVals, leftVals,
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "double3 tied vector-vector");
}
// ---- int2 -------------------------------------------------------------------------------
[Theory]
[InlineData(0)]
[InlineData(1)]
public void Int2EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index)
{
var a = new int2(3, -7);
var b = a;
b[index] += 5;
AssertEqualsAndHashCodeContract(a, a, true, "int2 self");
AssertEqualsAndHashCodeContract(a, b, false, $"int2 differing at {index}");
AssertElementwiseEqualityOperators<int2, int2, bool2>(
a, b, 2, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "int2 ==");
}
[Fact]
public void Int2ComparisonOperatorsDifferPerComponent()
{
var left = new int2(1, 5);
var right = new int2(2, 3);
var leftVals = new double[] { 1, 5 };
var rightVals = new double[] { 2, 3 };
AssertElementwiseComparisonOperators<int2, int2, bool2>(
left, right, leftVals, rightVals,
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "int2 vector-vector");
// Tied against itself (see the float2 tied-fixture comment above); integers make an
// exact tie the routine case, not the rare one.
AssertElementwiseComparisonOperators<int2, int2, bool2>(
left, left, leftVals, leftVals,
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "int2 tied vector-vector");
}
// ---- int3 -------------------------------------------------------------------------------
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
public void Int3EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index)
{
var a = new int3(3, -7, 4);
var b = a;
b[index] += 5;
AssertEqualsAndHashCodeContract(a, a, true, "int3 self");
AssertEqualsAndHashCodeContract(a, b, false, $"int3 differing at {index}");
AssertElementwiseEqualityOperators<int3, int3, bool3>(
a, b, 3, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "int3 ==");
}
[Fact]
public void Int3ComparisonOperatorsDifferPerComponent()
{
// No component is tied (see the float3 comment above).
var left = new int3(1, 5, 7);
var right = new int3(2, 3, 4);
var leftVals = new double[] { 1, 5, 7 };
var rightVals = new double[] { 2, 3, 4 };
AssertElementwiseComparisonOperators<int3, int3, bool3>(
left, right, leftVals, rightVals,
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "int3 vector-vector");
// Tied against itself (see the int2 tied-fixture comment above).
AssertElementwiseComparisonOperators<int3, int3, bool3>(
left, left, leftVals, leftVals,
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "int3 tied vector-vector");
}
// ---- int4 -------------------------------------------------------------------------------
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
public void Int4EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index)
{
var a = new int4(3, -7, 4, 9);
var b = a;
b[index] += 5;
AssertEqualsAndHashCodeContract(a, a, true, "int4 self");
AssertEqualsAndHashCodeContract(a, b, false, $"int4 differing at {index}");
AssertElementwiseEqualityOperators<int4, int4, bool4>(
a, b, 4, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "int4 ==");
}
[Fact]
public void Int4ComparisonOperatorsDifferPerComponent()
{
// No component is tied (see the float3 comment above).
var left = new int4(1, 5, 7, 2);
var right = new int4(2, 3, 4, 9);
var leftVals = new double[] { 1, 5, 7, 2 };
var rightVals = new double[] { 2, 3, 4, 9 };
AssertElementwiseComparisonOperators<int4, int4, bool4>(
left, right, leftVals, rightVals,
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "int4 vector-vector");
// Tied against itself (see the int2 tied-fixture comment above).
AssertElementwiseComparisonOperators<int4, int4, bool4>(
left, left, leftVals, leftVals,
(x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "int4 tied vector-vector");
}
// ---- bool2/3/4 (no ordering operators) ---------------------------------------------------
[Theory]
[InlineData(0)]
[InlineData(1)]
public void Bool2EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index)
{
var a = new bool2(true, false);
var b = a;
b[index] = !b[index];
AssertEqualsAndHashCodeContract(a, a, true, "bool2 self");
AssertEqualsAndHashCodeContract(a, b, false, $"bool2 differing at {index}");
AssertElementwiseEqualityOperators<bool2, bool2, bool2>(
a, b, 2, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "bool2 ==");
}
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
public void Bool3EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index)
{
var a = new bool3(true, false, true);
var b = a;
b[index] = !b[index];
AssertEqualsAndHashCodeContract(a, a, true, "bool3 self");
AssertEqualsAndHashCodeContract(a, b, false, $"bool3 differing at {index}");
AssertElementwiseEqualityOperators<bool3, bool3, bool3>(
a, b, 3, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "bool3 ==");
}
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
public void Bool4EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index)
{
var a = new bool4(true, false, true, false);
var b = a;
b[index] = !b[index];
AssertEqualsAndHashCodeContract(a, a, true, "bool4 self");
AssertEqualsAndHashCodeContract(a, b, false, $"bool4 differing at {index}");
AssertElementwiseEqualityOperators<bool4, bool4, bool4>(
a, b, 4, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "bool4 ==");
}
[Fact]
public void BoolVectorFalseAndTrueConstantsHaveEveryComponentSet()
{
// bool2.@false/@true and bool4.@false/bool3.@true have no other consumer in this suite
// (bool3.@false and bool4.@true are read by HlslSemanticsTests), so a wrong component in
// their field initializers would otherwise go unnoticed.
Assert.Equal(new bool2(false, false), bool2.@false);
Assert.Equal(new bool2(true, true), bool2.@true);
Assert.Equal(new bool3(false, false, false), bool3.@false);
Assert.Equal(new bool3(true, true, true), bool3.@true);
Assert.Equal(new bool4(false, false, false, false), bool4.@false);
Assert.Equal(new bool4(true, true, true, true), bool4.@true);
}
// ---- quaternion (== and != return a plain bool, not a component vector) -----------------
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
public void QuaternionEqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index)
{
var a = new quaternion(1.0f, 2.0f, 3.0f, 4.0f);
float4 asFloat4 = a;
asFloat4[index] += 5.0f;
quaternion b = asFloat4;
AssertEqualsAndHashCodeContract(a, a, true, "quaternion self");
AssertEqualsAndHashCodeContract(a, b, false, $"quaternion differing at {index}");
// quaternion's == / != are scalar bool, defined in terms of Equals: confirm they
// actually delegate rather than reimplementing (and weakening) the comparison.
var aCopy = new quaternion(a.x, a.y, a.z, a.w);
Assert.Equal(a.Equals(aCopy), a == aCopy);
Assert.Equal(!a.Equals(aCopy), a != aCopy);
Assert.Equal(a.Equals(b), a == b);
Assert.Equal(!a.Equals(b), a != b);
}
// ---- float2x2 / float3x3 (Equals/GetHashCode only, row granularity) ---------------------
[Theory]
[InlineData(0)]
[InlineData(1)]
public void Float2x2EqualsAndHashCodeDetectSingleRowDifference(int row)
{
var row0 = new float2(1.0f, 2.0f);
var row1 = new float2(3.0f, 4.0f);
var a = new float2x2(row0, row1);
var differingRow0 = row == 0 ? row0 + new float2(10.0f, 0.0f) : row0;
var differingRow1 = row == 1 ? row1 + new float2(0.0f, 10.0f) : row1;
var b = new float2x2(differingRow0, differingRow1);
AssertEqualsAndHashCodeContract(a, a, true, "float2x2 self");
AssertEqualsAndHashCodeContract(a, b, false, $"float2x2 differing at row {row}");
}
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
public void Float3x3EqualsAndHashCodeDetectSingleRowDifference(int row)
{
var row0 = new float3(1.0f, 2.0f, 3.0f);
var row1 = new float3(4.0f, 5.0f, 6.0f);
var row2 = new float3(7.0f, 8.0f, 9.0f);
var a = new float3x3(row0, row1, row2);
var differingRow0 = row == 0 ? row0 + new float3(10.0f, 0.0f, 0.0f) : row0;
var differingRow1 = row == 1 ? row1 + new float3(0.0f, 10.0f, 0.0f) : row1;
var differingRow2 = row == 2 ? row2 + new float3(0.0f, 0.0f, 10.0f) : row2;
var b = new float3x3(differingRow0, differingRow1, differingRow2);
AssertEqualsAndHashCodeContract(a, a, true, "float3x3 self");
AssertEqualsAndHashCodeContract(a, b, false, $"float3x3 differing at row {row}");
}
}

View file

@ -1,14 +0,0 @@
{
"stryker-config": {
"project": "CultMath.csproj",
"mutate": [
"**/packages/cultmath/src/CultMath/**/*.cs",
"!**/packages/cultmath/src/CultMath/Swizzles.g.cs",
"!**/packages/cultmath/src/CultMath/*.meta"
],
"mutation-level": "Standard",
"reporters": [ "progress", "html", "json" ],
"thresholds": { "high": 80, "low": 60, "break": 0 },
"test-runner": "mtp"
}
}

View file

@ -21,19 +21,6 @@ $families = @(
@{ Scalar = "bool"; Sizes = @(2, 3, 4); Functions = $true },
@{ Scalar = "double"; Sizes = @(2, 3); Functions = $false }
)
# Same-size vector conversions, as dxc lowers them: float to int is fptosi (toward zero; NaN
# and out-of-range are undefined in DXIL), int to float is sitofp, numeric to bool is `!= 0`
# (fcmp une, so NaN is true), bool to numeric is 0 or 1. Keyed "target<-source"; {0} is a
# source component. Identity forms (intN(intN), ...) come from the composition loop, which
# also keeps an int vector argument off the lossy intN(floatN) overload.
$conversions = [ordered]@{
"float<-int" = "{0}"
"float<-bool" = "{0} ? 1.0f : 0.0f"
"int<-float" = "(int){0}"
"int<-bool" = "{0} ? 1 : 0"
"bool<-float" = "{0} != 0.0f"
"bool<-int" = "{0} != 0"
}
$componentFields = @("x", "y", "z", "w")
$namings = @(@("x", "y", "z", "w"), @("r", "g", "b", "a"))
@ -99,20 +86,6 @@ foreach ($family in $families) {
}
}
foreach ($source in @("float", "int", "bool")) {
$expression = $conversions["$scalar<-$source"]
if (-not $expression) { continue }
$converted = for ($i = 0; $i -lt $size; $i++) { $expression -f "value.$($componentFields[$i])" }
Line " public $type($source$size value) : this($($converted -join ', ')) { }"
if ($family.Functions) {
$functionLines.Add(" public static $type $type($source$size value) => new(value);")
}
}
if ($scalar -eq "float" -and $family.Functions) {
# floatN(int) splat: without it floatN(0) is ambiguous between floatN(float) and floatN(intN).
$functionLines.Add(" public static $type $type(int value) => new($((@('value') * $size) -join ', '));")
}
# Single-component color aliases; x/y/z/w are the fields themselves.
$letters = $namings[1]
for ($i = 0; $i -lt $size; $i++) {

View file

@ -1,19 +0,0 @@
# Changelog
All notable changes to this package are documented in this file.
Earlier releases (0.2.0-0.2.3) predate this package's changelog and are not
backfilled here; see `docs/semver-policy.md` for why.
## [0.2.4]
### Added
- `math.erf` and `math.erfinv`: single-precision error function and its
inverse. `erf` is the Abramowitz & Stegun 7.1.26 rational/exponential fit
(max absolute error 1.5e-7 by that source, 6.621e-7 measured here against
an independent reference). `erfinv` is Giles' minimax polynomial
("Approximating the erfinv function", GPU Computing Gems, 2010), guarded
explicitly at the domain edges (`erfinv(1) = +Infinity`,
`erfinv(-1) = -Infinity`, outside `[-1, 1]` is `NaN`); measured worst
absolute error 5.066e-7, worst `erf(erfinv(y))` round trip 6.109e-7.

View file

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

View file

@ -4,7 +4,7 @@ CultMath is licensed under the Mozilla Public License 2.0 (`MPL-2.0`).
The complete license text is available from the Mozilla Foundation at
<https://www.mozilla.org/MPL/2.0/>. Source and modification history for the
precompiled assembly in this package are available in the GameCult/CultLib
repository (`packages/cultmath`) at the corresponding package tag.
precompiled assembly in this package are available in the GameCult/CultMath
repository at the corresponding package tag.
Third-party code retains its original license. See `THIRD-PARTY-NOTICES.md`.

View file

@ -2,7 +2,7 @@
This is CultMath's Unity 2021.3-compatible Git-UPM surface. It contains the
precompiled `CultMath.dll`, portable symbols, the numeric
`Shaders/CultMath.hlsl` mirror (with its `CultMath.Phacelle.hlsl` include), and the `CultMath.UnityBridge` assembly.
`Shaders/CultMath.hlsl` mirror, and the `CultMath.UnityBridge` assembly.
`CultMath.UnityBridge` is the only C# source Unity compiles here. It holds the
UnityEngine conversions the engine-free core cannot declare: `ToCultMath()` on

View file

@ -1,99 +0,0 @@
#ifndef CULTMATH_PHACELLE_HLSL
#define CULTMATH_PHACELLE_HLSL
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
// the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
//
// Phacelle noise. Copyright (c) 2025 Rune Skovbo Johansen (Shadertoy t3dyWl; MPL-2.0), read from the
// C# port PhacelleNoise in lpmitchell/AdvancedTerrainErosion, converted to burstable C# by Luke
// Mitchell (2026). Modified by GameCult: 3D cells, a caller-supplied stripe wave vector, an exact
// gradient and exact pruning. This file is an MPL-2.0 file; CultMath.hlsl, which includes it after
// its own definitions of CULTMATH_TAU and cultmath_pcg3d, is MIT (see THIRD-PARTY-NOTICES.md).
// Invariant 8's struct return shape for cultmath_phacelle, compared field by field and bit for bit
// against CultMath.CultPhasor, same field order.
struct CultPhasor
{
float4 cos;
float4 sin;
};
// Johansen's Phacelle noise on 3D cells. See math.phacelle's comment (math.Phacelle.cs) for the stripe wave
// vector, the weight, the normalization, the exact gradient and why the prune is exact. Mirrors
// the C# body operation for operation; the C#-only internal overload that switches the prune off
// has no shader twin.
CultPhasor cultmath_phacelle(float3 p, float3 side, float offset, float normalization)
{
float3 cell = floor(p);
float3 local = p - cell;
float phaseOffset = offset * CULTMATH_TAU;
float sumW = 0.0;
float sumCos = 0.0;
float sumSin = 0.0;
float3 gradW = float3(0.0, 0.0, 0.0);
float3 gradCos = float3(0.0, 0.0, 0.0);
float3 gradSin = float3(0.0, 0.0, 0.0);
for (int dz = -1; dz <= 2; dz++)
{
float gz = max(0.0, abs(local.z - (float)dz) - 0.5);
for (int dy = -1; dy <= 2; dy++)
{
float gy = max(0.0, abs(local.y - (float)dy) - 0.5);
for (int dx = -1; dx <= 2; dx++)
{
float gx = max(0.0, abs(local.x - (float)dx) - 0.5);
if (gx * gx + gy * gy + gz * gz >= 2.25)
continue;
float3 gridStep = float3(dx, dy, dz);
int3 hash = cultmath_pcg3d(int3(cell + gridStep));
float3 jitter = float3(
((uint)hash.x >> 8) * (1.0 / 16777216.0) - 0.5,
((uint)hash.y >> 8) * (1.0 / 16777216.0) - 0.5,
((uint)hash.z >> 8) * (1.0 / 16777216.0) - 0.5);
float3 v = local - gridStep - jitter;
float falloff = exp(-2.0 * dot(v, v));
float w = max(0.0, falloff - 0.01111);
float3 dw = w > 0.0 ? (-4.0 * falloff) * v : float3(0.0, 0.0, 0.0);
float phase = dot(v, side) + phaseOffset;
float c = cos(phase);
float s = sin(phase);
sumW += w;
sumCos += w * c;
sumSin += w * s;
gradW += dw;
gradCos += dw * c - (w * s) * side;
gradSin += dw * s + (w * c) * side;
}
}
}
float invW = 1.0 / sumW;
float rawCos = sumCos * invW;
float rawSin = sumSin * invW;
float3 gradRawCos = (gradCos - rawCos * gradW) * invW;
float3 gradRawSin = (gradSin - rawSin * gradW) * invW;
float rawLength = sqrt(rawCos * rawCos + rawSin * rawSin);
float floorLength = 1.0 - normalization;
CultPhasor result;
if (rawLength > floorLength)
{
float outCos = rawCos / rawLength;
float outSin = rawSin / rawLength;
float3 gradLength = (rawCos * gradRawCos + rawSin * gradRawSin) / rawLength;
result.cos = float4((gradRawCos - outCos * gradLength) / rawLength, outCos);
result.sin = float4((gradRawSin - outSin * gradLength) / rawLength, outSin);
}
else
{
result.cos = float4(gradRawCos / floorLength, rawCos / floorLength);
result.sin = float4(gradRawSin / floorLength, rawSin / floorLength);
}
return result;
}
#endif

View file

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

View file

@ -11,6 +11,7 @@
static const float CULTMATH_PI = 3.14159265358979323846;
static const float CULTMATH_TAU = 6.28318530717958647692;
static const float CULTMATH_HALF_PI = 1.57079632679489661923;
static const float CULTMATH_NORMALIZE_EPSILON = 1.0e-20;
float cultmath_radians(float degrees_value) { return degrees_value * (CULTMATH_PI / 180.0); }
float cultmath_degrees(float radians_value) { return radians_value * (180.0 / CULTMATH_PI); }
@ -76,8 +77,8 @@ float cultmath_smootherstep(float value)
float2 cultmath_snoise_mod289(float2 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; }
float3 cultmath_snoise_mod289(float3 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; }
float4 cultmath_snoise_mod289(float4 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; }
float3 cultmath_snoise_permute(float3 value) { return cultmath_snoise_mod289(((value * 34.0) + 10.0) * value); }
float4 cultmath_snoise_permute(float4 value) { return cultmath_snoise_mod289(((value * 34.0) + 10.0) * value); }
float3 cultmath_snoise_permute(float3 value) { return cultmath_snoise_mod289(((value * 34.0) + 1.0) * value); }
float4 cultmath_snoise_permute(float4 value) { return cultmath_snoise_mod289(((value * 34.0) + 1.0) * value); }
// Ashima Arts / Ian McEwan 3D simplex noise (MIT), same float32 evaluation
// order as the C# math.snoise(float3) mirror.
@ -125,9 +126,9 @@ float cultmath_snoise(float3 value)
p2 *= norm.z;
p3 *= norm.w;
float4 m = max(0.5 - float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
float4 m = max(0.6 - float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
m = m * m;
return 105.0 * dot(m * m, float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
return 42.0 * dot(m * m, float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
}
float cultmath_snoise(float2 value)
@ -162,114 +163,6 @@ float cultmath_snoise(float2 value)
return 130.0 * dot(m, g);
}
// Analytic gradient of cultmath_snoise(float3) (invariant 8), following webgl-noise
// src/noise3Dgrad.glsl (Ashima Arts / McEwan, MIT); same kernel, permutation and scale as
// cultmath_snoise(float3) (stegu/webgl-noise 22434e04d7). See the C# math.snoise_grad comment for the
// derivation. Every local up through p0..p3 and x0..x3 is cultmath_snoise(float3)'s own derivation
// verbatim.
float4 cultmath_snoise_grad(float3 value)
{
float2 c = float2(1.0 / 6.0, 1.0 / 3.0);
float3 i = floor(value + dot(value, c.yyy));
float3 x0 = value - i + dot(i, c.xxx);
float3 g = step(x0.yzx, x0.xyz);
float3 l = 1.0 - g;
float3 i1 = min(g.xyz, l.zxy);
float3 i2 = max(g.xyz, l.zxy);
float3 x1 = x0 - i1 + c.xxx;
float3 x2 = x0 - i2 + c.yyy;
float3 x3 = x0 - 0.5;
i = cultmath_snoise_mod289(i);
float4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute(
i.z + float4(0.0, i1.z, i2.z, 1.0)) + i.y + float4(0.0, i1.y, i2.y, 1.0)) + i.x + float4(0.0, i1.x, i2.x, 1.0));
const float n_ = 0.142857142857;
float3 ns = float3(2.0 * n_, 0.5 * n_ - 1.0, n_);
float4 j = p - 49.0 * floor(p * ns.z * ns.z);
float4 x_ = floor(j * ns.z);
float4 y_ = floor(j - 7.0 * x_);
float4 x = x_ * ns.x + ns.yyyy;
float4 y = y_ * ns.x + ns.yyyy;
float4 h = 1.0 - abs(x) - abs(y);
float4 b0 = float4(x.xy, y.xy);
float4 b1 = float4(x.zw, y.zw);
float4 s0 = floor(b0) * 2.0 + 1.0;
float4 s1 = floor(b1) * 2.0 + 1.0;
float4 sh = -step(h, 0.0);
float4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
float4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
float3 p0 = float3(a0.xy, h.x);
float3 p1 = float3(a0.zw, h.y);
float3 p2 = float3(a1.xy, h.z);
float3 p3 = float3(a1.zw, h.w);
float4 norm = 1.79284291400159 - 0.85373472095314 * float4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3));
p0 *= norm.x;
p1 *= norm.y;
p2 *= norm.z;
p3 *= norm.w;
float4 m0 = max(0.5 - float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
float4 m2 = m0 * m0;
float4 m3 = m2 * m0;
float4 m4 = m2 * m2;
float4 px = float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3));
float value2 = 105.0 * dot(m4, px);
float3 grad = -8.0 * m3.x * x0 * px.x + m4.x * p0
+ -8.0 * m3.y * x1 * px.y + m4.y * p1
+ -8.0 * m3.z * x2 * px.z + m4.z * p2
+ -8.0 * m3.w * x3 * px.w + m4.w * p3;
grad *= 105.0;
return float4(grad, value2);
}
// Octave sum of cultmath_snoise_grad (invariant 8); see the C# math.fbm_grad comment for the
// per-octave frequency/amplitude derivation and why octaves is clamped.
float4 cultmath_fbm_grad(float3 p, int octaves, float lacunarity, float gain)
{
octaves = clamp(octaves, 0, 16);
float amplitude = 1.0;
float frequency = 1.0;
float value = 0.0;
float3 gradient = float3(0.0, 0.0, 0.0);
for (int i = 0; i < octaves; i++)
{
float4 n = cultmath_snoise_grad(p * frequency);
value += amplitude * n.w;
gradient += amplitude * frequency * n.xyz;
frequency *= lacunarity;
amplitude *= gain;
}
return float4(gradient, value);
}
// Ridged multifractal noise (Musgrave, Texturing and Modeling, ch. 16); see the C# math.ridged_grad
// comment for the fold/sign derivation, the deliberate crease at n = 0, and why octaves is clamped.
float4 cultmath_ridged_grad(float3 p, int octaves, float lacunarity, float gain)
{
octaves = clamp(octaves, 0, 16);
float amplitude = 1.0;
float frequency = 1.0;
float value = 0.0;
float3 gradient = float3(0.0, 0.0, 0.0);
for (int i = 0; i < octaves; i++)
{
float4 n = cultmath_snoise_grad(p * frequency);
float s = sign(n.w);
value += amplitude * (1.0 - abs(n.w));
gradient += -amplitude * frequency * s * n.xyz;
frequency *= lacunarity;
amplitude *= gain;
}
return float4(gradient, value);
}
float cultmath_lengthsq(float2 value) { return dot(value, value); }
float cultmath_lengthsq(float3 value) { return dot(value, value); }
float cultmath_lengthsq(float4 value) { return dot(value, value); }
@ -278,6 +171,10 @@ float cultmath_distance(float2 left, float2 right) { return length(left - right)
float cultmath_distance(float3 left, float3 right) { return length(left - right); }
float cultmath_distance(float4 left, float4 right) { return length(left - right); }
float2 cultmath_normalize(float2 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); }
float3 cultmath_normalize(float3 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); }
float4 cultmath_normalize(float4 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); }
float2 cultmath_reflect(float2 incident, float2 normal_value)
{
return incident - 2.0 * dot(normal_value, incident) * normal_value;
@ -389,122 +286,6 @@ float cultmath_hash(float value) { return cultmath_frac(sin(value) * 43758.5453)
float cultmath_hash(float2 value) { return cultmath_hash(dot(value, float2(127.1, 311.7))); }
float cultmath_hash(float3 value) { return cultmath_hash(dot(value, float3(127.1, 311.7, 74.7))); }
// PCG hashes (Jarzynski and Olano, JCGT 2020), written over scalar uints so CultMath's int3/int4 carry the bits.
uint cultmath_pcg(uint value)
{
uint state = value * 747796405u + 2891336453u;
uint word = ((state >> (int)((state >> 28) + 4)) ^ state) * 277803737u;
return (word >> 22) ^ word;
}
int3 cultmath_pcg3d(int3 value)
{
uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u, z = (uint)value.z * 1664525u + 1013904223u;
x += y * z; y += z * x; z += x * y;
x ^= x >> 16; y ^= y >> 16; z ^= z >> 16;
x += y * z; y += z * x; z += x * y;
return int3((int)x, (int)y, (int)z);
}
int3 cultmath_pcg3d(float2 value) { return cultmath_pcg3d(float3(value, 0.0)); }
int3 cultmath_pcg3d(float3 value) { return cultmath_pcg3d(int3((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z))); }
int4 cultmath_pcg4d(int4 value)
{
uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u;
uint z = (uint)value.z * 1664525u + 1013904223u, w = (uint)value.w * 1664525u + 1013904223u;
x += y * w; y += z * x; z += x * y; w += y * z;
x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; w ^= w >> 16;
x += y * w; y += z * x; z += x * y; w += y * z;
return int4((int)x, (int)y, (int)z, (int)w);
}
int4 cultmath_pcg4d(float4 value) { return cultmath_pcg4d(int4((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z), (int)asuint(value.w))); }
// Inigo Quilez, "smooth minimum": quadratic-polynomial smin carried to value-and-gradient form. See
// math.smin_grad's comment (math.cs) for why the gradient blend is exact, not an approximation.
float4 cultmath_smin_grad(float4 a, float4 b, float k)
{
float h = saturate(0.5 + 0.5 * (b.w - a.w) / k);
float value = lerp(b.w, a.w, h) - k * h * (1.0 - h);
float3 gradient = lerp(b.xyz, a.xyz, h);
return float4(gradient, value);
}
// Invariant 8's one struct return shape (design.md, "HLSL Target: Source Transformations"):
// compared field by field and bit for bit against CultMath.CultCellular, same field order.
struct CultCellular
{
float4 nearest;
float4 edge;
float id;
};
// Worley, "A Cellular Texture Basis Function" (SIGGRAPH 1996). See math.cellular's comment
// (math.cs) for the hashing, gradient, search-radius exactness proof, id source and
// degenerate-point conventions this mirrors exactly, including the 5x5x5 lower-bound prune.
CultCellular cultmath_cellular(float3 p)
{
float3 cell = floor(p);
float f1 = 1.0e30;
float f2 = 1.0e30;
float3 c1 = float3(0.0, 0.0, 0.0);
float3 c2 = float3(0.0, 0.0, 0.0);
float3 cellId = float3(0.0, 0.0, 0.0);
for (int dz = -2; dz <= 2; dz++)
{
float lz = max(0.0, abs((float)dz) - 1.0);
for (int dy = -2; dy <= 2; dy++)
{
float ly = max(0.0, abs((float)dy) - 1.0);
for (int dx = -2; dx <= 2; dx++)
{
float lx = max(0.0, abs((float)dx) - 1.0);
float lowerBound = sqrt(lx * lx + ly * ly + lz * lz);
if (lowerBound >= f2)
continue;
float3 neighbor = cell + float3(dx, dy, dz);
int3 hash = cultmath_pcg3d(int3(neighbor));
float3 jitter = float3(
((uint)hash.x >> 8) * (1.0 / 16777216.0),
((uint)hash.y >> 8) * (1.0 / 16777216.0),
((uint)hash.z >> 8) * (1.0 / 16777216.0));
float3 feature = neighbor + jitter;
float d = cultmath_distance(p, feature);
if (d < f1)
{
f2 = f1; c2 = c1;
f1 = d; c1 = feature; cellId = neighbor;
}
else if (d < f2)
{
f2 = d; c2 = feature;
}
}
}
}
// F1 = 0 and F2 = 0 share the same degenerate-gradient guard; see math.cellular's comment
// (math.cs) for why F2 = 0 is reachable past roughly |p| = 2^23.
float3 grad1 = f1 > 0.0 ? (p - c1) / f1 : float3(0.0, 0.0, 0.0);
float3 grad2 = f2 > 0.0 ? (p - c2) / f2 : float3(0.0, 0.0, 0.0);
// id comes from pcg4d over the winning cell's integer coordinate alone (same int-hashing rule
// as the jitter above), never from the pcg3d hash that produced its jitter (F4).
int4 idHash = cultmath_pcg4d(int4(int3(cellId), 0));
CultCellular result;
result.nearest = float4(grad1, f1);
result.edge = float4(grad2 - grad1, f2 - f1);
result.id = ((uint)idHash.w >> 8) * (1.0 / 16777216.0);
return result;
}
#include "CultMath.Phacelle.hlsl"
float cultmath_value_noise(float2 position)
{
float2 cell = floor(position);

View file

@ -6,36 +6,6 @@
simplex-noise functions by Ian McEwan and Ashima Arts, as distributed by Stefan
Gustavson's `webgl-noise` project and Unity Mathematics.
`math.snoise(float3)` and `cultmath_snoise(float3)` follow `src/noise3D.glsl` and
`math.snoise(float2)` follows `src/noise2D.glsl` of stegu/webgl-noise at commit
`22434e04d7753f7e949e8d724ab3da2864c17a0f` (the simplex-boundary fix is
`21d9fe23d7`, scale `ff3b5d34ea`, permutation `a3e6d57095`).
`math.snoise_grad`/`cultmath_snoise_grad` (and the `fbm_grad`/`ridged_grad`
octave sums built on them) follow the analytic-gradient differentiation from
the same project's `src/noise3Dgrad.glsl`, with the same kernel radius,
permutation and scale as `snoise(float3)`.
- Copyright 2011 Ashima Arts.
- Upstream: <https://github.com/ashima/webgl-noise>
- License: MIT.
## Phacelle Noise
`math.phacelle` and `cultmath_phacelle` follow Rune Skovbo Johansen's Phacelle
noise (Shadertoy `t3dyWl`; erosion filter `wXcfWn`; blog, March 2026), read
from the C# port `PhacelleNoise` in `lpmitchell/AdvancedTerrainErosion`
(`AdvancedTerrainErosion.cs`, converted to burstable C# by Luke Mitchell,
2026). The cell weight, the phase blend and the normalization follow it. The
3D-cell generalisation, the caller-supplied stripe wave vector, the exact
gradient and the exact pruning are new here.
- Copyright (c) 2025 Rune Skovbo Johansen.
- Upstream: <https://github.com/lpmitchell/AdvancedTerrainErosion>
- License: MPL-2.0 (<https://mozilla.org/MPL/2.0/>). MPL-2.0 is file-level, so it
covers exactly the files that carry the port and nothing else:
`src/CultMath/math.Phacelle.cs`, `src/CultMath/CultPhasor.cs`,
`shaders/CultMath.Phacelle.hlsl` and the Unity copy of that include. Each
carries the MPL-2.0 header and this attribution. The rest of CultMath is MIT,
so the `GameCult.Math` NuGet package declares `MIT AND MPL-2.0` and ships this
file. The Unity package as a whole is MPL-2.0.

View file

@ -1,7 +1,7 @@
{
"name": "org.gamecult.cultmath",
"displayName": "CultMath",
"version": "0.2.4",
"version": "0.2.0",
"unity": "2021.3",
"description": "Precompiled CultMath numeric primitives and matching HLSL semantics for GameCult Unity runtimes.",
"license": "MPL-2.0",

View file

@ -37,7 +37,7 @@
"directory": "packages/cultmesh-browser"
},
"engines": {
"node": ">=20"
"node": ">=18"
},
"dependencies": {
"@msgpack/msgpack": "^3.1.3",

View file

@ -15,28 +15,42 @@ import {
type CultMeshSessionAcceptedMessage,
type CultMeshSessionOpenMessage,
} from "cultnet-ts/contracts";
import {
base64ToBytes,
bytesToBase64,
isLoopbackEndpoint,
isUnsignedCertificate,
routeCertificateView,
verifyAuthorityRoute,
verifyProviderSessionProof,
type CultMeshAuthorityIdentity,
type CultMeshAuthorityRouteCertificate,
type CultMeshAuthorityRouteView,
type CultMeshAuthorityTrustMode,
type CultMeshAuthorityTrustPolicy,
type CultMeshP256PublicKey,
} from "cultnet-ts/authority";
export type CultMeshBrowserIdentity = CultMeshAuthorityIdentity;
export type CultMeshBrowserRoute = CultMeshAuthorityRouteView;
export type CultMeshBrowserP256PublicKey = CultMeshP256PublicKey;
export type CultMeshBrowserRouteCertificate = CultMeshAuthorityRouteCertificate;
export type CultMeshBrowserAuthorityTrustMode = CultMeshAuthorityTrustMode;
export type CultMeshBrowserAuthorityTrustPolicy = CultMeshAuthorityTrustPolicy;
export interface CultMeshBrowserIdentity {
verseId: string;
authorityRuntimeId: string;
}
export interface CultMeshBrowserRoute extends CultMeshBrowserIdentity {
endpoint: string;
protocolId?: string;
protocolIds?: readonly string[];
priority?: number;
generation: string;
certificate?: CultMeshBrowserRouteCertificate;
}
export interface CultMeshBrowserP256PublicKey {
keyId: string;
x: string;
y: string;
}
export interface CultMeshBrowserRouteCertificate {
providerKey: CultMeshBrowserP256PublicKey;
odinKeyId: string;
issuedAtUnixMilliseconds: number;
expiresAtUnixMilliseconds: number;
signature: string;
}
export type CultMeshBrowserAuthorityTrustMode = "authenticated-remote" | "local-development";
export interface CultMeshBrowserAuthorityTrustPolicy {
mode: CultMeshBrowserAuthorityTrustMode;
odinRoots?: readonly CultMeshBrowserP256PublicKey[];
now?: () => number;
}
export interface CultMeshBrowserRendezvous {
resolve(
@ -780,6 +794,19 @@ export function decodeCultNetOperationPayload<T>(response: CultNetOperationRespo
return decode(base64ToBytes(response.payload)) as T;
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
}
return btoa(binary);
}
function base64ToBytes(value: string): Uint8Array {
const binary = atob(value);
return Uint8Array.from(binary, character => character.charCodeAt(0));
}
function requireWebSocketEndpoint(value: string, field: string): URL {
let endpoint: URL;
try {
@ -871,7 +898,17 @@ async function selectOdinRoute(
protocolIds: route.protocolIds,
priority: route.priority,
generation: route.generation,
...(route.certificate ? { certificate: routeCertificateView(route.certificate) } : {}),
...(route.certificate ? { certificate: {
providerKey: {
keyId: route.certificate.providerKeyId,
x: route.certificate.providerPublicKeyX,
y: route.certificate.providerPublicKeyY,
},
odinKeyId: route.certificate.odinKeyId,
issuedAtUnixMilliseconds: route.certificate.issuedAtUnixMilliseconds,
expiresAtUnixMilliseconds: route.certificate.expiresAtUnixMilliseconds,
signature: route.certificate.signature,
} } : {}),
};
try {
await verifyAuthorityRoute(candidate, trust);
@ -904,26 +941,136 @@ async function validateSessionAcceptance(
`expected '${request.verseId}/${request.authorityRuntimeId}/${protocolId}/${route.generation}'.`,
);
}
// `CultMeshVerifiedAuthorityRoute.IsLocalDevelopment` in C#: an unsigned
// loopback route under local-development needs no provider session proof.
if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint) && isUnsignedCertificate(route.certificate)) {
return undefined;
}
if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint) && !route.certificate) return undefined;
const providerKey = route.certificate?.providerKey;
if (!providerKey || message.providerKeyId !== providerKey.keyId || !message.providerSignature) {
return new Error("CultMesh authority did not prove possession of the Odin-certified provider key.");
}
if (!await verifyProviderSessionProof(request, route.endpoint, providerKey, message.providerSignature)) {
if (!await verifyP256(providerKey, canonicalSession(request, route.endpoint), message.providerSignature)) {
return new Error("CultMesh provider session proof is invalid.");
}
return undefined;
}
async function verifyAuthorityRoute(
route: CultMeshBrowserRoute,
trust: CultMeshBrowserAuthorityTrustPolicy,
): Promise<void> {
const certificate = route.certificate;
if (!certificate) {
if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint)) return;
throw new Error("Remote CultMesh routes require an Odin-signed authority certificate.");
}
const endpoint = new URL(route.endpoint);
if (endpoint.protocol !== "wss:" && !(trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint))) {
throw new Error("Authenticated remote CultMesh browser routes require wss:// channel protection.");
}
const now = trust.now?.() ?? Date.now();
if (now < certificate.issuedAtUnixMilliseconds || now >= certificate.expiresAtUnixMilliseconds) {
throw new Error("The Odin route certificate is not currently valid.");
}
const root = trust.odinRoots?.find(candidate => candidate.keyId === certificate.odinKeyId);
if (!root) throw new Error(`Odin key '${certificate.odinKeyId}' is not trusted by this consumer.`);
if (!await verifyP256(root, canonicalRoute(route), certificate.signature)) {
throw new Error("The Odin route certificate signature is invalid.");
}
}
function canonicalRoute(route: CultMeshBrowserRoute): Uint8Array {
const certificate = route.certificate!;
return canonicalFields(
"gamecult.cultmesh.route-certificate.v1",
route.verseId,
route.authorityRuntimeId,
route.endpoint,
[...(route.protocolIds ?? [route.protocolId ?? "cultmesh.documents.v1"])].sort().join("\u001f"),
String(route.priority ?? 0),
route.generation,
certificate.providerKey.keyId,
certificate.providerKey.x,
certificate.providerKey.y,
certificate.odinKeyId,
String(certificate.issuedAtUnixMilliseconds),
String(certificate.expiresAtUnixMilliseconds),
);
}
function canonicalSession(request: CultMeshSessionOpenMessage, endpoint: string): Uint8Array {
return canonicalFields(
"gamecult.cultmesh.session-proof.v1",
request.clientNonce,
request.messageId,
request.sourceRuntimeId,
request.verseId,
request.authorityRuntimeId,
request.protocolId,
endpoint,
request.routeGeneration,
);
}
function canonicalFields(...values: string[]): Uint8Array {
const encoder = new TextEncoder();
const encoded = values.map(value => encoder.encode(value));
const total = encoded.reduce((sum, value) => sum + 4 + value.byteLength, 0);
const result = new Uint8Array(total);
const view = new DataView(result.buffer);
let offset = 0;
for (const value of encoded) {
view.setUint32(offset, value.byteLength, false);
offset += 4;
result.set(value, offset);
offset += value.byteLength;
}
return result;
}
async function verifyP256(
key: CultMeshBrowserP256PublicKey,
payload: Uint8Array,
signatureBase64: string,
): Promise<boolean> {
try {
const x = base64ToBytes(key.x);
const y = base64ToBytes(key.y);
const signature = base64ToBytes(signatureBase64);
if (x.byteLength !== 32 || y.byteLength !== 32 || signature.byteLength !== 64) return false;
const raw = new Uint8Array(65);
raw[0] = 4;
raw.set(x, 1);
raw.set(y, 33);
const publicKey = await crypto.subtle.importKey(
"raw",
raw.slice().buffer as ArrayBuffer,
{ name: "ECDSA", namedCurve: "P-256" },
false,
["verify"],
);
return await crypto.subtle.verify(
{ name: "ECDSA", hash: "SHA-256" },
publicKey,
signature.slice().buffer as ArrayBuffer,
payload.slice().buffer as ArrayBuffer,
);
} catch {
return false;
}
}
function randomNonce(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32));
return bytesToBase64(bytes);
}
function isLoopbackEndpoint(value: string): boolean {
try {
const host = new URL(value).hostname.replace(/^\[|\]$/g, "").toLowerCase();
return host === "localhost" || host === "127.0.0.1" || host === "::1";
} catch {
return false;
}
}
function requireText(value: string, field: string): void {
if (!value || value.trim().length === 0) throw new Error(`CultMesh browser ${field} is required.`);
}

View file

@ -236,54 +236,6 @@ test("browser client rejects a credential-free authority impersonator", async ()
);
});
test("browser client opens an unsigned-certificate loopback session under local-development without a provider proof, as in C#", async () => {
const connect = (route: CultMeshBrowserRoute, trust: { mode: "local-development" | "authenticated-remote"; odinRoots: CultMeshBrowserP256PublicKey[] }) =>
CultMeshBrowserClient.connect({
...route,
runtimeId: "browser-unsigned-certificate-test",
rendezvous: { resolve: async () => route },
trust,
socketFactory: () => handshakeOnlySocket(route),
});
const blank = (route: CultMeshBrowserRoute, signature: string) =>
({ ...route, certificate: { ...route.certificate!, signature } });
const loopback = await createSignedRoute("ws://127.0.0.1:4501/mesh");
const localTrust = { mode: "local-development" as const, odinRoots: [loopback.odinPublic] };
const remote = await createSignedRoute("wss://provider.example/mesh");
// Empty is the easy case. The whitespace signatures are the ones that catch a
// local re-reading of "unsigned" here: an inline `signature === ""` passes
// every other test in this file and fails these, and U+0085 also separates
// `String.prototype.trim` from the C# `char.IsWhiteSpace` the shared
// `isUnsignedCertificate` uses. Both sides of the session check have to read
// "unsigned" the same way, or the route verifier accepts a session the
// acceptance check then demands a provider proof for.
for (const signature of ["", " ", "\u0085", "\u00a0\t\n"]) {
const label = JSON.stringify(signature);
const client = await connect(blank(loopback.route, signature), localTrust);
assert.equal(client.state, "connected", label);
await client.dispose();
await assert.rejects(
connect(blank(remote.route, signature), { mode: "local-development", odinRoots: [remote.odinPublic] }),
/Odin-signed authority certificate/,
label,
);
await assert.rejects(
connect(blank(remote.route, signature), { mode: "authenticated-remote", odinRoots: [remote.odinPublic] }),
/Odin-signed authority certificate/,
label,
);
}
// U+FEFF is whitespace to JavaScript and not to C#, so it is a signature, not
// an unsigned route, and the loopback route is refused for its bytes.
await assert.rejects(connect(blank(loopback.route, "\ufeff"), localTrust), /not base64|not IEEE P1363/);
// A signed loopback certificate is an authenticated route even under
// local-development; the provider must still prove its key.
await assert.rejects(connect(loopback.route, localTrust), /did not prove possession/);
});
test("browser client rejects a mutated or expired signed route before opening a provider socket", async () => {
const fixture = await createSignedRoute("wss://provider.example/mesh");
let opened = false;

View file

@ -1359,7 +1359,7 @@ fun defineCultNetSchemaDescriptor(
schemaJson = schemaJson,
)
private const val cultNetSchemaBase = "https://github.com/GameCult/CultLib/contracts/cultnet"
private const val cultNetSchemaBase = "https://github.com/GameCult/cultnet-ts/contracts"
private data class CultNetBuiltInSchemaSpec(
val schemaVersion: String,
@ -1377,58 +1377,58 @@ private val cultNetBuiltInSchemaSpecs = listOf(
title = "CultNet Hello Message",
schemaId = "$cultNetSchemaBase/cultnet.hello.schema.json",
requiredFields = listOf("schemaVersion", "runtimeId", "runtimeKind"),
contentHash = "f660ef803f293e2882650b3981c6eed835bd8e595c9b880d6c6271e0dcbd79e4",
contentHash = "1d2114bf0c92a2dd5e07dd735778ceeb939402c6424550d6d281d9db423f56b3",
),
CultNetBuiltInSchemaSpec(
schemaVersion = "cultnet.document_delete.v0",
title = "CultNet Document Delete Message",
schemaId = "$cultNetSchemaBase/cultnet.document-delete.schema.json",
requiredFields = listOf("schemaVersion", "messageId", "schemaId", "recordKey"),
contentHash = "ed7dc202e4f1ebe0d88c5d60c81c53005b44a2557698d3d3c5d2126c01b40f08",
contentHash = "037f7cf1b30756904e0bfada6bcff486269378997afbe3c4b6fc5c573fc8a4df",
),
CultNetBuiltInSchemaSpec(
schemaVersion = "cultnet.document_put_raw.v0",
title = "CultNet Raw Document Put Message",
schemaId = "$cultNetSchemaBase/cultnet.document-put-raw.schema.json",
contentHash = "34b61b14b49cfc887728673f08aa0f33df7ccd401e79c77d3bc324c72e547177",
contentHash = "b61e7e6cce8508653fbeccc4d956ef211c853a174d67c7e1903ce294e15985a2",
),
CultNetBuiltInSchemaSpec(
schemaVersion = "cultnet.snapshot_request.v0",
title = "CultNet Snapshot Request Message",
schemaId = "$cultNetSchemaBase/cultnet.snapshot-request.schema.json",
contentHash = "05267d9b5eb450d4aa8979179f74ba3aec2f3a82fead7ba4a16ab5462c7750fa",
contentHash = "9f6546f51a1aab85978554e6908d7a59e96b3329feff7b35022be210495dd721",
),
CultNetBuiltInSchemaSpec(
schemaVersion = "cultnet.snapshot_response_raw.v0",
title = "CultNet Raw Snapshot Response Message",
schemaId = "$cultNetSchemaBase/cultnet.snapshot-response-raw.schema.json",
contentHash = "fdefbdf5d6b0844dd0eabe2c7430d054295d78e41289f96b227dc39f4aab78b9",
contentHash = "0dbca3338a3ae5d4011aed2c973c75b9b47b6b9dfc0874459ac56da01fef4582",
),
CultNetBuiltInSchemaSpec(
schemaVersion = "cultnet.schema_catalog_request.v0",
title = "CultNet Schema Catalog Request Message",
schemaId = "$cultNetSchemaBase/cultnet.schema-catalog-request.schema.json",
wireContracts = listOf("cultnet.schema.v0", "gamecult.networking.v0"),
contentHash = "ed6d63e29de2d492bb831f030b26ff49ba6883cd2be791ec4936c385f765c76a",
contentHash = "bdd49b44428daf24b514c521e53027ec944aecfebb39ca0d10967661c303e096",
),
CultNetBuiltInSchemaSpec(
schemaVersion = "cultnet.schema_catalog_response.v0",
title = "CultNet Schema Catalog Response Message",
schemaId = "$cultNetSchemaBase/cultnet.schema-catalog-response.schema.json",
wireContracts = listOf("cultnet.schema.v0", "gamecult.networking.v0"),
contentHash = "10b4835d33181fe4b93a33e39b63886c5514899e93682de786e298f7e4a213e9",
contentHash = "b158de53d1ca614eacf2e05767122fb4b9e06504c037ad76c649cffdf71dd648",
),
CultNetBuiltInSchemaSpec(
schemaVersion = "cultnet.shard_catalog_request.v0",
title = "CultNet Shard Catalog Request Message",
schemaId = "$cultNetSchemaBase/cultnet.shard-catalog-request.schema.json",
contentHash = "b4edecd8a24f68ebccf0aca9b429d234618ff7e8858a10123d0b328aeae7eb3f",
schemaId = "cultnet.shard_catalog_request.v0",
contentHash = "0abd131a1db12f6731010333b86e104d550160bd58f539b18413a2b61198fa38",
),
CultNetBuiltInSchemaSpec(
schemaVersion = "cultnet.shard_catalog_response.v0",
title = "CultNet Shard Catalog Response Message",
schemaId = "$cultNetSchemaBase/cultnet.shard-catalog-response.schema.json",
contentHash = "d86a94c57d7820483912b8b5cef6214ac951d34cb2641d2d21dcb1f1bc0263c0",
schemaId = "cultnet.shard_catalog_response.v0",
contentHash = "93a0e90ac81bc6ab21b68925ad25e381edfac8773232333679d3275141cff37b",
),
CultNetBuiltInSchemaSpec(
schemaVersion = "cultnet.shard_log_request.v0",
@ -1468,7 +1468,7 @@ private val cultNetBuiltInSchemaSpecs = listOf(
schemaId = "$cultNetSchemaBase/cultnet.transport-profile.schema.json",
kind = "shared_contract",
requiredFields = listOf("schemaVersion", "runtimeId", "transports"),
contentHash = "63dfd484e1fd332c167caadf52cb5b4fe12d5ffea4415206c3ef111c196507aa",
contentHash = "84473264f543ca3443267999e43d72b7993b61b7b2ac53c642c9cbe8b6de0217",
),
)
@ -3717,16 +3717,16 @@ private fun cultNetReconnectControllerSchedulesAttemptsAndReset() {
private const val interopDocumentType = "cultnet.interop-note"
private const val interopSchemaVersion = "cultnet.interop_note.v0"
private const val interopMutationIntentType = "cultnet.interop-note-mutation-intent"
private const val interopMutationIntentSchemaId = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-note-mutation-intent.schema.json"
private const val interopMutationIntentSchemaId = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-note-mutation-intent.schema.json"
private const val interopMutationIntentSchemaVersion = "cultnet.interop_note_mutation_intent.v0"
private const val interopMutationReceiptType = "cultnet.interop-note-mutation-receipt"
private const val interopMutationReceiptSchemaId = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-note-mutation-receipt.schema.json"
private const val interopMutationReceiptSchemaId = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-note-mutation-receipt.schema.json"
private const val interopMutationReceiptSchemaVersion = "cultnet.interop_note_mutation_receipt.v0"
private const val interopFireCommandType = "cultnet.interop-fire-weapon-command"
private const val interopFireCommandSchemaId = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-fire-weapon-command.schema.json"
private const val interopFireCommandSchemaId = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-fire-weapon-command.schema.json"
private const val interopFireCommandSchemaVersion = "cultnet.interop_fire_weapon_command.v0"
private const val interopFireReceiptType = "cultnet.interop-fire-weapon-receipt"
private const val interopFireReceiptSchemaId = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-fire-weapon-receipt.schema.json"
private const val interopFireReceiptSchemaId = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-fire-weapon-receipt.schema.json"
private const val interopFireReceiptSchemaVersion = "cultnet.interop_fire_weapon_receipt.v0"
private const val rudpInteropConnectionId = 0x43554c54L
private const val rudpInteropMaxFragmentBytes = 1024
@ -4627,11 +4627,11 @@ private fun cultNetBuiltInSchemaCatalogAdvertisesWireContracts() {
check(schemaCatalogRequest["wireContracts"] == listOf("cultnet.schema.v0", "gamecult.networking.v0"))
check(!schemaCatalogRequest.containsKey("schemaJson"))
val shardCatalogRequest = schemas.single { it["schemaVersion"] == "cultnet.shard_catalog_request.v0" }
check(shardCatalogRequest["schemaId"] == "$cultNetSchemaBase/cultnet.shard-catalog-request.schema.json")
check(shardCatalogRequest["schemaId"] == "cultnet.shard_catalog_request.v0")
check(shardCatalogRequest["wireContracts"] == listOf("cultnet.schema.v0"))
check(shardCatalogRequest["contentHash"] == "b4edecd8a24f68ebccf0aca9b429d234618ff7e8858a10123d0b328aeae7eb3f")
check(shardCatalogRequest["contentHash"] == "0abd131a1db12f6731010333b86e104d550160bd58f539b18413a2b61198fa38")
val shardCatalogResponse = schemas.single { it["schemaVersion"] == "cultnet.shard_catalog_response.v0" }
check(shardCatalogResponse["schemaId"] == "$cultNetSchemaBase/cultnet.shard-catalog-response.schema.json")
check(shardCatalogResponse["schemaId"] == "cultnet.shard_catalog_response.v0")
check(shardCatalogResponse["wireContracts"] == listOf("cultnet.schema.v0"))
val inlineCatalog = cultNetBuiltInSchemaCatalog(includeSchemaJson = true)
@ -4644,7 +4644,7 @@ private fun cultNetBuiltInSchemaCatalogAdvertisesWireContracts() {
)
val transportProfile = mapList(transportResponse.body["schemas"]).single()
check(transportProfile["kind"] == "shared_contract")
check(transportProfile["contentHash"] == "63dfd484e1fd332c167caadf52cb5b4fe12d5ffea4415206c3ef111c196507aa")
check(transportProfile["contentHash"] == "84473264f543ca3443267999e43d72b7993b61b7b2ac53c642c9cbe8b6de0217")
check(!transportProfile.containsKey("schemaJson"))
val filteredTransportCatalog = CultMesh.createBuiltInSchemaCatalog(

View file

@ -157,7 +157,7 @@ class CultMesh:
bind_host: str = "127.0.0.1",
bind_port: int = 0,
socket: socket_module.socket | None = None,
initial_sequence: int | None = None,
initial_sequence: int = 1,
resend_delay_ms: int = 250,
transport_id: str = "rudp",
max_payload_bytes: int | None = None,
@ -191,7 +191,7 @@ class CultMesh:
bind_host: str = "127.0.0.1",
bind_port: int = 0,
socket: socket_module.socket | None = None,
initial_sequence: int | None = None,
initial_sequence: int = 1,
resend_delay_ms: int = 250,
transport_id: str = "rudp",
max_payload_bytes: int | None = None,

View file

@ -224,19 +224,15 @@ class CultMeshLocalServer:
now_ms = _now_ms()
peer = peers.get(remote_addr)
if packet.packet_type == CultNetRudpPacketType.CONNECT:
# The session decides whether this Connect repeats the one it
# accepted. Any other Connect is a new client session, with new
# subscriptions.
if peer is None or not peer.session.connect_repeats(packet):
peer = _RudpPeerConnection(
CultNetRudpSession(
CultNetRudpSessionOptions(
connection_id=self.rudp_connection_id,
resend_delay_ms=self.rudp_resend_delay_ms,
)
peer = _RudpPeerConnection(
CultNetRudpSession(
CultNetRudpSessionOptions(
connection_id=self.rudp_connection_id,
resend_delay_ms=self.rudp_resend_delay_ms,
)
)
peers[remote_addr] = peer
)
peers[remote_addr] = peer
self._send_rudp_packet(
rudp_socket,
remote_addr,

View file

@ -1759,11 +1759,11 @@ class CultMeshTests(unittest.TestCase):
)
self.assertEqual(
builtins.list()[0].schema_id,
"https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.transport-profile.schema.json",
"https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json",
)
self.assertIsNone(builtins.list()[0].schema_json)
peer_exchange_schema_id = (
"https://github.com/GameCult/CultLib/contracts/cultnet/cultmesh.peer-exchange-request.schema.json"
"https://github.com/GameCult/cultnet-ts/contracts/cultmesh.peer-exchange-request.schema.json"
)
filtered_builtin = CultMesh.create_builtin_schema_catalog(
include_schema_json=True,
@ -4033,7 +4033,7 @@ class CultMeshTests(unittest.TestCase):
self.assertIn("cultmesh.peer_exchange_response.v0", wire_descriptors)
self.assertIn("cultnet.error.v0", {descriptor.schema_version for descriptor in synced_wire_descriptors})
self.assertIn("cultnet.document_put_raw.v0", {descriptor.schema_version for descriptor in synced_wire_descriptors})
self.assertIsNotNone(synced_schema_catalog.get("https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.document-put-raw.schema.json"))
self.assertIsNotNone(synced_schema_catalog.get("https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put-raw.schema.json"))
self.assertEqual(snapshot_response["documents"][0]["recordKey"], "note:1")
self.assertEqual(typed_snapshot_response.documents[0].record_key, "note:1")
self.assertEqual(typed_snapshot_response.documents[0].schema_id, "mesh.server_note.v1")
@ -4317,14 +4317,6 @@ class CultMeshTests(unittest.TestCase):
{"schemaVersion": "cultnet.document_delete.v0", "messageId": "bad-delete", "schemaId": "mesh.error_note.v1"},
expected_schema_version="cultnet.error.v0",
)
# R-2, owed by Cut 1 (docs/cultnet-selection-cut.md, section 9): this v0-only Mesh server
# refuses a cultnet.selection.v1 carrier message through the same delegating path
# (_handle_connection_message -> handle_message) as any other unsupported schema, not a
# silent empty response.
unsupported_v1 = raw_client.request(
{"schemaVersion": "cultnet.snapshot_request.v1", "messageId": "bad-v1-selection"},
expected_schema_version="cultnet.error.v0",
)
finally:
server.stop()
@ -4336,9 +4328,6 @@ class CultMeshTests(unittest.TestCase):
self.assertEqual(malformed_put["code"], "malformed_document_put")
self.assertEqual(malformed_delete["messageId"], "bad-delete")
self.assertEqual(malformed_delete["code"], "malformed_document_delete")
self.assertEqual(unsupported_v1["messageId"], "bad-v1-selection")
self.assertEqual(unsupported_v1["code"], "unsupported_schema_version")
self.assertEqual(unsupported_v1["details"]["schemaVersion"], "cultnet.snapshot_request.v1")
def test_cultmesh_authority_lease_requires_live_matching_lease(self) -> None:
peer = CultMeshPeerCard(

View file

@ -1,159 +0,0 @@
"""The CultMesh local server's RUDP loop asks each peer's session whether a Connect
repeats. A retransmitted Connect keeps the peer; a restarted client on the same
address is a new one, with none of the old one's subscriptions."""
from __future__ import annotations
import socket
import time
import unittest
from dataclasses import dataclass
import msgpack
from cultcache_py import define_database_entry_type
from cultmesh_py import create_node
from cultmesh_py.server import CultMeshLocalServer
from cultnet_py import (
CultNetRudpSendOptions,
CultNetRudpSession,
CultNetRudpSessionOptions,
document_put_raw,
)
from cultnet_py.transport import CultNetRudpPacketType, decode_rudp_packet, encode_rudp_packet
CONNECTION_ID = 0x10203091
SCHEMA_ID = "mesh.rudp_generation_note.v1"
RELIABLE = CultNetRudpSendOptions(reliable=True, ordered=True)
@dataclass
class Note:
body: str
NOTE = define_database_entry_type(
"mesh.rudp_generation_note",
[("body", 0)],
cls=Note,
schema_id=SCHEMA_ID,
schema_name="mesh.rudp_generation_note",
schema_version=SCHEMA_ID,
)
def wire(message: dict) -> bytes:
return msgpack.packb(message, use_bin_type=True)
class RudpServerGenerationTests(unittest.TestCase):
def setUp(self) -> None:
node = create_node(runtime_id="rudp-generation")
node.database.register_document(NOTE)
self.server = CultMeshLocalServer(node=node, rudp_connection_id=CONNECTION_ID)
self.server.start()
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.sock.bind(("127.0.0.1", 0))
self.sock.settimeout(0.02)
self.target = ("127.0.0.1", self.server.port)
# Every schema data packet the server sent, by sequence, whether or not the receiving
# session delivered it: a new server-side session numbers its packets afresh.
self.sent: dict[int, str] = {}
def tearDown(self) -> None:
self.sock.close()
self.server.stop()
def to_server(self, packet) -> None:
self.sock.sendto(encode_rudp_packet(packet), self.target)
def pump(self, session: CultNetRudpSession, until, seconds: float = 2.0) -> bool:
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
if until():
return True
try:
data, _ = self.sock.recvfrom(65535)
except TimeoutError:
continue
packet = decode_rudp_packet(data)
session.receive(packet, int(time.monotonic() * 1000))
if packet.reliable or packet.packet_type == CultNetRudpPacketType.ACCEPT:
self.to_server(session.create_ack_for_received(packet.sequence))
if packet.packet_type == CultNetRudpPacketType.DATA and packet.channel_id == "schema":
self.sent[packet.sequence] = msgpack.unpackb(packet.payload, raw=False).get("schemaVersion")
return until()
def count(self, schema_version: str) -> int:
return sum(1 for value in self.sent.values() if value == schema_version)
def new_session(self, initial_sequence: int) -> CultNetRudpSession:
return CultNetRudpSession(CultNetRudpSessionOptions(connection_id=CONNECTION_ID, initial_sequence=initial_sequence))
def test_a_retransmitted_connect_keeps_the_peer_and_a_restarted_client_is_a_new_one(self) -> None:
first = self.new_session(50_000)
connect = first.create_connect(0, b"same")
self.to_server(connect)
self.assertTrue(self.pump(first, lambda: first.connected), "the first Accept")
hello = first.send(
"schema", wire({"schemaVersion": "cultnet.hello.v0", "runtimeId": "probe", "runtimeKind": "test"}), RELIABLE
)
self.to_server(hello)
self.assertTrue(self.pump(first, lambda: self.count("cultnet.hello.v0") == 1), "the first answer")
self.to_server(
first.send(
"schema",
wire({
"schemaVersion": "cultnet.database_subscribe.v0",
"messageId": "subscribe",
"subscriptionId": "old-incarnation",
"schemaIds": [SCHEMA_ID],
"includeSnapshot": False,
}),
RELIABLE,
)
)
self.pump(first, lambda: False, 0.2)
first_put = document_put_raw(
message_id="first-put",
key="note:a",
schema_id=SCHEMA_ID,
stored_at="2026-09-30T00:00:00Z",
payload=NOTE.encode_payload(Note("first")),
)
self.to_server(first.send("schema", first_put.to_bytes(), RELIABLE))
self.assertTrue(
self.pump(first, lambda: self.count("cultnet.database_change_raw.v0") == 1),
"the subscription did not notify its own connection",
)
# A retransmitted Connect repeats: the data already delivered is not delivered, or answered, again.
self.to_server(connect)
self.to_server(hello)
self.pump(first, lambda: False, 0.4)
self.assertEqual(self.count("cultnet.hello.v0"), 1, "the retransmitted Connect reset the peer")
# The same client restarted on the same address, connection id and payload: a new peer.
second = self.new_session(7)
self.to_server(second.create_connect(0, b"same"))
self.assertTrue(self.pump(second, lambda: second.connected), "the restarted client's Accept")
self.to_server(
second.send(
"schema", wire({"schemaVersion": "cultnet.hello.v0", "runtimeId": "probe", "runtimeKind": "test"}), RELIABLE
)
)
self.assertTrue(self.pump(second, lambda: self.count("cultnet.hello.v0") == 2), "the restarted client's answer")
# It inherited none of the old incarnation's subscriptions.
put = document_put_raw(
message_id="second-put",
key="note:a",
schema_id=SCHEMA_ID,
stored_at="2026-09-30T00:00:01Z",
payload=NOTE.encode_payload(Note("second")),
)
self.to_server(second.send("schema", put.to_bytes(), RELIABLE))
self.pump(second, lambda: False, 0.4)
self.assertEqual(self.count("cultnet.database_change_raw.v0"), 1, "the new peer inherited a subscription")
if __name__ == "__main__":
unittest.main()

View file

@ -820,7 +820,7 @@ pub fn request_raw_snapshot_from_rudp_catalog(
} if received == &message_id => {
snapshot_response = Some(message);
}
cultnet_rs::CultNetMessage::Error { error, .. } => {
cultnet_rs::CultNetMessage::Error { error } => {
let _ = client.disconnect(b"snapshot-error".to_vec());
anyhow::bail!("CultMesh RUDP snapshot failed: {error}");
}
@ -961,7 +961,7 @@ fn connect_rudp_client(
mode: cultnet_rs::CultNetRudpSocketMode::Client,
remote_addr: Some(target),
connection_id: fresh_rudp_connection_epoch(),
initial_sequence: None,
initial_sequence: 1,
resend_delay_ms,
transport_id: Some(transport_id.to_string()),
max_payload_bytes: None,
@ -1406,7 +1406,7 @@ mod tests {
let mut session =
cultnet_rs::CultNetRudpSession::new(cultnet_rs::CultNetRudpSessionOptions {
connection_id: connect.connection_id,
initial_sequence: Some(1),
initial_sequence: 1,
resend_delay_ms: 5,
max_pending_reliable_packets: Some(64),
});

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