Compare commits

..
521 changed files with 5840 additions and 95364 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

@ -12,13 +12,6 @@ jobs:
name: C# / TypeScript / Rust / Python / Kotlin
runs-on: windows-latest
timeout-minutes: 60
# Windows runners default to pwsh, which does not stop when a native command fails: a
# multi-command step reported only its last command, and a failing cultnet-py suite
# went green that way. Git Bash runs as `bash -e -o pipefail`, so every command counts.
# Steps that name `shell: pwsh` check $LASTEXITCODE themselves.
defaults:
run:
shell: bash
env:
DOTNET: dotnet
@ -72,36 +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"
# packages/cultnet-rs/contracts/cultnet/ holds R-AB's vendored byte copies (the crate must build
# when copied alone). Their drift guard is packages/cultnet-rs/tests/schema_discovery_contracts.rs:74-99.
$tracked = git ls-files '*.schema.json'
if ($LASTEXITCODE -ne 0) { throw "git ls-files failed" }
$stray = $tracked | Where-Object { $_ -notmatch '^(packages/cultnet-rs/)?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.

View file

@ -1,141 +0,0 @@
{
"campaign": "cultmath-tapes",
"cut": "glsl-lowering",
"revision": 1,
"title": "GLSL lowering: shaders/CultMath.glsl is generated from CultMath.hlsl by one enumerated textual transformation, pinned by a test, compiled by glslang, with a committed golden fixture for WebGL2 parity",
"repo": "GameCult/CultLib",
"branch": "hands/cultmath-glsl-lowering",
"base": "c49c76ee",
"depends_on": [],
"first": [
"Map: docs/cultmath-interval-ground-cut.md, sections 'Where the interval math lives' (route C) and 'The authority map'. Model: the HLSL -> C# mirror in tests/CultMath.Tests/HlslSourceCompatibilityTests.cs (wrapper at :348) and its complete transformation list in packages/cultmath/docs/design.md:92-134. The GLSL lowering follows the same discipline: a complete, enumerated list; anything outside it fails the test rather than being patched around.",
"Read how HlslSourceCompatibilityTests resolves #include \"CultMath.Phacelle.hlsl\" (CultMath.hlsl:506); the GLSL lowering inlines the included file at that line, because GLSL has no #include.",
"Target dialect: GLSL ES 3.00 (#version 300 es), WebGL2. No implicit int/uint -> float conversion; step(vec4, float) and max(vec4, float) differ (max(genType, float) exists, step(genType, float) does not); C-style casts do not exist; lerp/frac/saturate/rsqrt/asuint are mix/fract/clamp(x,0.0,1.0)/inversesqrt/floatBitsToUint; floatN/intN are vecN/ivecN; static const is const. Mixed-signedness shifts are allowed.",
"Where the lowered text does not compile, fix the HLSL into the common subset with a legal, bit-identical HLSL edit (e.g. CultMath.hlsl:114 and :200 step(h, 0.0) -> step(h, float4(0.0, 0.0, 0.0, 0.0)); :471-473 and :502 (float)((uint)hash.x >> 8) before the float multiply). The HLSL -> C# mirror test and the FXC/dxc compiles keep those edits honest. Never special-case the lowering for one function.",
"Casts need a balanced-paren scanner, not a regex: (int)((state >> 28) + 4) at CultMath.hlsl:396 is the case that breaks a regex.",
"Dropped, as the C# mirror drops them: include guards (replaced by CULTMATH_GLSL guards), and the Texture2D/SamplerState functions at CultMath.hlsl:535-573. The GLSL declares no precision; the host shader does (precision highp float; precision highp int; the pcg hashes need 32-bit int).",
"Regeneration: the pin test writes shaders/CultMath.glsl when the environment variable CULTMATH_WRITE_GLSL=1 is set, then asserts equality. No new project, no PowerShell copy of the transformation.",
"Golden fixture: tests export 256 seeded points per function family (snoise float3/float2, snoise_grad, fbm_grad 4 octaves, ridged_grad, cellular, pcg3d/pcg4d, iv_* if present on main by then) with outputs as float32 bit patterns in hex, to tests/CultMath.Tests/fixtures/glsl-parity.json. JSON is tolerated here: the consumer is a browser-pane WebGL2 readback, a xenos boundary. The site cut evaluates it.",
"glslang: tools/get-glslang.ps1 fetches a pinned glslangValidator release the way tools/get-dxc.ps1 does; tools/compile-glsl.ps1 compiles a #version 300 es fragment wrapper that includes shaders/CultMath.glsl and calls every public function once. Run on Starfire, one job, detached with a log, polled in the foreground.",
"Verification on Yggdrasil: dotnet test from packages/cultmath/tests/CultMath.Tests; Stryker --since:c49c76ee from a plain clone (not a linked worktree: Stryker resolves --since paths against the main checkout). Never run verification as a background shell task."
],
"deletes": [],
"keeps_moves": [
"Both tracked CultMath.hlsl copies (packages/cultmath/shaders and unity/org.gamecult.cultmath/Shaders) stay one blob; the common-subset edits land in both.",
"The C# mirror test, the FXC probe and dxc -T lib_6_3 -HV 2021 all still pass on the edited HLSL; the Unity package does not ship the GLSL file.",
"design.md's HLSL -> C# transformation list (92-134) is unchanged; the GLSL list is a new sibling section."
],
"adds": [
"tests/CultMath.Tests/GlslLowering.cs: the HLSL -> GLSL ES 3.00 transformation, one static function string -> string, with the list as named steps in order: inline includes; drop guards and texture functions; type names; static const; casts via the balanced scanner; intrinsic renames (lerp, frac, saturate, rsqrt, asuint/asfloat); float literal passthrough; guards CULTMATH_GLSL added; a provenance header line naming the source file and that the file is generated.",
"tests/CultMath.Tests/GlslMirrorTests.cs: CommittedGlslEqualsLowering (writes when CULTMATH_WRITE_GLSL=1, prints a unified diff on failure); NoHlslOnlyTokensSurvive (regex over the output for float[234]\\(|int[234]\\(|lerp\\(|frac\\(|saturate\\(|static const|\\((float|int|uint)\\)|Texture2D|SamplerState -> 0 matches); EveryPublicFunctionIsLowered (the set of cultmath_* function names in the HLSL minus the texture ones equals the set in the GLSL); GoldenFixtureMatchesCSharp (regenerates the fixture in memory from C# and asserts equality with the committed file).",
"shaders/CultMath.glsl: the committed generated artifact.",
"tests/CultMath.Tests/fixtures/glsl-parity.json: the golden fixture (seed, points, per-function expected bit patterns, tolerance class per function: exact for integer-hash paths, ulp-bounded for transcendental).",
"tools/get-glslang.ps1 and tools/compile-glsl.ps1, mirroring get-dxc.ps1 / compile-hlsl-spirv.ps1 in shape.",
"packages/cultmath/docs/design.md: a section 'GLSL Target: Source Transformations' after :134 with the complete list, the common-subset rule for HLSL, the regeneration command, and that the GLSL is a derived file never edited by hand.",
"packages/cultmath/README.md: one paragraph that CultMath.glsl exists, what dialect it is, and how a WebGL consumer includes it (string concatenation before its own #version-led source; declare highp)."
],
"file_changes": [
{
"location": { "path": "packages/cultmath/shaders/CultMath.hlsl", "line": 114 },
"change": "step(h, 0.0) -> step(h, float4(0.0, 0.0, 0.0, 0.0)); same at :200. Bit-identical in HLSL and C#."
},
{
"location": { "path": "packages/cultmath/shaders/CultMath.hlsl", "line": 470 },
"change": "jitter components and the id at :502: an explicit (float) cast around the shifted uint before the float multiply, so the lowered text has no uint*float."
},
{
"location": { "path": "packages/cultmath/shaders/CultMath.Phacelle.hlsl", "line": 1 },
"change": "whatever common-subset edits the glslang compile demands; each one bit-identical, each one listed in the cut report."
},
{
"location": { "path": "packages/cultmath/docs/design.md", "line": 134 },
"change": "new section: GLSL Target: Source Transformations."
}
],
"authority_map": {
"owner": "GlslLowering (tests/CultMath.Tests) owns the HLSL -> GLSL transformation; shaders/CultMath.glsl is its derived output, pinned by GlslMirrorTests. CultMath.hlsl stays the one hand-written shader source.",
"inputs": [
"shaders/CultMath.hlsl and shaders/CultMath.Phacelle.hlsl text",
"C# math for the golden fixture values"
],
"outputs": [
"shaders/CultMath.glsl (GLSL ES 3.00 library, every public cultmath_* function except texture samplers)",
"tests/CultMath.Tests/fixtures/glsl-parity.json"
],
"derived_state": [
"CultMath.glsl is derived; a hand edit fails CommittedGlslEqualsLowering",
"the fixture is derived from C#; a stale fixture fails GoldenFixtureMatchesCSharp"
],
"forbidden_writers": [
"no per-function special case in the lowering; a dialect gap is fixed in the HLSL in the common subset",
"no second transformation implementation (no PowerShell or JS copy of the rules)",
"no GLSL in the Unity package; no precision statement in the library file"
],
"shared_paths": [
"both tracked CultMath.hlsl copies receive the same edits; the existing one-blob rule covers them",
"the same dotnet test run pins the C# mirror, the GLSL pin and the fixture"
],
"deletion_line": "Nothing is deleted in CultLib. The liability bought is one generated file, one test file, one fixture and two tool scripts; what it retires is every future hand copy of CultMath noise in a GLSL consumer, starting with the site."
},
"verification": {
"builds": [
"Yggdrasil: dotnet test packages/cultmath/tests/CultMath.Tests green; paste counts",
"Yggdrasil: dotnet stryker --since:c49c76ee from a plain clone; triage every survivor by name",
"Starfire: tools/compile-glsl.ps1 exits 0 on the wrapper over shaders/CultMath.glsl; a negative-control wrapper with one lerp( call must fail (records that the compile check sees errors)",
"Starfire: FXC cs_5_0 and dxc -T lib_6_3 -HV 2021 still compile both HLSL copies after the common-subset edits"
],
"tests": [
{
"name": "GlslMirrorTests.CommittedGlslEqualsLowering",
"pins": "the committed GLSL is exactly the lowering of the committed HLSL; the regeneration path is the same code."
},
{
"name": "GlslMirrorTests.NoHlslOnlyTokensSurvive",
"pins": "the list is complete: no HLSL-only token survives in the output."
},
{
"name": "GlslMirrorTests.EveryPublicFunctionIsLowered",
"pins": "no function is silently dropped except the texture samplers."
},
{
"name": "GlslMirrorTests.GoldenFixtureMatchesCSharp",
"pins": "the fixture the site will read on WebGL2 is C#'s current truth."
},
{
"name": "HlslSourceCompatibilityTests.EveryMirrorFunctionMatchesCSharpMath (existing)",
"pins": "the common-subset edits changed no bit of any HLSL function."
}
],
"negative": [
{
"pattern": "lerp\\(|frac\\(|saturate\\(|float[234]\\(|int[234]\\(|static const|\\((float|int|uint)\\)|Texture2D|SamplerState|#include",
"scope": "packages/cultmath/shaders/CultMath.glsl: 0 matches."
},
{
"pattern": "precision (high|medium|low)p",
"scope": "packages/cultmath/shaders/CultMath.glsl: 0 matches (the host declares precision)."
},
{
"pattern": "CultMath\\.glsl",
"scope": "packages/cultmath/unity/**: 0 matches; the Unity package does not ship the GLSL."
},
{
"pattern": "git diff c49c76ee..HEAD -- packages/cultmath/shaders/CultMath.hlsl | grep '^[-+]' | grep -v '^[-+][-+]'",
"scope": "every changed HLSL line is listed in the cut report with the reason (dialect gap it closes); none changes a value, proven by the mirror test and the existing goldens."
}
],
"operator": []
},
"estimate": {
"lines_added": 1100,
"lines_removed": 8,
"dependencies_added": [],
"dependencies_removed": [],
"formats_added": ["GLSL ES 3.00 library file (generated)", "glsl-parity.json fixture"],
"formats_removed": [],
"targets_added": [],
"targets_removed": []
},
"rulings": [],
"questions": []
}

View file

@ -1,146 +0,0 @@
{
"campaign": "cultmath-tapes",
"cut": "interval-ops",
"revision": 1,
"title": "Interval ops: cultmath_iv_* over float2(lo, hi) in C# and HLSL with enclosure tests, a committed Lipschitz constant for snoise, and ball bounds for snoise and fbm",
"repo": "GameCult/CultLib",
"branch": "hands/cultmath-interval-ops",
"base": "c49c76ee",
"depends_on": [],
"first": [
"Map: docs/cultmath-interval-ground-cut.md, sections 'Ownership', 'Interval culling along the ray' and 'The proving-ground contract'. Parked target: docs/cultmath-tape-target.md, sketch step 1 (:59-64) and 'Open before unparking' (:106-115). This cut is step 1 and nothing after it: no tape, no pruning, no min/max choice tracking, no affine arithmetic (map D1).",
"Common rules from the Asura cut map (Asura docs/cut-map.md, 'Common rules for every addition', :453-489): C# in packages/cultmath/src/CultMath as a partial of math (the Phacelle pattern: math.Phacelle.cs beside math.cs), HLSL in a new shaders/CultMath.Interval.hlsl included from CultMath.hlsl the way CultMath.Phacelle.hlsl is (:506), names prefixed cultmath_, both tracked HLSL copies one blob, bodies inside design.md's transformation list (:92-134), THIRD-PARTY-NOTICES untouched (nothing is ported; Fidget's ideas are used, not its code).",
"Representation: an interval is float2(lo, hi), lo <= hi, both finite. The empty interval does not exist; a caller that needs 'provably empty' tests hi < cutoff. Point intervals are iv_point(x) = float2(x, x).",
"Op set, each with the C# twin the mirror test demands: iv_point, iv_add, iv_sub, iv_neg, iv_mul, iv_scale(iv, float), iv_abs, iv_min, iv_max, iv_sqr, iv_sqrt (lo clamped at 0), iv_exp, iv_clamp(iv, float, float), iv_saturate, iv_smoothstep(float e0, float e1, iv) (monotone: endpoints), iv_lerp(iv, iv, float t). No others until a consumer names one.",
"Noise bounds: CULTMATH_SNOISE_LIPSCHITZ (static const float, provenance in the comment: measured max |snoise_grad| over 1e6 seeded points plus 1e4 gradient-ascent refinements, times 1.10; the test that measures it is committed as a slow, explicitly-run test and the constant is pinned by a fast test). iv_snoise_ball(float3 c, float r) = [snoise(c) - L r, snoise(c) + L r] intersected with [-1, 1]. iv_fbm_ball(c, r, octaves, lacunarity, gain) = the octave sum of iv_snoise_ball(c * f_i, r * f_i) scaled by a_i, octaves clamped to [0, 16] like fbm_grad.",
"Write the enclosure property first, as the shared harness: for each op, 10,000 seeded random intervals (widths from 1e-4 to 10, centres in [-20, 20]) and 16 points inside each; assert f(x) in op(I) with no tolerance (the bound is computed in the same float32 arithmetic; where rounding could exclude an endpoint, widen the op by one ulp in the implementation, not the test, and say so in design.md). For the ball bounds: 2,000 balls, r in [1e-3, 2], 64 points inside each.",
"The saving test: a representative coverage field (one snoise octave at F0 = 0.35, cutoff 0.25) and 1,000 seeded rays through a slab of depth 12; march once at the dense step and once with the adaptive interval march from the map (Δ0, ΔMAX = 8 Δ0, ΔDENSE = Δ0/2, grow on skip, halve on maybe); count snoise evaluations; assert the integrated transmittance agrees within 1e-3 and the evaluation count is at least 2x lower. Print both counts and the ratio; the cut report quotes them.",
"The warp enclosure test (the site's march rests on it): for 2,000 seeded (centre c, radius r, warp bound D) triples and random flow vectors with |flow| <= D, every warped point c' = x + flow (x in the ball of radius r) lies in the ball of radius r + D, and snoise(c') lies in iv_snoise_ball(c, r + D). This is a property of the existing bound, not a new function; it is written as a named test so the domain-warp use is pinned and documented in design.md.",
"The saving test's field is domain-warped: coverage = sum over two phases of snoise(p + flow * (phase_k - 0.5) * PERIOD) with a bounded flow, matching the map's field shape, so the interval radius includes D.",
"Tightness: a committed test prints the mean width ratio (interval width / true range over the 64 points) for iv_snoise_ball at r = 0.05, 0.25, 1.0 and for iv_fbm_ball at 4 octaves. No assertion beyond finiteness; the number is for the report and for a later affine cut to beat.",
"Verification on Yggdrasil: dotnet test from packages/cultmath/tests/CultMath.Tests; Stryker --since:c49c76ee from a plain clone. Starfire: FXC cs_5_0 kernel calling every iv_* function and both ball bounds; dxc -T lib_6_3 -HV 2021. Detached with a log, polled in the foreground; never a background shell task. Numbers in comments or docs come from committed tests only.",
"If glsl-lowering has merged to main before this branch does, rebase and regenerate shaders/CultMath.glsl (CULTMATH_WRITE_GLSL=1) so the pin test is green on the merge; if not, the lowering cut regenerates after this one. Either order works because the GLSL is derived."
],
"deletes": [],
"keeps_moves": [
"snoise, snoise_grad, fbm_grad, ridged_grad, cellular, phacelle: untouched. The bounds call snoise (value) only; snoise_grad is used by the Lipschitz measurement test, not by any bound (map D2).",
"design.md's invariant 8 section (136-) is unchanged; intervals are not value-and-gradient primitives and do not claim the float4(grad, value) layout.",
"HlslMirrorTests' ban on erosion kernels stands; nothing named interval touches it."
],
"adds": [
"packages/cultmath/src/CultMath/math.Interval.cs: public static partial class math with iv_point, iv_add, iv_sub, iv_neg, iv_mul, iv_scale, iv_abs, iv_min, iv_max, iv_sqr, iv_sqrt, iv_exp, iv_clamp, iv_saturate, iv_smoothstep, iv_lerp, iv_snoise_ball, iv_fbm_ball, and the constant SNOISE_LIPSCHITZ; each with a doc comment stating the enclosure it guarantees and the ulp widening where one is needed.",
"packages/cultmath/shaders/CultMath.Interval.hlsl: the HLSL mirrors with the same names prefixed cultmath_, and CULTMATH_SNOISE_LIPSCHITZ as static const; included from CultMath.hlsl after the Phacelle include (:506). Both tracked HLSL copies.",
"tests/CultMath.Tests/IntervalTests.cs: the enclosure harness over every op; iv_mul sign cases (all four quadrant combinations and a zero-straddling factor); iv_sqrt with lo < 0; iv_abs straddling zero gives [0, max(|lo|,|hi|)]; iv_smoothstep monotonicity; iv_min/iv_max against the pointwise min/max; a mutant that shrinks any bound by one ulp dies (hand-mutation list in the file header for the float arithmetic Stryker cannot reach, as in 2a-ii-a).",
"tests/CultMath.Tests/NoiseBoundTests.cs: iv_snoise_ball and iv_fbm_ball enclosure; LipschitzConstantPinsSampledGradients (fast, 1e5 points); MeasureLipschitz (slow, explicit trait, prints the max and the refined max); TightnessReport; the saving test (InvalSkipHalvesEvaluations) with its printed counts.",
"packages/cultmath/docs/design.md: a section 'Intervals' after the invariant 8 section: representation, the op list, the ulp-widening rule, the Lipschitz provenance and margin, what the ball bounds guarantee and what they do not (nothing about tapes), and that the site's ground shader and Aetheria's nebula raymarch are the consumers.",
"packages/cultmath/README.md: one paragraph under the noise entry naming the interval family and its purpose (empty-space culling with a proof)."
],
"file_changes": [
{
"location": { "path": "packages/cultmath/shaders/CultMath.hlsl", "line": 506 },
"change": "add #include \"CultMath.Interval.hlsl\" after the Phacelle include; both tracked copies."
},
{
"location": { "path": "packages/cultmath/src/CultMath/math.cs", "line": 602 },
"change": "no change to snoise; a comment pointer from snoise to SNOISE_LIPSCHITZ in math.Interval.cs so the next kernel change knows the constant must be re-measured."
},
{
"location": { "path": "packages/cultmath/docs/design.md", "line": 136 },
"change": "new 'Intervals' section after invariant 8."
},
{
"location": { "path": "packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs", "line": 348 },
"change": "none expected; confirm the mirror resolves the second #include and that every cultmath_iv_* finds its C# twin (a mirror without a twin fails Assert.Contains MirrorOnly)."
}
],
"authority_map": {
"owner": "CultMath owns interval arithmetic and the noise bounds in C# and HLSL (and GLSL by derivation). The Lipschitz constant is owned by the committed measurement test; the kernel of snoise owns its value.",
"inputs": [
"the existing snoise(float3) value function; snoise_grad for the measurement test only",
"seeded random points and intervals in tests"
],
"outputs": [
"cultmath_iv_* and the two ball bounds, each enclosing its function over its input region",
"three printed numbers for the report: the Lipschitz max, the tightness ratios, the evaluation saving"
],
"derived_state": [
"CULTMATH_SNOISE_LIPSCHITZ is derived from the snoise kernel; a kernel change must re-run MeasureLipschitz and re-pin",
"shaders/CultMath.glsl (if the lowering has landed) is derived and regenerated, never edited"
],
"forbidden_writers": [
"no interval op without the enclosure test and a C# twin",
"no bound that reads snoise_grad; no value-only/gradient twin of any iv_* function",
"no tape, bytecode, pruning, choice flag or affine form in this cut; names containing tape, prune or affine do not appear",
"no number in a comment or design.md that a committed test did not print"
],
"shared_paths": [
"the same IntervalTests enclosure harness runs every op; a new op is one entry in its table",
"the existing HLSL mirror test covers every HLSL interval function with no new comparison path"
],
"deletion_line": "Nothing is deleted. The liability is one C# file, one HLSL file, two test files and a doc section; it buys the invariant intervals-enclose for every GPU consumer, and it is step 1 of the parked tapes campaign landed in advance."
},
"verification": {
"builds": [
"Yggdrasil: dotnet test packages/cultmath/tests/CultMath.Tests green (MeasureLipschitz excluded by trait); paste counts",
"Yggdrasil: dotnet stryker --since:c49c76ee from a plain clone; 0 survivors in math.Interval.cs except listed equivalents; hand mutations for float3 arithmetic listed and killed",
"Starfire: FXC cs_5_0 kernel over every new function; dxc -T lib_6_3 -HV 2021 on both HLSL copies"
],
"tests": [
{
"name": "IntervalTests.EveryOpEnclosesItsPoints",
"pins": "intervals-enclose for every op, no tolerance."
},
{
"name": "NoiseBoundTests.SnoiseBallEnclosesPoints / FbmBallEnclosesPoints",
"pins": "the ball bounds enclose snoise and fbm over 2,000 balls x 64 points."
},
{
"name": "NoiseBoundTests.WarpedPointsStayEnclosed",
"pins": "a domain warp bounded by D is enclosed by enlarging the ball radius by D; the site's interval march is sound."
},
{
"name": "NoiseBoundTests.LipschitzConstantPinsSampledGradients",
"pins": "no sampled |snoise_grad| exceeds the constant; a constant lowered by 1% fails."
},
{
"name": "NoiseBoundTests.IntervalSkipHalvesEvaluations",
"pins": "the adaptive march needs <= half the evaluations of the dense march for the same transmittance within 1e-3."
},
{
"name": "HlslSourceCompatibilityTests.EveryMirrorFunctionMatchesCSharpMath (existing)",
"pins": "every cultmath_iv_* and both bounds match C# bit for bit."
}
],
"negative": [
{
"pattern": "tape|prune|affine|choice",
"scope": "packages/cultmath/src/CultMath/math.Interval.cs and shaders/CultMath.Interval.hlsl: 0 matches (case-insensitive)."
},
{
"pattern": "snoise_grad|fbm_grad",
"scope": "math.Interval.cs and CultMath.Interval.hlsl: 0 matches; the bounds use values only."
},
{
"pattern": "spherical_erosion|AdvancedErosionFilter",
"scope": "packages/cultmath: 0 matches (HlslMirrorTests' standing rule)."
},
{
"pattern": "git diff --name-only c49c76ee..HEAD",
"scope": "only packages/cultmath/** (and shaders/CultMath.glsl if regenerated); no other package or src tree."
}
],
"operator": []
},
"estimate": {
"lines_added": 900,
"lines_removed": 0,
"dependencies_added": [],
"dependencies_removed": [],
"formats_added": [],
"formats_removed": [],
"targets_added": [],
"targets_removed": []
},
"rulings": [],
"questions": []
}

View file

@ -1,206 +0,0 @@
{
"campaign": "cultmath-tapes",
"cut": "site-ground",
"revision": 2,
"title": "Site ground: a per-pixel WebGL2 nebula under every page, flow-warped, interval-culled, TAA-reprojected, budget-controlled by measurement; the CSS wash is sampled from the result, seeds fall back",
"repo": "GameCult/gamecult-site",
"branch": "main",
"base": "535d170",
"depends_on": [
"site-masthead:cut_spec:wash-home-only.r1",
"cultmath-tapes:cut_spec:glsl-lowering.r1",
"cultmath-tapes:cut_spec:interval-ops.r1"
],
"first": [
"Map: F:/Projects/CultLib/docs/cultmath-interval-ground-cut.md, 'The shader design' (field, culling with the warp, TAA motion-aware, sampled wash, palette input), 'Budget', D4-D8, Q4 and Q5. Rulings in force: site-masthead:ruling:operator-chromeless-column; wash-home-only's authority map (no second wash token, no class toggled by script, no page rule setting a radial alpha). Re-pin base to main after wash-home-only lands; line numbers below are from 535d170; re-read by selector. Stardust and DoF are the next cut: leave the resolve pass with a named, empty composite slot for them (a uniform-gated branch is not needed; a comment and a function stub are).",
"Aetheria reference, read once, copy nothing: Volumetric.cginc:165-221 (two-phase flow-map warp, tri2 cross-fade), :130-135 (globalFlow); Raymarching/CloudShader.shader:83-104 (integration) and :261-314 (what not to do: gamma 0.5 clip, fixed 0.05 blend, packed depth, the density2 bug at :311).",
"Engine facts to confirm before writing: how GameCult-Quartz bundles a component's afterDOMLoaded and whether a dynamic import() of /static/ground/ground.js is allowed, else emit a module script tag; site/quartz/static/** served at /static/**; enableSPA false (quartz.config.ts:15). Local build: scripts/quartz/quartz.ps1 dev.",
"Tokens. Inputs (Q4 a), on body in custom.scss beside --gamecult-wash: --gamecult-nebula-0/1/2 (\"255 138 42\", \"109 96 255\", \"89 183 255\") and --gamecult-nebula-base (#07111a). Outputs with seeds, on :root: --gamecult-ground-0/1/2 (#03070d #07111a #09141f), and for N in 0..2 --gamecult-light-N (rgb triple), -a (0.18/0.16/0.14), -at (\"78% 14%\"/\"18% 10%\"/\"52% 0%\"), -r (18%/24%/28%), plus --gamecult-ground-mean (#07111a). The body background becomes radial-gradient(circle at var(--gamecult-light-0-at), rgb(var(--gamecult-light-0) / calc(var(--gamecult-light-0-a) * var(--gamecult-wash))), transparent var(--gamecult-light-0-r)) x3 over linear-gradient(180deg, var(--gamecult-ground-0) 0%, var(--gamecult-ground-1) 44%, var(--gamecult-ground-2) 100%).",
"Ritual essays: their seeds go under :root:has(body[data-slug=\"Blog/...\"]) for the three slugs (yellow 255 178 30 .15 at 78% 8% 24rem; cobalt 49 88 255 .22 at 18% 4% 30rem; violet 91 54 184 .19 at 44% 20% 34rem; stops #030414 0%, #070b24 42%, #040514 100%; nebula inputs likewise overridden to yellow/cobalt/violet and base #050614). The ritual block's own background declaration is deleted; it inherits body's.",
"ground.js owns: WebGL2 context on #gamecult-ground (alpha:false, antialias:false, powerPreference 'low-power'); capability check (WebGL2, EXT_color_buffer_half_float or float); input token read via getComputedStyle(document.body) -> uniforms; the flow texture pass (quarter res, RG16F, two snoise per texel, once per frame); the march pass; the TAA resolve with reprojection; the 8x8 downsample and the async PBO readback; the token writer on document.documentElement.style; the scheduler (30 fps cadence, visibilityState pause, reduced-motion freeze and stop at 32 frames); the budget controller; the debug flag (?ground=debug) and parity flags.",
"Sampled wash: after the resolve, draw the final frame into an 8x8 RGBA8 target; gl.readPixels into a PIXEL_PACK_BUFFER; gl.fenceSync; on later frames poll getSyncParameter(SYNC_STATUS) and only when SIGNALED call getBufferSubData. Never readPixels to client memory directly (that stalls). At most 2 Hz. Compute the token values as the map states (row means for the stops; light colour and excess at the seed light's centre texel; the mean for --gamecult-ground-mean) and write them with setProperty on :root only when a value moved by more than 1/255. Q5 (a): keep the last written set in sessionStorage under one key and apply it on load before the first frame; it is a cache, and a missing or malformed entry means the seeds.",
"Budget controller, one function, one state machine: GPU time per frame by EXT_disjoint_timer_query_webgl2 when present, else a fenceSync after the resolve polled to completion, else rAF cadence. Over a 60-frame window: if the median exceeds the budget (4.0 ms), step down: S 16 -> 8 -> 4, then dpr -> 1 (Q2), then remove the canvas (the seeds paint). Never step up within a session. A median rising 50% over 5 minutes also steps down. No storage of the verdict, no userAgent, no maxTouchPoints.",
"cloud.frag.glsl: #version 300 es; precision highp float; precision highp int; the vendored CultMath.glsl is concatenated by ground.js before this file's body. The field, warp, interval march (ball radius F0 * (Δ/2 + D) with D = FLOW_MAX * PERIOD / 2) and integration exactly as the map's pseudocode; hue from the flow texture's channels mixed over the three nebula inputs; uniforms: resolution, time, dt, frame, jitter, S, intensity, nebula colours, base, Z0/Z1, F0, CUTOFF, K, PERIOD, FLOW_MAX. flow.frag.glsl: the two low-frequency snoise. taa.frag.glsl: motion vector from the flow texture (mv = project(-flow * dt)), history fetched at uv - mv, 3x3 min/max clamp, alpha = max(1/frames, 1/32), out-of-bounds alpha 1; writes new history and the screen (luminance clamp 0.035) via drawBuffers or two draws.",
"Vendoring (D3): site/quartz/static/ground/CultMath.glsl byte-equal to CultLib shaders/CultMath.glsl at the commit named in its first line. scripts/ground/check-cultmath-glsl.ps1 diffs it against git show <sha>:packages/cultmath/shaders/CultMath.glsl from a CultLib checkout and exits non-zero on drift.",
"Parity on the device: with ?ground=parity, ground.js evaluates the vendored fixture (CultLib tests/CultMath.Tests/fixtures/glsl-parity.json) one pixel per point, reads back float bit patterns, and reports per function the max ulp distance and the count outside its tolerance class. With ?ground=parity-wash, density and stardust are forced to 0 and the flow frozen; the canvas must equal the CSS seed wash within 2/255 at 16 points on / and on a ritual essay (this pins that the seed values and the shader's base agree at zero density, which is what the fallback promises).",
"Prove in the browser pane: max relative luminance after 32 frames on / at intensity 1 (<= 0.035); the parity-wash probe; the debug readback (mean evals per pixel, unfinished-ray fraction < 1%); the token writer (after 2 s on /, :root's inline style carries all fifteen tokens and --gamecult-ground-mean, each within the expected range, and getComputedStyle(body).backgroundImage resolves to the sampled values); the motion test (with ?ground=debug-mv the resolve writes |mv| into red: non-zero where flow is non-zero, zero under reduced motion); GPU ms per pass with the timer query at the pane's desktop size and at the mobile preset's pixel count. Phone GPU time is operator verification."
],
"deletes": [
{
"path": "site/quartz/styles/custom.scss",
"lines": 6,
"note": "ritual body block 119-124: its own background (three radials and the linear gradient) goes; its seeds live under :root:has(body[data-slug]) and it inherits body's ground."
},
{
"path": "site/quartz/styles/custom.scss",
"lines": 9,
"note": "body 10-27: the six literal colours and three literal stops leave as literals (rewritten as token references in the same lines; counted 9 removed, 9 added)."
}
],
"keeps_moves": [
"--gamecult-wash and the index override from wash-home-only: untouched; the shader reads it as intensity and the CSS wash still multiplies by it.",
".page::before (the grid overlay), the tour/Pitch VN ground, the Thing deck's .hero-bg, the masthead, the engine: untouched.",
"The CSS wash stays the ground when the canvas is absent, not yet drawn, or removed by the controller; it renders the sampled tokens when there are any and the seeds otherwise.",
"docs/brand-design-language.md 'The ground is a wash, not a flat fill' (69-84): rewritten: the ground is the shader; the CSS wash is sampled from it; the seeds are today's literal wash; the four nebula inputs are where the palette is tuned."
],
"adds": [
"site/quartz/components/GameCultGround.tsx: renders <canvas id=\"gamecult-ground\" aria-hidden=\"true\"></canvas>, mounts /static/ground/ground.js after DOM load; in sharedPageComponents.header after GameCultThemeLock (quartz.layout.ts:19). Its beforeDOMLoaded script applies the sessionStorage token cache to :root if present (Q5 a), so the first paint matches the last sample.",
"site/quartz/static/ground/ground.js (~400 lines, no dependencies, no build step): context, tokens in and out, flow/march/resolve/downsample passes, PBO readback, scheduler, controller, debug and parity modes, a named composite slot for the stardust cut.",
"site/quartz/static/ground/flow.frag.glsl, cloud.frag.glsl, taa.frag.glsl, downsample.frag.glsl; a shared 3-vertex full-screen vertex shader inline in ground.js.",
"site/quartz/static/ground/CultMath.glsl and glsl-parity.json, vendored from CultLib with the pin in the header.",
"scripts/ground/check-cultmath-glsl.ps1: the vendoring drift check.",
"custom.scss: the four nebula input tokens on body; the fifteen ground seeds plus --gamecult-ground-mean on :root with the ritual overrides under :root:has(body[data-slug]); #gamecult-ground { position: fixed; inset: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none; }.",
"docs/brand-design-language.md: the wash section rewritten as above; one paragraph on the ground shader: what it reads (inputs), what it writes (outputs), what it may not hold, and the fallback."
],
"file_changes": [
{
"location": { "path": "site/quartz/styles/custom.scss", "line": 4, "end_line": 8 },
"change": ":root block: the fifteen ground seeds and --gamecult-ground-mean declared beside color-scheme."
},
{
"location": { "path": "site/quartz/styles/custom.scss", "line": 10, "end_line": 31 },
"change": "body: four nebula input tokens declared; radials and the linear gradient reference the ground tokens; alphas keep the wash-home-only calc."
},
{
"location": { "path": "site/quartz/styles/custom.scss", "line": 103, "end_line": 124 },
"change": "ritual block: background declaration deleted; a :root:has(body[data-slug]) rule carries its seeds and nebula inputs."
},
{
"location": { "path": "site/quartz.layout.ts", "line": 19 },
"change": "header: [GameCultThemeLock(), GameCultGround(), GameCultMasthead()]."
},
{
"location": { "path": "docs/brand-design-language.md", "line": 69, "end_line": 84 },
"change": "wash section rewritten: shader-owned ground, sampled wash, seeds, nebula inputs."
}
],
"authority_map": {
"owner": "The shader owns the ground. custom.scss owns the four nebula inputs, --gamecult-wash and the seed values; ground.js owns the flow texture, the march, the TAA, the readback and the sampled ground tokens on :root; CultLib owns every noise and interval function the field calls.",
"inputs": [
"the four nebula tokens and --gamecult-wash (computed on body)",
"prefers-reduced-motion, visibilityState, canvas size, devicePixelRatio, elapsed time",
"measured GPU frame time (timer query, fence, or rAF cadence)",
"the shader's own 8x8 readback",
"the vendored CultMath.glsl at its pinned CultLib commit"
],
"outputs": [
"one opaque moving canvas under the page, luminance <= 0.035",
"the fifteen ground tokens and --gamecult-ground-mean on :root inline, at most 2 Hz",
"debug and parity readbacks (console, flag only)"
],
"derived_state": [
"the ground tokens are derived from the shader's result while it runs; the seeds are the fallback; the CSS wash renders whichever is in force; the shader never reads them",
"the flow texture is the one owner of motion: warp, hue, motion vectors (and the stardust, next cut) read it",
"the TAA history is a cache, reprojected by the flow; reset on resize, first frame and budget step",
"S and dpr are derived from measurement within the session; never stored",
"the sessionStorage token set is a cache of the last sample; absent or malformed means seeds",
"CultMath.glsl in the site is derived from CultLib at the pinned commit"
],
"forbidden_writers": [
"no colour, alpha, position or radius literal in ground.js or any GLSL file",
"no read of --gamecult-ground-* or --gamecult-light-* by the shader or by ground.js, except the seed read before the first readback",
"no navigator.userAgent, platform, maxTouchPoints, or any device-name branch",
"no class or attribute set on body or html by ground.js; token writes are setProperty on :root only",
"no second wash token; no page rule setting a radial alpha; no second flow evaluation outside flow.frag.glsl",
"no noise or interval function defined in the site's GLSL; only cultmath_* calls",
"no synchronous readPixels to client memory; no setInterval; one requestAnimationFrame scheduler; localStorage never"
],
"shared_paths": [
"home, every content page and the ritual essays paint one canvas and one token set, in CSS and in the shader",
"slow device, hidden tab, reduced motion, missing WebGL2 and missing half-float all pass through the one controller",
"the 8x8 readback is the only path from the shader to CSS; the beforeDOMLoaded cache apply and the live writer set the same tokens the same way",
"the same Deploy Quartz workflow; static files ship with the overlay"
],
"deletion_line": "The ritual block's own background and the nine body literals go in the same commit the tokens are declared. The canvas, four shaders, ground.js and the vendored library are the whole addition; nothing else in the engine or the content changes."
},
"verification": {
"builds": [
"Deploy Quartz green on the landing commit: gh run list -R GameCult/gamecult-site -w \"Deploy Quartz\" -L 1",
"scripts/ground/check-cultmath-glsl.ps1 exits 0 against a CultLib checkout at the pinned commit",
"the built stylesheet contains rgb(var(--gamecult-light-0) / calc(var(--gamecult-light-0-a) * var(--gamecult-wash))) verbatim and a :root:has(body[data-slug=\"Blog/small-ritual-of-reach\"]) rule"
],
"tests": [
{
"name": "browser pane, /?ground=debug: max relative luminance over the canvas after 32 frames <= 0.035",
"pins": "text contrast: body text keeps >= 7:1 on every ground pixel."
},
{
"name": "browser pane, /?ground=parity-wash and a ritual essay: canvas vs CSS seed wash within 2/255 at 16 points with density 0 and flow frozen",
"pins": "the seeds and the shader's base agree; the fallback promise holds."
},
{
"name": "browser pane, / after 2 s: :root inline style carries the sixteen tokens; body's computed backgroundImage resolves to the sampled values; a read 1 s later differs only where the frame changed",
"pins": "the CSS wash is sampled from the shader, at most 2 Hz, without a stall (no readPixels-to-client in the profiler)."
},
{
"name": "browser pane, /?ground=parity: every fixture function within its tolerance class on WebGL2; numbers and GPU string in the report",
"pins": "GLSL = C# on a device; the proving ground's third runtime."
},
{
"name": "browser pane, /?ground=debug at desktop and the mobile preset: mean evals per pixel, unfinished rays < 1%, GPU ms per pass; ?ground=debug-mv shows motion where flow is non-zero",
"pins": "the step budget, the interval saving with the warp, and the reprojection on the shipped field."
},
{
"name": "reduced motion emulated: frames stop at 32, mv is 0, time frozen; hidden tab: no frames; WebGL2 disabled: no canvas in the DOM and the seed wash paints; sessionStorage cleared: seeds paint first",
"pins": "every fallback goes through the controller and lands on the same CSS ground."
},
{
"name": "git diff base..head --stat: custom.scss, quartz.layout.ts, docs/brand-design-language.md, the new component, static/ground/*, scripts/ground/* only",
"pins": "no engine, content or masthead change."
}
],
"negative": [
{
"pattern": "#[0-9a-fA-F]{6}\\b|rgba?\\(|\\b(255|138|109|183)\\b",
"scope": "site/quartz/static/ground/*.js and *.glsl: 0 matches."
},
{
"pattern": "gamecult-ground-|gamecult-light-",
"scope": "site/quartz/static/ground/*.glsl: 0 matches; in ground.js only inside the token writer and the seed read, never in a uniform upload."
},
{
"pattern": "userAgent|navigator\\.platform|maxTouchPoints|localStorage|setInterval|classList|setAttribute\\(",
"scope": "site/quartz/static/ground/ground.js: 0 matches."
},
{
"pattern": "readPixels\\([^)]*(Uint8Array|Float32Array|Uint16Array)",
"scope": "ground.js: 0 matches outside the parity and debug readbacks, which are flag-gated and named."
},
{
"pattern": "float cultmath_|vec4 cultmath_|float snoise|hash\\(",
"scope": "flow/cloud/taa/downsample.frag.glsl: 0 matches; cultmath_* is defined only in the vendored CultMath.glsl."
},
{
"pattern": "radial-gradient\\([^)]*rgba?\\([0-9]",
"scope": "site/quartz/styles/custom.scss, body rule and ritual block: 0 matches."
},
{
"pattern": "gamecult-titlebar|gamecult-nav-link|sidebar|composite|GameCult-Quartz",
"scope": "git diff base..head changed lines: 0 matches."
}
],
"operator": [
"open / on the desktop and on the phone; say whether the nebula's motion and intensity are right (uniforms: PERIOD, FLOW_MAX, intensity)",
"on the phone with ?ground=debug, read the logged GPU ms and the controller's final S/dpr; say whether it got warm after five minutes",
"navigate / -> /Blog -> back; say whether the ground is continuous or shows a seed flash (Q5)",
"open a ritual essay and confirm its ground still reads as its own paper"
]
},
"estimate": {
"lines_added": 980,
"lines_removed": 15,
"dependencies_added": [],
"dependencies_removed": [],
"formats_added": ["GLSL ES 3.00 shader files in static/ground", "vendored glsl-parity.json", "one sessionStorage key holding the last sampled token set"],
"formats_removed": [],
"targets_added": [],
"targets_removed": []
},
"rulings": [
"site-masthead:ruling:operator-chromeless-column"
],
"questions": [
"Q4 in the map: palette input tokens (a, recommended)",
"Q5 in the map: carry the last sampled wash across page loads (a, recommended)"
]
}

View file

@ -1,141 +0,0 @@
{
"campaign": "cultmath-tapes",
"cut": "site-stardust",
"revision": 1,
"title": "Site stardust: stateless particles advected by the ground's flow texture, rendered to a half-res layer, blurred by the clouds' accumulated opacity (raymarch-driven DoF), composited behind the nebula",
"repo": "GameCult/gamecult-site",
"branch": "main",
"base": "535d170",
"depends_on": [
"cultmath-tapes:cut_spec:site-ground.r2"
],
"first": [
"Map: F:/Projects/CultLib/docs/cultmath-interval-ground-cut.md, 'Stardust', 'Depth of field driven by the raymarch', 'Budget', D4, D9, D10. Re-pin base to main after site-ground lands. The composite slot site-ground left in the resolve is where this cut plugs in; do not restructure the resolve.",
"Aetheria reference, read once, copy nothing: Compute/Stardust/Stardust.compute:118-147 (grid cell -> hashed spawn, lifetime = frac(time/period + hash), position -= flow * lifetime * period, size = parabola(lifetime, 2), colour from a hue ramp x nebula tint); Stardust.shader:49-86 (billboard from quadPoints by SV_VertexID/SV_InstanceID, powerPulse falloff, distance exponents); CustomDoF/DepthOfField.hlsl:29-32 (CoC read from a raymarch output), :150-201 (disk gather weighted by CoC), :217-242 (combine). The web version is far smaller: no distance exponents (the slab is thin), no shadow pass, no near/far split.",
"Particles are stateless: no buffers are updated. One static 6-vertex quad VBO; drawArraysInstanced with count N; the vertex shader derives everything from gl_InstanceID, time and the flow texture (vertex texture fetch is standard in WebGL2): cell = (id mod G, id / G) over the slab mid-plane; hashes via cultmath_pcg3d (the vendored CultMath.glsl is concatenated before the vertex source too) for spawn jitter, lifetime offset, size and brightness; lifetime = fract(t / PERIOD_P + offset); position = spawn - flow(spawn uv) * lifetime * PERIOD_P; size = base * parabola(lifetime, 2); colour = hue at the screen position (from the flow texture's channels, the same mix as the clouds) * brightness. Reduced motion freezes t as site-ground does.",
"Stardust layer: half-resolution RGBA16F target, cleared each frame, additive blend, powerPulse falloff in the fragment shader; N = 16384 desktop, 4096 phone; the controller scales N with S (N halves when S halves) and the layer follows dpr.",
"DoF: in the resolve's composite slot, read opacity = 1 - T from the reprojected, clamped history (not the current frame) and gather the stardust layer with an 8-tap Poisson disk of radius RMAX * opacity in layer texels (RMAX ~ 12 px at dpr 1, scaled by dpr); weight taps by a soft disk; composite out = ground + clouds + stardust_blurred * T, then the luminance clamp. All stardust is behind the whole cloud layer (D9).",
"Debug flags: ?ground=debug-stardust writes the stardust layer alone; ?ground=debug-coc writes opacity into red. Prove in the browser pane: the stardust and the clouds move together (with ?ground=debug-mv from site-ground and the particle velocity written in green under ?ground=debug-stardust-v, the two directions agree within 5 degrees at 64 sampled pixels where |flow| is above a threshold); luminance clamp still holds after the composite; GPU ms per pass (stardust layer, gather) with the timer query at desktop and at the mobile preset's pixel count; the full-stack total against the budget table; the unfinished-ray fraction unchanged by this cut.",
"Budget contract (map, 'Budget'): the stardust layer plus the gather cost no more than a quarter of the march at the same S; if the measurement says otherwise, the gather drops to 4 taps before N drops, and the report says which. Stardust is never cut before the clouds' S on the ladder."
],
"deletes": [],
"keeps_moves": [
"site-ground's flow texture, march, TAA, readback and token writer: untouched; the stardust reads the flow texture and the hue mix the same way the clouds do.",
"The sampled wash now includes the stardust in its 8x8 readback by construction (it samples the final frame); nothing changes in the writer.",
"The budget controller gains one scaled quantity (N) and no new state; the ladder order is unchanged."
],
"adds": [
"site/quartz/static/ground/stardust.vert.glsl and stardust.frag.glsl: the instanced billboard and the powerPulse sprite.",
"ground.js: the stardust pass (one VBO, one program, drawArraysInstanced), the half-res layer, N in the controller, the gather in the resolve's composite slot, the two debug flags.",
"taa.frag.glsl: the composite slot filled: opacity from history, the 8-tap gather, out = ground + clouds + stardust_blurred * T.",
"docs/brand-design-language.md: two sentences under the ground shader paragraph: the stardust and the DoF, and that they read the same flow and hue as the nebula."
],
"file_changes": [
{
"location": { "path": "site/quartz/static/ground/ground.js", "line": 1 },
"change": "stardust pass, layer, N scaling, gather hookup, debug flags; line numbers are site-ground's and are re-read by function name."
},
{
"location": { "path": "site/quartz/static/ground/taa.frag.glsl", "line": 1 },
"change": "the named composite slot becomes the opacity-weighted gather and the final composite."
},
{
"location": { "path": "docs/brand-design-language.md", "line": 69 },
"change": "two sentences on stardust and DoF under the ground shader paragraph (post site-ground numbering)."
}
],
"authority_map": {
"owner": "ground.js owns the stardust pass and the DoF gather; the flow texture (site-ground) owns the particles' motion; the TAA history owns the opacity the DoF reads; the nebula input tokens own the particles' hue through the same mix as the clouds.",
"inputs": [
"the flow texture and the hue mix (site-ground)",
"the reprojected, clamped history's transmittance",
"time, N and dpr from the controller; gl_InstanceID"
],
"outputs": [
"a half-res stardust layer per frame",
"the composite with stardust blurred by cloud opacity behind the nebula, still under the luminance clamp"
],
"derived_state": [
"every particle's position, lifetime, size and colour are derived from gl_InstanceID, time and the flow texture each frame; nothing is stored or updated",
"N is derived from S by the controller; the layer size from dpr",
"the blur radius is derived from the accumulated opacity; there is no second CoC source"
],
"forbidden_writers": [
"no particle buffer update, transform feedback or CPU-side particle state",
"no second flow function or hue mix; the vertex shader samples the flow texture",
"no colour literal; no per-depth transmittance; no second march",
"no new controller state beyond N; the ladder order is not changed",
"no change to the readback or the token writer"
],
"shared_paths": [
"clouds and stardust read one flow texture and one hue mix, so they move and colour as one",
"the one resolve pass composites ground, clouds and stardust and applies the one luminance clamp",
"the one controller scales S, N and dpr together"
],
"deletion_line": "Nothing is deleted. The addition is two small shaders, one pass and one gather; it buys the stardust and the DoF the operator named, and it must prove it fits the same budget."
},
"verification": {
"builds": [
"Deploy Quartz green on the landing commit",
"scripts/ground/check-cultmath-glsl.ps1 still exits 0"
],
"tests": [
{
"name": "browser pane, /?ground=debug-stardust-v with ?ground=debug-mv: particle velocity and cloud motion vectors agree within 5 degrees at 64 sampled pixels where |flow| exceeds the threshold",
"pins": "one flow: the stardust and the nebula move as one."
},
{
"name": "browser pane, /?ground=debug-coc: opacity is 0 where the clouds are empty and rises inside them; the stardust gather radius follows it (a visible blur under a dense cloud, sharp in a gap)",
"pins": "the raymarch drives the DoF, as in Aetheria's hack."
},
{
"name": "browser pane, /?ground=debug: max relative luminance after 32 frames <= 0.035 with stardust on",
"pins": "the clamp holds after the composite."
},
{
"name": "browser pane, timer query at desktop and at the mobile preset: stardust layer + gather <= 25% of the march at the same S; full-stack totals quoted against the budget table",
"pins": "the stardust fits the same budget or the report says what gave."
},
{
"name": "reduced motion: particles frozen, frames stop at 32; hidden tab: no frames",
"pins": "the stardust obeys the one controller."
}
],
"negative": [
{
"pattern": "bufferSubData|transformFeedback|TRANSFORM_FEEDBACK|new Float32Array\\((N|count|particles)",
"scope": "ground.js stardust section: 0 matches; particles are stateless."
},
{
"pattern": "#[0-9a-fA-F]{6}\\b|rgba?\\(",
"scope": "stardust.vert.glsl, stardust.frag.glsl, ground.js: 0 matches."
},
{
"pattern": "snoise\\(|float cultmath_",
"scope": "stardust.vert.glsl and stardust.frag.glsl: 0 matches; the flow and hue come from the texture, not from a second evaluation."
},
{
"pattern": "readPixels|setProperty",
"scope": "git diff base..head changed lines in ground.js: 0 matches; the readback and the token writer are not touched."
}
],
"operator": [
"open / on the desktop and on the phone; say whether the stardust sells the motion and whether the blur under the clouds reads like the video (RMAX, N, PERIOD_P are the dials)",
"on the phone with ?ground=debug, read the full-stack GPU ms and the final S/N/dpr; say whether it got warm"
]
},
"estimate": {
"lines_added": 320,
"lines_removed": 4,
"dependencies_added": [],
"dependencies_removed": [],
"formats_added": [],
"formats_removed": [],
"targets_added": [],
"targets_removed": []
},
"rulings": [
"site-masthead:ruling:operator-chromeless-column"
],
"questions": []
}

View file

@ -1,447 +0,0 @@
# CultMath intervals and the gamecult.org ground: cut map
Status: Imagination pass 2, 2026-10-02. Pass 1's Q1–Q3 are answered (below);
the Self opens the `cultmath-tapes` campaign from this revision. Where this
map and the Body disagree, the Body wins and this map is stale.
Pinned HEADs (every `file:line` below is against these):
| Repo | Ref | SHA |
| --- | --- | --- |
| CultLib | `main` | `c49c76ee` (CultMath 0.3.0 merged at `614fd445`) |
| gamecult-site | `main` | `535d170` (before `wash-home-only` lands) |
| Aetheria | local checkout | read only; `Volumetric.cginc`, `Raymarching/CloudShader.shader`, `Compute/Stardust/Stardust.{compute,shader,cs}`, `CustomDoF/DepthOfField.hlsl`, `Zone Display/VolumeCloudRenderer.cs` |
## The operator's words
1. "Can we replace the site's background with a fancy yet unobtrusive and
inexpensive shader thing? It's odd that my main site wouldn't have that,
considering my specialization. Would it be too much to have some optimized
subset of Aetheria's volumetric clouds shader in there?"
2. After the Self proposed a quarter-resolution, 8–16 step, static-on-phones
design: "map it as a cut that includes the GPU interval arithmetic we're
borrowing from Fidget for a planned Asura campaign. I'd like this shader to
be the proving ground for that - with the right math we can absolutely run
this fullscreen on mobile, per pixel as long as the sample count is low and
we're accumulating samples with TAA (Aetheria's TAA is honestly
embarrassing and it still handles smooth 1080p clouds on my tired old
1070)"
3. On pass 1's Q3 (ground tokens as the shader's input): "I like that, but the
background should be nice and dynamic and we should sample the wash from
the result instead. Is there any point sending you video? I don't think
your vision model has anything in it to encode motion. Anyway, this video
really blew people away, with its combination of the clouds, the flowing
stardust and my hacked DoF shader where I replaced the circle of confusion
calculation with an output from the raymarching shader, I think it was
final transmittance? Doesn't matter, looks great to have the clouds blur
what's behind them. This does remind me we're totally gonna need that
stardust too, it really sells the motion of the nebulae"
Rulings from pass 1: **Q1** new campaign `cultmath-tapes`. **Q2** the slow
ladder is samples, then resolution, then the CSS wash. **Q3** answered outside
the options: the shader owns the ground and it moves; the CSS wash is sampled
from the shader's result; the ground tokens are seed and fallback only, never
the shader's input; stardust and the raymarch-driven DoF are in scope.
## Ownership
### Where the interval math lives
**CultLib, `packages/cultmath`.** This is the parked campaign's own first step
(`docs/cultmath-tape-target.md`, sketch step 1: "CultMath `interval` in C# and
HLSL with parity tests ... It stands alone, and is the first cut to take even
if the rest stays parked"). The "planned Asura campaign" in the operator's
words is that document; its first named consumer is Aetheria's volumetric
raymarching, and Asura itself does not need intervals
(`cultmath-tape-target.md:103`).
The site speaks WebGL2 GLSL ES 3.00. Three routes were weighed:
| Route | What | Why not |
| --- | --- | --- |
| A | Hand-port the needed functions to GLSL inside gamecult-site | A second noise authority with no parity test; the exact thing Aetheria's `GPU Noise` copies are (follow-up `aetheria-snoise-pin-bump`). |
| B | `dxc -spirv` then SPIRV-Cross to GLSL ES, using the existing `tools/compile-hlsl-spirv.ps1` chain | SPIRV-Cross emits one whole shader with mangled names; there is no library a site shader can call `cultmath_snoise` from. |
| C | **A textual lowering HLSL → GLSL in CultLib with a complete, enumerated transformation list, the discipline of the HLSL → C# mirror (`packages/cultmath/docs/design.md:92-134`)** | Chosen. |
Under C, `shaders/CultMath.glsl` is a committed generated artifact, like
`Swizzles.g.cs`. One implementation of the lowering lives in the test project;
a test pins the committed file equal to the lowering of `CultMath.hlsl` and
regenerates it when asked. GLSL's stricter typing is not patched in the
lowering: the HLSL is written in the common subset (legal, bit-identical) and
glslang compiles the lowered text in CultLib's tooling. Parity runs in two
legs, each with one owner: HLSL ⇔ C# by the existing bit-for-bit mirror;
GLSL ⇔ C# by a committed golden fixture evaluated on WebGL2 in the browser
pane and read back. CultLib has no GPU harness (follow-up
`gpu-snoise-parity`); the browser pane is one. The site vendors
`CultMath.glsl` with the CultLib commit in its header.
### Campaign
`cultmath-tapes` (Q1), repos `GameCult/CultLib` and `GameCult/gamecult-site`,
target doc `docs/cultmath-tape-target.md` unparked for step 1 only. The site
cuts cite `site-masthead:ruling:operator-chromeless-column` and depend on
`wash-home-only` landing first, because they rewrite the same lines.
### Owner, consumers, invariant
- Owner: CultMath owns interval arithmetic and the noise bounds in C# and HLSL,
GLSL derived. The site owns its field, flow, march, TAA, stardust, DoF and
budget controller. `custom.scss` owns the brand palette the shader reads
(four nebula tokens) and the seed values of the ground tokens. `ground.js`
owns the sampled ground tokens while it runs.
- Consumers: the site ground now; Aetheria's nebula raymarch at its CultMath
pin bump; the tapes campaign's HLSL interpreter later.
- Protected invariant, **`intervals-enclose`**: every interval function returns
a `[lo, hi]` containing `f(x)` for every `x` in its input region, in C#, HLSL
and GLSL alike. A consumer that skips a region because `hi < cutoff` has
skipped nothing that was there. The domain warp does not weaken it: a warped
point lies in the ball enlarged by the warp bound, and a test says so.
## The authority map (all four cuts)
- Owner: `packages/cultmath` for `iv_*` and the ball bounds; `GlslLowering`
(test project) for `shaders/CultMath.glsl`; `custom.scss` for the four
nebula input tokens, `--gamecult-wash`, and the seed values of the fifteen
ground tokens; `ground.js` for the flow texture, scheduling, budget, TAA,
the stardust and DoF passes, and the sampled ground tokens; the site GLSL
files for the field, the particles and the resolve.
- Inputs: CultMath reads nothing new. The lowering reads the two HLSL files.
`ground.js` reads the nebula tokens, `--gamecult-wash`,
`prefers-reduced-motion`, `visibilityState`, canvas size, device pixel
ratio, the measured GPU frame time and its own 8×8 readback. The field
reads uniforms and the flow texture only.
- Outputs: `CultMath.glsl`; the interval functions; one canvas under the
page; the fifteen ground tokens written on `:root` from the readback; a
debug log behind a flag.
- Derived state: the ground tokens are derived from the shader's result while
it runs and are seed values otherwise; the CSS wash renders them; nothing
reads them back into the shader. The flow texture is the one owner of
motion: the warp, the TAA motion vectors and the stardust all read it.
`CultMath.glsl` is derived from `CultMath.hlsl`. The TAA history is a cache.
`S`, `dpr` and the particle count are derived from measurement within the
session.
- Forbidden writers: no colour literal in `ground.js` or any GLSL; the shader
never reads a ground token; `ground.js` never reads the ground tokens except
to seed before its first readback; no user-agent, platform or touch-point
sniffing; no class on `body` or `html` by script; no second wash token; no
second flow function (particles and clouds read one texture); no GLSL edited
by hand; no `iv_*` without a C# twin; no noise defined in the site.
- Shared paths: every page's ground, the ritual essays included, is one
canvas and one token set, in CSS and in the shader. Every reduced budget
(slow device, hidden tab, reduced motion, missing WebGL2 or half-float) goes
through the one controller. The 8×8 readback is the one path from the
shader to CSS.
- Deletion line: in the site, the six literal radial colours and three
literal stops become token references and the ritual block's own ground
goes, in the same commit the tokens are declared. In CultLib nothing is
deleted. The subtraction this buys is upstream: Aetheria's
`Assets/Plugins/GPU Noise/*.cginc` at its pin bump, and the tapes campaign's
step 1 already landed.
## The shader design
### What Aetheria contributes, and what the web version keeps
| Aetheria | Mechanism | Web subset |
| --- | --- | --- |
| `Volumetric.cginc:165-221` density | fill + coverage, domain-warped by `flow()` in two half-period phases cross-faded with `tri2` (a flow map; the advection never accumulates) | kept, one coverage octave and one detail octave, CultMath noise |
| `Volumetric.cginc:130-135` `globalFlow` | `Tri3D(p/scale − scroll) · amplitude` | replaced by two `snoise` at low frequency, rendered once per frame into a quarter-res flow texture |
| `CloudShader.shader:83-104` | transmittance integration | kept |
| `CloudShader.shader:261-314` TAA | reprojection by `_PrevVP`, γ=0.5 variance clip, fixed 5% blend, packed depth, the `density2` bug at `:311` | not kept; see TAA |
| `Stardust.compute:118-147` | stateless particles: grid cell hashed to a spawn point, `position −= flow · lifetime · period`, size `parabola(lifetime, 2)`, colour from a hue ramp × nebula tint | kept as an instanced vertex shader reading the flow texture; `gl_InstanceID` is the cell |
| `DepthOfField.hlsl:29-32` `FragCoC` | the CoC is read straight from a global `_DoFBlurTex` (its writer is not in this checkout; the operator recalls final transmittance), then the bokeh gather `FragBlur` `:150-201` | a gather on the stardust layer with radius ∝ cloud opacity at that pixel |
### Density field (site-owned, `cloud.frag.glsl`)
```text
camera at the origin, looking +z, static; the slab z in [Z0, Z1] fills the view
flow(uv) = A · (snoise(q·Ff + o1), snoise(q·Ff + o2), 0) q = slab mid-plane point; quarter-res texture, once per frame
phase_k(t) = fract(t / PERIOD + k/2), w_k = tri2(phase_k) k = 0,1
warp_k(p) = p + flow · (phase_k − 0.5) · PERIOD
coverage(p) = Σ_k w_k · snoise(warp_k(p) · F0) two snoise
occupancy = max(0, coverage − CUTOFF) · K
detail(p) = 1 − |snoise(p · 4F0 + 17)| one snoise; the dune envelope
density = occupancy · mix(0.6, 1.0, detail)
hue(uv) = mix of the three nebula colours by flow.xy / A free: the flow texture's own channels
colour = hue · intensity
```
Three `snoise` per dense sample, one per empty probe. The pattern moves at
velocity `−flow` everywhere, because both phases advance at the same rate;
the cross-fade only changes content. That is what makes the motion vectors
below exact.
### Interval culling along the ray, with the warp
`cultmath_iv_snoise_ball(c, r)` returns `[n(c) − L r, n(c) + L r] ∩ [−1, 1]`
with `L` the committed Lipschitz constant. Over a segment `[t, t+Δ]` the
unwarped ball is centred at `t + Δ/2` with radius `Δ/2`; the warp displaces
any point by at most `D = |flow|max · PERIOD / 2`, so the ball `(c, Δ/2 + D)`
contains both phases' warped points, and one evaluation bounds the whole
coverage term. The march is adaptive subdivision, Fidget's region test on a
1-D region:
```text
Δ = Δ0; t = Z0/dir.z + jitter · Δ0
while t < t_exit and evals < S:
iv = iv_snoise_ball(p(t + Δ/2) · F0, F0 · (Δ/2 + D)) 1 eval
if iv.hi < CUTOFF: t += Δ; Δ = min(2Δ, ΔMAX) provably empty: skip, grow
elif Δ > ΔDENSE: Δ = Δ / 2 maybe dense: refine
else: integrate density(p(t + Δ/2)) over Δ; t += Δ 3 evals
if transmittance < 0.02: break
if t < t_exit: integrate one coarse sample over the rest the budget ran out; measured, must be rare
```
`iv.hi < CUTOFF` is the interval proof that the `0` branch of
`max(0, coverage − CUTOFF)` wins on the whole segment: Fidget's `min`/`max`
pruning at its smallest, applied by hand to a two-clause expression. Tape and
automatic pruning stay parked; this proves the arithmetic they rest on.
### Step budget
`S` counts `snoise` evaluations per pixel per frame. `S = 16` desktop, `8`
phone, `4` floor; the controller sets it from measurement. A debug mode writes
`evals / S` and an unfinished flag into the colour. Contract: under 1% of rays
unfinished at the chosen `S` on the shipped field.
### TAA, motion-aware
The camera is static but the field moves, so history is reprojected by the
known motion, which the flow texture owns:
- Motion vector: `mv = project(−flow(uv) · dt)` in pixels; history is fetched
at `uv − mv`. Exact for the pattern between cross-fades (see above); the
cross-fade and the detail term are handled by rejection.
- Jitter: Halton(2,3) frame offset on the ray start plus interleaved gradient
noise per pixel, rotated per frame.
- Accumulation: `α = max(1/frames, 1/32)` on colour and transmittance.
- Rejection: a 3×3 min/max box of the current frame clamps the reprojected
history (AABB clamp, not variance clipping); out-of-bounds fetches take
`α = 1`.
- Reset on resize, on the first frame and on a budget step.
- Buffers: two RGBA16F history targets at canvas size (8-bit stalls
convergence at `α = 1/32` on a ground this dark); missing half-float falls
back to the CSS wash through the controller.
### Stardust (cut `site-stardust`)
Stateless, as Aetheria's: instance `i` is a cell of an `N×N` grid over the
slab's mid-plane, hashed by `cultmath_pcg3d` to a spawn point and a lifetime
offset; `lifetime = fract(t/PERIOD_P + offset)`; position = spawn − flow(uv
of spawn) · lifetime · PERIOD_P, read from the flow texture in the vertex
shader; size = `parabola(lifetime, 2)` × a hashed size; colour = the nebula
hue at its screen position × a hashed brightness. Rendered as instanced
billboards (one 6-vertex quad, `gl_InstanceID`) with additive blending into a
half-resolution RGBA16F stardust layer, with the `powerPulse` falloff of
`Stardust.shader:74-78`. Count: 16,384 desktop, 4,096 phone, controller-scaled
with `S`. The particles and the clouds move as one because they read the same
texture; there is no second flow.
### Depth of field driven by the raymarch (cut `site-stardust`)
Aetheria's hack: the CoC is a raymarch output, so clouds blur what is behind
them. Web version: in the resolve pass, the stardust layer is gathered with an
8-tap Poisson disk whose radius is `RMAX · opacity(uv)`, where
`opacity = 1 − T` is the TAA-accumulated cloud transmittance at that pixel
(the stable, converged value, which is why it is read from history and not
from the current frame). Composite: `ground + clouds + stardust_blurred · T`.
All stardust is treated as behind the whole cloud layer; a per-depth
transmittance would cost a second march and is not in scope.
### Sampled wash: the CSS reads the shader
After the resolve, a tiny pass downsamples the final frame to 8×8 (RGBA8).
`ground.js` reads it back asynchronously: `readPixels` into a
`PIXEL_PACK_BUFFER`, a `fenceSync`, then `getBufferSubData` on a later frame
once the fence has signalled. Never a synchronous readback. At most twice a
second, and only written when a value moved by more than 1/255:
- `--gamecult-ground-0/1/2`: the mean of rows 0–1, 3–4 and 6–7.
- `--gamecult-light-N`, `-a`: the colour at the seed light's centre texel,
split into the hue (normalised) and the excess over the ground stop there.
- `--gamecult-light-N-at`, `-r`: the seed values; positions are not fitted.
- `--gamecult-ground-mean`: the 8×8 mean, for any tinted UI.
These are written to `:root`'s inline style, so they beat the stylesheet's
seed declarations. The seeds live on `:root` in `custom.scss` (today's
literal wash), with the ritual essays' seeds under
`:root:has(body[data-slug="Blog/..."])`. The body background and the ritual
ground read the tokens. When WebGL2 is absent or the controller removes the
canvas, the seeds are what paints. Across page loads the last sample may be
carried as a cache (Q5).
### Palette input
The shader reads four input tokens from `custom.scss`, which is where the
brand says colour is defined: `--gamecult-nebula-0/1/2` (today orange
`255 138 42`, violet `109 96 255`, sky `89 183 255`) and
`--gamecult-nebula-base` (`#07111a`), plus `--gamecult-wash` as intensity.
Inputs and outputs are different tokens, so there is no cycle (Q4).
### Resolution and the slow ladder (Q2)
Full device pixels. The controller's ladder: `S` 16 → 8 → 4, then `dpr → 1`
(the stardust layer and DoF follow the canvas), then the CSS wash. Never a
step up within a session.
### Pausing and fallbacks
- `visibilityState !== "visible"`: no frames; on return, history is kept and
the motion vector uses the real elapsed `dt`, clamped to one frame.
- `prefers-reduced-motion: reduce`: time is frozen (phase, flow and particle
lifetimes fixed), render until 32 accumulated frames, stop; resize
re-renders. The clouds and stardust are there, still.
- No WebGL2, context lost, no half-float: canvas removed; the seeds paint.
- Too slow: measured; see below.
- The canvas is `position: fixed; inset: 0; z-index: -1; pointer-events:
none; aria-hidden`. The root background paints first, so the canvas covers
the CSS wash only once drawn.
### Text contrast
The resolve clamps relative luminance at 0.035 (about today's orange hotspot,
`#07111a` + `rgba(255,138,42,.18)` ≈ `(53, 42, 34)`, luminance 0.025), after
stardust and DoF are composited. Body text `#b7c7d9` (0.55) keeps ≥ 7:1 on any
ground pixel. A readback test asserts the max after 32 frames on `/`.
## Budget and how it is measured
| Device | Pixels | GPU per frame at 30 fps | Proxy |
| --- | --- | --- | --- |
| Desktop, GTX 1070 class, 2560×1440 | 3.7 M | ≤ 2.0 ms | 6% GPU duty |
| Laptop iGPU, Iris Xe, 1920×1080 at dpr 1.25 | 3.2 M | ≤ 3.5 ms | 10% duty |
| Mid-range phone, Pixel 6a / Galaxy A54 class, 1080×2400 | 2.6 M | ≤ 4.0 ms | ≤ 12% duty; no upward drift of the median over 5 minutes |
Cost model for the full stack on the phone (one `snoise` ≈ 150 flops, ~1
TFLOP available; estimates, replaced by measurement):
| Pass | Work | ms |
| --- | --- | --- |
| Flow texture, quarter res | 160 k texels × 2 snoise | 0.05 |
| March at `S = 8` | 2.6 M × 8 snoise | 3.1 |
| TAA resolve + DoF gather | ~19 texel reads per pixel | 1.0 |
| Stardust layer, half res, 4,096 particles | fill-bound, small | 0.2 |
| 8×8 downsample + PBO readback at 2 Hz | tiny | 0.05 |
| **Total at `S = 8`** | | **≈ 4.4** |
| Total at `S = 6` / `S = 4` | | ≈ 3.6 / 2.9 |
Said plainly: at `S = 8` the full stack does not fit the 4 ms phone budget on
the estimate. The controller will settle a mid-range phone at `S = 4–6`,
which the TAA covers (convergence in ~32 frames, about a second). What gives,
in order, is the sample count, then the resolution; the stardust and the DoF
are never dropped before the clouds' samples, because they cost a quarter of
the march. On desktop at `S = 16` the stack is ≈ 1.6 ms.
Measurement, in this order, all in `ground.js` behind a debug flag and used by
the controller: `EXT_disjoint_timer_query_webgl2` where present (Chrome
desktop) per pass; else a `fenceSync` after the resolve, polled (universal on
WebGL2, phones and Safari included; the queue of a background page is
otherwise empty); else `requestAnimationFrame` cadence. Hands proves it in the
browser pane: desktop numbers with the timer query, the phone's pixel count
from the mobile preset, the phone's GPU time from the operator's phone.
## The proving-ground contract (what the tapes campaign inherits)
1. **Enclosure tests in C#** (and HLSL by the mirror): every `iv_*` op over
random intervals and points; `iv_snoise_ball`/`iv_fbm_ball` over 2,000
balls × 64 points. A mutant that shrinks any bound dies.
2. **The warp is enclosed**: for random flows bounded by `D`, every point
`warp_k(p)` over the segment lies in the enlarged ball and `snoise` of it
lies in `iv_snoise_ball(c, r + D)`. This is the test the site's march rests
on.
3. **The Lipschitz constant has provenance**: the max of `|snoise_grad|` over
10⁶ points refined by gradient ascent, × 1.10, measured by a committed
test and pinned by another. Empirical with a margin, not a proof; the
enclosure tests are the defence.
4. **Tightness is a number**: mean interval width over true range, printed
by a committed test, for a later affine cut to beat.
5. **The saving is a number**: a C# test marches 1,000 rays through a
representative warped coverage field with and without interval skipping
and asserts ≥ 2× fewer evaluations at equal transmittance; the shipped
field's evals per pixel and unfinished fraction come from the browser.
6. **Three runtimes agree**: the GLSL golden fixture passes on WebGL2, with
tolerance class and device named in the report.
Not proved here, said plainly: tape evaluation, automatic pruning, `min`/`max`
choice tracking, affine arithmetic. Those unpark with steps 2–4.
## Cut order
```text
glsl-lowering (CultLib) ─┐
interval-ops (CultLib) ─┴─> site-ground (clouds, flow, TAA, sampled wash) ─> site-stardust (particles, DoF)
^ after site-masthead wash-home-only
```
Four cuts. The two CultLib cuts are as in pass 1, except `interval-ops` gains
the warp enclosure test. `site-ground` grew (flow texture, reprojection, the
readback and token restructure); `site-stardust` is split out because its
Soul vocabulary is different (particle statistics, blur quality, the composite)
and because the ground must be measured on its own before the stardust is
charged against the same budget. No CultMath release is in the sequence; the
site consumes a commit.
Specs: `docs/cultmath-interval-ground-cut-glsl-lowering.spec.json`,
`docs/cultmath-interval-ground-cut-interval-ops.spec.json`,
`docs/cultmath-interval-ground-cut-site-ground.spec.json`,
`docs/cultmath-interval-ground-cut-site-stardust.spec.json`.
## Standing design decisions (means; Self may overrule)
- D1. Intervals are `float2(lo, hi)`. Affine arithmetic is not taken; the
tightness number says whether it is ever worth it.
- D2. No `iv_*` function has a value-only or gradient twin; the bounds take
the point value only.
- D3. The site vendors `CultMath.glsl` by CultLib commit, SHA in the header, a
drift check in the site cut.
- D4. The flow texture is the one owner of motion. Warp, motion vectors, hue
and particles read it; nothing evaluates flow a second way.
- D5. Routes are page loads (`enableSPA: false`, `quartz.config.ts:15`); if
SPA is ever enabled, `ground.js` listens for Quartz's `nav` event and
re-reads the input tokens.
- D6. The budget verdict is session-only; a stored verdict would outlive a
driver update.
- D7. Inputs and outputs are different tokens: four nebula inputs, fifteen
ground outputs with seeds. The shader never reads an output; CSS never reads
an input except through the shader's result.
- D8. The ground tokens are written on `:root` inline, so the seed in the
stylesheet loses to the sample without a class or a second rule.
- D9. The DoF blurs all stardust by the final opacity; per-depth transmittance
is out of scope.
- D10. Stardust and DoF land after the ground has been measured alone, so the
report can say what each costs.
## Questions
**Q4. Where the shader's palette input comes from.**
- (a) Four new input tokens in `custom.scss` (`--gamecult-nebula-0/1/2`,
`--gamecult-nebula-base`), seeded with today's wash colours.
**Recommended.** The nebula palette can be tuned without moving the UI
accent, and the brand doc's rule (colour is defined in `custom.scss`) holds.
- (b) Reuse `--secondary` and `--tertiary` from `quartz.config.ts` and promote
the violet to a config token. Fewer tokens; ties the nebula to the UI accent
for good.
**Q5. Carrying the last sampled wash across page loads.**
- (a) `ground.js` keeps the last written token set in `sessionStorage` and
applies it before the first paint on the next page; the seeds apply when
nothing is stored. A cache, never authority. **Recommended.** Without it,
every navigation shows the seed wash for the first frames, then the shader.
- (b) No storage; the seed paints first on every page. Simpler; a visible
shift on every navigation.
## Unsettled
- The Lipschitz constant is empirical with a margin; a per-cell polynomial
proof is possible and not in this cut.
- `_DoFBlurTex`'s writer is not in the Aetheria checkout; the web DoF takes the
operator's recollection (final transmittance) as the driver. If stills of
the video arrive, Hands can match the blur radius against them, but the
mechanism does not depend on it.
- The ritual ground's radii are `rem` lengths and its first gradient stop is
unpositioned (`custom.scss:124`); Hands normalises it when tokenising.
- Whether GameCult-Quartz's bundler permits a dynamic `import()` from a
component's `afterDOMLoaded` or needs a script tag; one line either way.
- `:root:has(body[data-slug])` for the ritual seeds: supported in every
current browser; a browser without `:has` gets the default seeds, which is
acceptable for a fallback of a fallback.

View file

@ -1,114 +0,0 @@
# CultMath tapes: interval-pruned field evaluation on CPU and GPU
Status: **parked**, 2026-10-02. The operator wants Asura working first. This
note records the direction so it can be picked up without re-deriving it. It is
not a cut map: no Imagination pass has run, and nothing here is ruled except
that the campaign exists and waits.
## Intent
Evaluate large implicit fields, meaning deeply nested CSG trees of brushes with
thousands of nodes, cheaply enough to mesh at many levels of detail and to
raymarch. The operator's words (2026-10-02): "I would love to design a space
station out of a grammar of nested brushes, and have the ability to
automatically mesh it at multiple levels of detail so I can get arbitrarily
close to its tiny details without melting my GPU."
GameCult has no deep CSG trees today because nothing could evaluate them
cheaply. The capability comes first, and the content follows.
## Prior art
[Fidget](https://www.mattkeeter.com/projects/fidget/) by Matt Keeter
(MPL-2.0, Rust, v0.5.1 on 2026-09-27; repo `mkeeter/fidget`).
- It compiles an expression to a register-allocated bytecode tape.
- It evaluates that tape as points, SIMD batches, intervals, and forward-mode
gradients.
- The central trick is interval evaluation over a region. When it proves that
one operand of a `min` or `max` always wins, the tape for that region is
rewritten without the other branch. In the project's example, the tape
shrinks from 254 clauses to about 20 at pixel level.
- `fidget-jit` targets x86-64 and AArch64.
- `fidget-wgpu` has a WGSL tape interpreter, interval ops and tape
simplification on the GPU (`interval_ops.wgsl`, `tape_interpreter.wgsl`,
`tape_simplify.wgsl`).
- `fidget-mesh` implements Manifold Dual Contouring. It is known to be weak on
thin features.
- Halfspace is the IDE codesigned with `fidget-wgpu` (Rust, egui and wgpu,
MPL-2.0).
## Chosen shape (option c)
The ideas are ported into CultLib and owned here. Fidget is not a dependency.
Unity renders HLSL on its own graphics backend, so `fidget-wgpu` cannot run
inside it.
Three options were weighed on 2026-10-02:
| Option | What it is | Trade |
|---|---|---|
| a | Fidget as a Rust native plugin in Unity | Fastest demo; costs per-platform native builds, an FFI boundary, and an upstream that warns of breaking changes |
| b | Offline bake with a Rust tool; ship meshes | Simplest; the baked depth caps how close a viewer can get |
| c | CultLib owns tape, interval and pruning in C# and HLSL | Most coherent end state; the largest code liability we would own |
The operator parked (c). An (a) spike to validate authoring and LOD behaviour
before owning code was suggested but not ruled.
Sketch, to be replaced by the target pass:
1. **CultMath `interval`** in C# and HLSL with parity tests:
- arithmetic, `min`/`max`, `smin`, `sqrt`, `clamp` and `smoothstep`;
- interval or Lipschitz bounds for every CultMath noise primitive.
This step needs no tape. It stands alone, and is the first cut to take even
if the rest stays parked.
2. **Tape**: an expression graph lowered to bytecode, with point, interval and
gradient evaluators in C#. Gradients reuse CultMath's analytic-gradient
primitives, which Asura cut 2a built.
3. **Pruning**: per-region tape simplification driven by interval results.
4. **HLSL interpreter** for the same bytecode, with interval ops and pruning.
Fidget's MPL-2.0 WGSL is the reference. MPL code keeps its own MPL-headed
files, as Phacelle does.
5. **Meshing**: an octree of chunks with seams stitched between LOD levels.
- The default is surface nets (GameCult.Geometry, Asura Cut 2) plus
Asura's tile pass: Newton refinement onto `f = 0`, then crease snapping
from field gradients (Asura Cut 6c).
- The operator's lean (2026-10-02) is that gradient work beats dual
contouring here. Thin authored bevels can be shaded by their analytic
normals, and the refined positions can be iterated further.
- Dual contouring is not planned. The tile pass on station brushes is its
test.
## Truth and lowerings
The field is data: a typed CultCache document holding the expanded tree. A
generator, such as a brush grammar, expands into that document, and everything
downstream caches it. The CPU evaluator, the HLSL interpreter, the mesher and
collision are all lowerings of that one document. This is how "one
implementation of the field" survives having both CPU and GPU evaluators.
Asura's invariant 2 had to forbid CPU evaluation because its field is HLSL
source.
## Known consumers
- **Aetheria volumetric raymarching** (clouds, nebulae). Step 1 alone gives it
empty-space skipping. Coverage and shape terms carry the culling, because
detail octaves only bound to ±amplitude.
- Prerequisite: Aetheria's shaders must use CultMath noise, not their own
copies. That is already owed at Aetheria's CultMath pin bump (see the Asura
cut map, snoise release follow-ups).
- **Space stations from a brush grammar**, meshed in LOD chunks around the
camera. This is a separate project with its own target. This campaign
supplies its evaluator.
- Asura does not need this campaign. Its field is noise-dominated, and its
`A_max` shell and band-limiting already give the cheap culling.
## Open before unparking
- Run the (a) spike first, or go straight to (c)?
- Pick the interval representation (plain intervals or affine arithmetic) and
the noise bound strategy.
- Choose the bytecode register model for the HLSL interpreter, given GPU
register pressure.
- Decide whether the brush-grammar station project lives in Aetheria or in
its own repo.

View file

@ -1,663 +0,0 @@
# CultNet error contract: cut map
Status: cut map, not started. Imagination pass 1, 2026-09-30, against CultLib `main` at `8fc74c70`.
This document owns the means. There is no separate target doc: section 1 holds the ends until the
operator rules on section 3. After that, Self moves the rulings and invariants into
`contracts/cultnet/cultnet-distributed-database.md` ("Error Codes") and the schema's own description.
Open: operator questions Q1-Q8 (section 3). No cut is dispatched until Q1, Q2, Q3 and Q8 are ruled. Q4-Q7
change only single emit sites and can be ruled during Cut 1.
Depends on: `hands/cultmesh-put-serve-bound` merging first. It introduces `document_unservable` in Python
(`fd394d54`), a codeless unservable refusal in cultmesh-ts (`0eb7434a`) and `CultMeshRudpRejectionReason` in
cultmesh-rs (`c9cdad12`, `85288a5a`). The cuts below convert those sites. Their anchors are by commit, not by
line, because the branch is not on main.
Found by the put-serve Hands (batch 3, `38000efa`, 2026-09-30); they belong to this map's cuts:
- **Python codes outside the closed code list.** Python refuses with `document_unservable`,
`malformed_document_put` and `snapshot_*_exceeded`. None of these are in the closed `cultnet.error.v0` code
list, and Rust and TS peers refuse to decode an unknown code.
- **Two error-map shapes.** TS sends a two-key error map that its own peers cannot parse. Rust sends the five-key
contract shape. The two agree only at the message level.
- **The session after a refusal.** TS and Python keep the session open after a refusal; Rust sends a goodbye and
ends it.
- **New Rust refusal frame.** The put-serve branch adds a Rust refusal frame, `cultnet.error.v0` carrying the
reason text with no code and no details, sent before the goodbye and acknowledging nothing. Cut 1 gives it a
code.
- Found by the put-serve Soul (merge verdict, 2026-09-30):
- A store `OSError(ENOSPC)` in Python is refused as `malformed_document_put`, with the text "Raw put document could
not be stored." That is a server fault reported as the peer's fault.
- Over RUDP, Python's `document_unservable` details add `fragmentCount` and `maxFragmentCount`, and send
`maxSnapshotBytes: null` when it is unbounded. The map specifies `{responseBytes, maxSnapshotBytes}`.
- `response_unqueueable` (Python, P3) is a new code outside the closed list.
- A TS peer cannot tell "stored, receipt lost" from "refused" (put-serve Soul F4, pass 3).
Map branch: `main`.
---
## 0. What the Body does today (probed)
Every claim in this table was established by running code. The C#, Rust and TypeScript probes ran on
Yggdrasil through `ygg-verify.sh` at `8fc74c70`. The Python bytes came from `msgpack.packb` on the exact
dicts `cultmesh_py/server.py:573-589` and `cultmesh-ts/src/index.ts:5921-5926` build. The six probe inputs:
| Input | Bytes from |
|---|---|
| `cs_cursor_stale` | Rust `tests/error_message.rs` captured C# hex (`cursor_stale`, details `{asOf:1,current:2}`) |
| `cs_routing` | C# `new CultNetErrorMessage { Error, RoutingHint = { Reason="not_primary", Shard=... } }` serialized with `CultNetSchemaMessageSerialization` |
| `cs_codeless` | C# `new CultNetErrorMessage { Error = "boom" }` |
| `cs_unknown_code` | C# with `Code = "document_unservable"`, empty `CultNetErrorDetails` |
| `py_snapshot_byte_limit` | the Python `_error_response` shape: `messageId`, `code: "snapshot_byte_limit_exceeded"`, `details {responseBytes, maxSnapshotBytes}` |
| `ts_two_key` | the cultmesh-ts shape `{schemaVersion, error}` |
Decoded by:
| Input | C# `CultNetSchemaMessageSerialization.Deserialize` | Rust `decode_cultnet_message_from_slice` | TS `parseCultNetMessage` |
|---|---|---|---|
| `cs_cursor_stale` | ok | ok, typed | ok |
| `cs_routing` | ok | ok, **routingHint dropped** (`code: None, details: None`) | ok (untyped passthrough) |
| `cs_codeless` | ok | ok | ok |
| `cs_unknown_code` | ok (free string) | **Err: "not a recognized cultnet.error.v0 code"** | **throws: /code must be one of the allowed values** |
| `py_snapshot_byte_limit` | ok, but **details silently become an all-null object** (`responseBytes`, `maxSnapshotBytes` lost), `messageId` dropped | **Err: not a recognized code** | **throws** (missing routingHint, extra messageId, code enum, details keys) |
| `ts_two_key` | ok | ok | **throws: missing routingHint, code, details** — TypeScript cannot parse the errors TypeScript sends |
Captured C# bytes for the two shapes Rust's vector file does not hold (for Hands' reuse):
- `cs_codeless`: `85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72A4626F6F6DAB726F7574696E6748696E74C0A4636F6465C0A764657461696C73C0`
- `cs_routing`: see the probe; the shard descriptor writes ten keys, nil included.
Other findings, from reading source at `8fc74c70`:
1. **The interop CI lane has been red for 40 consecutive `main` runs.** It fails at "Verify CultNet schema
contracts stayed consolidated" (`.github/workflows/cultnet-interop.yml:67-90`). That step forbids every
`*.schema.json` outside `contracts/cultnet/`, and R-AB deliberately vendors 22 copies into
`packages/cultnet-rs/contracts/cultnet/`. Those copies are held byte-equal by
`packages/cultnet-rs/tests/schema_discovery_contracts.rs:74-99`. Nothing after that step (every
cross-runtime interop test) has run on main in that time. Run `36714768445` is the latest.
2. **The schema contradicts itself.** `contracts/cultnet/cultnet.error.schema.json:29` says the code is
"Additive: a v0 peer that does not know this field ignores it" and, in the same sentence, "a closed
enum: Rust and TypeScript refuse". The closed-enum text came from Soul pass 4d (`3f942a57`). It
described what the code did; no operator ruled it. `docs/` holds no ruling on unknown codes.
3. **Emitters that break the schema:**
- cultmesh-ts sends `{schemaVersion, error}` only, at `packages/cultmesh-ts/src/index.ts:5875-5878`,
`5885-5888`, `5913-5916` and `5922-5925`, and on the branch at `0eb7434a`.
- cultmesh-py `_error_response` (`packages/cultmesh-py/src/cultmesh_py/server.py:573-589`) adds a
top-level `messageId`, omits `routingHint`, omits `code` and `details` rather than sending nil, and
sends eight out-of-enum codes: `unsupported_schema_version` (`:161`), `malformed_document_put`
(`:358`), `unregistered_document_put` (`:365`), `malformed_document_delete` (`:386`),
`unregistered_document_delete` (`:393`), `snapshot_document_limit_exceeded` (`:558`),
`snapshot_byte_limit_exceeded` (`:567`) and `simulation_observations_disabled` (`:596`). The branch
adds a ninth, `document_unservable` (`fd394d54`).
- `packages/cultnet-py/src/cultnet_py/interop_peer.py:409-417` also sends `unsupported_schema_version`.
R-2 (`docs/cultnet-selection-cut.md:2046-2053`) required that code, but it never entered the schema
enum.
4. **A second schema authority.** `packages/cultnet-py/src/cultnet_py/schema_catalog.py:17`, `:272` and
`:357-362` publish a hand-built `cultnet.error.v0` descriptor under the canonical `$id`. It has a
different shape: `messageId`, `code` as any string, `details` open, and only `schemaVersion` and `error`
required. The same file does this for every wire message (about 350 lines). This is recorded as
follow-up F-1, not cut here.
5. **Silent refusals.** cultmesh-rs's RUDP document server returns every rejection to the local caller only
(`packages/cultmesh-rs/src/rudp_document_server.rs:470-590`, `CultMeshRudpApplicationRejection`). The
peer gets nothing and times out. On the branch this includes the new `DocumentUnservable`.
6. **Receivers drop the code.**
- Every C# receiver turns an error into `new IOException(error.Error)` or
`new InvalidOperationException(error.Error)`, so a caller cannot branch on the code. The sites:
`src/GameCult.Mesh/CultMeshBodySessions.cs:310`, `CultMeshContentSessions.cs:178/256-260`,
`CultMeshPeerExchange.cs:430/466`, `CultMeshSnapshots.cs:91/107/222-226/743` and
`CultMeshVerseDiscovery.cs:273`, plus `src/GameCult.Networking/CultNetShardReplication.cs:440/574`.
- Rust reads `Error { error, .. }` at `packages/cultmesh-rs/src/lib.rs:823`.
- TS types `CultNetErrorMessage` as `{schemaVersion, error}` only (`packages/cultnet-ts/src/contracts.ts:167-170`).
- Kotlin has no error type. A request that gets an error back fails a
`require(response.schemaVersion == ...)` (`packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt:1278`,
`:1334`), and the peer's text is lost.
7. **No correlation.** Only Python echoes `messageId`.
- `cultmesh-browser` rejects an error only when exactly one operation is pending
(`packages/cultmesh-browser/src/index.ts:724-731`); otherwise the error is dropped and the operation
times out.
- C# `CultMeshContentSessions.OnError` (`:256-260`) fails every pending request on any error.
8. **The reference is inconsistent with itself.** `src/GameCult.Networking/CultNetDatabaseServer.cs:560`
refuses a variant change with a codeless error. The subscription server refuses the same condition with
`ForVariantUnsupported` (`CultNetDatabaseSubscriptionServer.cs:211/333/366`).
9. **routingHint is never consumed.** No receiver in any runtime reads it. C# builds it at
`CultNetDatabaseServer.cs:638-650`, and Rust's decode discards it.
10. **Consumer audit** (read-only, all of `F:\Projects` except CultLib; no git in Aetheria):
- Outside CultLib, nothing sets `code`, `details` or `routingHint`, and nothing branches on a code.
- Emitters:
- Huginn: `crates/huginn-daemon/src/serve.rs:288/307/312`, Rust struct literals
`Error { error, code: None, details: None }`, pinned at cultnet-rs `8fc74c70`.
- AetheriaEve: `Aetheria.State.Daemon/Program.cs:1737/1778`, C# object initializers.
- Decoders read `.error` only: Gjallar `Program.cs:574`, Mimir
`MimirOdinMoveProofEvidenceRingProvider.cs:80`, Hermodr `hermodr-daemon.cjs:1380` (hand-rolled),
AetheriaEve's smoke test (text match against its own emit text), and Huginn's tests (text match).
- Stale vendored TS:
- `StreamPixels/vendor/CultLib` carries the current closed schema, so any change here breaks its
TS error decode until it is re-vendored.
- `Heimdall/vendor/CultLib` carries a pre-R-X schema with `additionalProperties:false` and no
`routingHint`, so it already refuses every error C# sends today.
- Odin, Idunn, Muninn, Ghostlight and voidbot: zero hits.
## 1. Ends
- **One schema.** `contracts/cultnet/cultnet.error.schema.json` is the only authority on the shape and on the
code registry. C# embeds it (`GameCult.Networking.csproj:19`), TS mirrors it at build time, and Rust
vendors a copy held byte-equal by test. Nothing else publishes a `cultnet.error.v0` shape (F-1 covers
Python).
- **Every runtime emits only schema-valid errors.** This is proven per emit path against the schema, and
byte-equal to the C# reference for every registry code.
- **Every runtime decodes every error it can receive into a typed value.** A known code becomes its typed
variant with its typed details. An unknown code, if Q1 says so, becomes a typed "other" that keeps the
code string and the raw details. No well-formed error is ever refused, and no field is silently dropped
(routingHint, messageId).
- **Code and details have one owner.** The details a message carries are decided by its code. The types in
every runtime make "cursor_stale with a field" unrepresentable. No free-standing code string plus a
grab-bag details object.
- **A refusal is an answer, not a silence** (R-2 precedent, and Q4). A server that refuses a peer's request,
and can still send, sends a coded error that correlates to the request.
- **The public surface is complete in every runtime**, per the consumer-expectation doctrine:
- a typed error value;
- a constructor per registry code;
- a typed exception or error that request APIs raise when the peer refuses, carrying the typed value.
## 2. Identity, lifecycle and authority
| Kind | What names it | Lifecycle | Who decides |
|---|---|---|---|
| Error message | `schemaVersion: cultnet.error.v0`; correlated to its request by `messageId` (Q3) | Sent once, in reply to one request or subscription. Never persisted. | The server that refused. |
| Error code | A snake_case string in the schema's registry (`$defs/knownCode`) | Added by one coordinated change: schema, C# constructor, a C#-written vector, and a typed variant in every runtime. Never renamed and never reused. A retired code stays in the registry marked retired, so an old reader still decodes it. | The operator rules meaning; Self keeps the registry. |
| Details for a code | The code | Frozen with the code. Adding a key to an existing code's details is a new code (or a v1), never an in-place edit (see Q2). | Same as the code. |
| Unknown code, at a reader | The raw string | Preserved as typed `Other`. The reader acts on the `error` text and the absence of a known code. | The reader (Q1). |
| Vector file | `contracts/cultnet/interop/error-vectors.cs-written.json` | Regenerated only by the C# writer test under `CULTNET_WRITE_VECTORS=1` and committed. Every other runtime judges it read-only. | C#, the reference. |
| routingHint | Shard authority refusal | Sent with a null code (unchanged). Typed in every decoder. | CultNetDatabaseServer's authority path. |
## 3. Operator questions
**Rulings (operator, 2026-09-30):** "Q1: B / Q2: B / Q3: A / Q8: A".
- **Q1:** closed for emitters, open for readers. An unknown code decodes to a typed `Other`.
- **Q2:** per-code details.
- **Q3:** a nullable top-level `messageId`.
- **Q8:** v0 is corrected in place, with the semver bumps the policy requires.
- Q4-Q7 are still open.
- Cut 1 waits for `hands/cultmesh-put-serve-bound` to merge.
### Q1. What does a reader do with an error code it does not know?
Today Rust and TypeScript refuse the whole message. The probes above show the result: the peer's error text
is lost and the caller sees "not a recognized code". In cultmesh-rs's snapshot client the `?` at `lib.rs:812-815`
even turns a peer refusal into a local decode failure. So under a closed enum every new code breaks every
reader that has not upgraded. By `docs/semver-policy.md:86-91` that makes each new code a breaking wire
change, and nine codes are already in flight.
- **A. Closed enum (today).** Readers refuse unknown codes. Every code addition is a coordinated,
semver-major wire change across all runtimes and consumers.
- **B. Closed for emitters, open for readers.** The schema keeps the registry, and every runtime's emit
constructors can produce only registry codes (tests prove each emit path valid against the schema).
Readers decode an unrecognized code (matching `^[a-z][a-z0-9_]*$`) into a typed `Other { code, details }`.
A new code is an additive, minor change: old readers still surface the text and know "an error I cannot
classify". This is how gRPC status codes and protobuf open enums behave.
- **C. Fully open.** No registry; any string, emit and decode.
**Recommended: B.** A refusal must never fail to arrive because it is more specific than the reader
expected. B keeps the registry as the single meaning authority. C loses the meaning authority, and A makes
the error channel the most brittle message in CultNet.
Depends on this: schema `code` shape, every decoder, the doc text at `cultnet-distributed-database.md:107-115`.
### Q2. How are details shaped on the wire?
Today C# writes all four detail keys (`field`, `value`, `asOf`, `current`), nil included, because
`CultNetErrorDetails` (`CultNetSchemaMessages.cs:592-603`) is a MessagePack map-mode object. The codes in
flight need new keys: `responseBytes`, `maxSnapshotBytes`, `documentCount`, `maxSnapshotDocuments` and
`schemaVersion`.
- **A. One shared details record; grow it.** Every detail key of every code sits on one record, written as
nil when unused. Each new key changes the bytes of every existing error that has details, so all of the
C# vectors must be recaptured on every addition. It also breaks every strict reader: TS Ajv, and
StreamPixels' vendored schema, have `additionalProperties:false`. And the type cannot tie a key to its
code.
- **B. Per-code details.** `details` carries exactly the keys its code defines. A defined-but-optional key
is present as nil; another code's keys are absent. Keys are named for their meaning, which retires the
`field: "schemaId"` / `field: "subscriptionId"` indirection that exists only because the record is
shared. Adding a code never changes an existing code's bytes. In every runtime, code and details are
fused into one tagged type:
- Rust: an enum with data.
- C#: sealed subclasses, with a custom formatter for `CultNetErrorMessage`. There is precedent in
`src/GameCult.Mesh/CultMeshRouteRecordFormatter.cs`.
- TS: a discriminated union.
- Kotlin: a sealed class.
- Python: constructors per code over a frozen dataclass.
The churn is one time. The five existing C# cases with details change bytes, and all eight Rust hex
cases move into the shared vector file anyway (Cut 1).
- **C. Opaque string map.** Untyped; no runtime can offer a typed accessor.
**Recommended: B.** A is the churn the brief warned of, and it recurs on every future code. B pays it once,
at the moment every vector is being recaptured regardless.
Depends on this: the schema's `details`, the C# model and formatter, `encode_error_details`
(`packages/cultnet-rs/src/contracts.rs:1339-1366`), and every decoder.
### Q3. Does an error carry the `messageId` of the request it refuses?
Python already sends `messageId` (`server.py:583`); the schema forbids it. Without it:
- `cultmesh-browser` drops an error whenever two operations are pending (`index.ts:724-731`);
- C# content sessions fail every pending request on any error (`CultMeshContentSessions.cs:256-260`).
Adding a top-level key changes the bytes of every C# error. That happens once, in the same recapture as Q2.
- **A.** Add a nullable top-level `messageId`. A reply to a request echoes the request's id, and an error
with no request (a subscription push, an unsolicited refusal) sends nil. Receivers correlate by it and
fall back to their current behavior when it is nil.
- **B.** No correlation. Python deletes the field.
**Recommended: A.** Correlation is a capability any multiplexing client needs, and one runtime already
depends on it.
### Q4. Do servers send `document_unservable` (and every other refusal they can) to the peer?
Self ruled that "unservable" must be distinguishable as a typed code in every runtime. On the branch:
Python sends it coded, TypeScript sends it codeless, and Rust tells only its local caller, so the Rust
peer times out. The same Rust silence covers every other document-server rejection: a sink refusal, a
failed snapshot source, too many snapshot documents, and a snapshot response that is too large.
- **A.** Every refusal of a peer's request that the server can still send is answered with a coded error
carrying the request's `messageId`, and is also reported locally (Rust's `ApplicationRejected` stays as
the local report). The refusals that cannot be answered are the ones where the answer itself cannot be
queued: Rust `PayloadBudgetFull` and `ResponseQueueFailed`. Those stay local-only, and the doc says so.
- **B.** Local-only in Rust; peer replies only where a runtime already sends them.
**Recommended: A.** R-2 already ruled that a silence is worse than a missing feature
(`docs/cultnet-selection-cut.md:2049-2053`), and the operator's ruling that CultMesh "refuses" puts only
means something to the party that sent the put.
### Q5. Does Python's `unregistered_document_put` / `unregistered_document_delete` become `unowned_schema`?
Python sends these when the node has no registered document type for the record's schemaId
(`cultmesh_py/node.py:653-666`). C# `unowned_schema` means "no shard owns this schema and key"
(`CultNetSchemaMessages.cs:537-544`). To a peer both mean "this server will not take that schema here; route
elsewhere". The put and delete variants duplicate what the peer already knows (it sent the message).
- **A.** Both collapse into `unowned_schema { schemaId }`. The registry text broadens to "this server
stores no document for that schema (and key)".
- **B.** Join as a separate `schema_unregistered { schemaId }`.
- **C.** Join both codes as they are.
**Recommended: A.** It is one meaning at the peer, and there is one action to take.
### Q6. Does Python's `malformed_document_put` / `malformed_document_delete` become one `message_invalid { field }`?
These are sent when the request lacks a required field (`server.py:355-359`, `383-387`). C# has the same
condition, codeless, at `CultNetDatabaseServer.cs:373/415` and `CultNetDatabaseSubscriptionServer.cs:148`
("requires a subscriptionId or messageId").
- **A.** One `message_invalid { field }`, naming the missing or ill-typed field. It is used by Python's four
sites and C#'s three.
- **B.** Join both per-message codes and leave C# codeless.
**Recommended: A.** The per-message suffix repeats the request's own schemaVersion, and C# gains a code for
free.
### Q7. Does `simulation_observations_disabled` become `unsupported_schema_version`?
Python sends it when the observation hub is off (`server.py:593-598`), with
`details { schemaVersion: "cultnet.simulation_observation.v0" }`, which is exactly the
`unsupported_schema_version` shape. cultmesh-ts's "no cache for snapshot requests" and "no operation
handler" refusals (`index.ts:5875`, `5913`) mean the same thing: this server does not serve that message.
- **A.** Collapse. `unsupported_schema_version` means "this server does not serve messages of this
schemaVersion: unknown, a newer version, or not enabled here". The three TS sites and Huginn's "answers
only X, Y, Z" adopt it.
- **B.** Keep a separate `feature_disabled`-style code.
**Recommended: A.** A peer cannot act differently on "not enabled" and "not known". Python's
`verify.py:335` check follows the rename.
### Q8. Is this a correction of `cultnet.error.v0` in place, or a new `cultnet.error.v1`?
Under Q2-B and Q3-A, strict v0 readers (TS Ajv and vendored copies) refuse the new bytes. Tolerant readers
(C#, Rust, Python) keep working, because the probes show they ignore unknown keys and treat a missing key
as null.
- **A. In place.** The v0 contract has never been decodable across all five runtimes (section 0). No
consumer outside CultLib reads code or details (finding 10). Every publishing package takes the semver
bump its policy requires:
- `org.gamecult.cultlib` major, because the public `CultNetErrorDetails` properties change;
- `cultnet-py` and `cultmesh-py` major, because emitted codes change;
- Rust pins move deliberately.
StreamPixels and Heimdall re-vendor.
- **B. v1.** Add `cultnet.error.v1`, keep v0 emit for peers that did not advertise v1 in hello, and run
two error encoders in every runtime until v0 retires.
**Recommended: A.** B builds a version-negotiation path for a message nobody outside CultLib parses beyond
`.error`. The one thing every old reader does need, the `error` text, survives A in every tolerant runtime.
## 4. The registry (as recommended; rewrite on each ruling)
| Code | Meaning | Details (per Q2-B; `?` = present-as-nil when absent) | Emitted by after this map |
|---|---|---|---|
| `selection_invalid` | A selection field failed the door. | `{ field, value? }` | C#, Rust (selection) |
| `cursor_stale` | The cursor predates the server's retained history. | `{ asOf, current }` | C#, Rust |
| `cursor_invalid` | The cursor could not be parsed. | none (details nil) | C#, Rust |
| `reference_outside_target` | A hop reference left the declared target. | none | C#, Rust |
| `variant_unsupported` | The selection selects a document variant CultNet does not carry. | `{ subscriptionId? }` | C# (incl. `DatabaseServer.cs:560`) |
| `unowned_schema` | This server stores no document for that schema (and key). | `{ schemaId }` | C#, Python (Q5) |
| `unsupported_schema_version` | This server does not serve messages of this schemaVersion. | `{ schemaVersion }` | Python, TS, Huginn (Q7) |
| `message_invalid` | The request lacks a required field or has an ill-typed one. | `{ field }` | C#, Python (Q6) |
| `snapshot_document_limit_exceeded` | The snapshot answer would exceed the document bound. | `{ documentCount, maxSnapshotDocuments }` | Python, Rust (Q4) |
| `snapshot_byte_limit_exceeded` | The snapshot answer would exceed the byte bound. | `{ responseBytes, maxSnapshotBytes }` | Python, Rust (Q4) |
| `document_unservable` | A put was refused because no snapshot could ever return the stored document under the byte bound. | `{ responseBytes, maxSnapshotBytes }` | Python, TS, Rust (Q4) |
A null code is legal and means "unclassified". Examples: an exception message from a failed snapshot
source, an authority refusal carrying `routingHint`, and "not authorized"
(`CultNetDatabaseSubscriptionServer.cs:165`). This map does not invent codes for those.
`snapshot_byte_limit_exceeded` and `document_unservable` have identical details. That is deliberate, and it
is the fixture trap named in the verification rules.
---
## 5. Cuts
All cuts are made on one campaign branch, `hands/error-contract`, from `main` after
`hands/cultmesh-put-serve-bound` merges. The branch merges to `main` once, after Cut 5's Soul pass. The
reason: Cut 1 changes C# bytes, and Rust's static hex cases would stay green against the stale bytes
(finding: `tests/error_message.rs` pins constants, not the reference). A partial merge would ship a
wire split while every suite passed. Each cut gets its own worktree, created by Self. Heavy verification
runs through `ygg-verify.sh`, never on Starfire.
### Cut 0. Unblock the interop lane
- **Repo/branch:** CultLib, `hands/interop-guard` from `main`, merged alone ahead of the campaign. It
does not depend on the rulings.
- **Deletes first:** nothing.
- **Per-file changes:** `.github/workflows/cultnet-interop.yml:72`. The `$stray` filter also accepts
`^packages/cultnet-rs/contracts/cultnet/`, with a comment naming R-AB and
`packages/cultnet-rs/tests/schema_discovery_contracts.rs:74-99` as the equality guard. The namespace
check is unchanged.
- **Authority map:** no ownership change. The canonical owner stays `contracts/cultnet/`, and the Rust
mirror is derived from it, with drift judged by the Rust test.
- **Verification:**
- Push the branch and run the workflow. Report every step that goes red after the guard. None of those
steps has run on main for 40 runs, so expect more red, and treat each failure as its own finding;
do not fix it in this cut.
- Negative: edit one byte of a vendored copy locally and check that the Rust equality test fails.
Reach it on Yggdrasil with `cd packages/cultnet-rs && cargo test --test schema_discovery_contracts`.
- **Ledger:** +3 lines.
### Cut 1. The contract and the C# reference
- **Repo/branch:** CultLib `hands/error-contract`, worktree named by Self.
- **First:** capture the current C# bytes for all eight factory cases, plus `cs_codeless` and `cs_routing`,
into the Hands report. This is the before-image a Soul pass compares against.
- **Deletes first:**
- `CultNetErrorDetails` (`src/GameCult.Networking/CultNetSchemaMessages.cs:588-603`, 16 lines) as a
shared record.
- The `field`/`value`/`asOf`/`current` property block and the `[MessagePackObject]`/`[Key]` attributes
on `CultNetErrorMessage` (`:497-521`). Its serialization moves to one formatter.
- `SoulP4dProbeTests.Wire_UnownedSchemaBytes` (`tests/GameCult.Networking.Tests/SoulP4dProbeTests.cs:191-199`).
It is an `[Explicit]` hex printer, superseded by the vector writer.
- The closed-enum paragraph at `contracts/cultnet/cultnet-distributed-database.md:107-115` and
the `code`/`details` descriptions in the schema (`:28-42`).
- **Adds:**
- **Schema** (`contracts/cultnet/cultnet.error.schema.json`, rewritten; the Rust mirror is re-copied
byte-equal):
- Top level: `schemaVersion`, `error` and `routingHint` as today, plus `messageId: string|null` (Q3)
and `code`. All five are required, present-as-nil (R-X is unchanged at the top level).
- `$defs/knownCode` is the registry enum from section 4.
- `code` is `null`, or a string matching `^[a-z][a-z0-9_]*$` (Q1-B).
- `details` is decided by a top-level `oneOf`, one branch per known code, each fixing `code` by
`const` and `details` by exact `properties`/`required`/`additionalProperties:false`. Two more
branches: a null code with null details, and an unknown code (`not: {$ref knownCode}`) with details
`object|null`, open.
- Use `oneOf`/`not`/`const` only. `tests/GameCult.Networking.Tests/MiniJsonSchemaValidator.cs:30-35`
implements those and **not** `if`/`then`/`allOf`, and it refuses unimplemented keywords. TS
compiles with Ajv2020 (`packages/cultnet-ts/src/contracts.ts:1,674`), which handles both.
- **C# model** (`GameCult.Networking`):
- `CultNetErrorMessage { SchemaVersion, Error, MessageId?, RoutingHint?, Refusal? }`, where
`Code => Refusal?.Code` is derived and not settable.
- `abstract CultNetRefusal { string Code }`, with one sealed subclass per registry code carrying its
typed details (`CultNetCursorStaleRefusal(ulong AsOf, ulong Current)` and so on), plus
`CultNetUnknownRefusal(string Code, IReadOnlyDictionary<string, object?>? Details)` for Q1-B.
- `CultNetErrorCodes`: string constants, one per registry code.
- `CultNetErrorMessageFormatter : IMessagePackFormatter<CultNetErrorMessage>`. It writes the five
top-level keys in order (schemaVersion, error, messageId, routingHint, code, details), and details
as exactly the refusal's keys. It reads by key name, tolerates unknown keys at both levels, and
maps an unknown code to `CultNetUnknownRefusal`. Register it through `[MessagePackFormatter]` on
the type, the way `CultMeshRouteRecordFormatter` is wired.
- Factories: keep `ForSelectionInvalid`, `ForCursor`, `ForReferenceOutsideTarget`,
`ForVariantUnsupported` and `ForUnownedSchema` as thin constructors over the refusal types, and add
one per new code. Each takes an optional `messageId`.
- `CultNetPeerErrorException : IOException`, carrying the `CultNetErrorMessage`.
- **Vector writer:** `ErrorVectorTests.WriteErrorVectors`, gated on `CULTNET_WRITE_VECTORS=1` like
`SelectionParityVectorTests.cs:460-552`. It writes `contracts/cultnet/interop/error-vectors.cs-written.json`:
one entry per registry code (both optional-key states where a key is optional), `cs_codeless`,
`cs_routing`, one unknown code with open details, and one error with `messageId` set. Each entry
holds `{ name, messagePackBase64, expected: { error, messageId, code, details, routingHint } }`,
where `expected` is a JSON projection every runtime compares its typed decode against.
- **Per-file changes** (`8fc74c70`):
- `CultNetDatabaseServer.cs:560`: `ForVariantUnsupported(refusal.Message, subscriptionId)`.
- `CultNetDatabaseServer.cs:373`, `:415` and `CultNetDatabaseSubscriptionServer.cs:148` become
`message_invalid { field: "subscriptionId" }` (Q6-A).
- Every `SendCultNet(CultNetErrorMessage...)` that answers a request passes the request's `MessageId`.
- Each receiver listed in finding 6 throws `new CultNetPeerErrorException(error)` instead of
wrapping `error.Error`. `CultMeshContentSessions.OnError` (`:256-260`) fails only the pending entry
whose id matches `error.MessageId`, and falls back to all entries only when the id is nil.
- `CultNetSchemaContractTests.cs:165-214`: the per-factory tests are replaced by one test that validates
every vector's decoded JSON against the schema with `MiniJsonSchemaValidator`, and one test that
serializes each registry factory and asserts byte equality with its committed vector.
- Rewrite `contracts/cultnet/cultnet-distributed-database.md` "Error Codes" to the registry, and state
the Q1 and Q2 rules.
- **Authority map:**
- Owner: the schema's registry owns code meaning and detail shape. `CultNetErrorMessageFormatter` owns
C# bytes. The refusal subclass owns the code-to-details binding.
- Inputs: the refusal instance, messageId, routingHint.
- Outputs: bytes, and `cs-written.json` for the other runtimes.
- Derived state: `CultNetErrorMessage.Code` becomes derived; the refusal decides it.
- Forbidden writers: any `Code = "..."` assignment (it cannot compile); `[Key]` attribute serialization
of the message; hand-typed hex anywhere.
- Shared paths: every server send uses the factories; every receiver uses `CultNetPeerErrorException`.
- Deletion line: `CultNetErrorDetails` and the settable `Code`.
- **Verification** (`dotnet` image):
- Builds: `tests/GameCult.Networking.Tests`, `tests/GameCult.Mesh.Tests`.
- `ErrorVectorTests` with `CULTNET_WRITE_VECTORS=1`, then without, and check that the committed file
reproduces byte-for-byte.
- Tests and the rules they pin:
- vector round trip: bytes, decode, bytes are equal for every entry;
- a registry factory is byte-equal to its vector;
- every vector validates against the schema;
- an unknown code decodes to `CultNetUnknownRefusal` with its raw details intact;
- a Python-shaped error (`py_snapshot_byte_limit` above, with the Q2/Q3 shape) decodes typed with
`responseBytes` and `maxSnapshotBytes` intact. Today's C# silently empties those.
- content-session correlation: two pending requests, an error for the second, and the first still
completes.
- Mutation: Stryker.NET on the formatter and factories. The survivors that must die:
- swapping two known codes whose details are identical (`snapshot_byte_limit_exceeded` and
`document_unservable`);
- padding a nil key into details;
- dropping `messageId` on encode;
- mapping an unknown code to null.
- Negative greps (checked at HEAD, where they hit the sites being cut):
- `git grep -nE 'new (IOException|InvalidOperationException)\(error\.Error\)' -- src` must return 0
(9 hits at HEAD).
- `git grep -nE 'Field = "(schemaId|subscriptionId)"' -- src` must return 0 (2 at HEAD).
- Operator: none.
- **Ledger:** about -60 C# (the details record, the settable code, the probe printer, and the per-factory
schema tests), +250 (the formatter, the refusal types, the vector writer). The schema grows by about 90
JSON lines. The net is positive, bought by Q1/Q2 typing in the reference.
### Cut 2. Rust: cultnet-rs and cultmesh-rs
- **Deletes first:**
- `CultNetErrorCode` and `CultNetErrorDetails` as separate types (`packages/cultnet-rs/src/contracts.rs:252-310`,
59 lines);
- `require_error_details` (`:1121-1146`);
- `encode_error_details` (`:1339-1366`);
- the eight hex `Case`s and the header comment in `packages/cultnet-rs/tests/error_message.rs:1-160`.
That file is rewritten to read the vector file.
- Keep `require_legacy_optional_u64` (`:1149`). Check with `git grep` for other callers first; at HEAD,
only the error details use it.
- **Adds:**
- `CultNetMessage::Error { error: String, message_id: Option<String>, routing_hint: Option<CultNetShardRoutingHint>, refusal: Option<CultNetRefusal> }`.
- `enum CultNetRefusal`, with one variant per registry code and its typed fields, plus
`Other { code: String, details: Option<rmpv::Value> }`.
- `CultNetRefusal::code(&self) -> &str`.
- A constructor `CultNetMessage::error(text)` and builder-style `with_refusal` / `with_message_id`, so
consumers stop writing struct literals. Huginn has three.
- Decode reads every top-level key by name and routes unknown codes to `Other` (Q1-B).
- Encode writes the Cut 1 key order.
- `CultNetShardRoutingHint` is typed, reusing the crate's existing shard descriptor type.
- **Per-file changes:**
- `packages/cultnet-rs/src/contracts.rs:361-368` (variant), `:1097-1115` (decode), `:1300-1336` (encode).
- `packages/cultnet-rs/src/selection.rs:1210-1255`: the four refusals build `CultNetRefusal` variants.
- `packages/cultnet-rs/src/content.rs:316`: uses `CultNetMessage::error`.
- `packages/cultmesh-rs/src/lib.rs:812-827`: a peer error becomes a typed error carrying the refusal,
instead of `anyhow!` text. Name it `CultMeshPeerError`, next to the crate's existing error types.
- `packages/cultmesh-rs/src/rudp_document_server.rs` (Q4-A): each `CultMeshRudpRejectionReason` that
can be answered sends the matching coded error, correlated by the request's message id, before
returning `ApplicationRejected`. `PayloadBudgetFull` and `ResponseQueueFailed` stay local.
- **Authority map:**
- Owner: `CultNetRefusal` owns code and details. The schema owns the registry.
- Forbidden writers: string codes (`from_wire_str` survives only inside decode); struct literals of
details.
- Shared paths: selection refusals, document-server refusals and content errors all go through the one
encoder.
- **Verification** (`rust` image, `cd packages/cultnet-rs` then `cd ../cultmesh-rs`):
- `cargo test --workspace`.
- `tests/error_message.rs` pins: every vector decodes to `expected`; encoding the decoded value
reproduces the vector's bytes; the unknown-code vector decodes to `Other`; the routing vector keeps
`routing_hint`.
- cultmesh-rs: a put over the bound makes the peer receive `document_unservable` with the request's
message id, and details equal to the computed size and the bound. Run it at two bounds so that a
constant or an offset in `maxSnapshotBytes` dies.
- A peer that sends a Python-shaped `snapshot_byte_limit_exceeded` produces a `CultMeshPeerError`, not
a decode failure.
- Mutation: `cargo mutants --in-diff` on the cut's diff. Triage every survivor by name.
- Negative: `git grep -n 'CultNetErrorDetails' -- packages` must return 0.
- **Consumer note:** Huginn's `serve.rs:288/307/312` and its tests stop compiling at the next pin bump.
The migration is mechanical (`CultNetMessage::error(...)`), and `:312` adopts `unsupported_schema_version`
(Q7). Self routes it as a Huginn change; it is not part of this branch.
### Cut 3. TypeScript: cultnet-ts, cultmesh-ts and cultmesh-browser
- **Deletes first:**
- the two-field `CultNetErrorMessage` interface (`packages/cultnet-ts/src/contracts.ts:167-170`);
- the four literal error constructions in `packages/cultmesh-ts/src/index.ts:5875-5878`, `5885-5888`,
`5913-5916` and `5922-5925`, and the branch's codeless unservable site (`0eb7434a`);
- `rejectCorrelated`'s single-pending heuristic (`packages/cultmesh-browser/src/index.ts:724-731`).
- **Adds:**
- `CultNetErrorMessage` with every field.
- `type CultNetRefusal` as a discriminated union on `code`, with an `{ code: string; details: Record<string, unknown> | null; known: false }` arm.
- `createCultNetError(text, { messageId, refusal })` as the only constructor.
- `class CultNetPeerError extends Error { message: CultNetErrorMessage }`.
- `parseCultNetMessage` keeps Ajv validation against the canonical schema; it now accepts every schema
branch, including unknown codes.
- **Per-file changes:**
- cultmesh-ts: the three "does not serve this" sites (`5875`, `5913`, `5922`) send
`unsupported_schema_version` (Q7-A); the snapshot catch (`5885`) sends a null code; the unservable site
sends `document_unservable` with details.
- cultmesh-ts `6527-6529` throws `CultNetPeerError`.
- cultmesh-browser `163`, `584` and `690`: correlate by `messageId`, falling back to today's behavior only
for a nil id.
- `packages/cultnet-ts/src/idunn-odin-presence-publisher.ts:118-120` throws `CultNetPeerError`.
- The legacy `gamecult.networking.v0` path (`contracts.ts:840-844`, `:899-900`) is unchanged. That is a
separate legacy contract.
- **Verification** (`kotlin` image, which carries Node 24; `npm install`, then build cultcache-ts before
cultnet-ts):
- A new `test/error-vectors.test.ts` pins that every vector parses and deep-equals `expected`, and that
`encode(createCultNetError(...))` is byte-equal to each registry vector.
- The cultmesh-ts document-server tests assert that each refusal parses with TypeScript's own
`parseCultNetMessage`. That is the probe's `ts_two_key` failure, pinned.
- Browser: two pending operations, an error for the second, and the first still resolves.
- Mutation: StrykerJS on the diff.
- Negative: `git grep -nE 'schemaVersion: "cultnet\.error\.v0",' -- packages/cultmesh-ts/src packages/cultmesh-browser/src`
must return 0. Exclude `cultnet-ts/src/schema-discovery.ts:302` (a descriptor) and
`contracts.ts:842` (legacy decode); both are legitimate hits for the unscoped pattern.
### Cut 4. Python: cultnet-py and cultmesh-py
- **Deletes first:**
- `_error_response` (`packages/cultmesh-py/src/cultmesh_py/server.py:573-589`);
- `unsupported_schema_version_error` (`packages/cultnet-py/src/cultnet_py/interop_peer.py:409-417`);
- every string literal code in `server.py` (lines in finding 3).
- **Adds:**
- `cultnet_py/errors.py`, holding:
- `CultNetErrorCode(StrEnum)`, the registry;
- a frozen `CultNetError` dataclass (`error`, `message_id`, `routing_hint`, `code: CultNetErrorCode | str | None`,
`details: Mapping | None`);
- one constructor function per code, taking exactly that code's keys;
- `to_wire()`, which writes the five top-level keys in the Cut 1 order, nil included;
- `parse_cultnet_error(dict)`.
- `CultNetPeerError` (`client.py:46-50`) keeps `.response` and gains `.error: CultNetError`.
- **Per-file changes:**
- Sites `server.py:158-163`, `355-394`, `555-569` and `593-598` and the branch's `document_unservable`
site use the constructors, with the Q5-A/Q6-A/Q7-A codes.
- `interop_peer.py` uses the constructor.
- `verify.py:335` reads `error.error.code`.
- Tests `packages/cultmesh-py/tests/test_cultmesh.py:2088`, `3791`, `4323` and `4363-4371`, and
`packages/cultnet-py/tests/test_cultnet.py:2342`, follow the renames.
- **Verification** (the image the campaign already uses for Python; state it in the report):
- A vector test pins that every vector parses to `expected` and that each constructor's `msgpack.packb`
is byte-equal to its vector.
- Mutation: mutmut on `errors.py` and the changed server sites.
- Negative: `git grep -nE '"schemaVersion": "cultnet\.error\.v0"' -- packages/cultnet-py/src packages/cultmesh-py/src`
returns only `errors.py`.
- **Not here:** F-1 (`schema_catalog.py` publishes its own shapes).
### Cut 5. Kotlin: cultmesh-kotlin
- **Adds:**
- A `CultNetErrorMessage` data class and a `sealed class CultNetRefusal` (known codes plus `Other`),
with a `CultNetMessage.asError(): CultNetErrorMessage?` view.
- `class CultNetPeerException(val error: CultNetErrorMessage) : IOException`.
- At `CultMesh.kt:1278` and `:1334`, and at every `receiveSchemaMessage` consumer that expects a
response, an error response throws `CultNetPeerException` before the `require`.
- **Verification** (`kotlin` image, `pwsh -File build.ps1`):
- A vector test pins that every vector decodes to `expected`.
- Kotlin emits no errors today, so encode parity applies only if a Kotlin server path is added. Say
so in the report; do not invent one.
- There is no mutation tool. Behavioural tests cover it: an error reply to a schema-catalog request
raises `CultNetPeerException` carrying the code.
### Cut 6. Merge, release, consumers
- Soul runs over the whole branch before the merge:
- rerun every vector suite;
- decode each runtime's live emissions with every other runtime. The interop lane from Cut 0 is the
harness; add an error round to `tests/GameCult.Networking.InteropPeer` and
`packages/cultnet-ts/test/interop` if Soul finds a runtime pair unreached.
- Release per `docs/semver-policy.md`, with a Soul pass before tags:
- `org.gamecult.cultlib` major, and the tracked Unity DLLs rebuilt, since they embed the schema;
- `cultnet-py` and `cultmesh-py` major.
- Consumers, routed by Self as separate repo changes:
- Huginn: pin bump plus Cut 2's note.
- StreamPixels and Heimdall: re-vendor. Heimdall is already broken by R-X.
- AetheriaEve, Gjallar and Mimir compile against the live CultLib path and read only `.Error`, so they
need no change. That is inferred from the audit; the next build of each confirms it.
- Hermodr: none.
## 6. Subtraction ledger
| Cut | Removed (estimate) | Added (estimate) | Formats / targets |
|---|---|---|---|
| 0 | 0 | 3 | none |
| 1 | ~60 C#, schema text | ~250 C#, ~90 schema | + `error-vectors.cs-written.json`; - `[Explicit]` hex printer |
| 2 | ~150 (types, two helpers, 160-line hex test) | ~200 | hex constants gone; vectors read from file |
| 3 | ~40 | ~120 | none |
| 4 | ~40 | ~120 (`errors.py`) | 8 ad-hoc codes → registry |
| 5 | 0 | ~80 | none |
The net is positive, by roughly 500 lines, across five runtimes. It buys typed decode where there was
none (Kotlin, TS and Python), a correlation capability, and one vector file in place of hand-copied hex.
The pressure point: if Cut 1's formatter passes 150 lines, report it rather than trimming typing.
## 7. Follow-ups outside this map
- **F-1.** `packages/cultnet-py/src/cultnet_py/schema_catalog.py` publishes about 350 lines of hand-built
schemas under canonical `$id`s. Python should package and serve `contracts/cultnet/*.schema.json`, the
way Rust vendors them. It is a separate cut, because it covers every message, not errors. It can wait
because the catalog is advisory: no runtime validates a peer's message against a published descriptor.
- **F-2.** Codes for the remaining codeless refusals (authorization, failed snapshot sources, routing).
Wait for a consumer that branches on them.
- **F-3.** Whatever the Cut 0 lane reveals once its later steps run again.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -271,15 +271,6 @@ Current progress:
flush where the runtime supports one, so callers that require confirmed
transport delivery do not substitute a fixed sleep. TCP-framed profiles do
not publish the field because they do not own the RUDP pending-reliable queue.
- The receive window is a contract, not a discovery. A receiver must hold 1,023
sequences and 4 MiB of payload beyond its contiguous received watermark, and
a conforming sender never exceeds either. The sender's flow window enforces
it without a wire field: a reliable packet is admitted only while its
sequence is at most 1,023 above the lowest unacked sequence and the payload
above that sequence stays within 4 MiB. The first packet is always
admissible, so a payload larger than 4 MiB goes out alone, and packets wait
in the FIFO in order, so a small packet never overtakes a large one. C#,
Rust, TypeScript, and Python enforce it; Kotlin does not yet.
- TypeScript, C#, Rust, Python, and Kotlin now share a portable
`cultnet.reconnect_policy.v0` document and deterministic exponential delay
helper, and RUDP transport profiles advertise that policy under

File diff suppressed because it is too large Load diff

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.
@ -11,12 +8,9 @@ CultNet, and CultCache ownership boundaries.
powershell -ExecutionPolicy Bypass -File .\scripts\pack-nuget.ps1
```
The command packs `GameCult.Mesh`, `GameCult.Networking.WebSockets`, and every
project they reach through `ProjectReference` (including `GameCult.Math`, which
carries its own version) into a local feed under `artifacts/nuget`. It verifies
that each of those packages exists at the identity MSBuild reports for it, then
restores and runs a clean .NET consumer using only
`PackageReference Include="GameCult.Mesh"`.
The command creates a local feed under `artifacts/nuget`, verifies that every
internal dependency package exists at the same version, then restores and runs
a clean .NET consumer using only `PackageReference Include="GameCult.Mesh"`.
The Unity distribution remains `org.gamecult.cultlib`, built by
`scripts/build-unity-package.ps1`, because Unity consumes the managed assembly
@ -25,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

@ -1,158 +0,0 @@
# Cross-process file locking and atomic replace: prior art
Facts only. Gathered 2026-10-02 for fork `variants:question:ts-python-write-lock`. No conclusions, no recommendation.
Provenance legend: **[src]** read from CultLib source this pass; **[primary]** vendor/spec/upstream doc or source fetched this pass (the fetch tool summarises pages through a small model, so quoted phrasing is paraphrase-grade unless shown in quotes); **[secondary]** search-result snippet or third-party issue, weak; **[UNVERIFIED]** not confirmed by any source read, or derived by inference only. Nothing here was tested by running code on Windows or Linux.
Dates caveat: the doc-site fetches reported release dates for `fs2 0.4.3` ("September 9, 2026") and `fs4 1.1.0` ("September 23, 2026"). The fs2 date is implausible against my memory of that crate (long stable, last release years earlier); treat crate dates as **[UNVERIFIED]**.
---
## 0. What CultLib does today (source, this checkout)
| Runtime | Lock | Temp name | Replace | Pointer |
|---|---|---|---|---|
| C# | Sidecar `<file>.lock`, `new FileStream(lockPath, OpenOrCreate, ReadWrite, FileShare.None, bufferSize 1)`. On `IOException`: if `wait` is false return null, else sleep 10 ms and retry for up to 30 s, after which the exception propagates. | `.{name}.{Guid:N}.tmp`, `FileMode.CreateNew`, `FileShare.None`, `WriteThrough`, `Flush(true)` | `File.Replace(temp, path, null)` if target exists, else `File.Move` | `F:\Projects\CultLib\src\GameCult.Caching\CultCache.cs` lines ~3475-3530 |
| Rust | Sidecar `<file>.lock` (`.lock` appended to full file name), opened `create/read/write/truncate(false)` (std default share mode), then `fs2::FileExt::lock_exclusive` / `lock_shared` / `try_lock_exclusive` (WouldBlock => `None`). Unlock explicit, then drop. | `{name}.{uuid v4}.tmp`; staged file `sync_all` then closed | Unix: `fs::rename`. Windows: `MoveFileExW(MOVEFILE_REPLACE_EXISTING \| MOVEFILE_WRITE_THROUGH)` retried 25 times, 20 ms apart, on ERROR_ACCESS_DENIED(5)/SHARING_VIOLATION(32)/LOCK_VIOLATION(33). Comment cites a Muninn crash on 2026-09-11. | `F:\Projects\CultLib\packages\cultcache-rs\src\lib.rs` ~925-945, 1040-1084, 2870-3030; `Cargo.toml`: `fs2 = "0.4"`, edition 2024 |
| TypeScript | None. In-process promise queue only (`next`/`result` chain). | `${file}.tmp-${pid}-${Date.now()}-${random}` (unique per writer) | `fs/promises.rename`, retried 10 times with 10 ms doubling on EPERM/EBUSY/EACCES; temp removed on error. No fsync of temp. | `F:\Projects\CultLib\packages\cultcache-ts\src\single-file-messagepack-backing-store.ts` ~143-205 |
| Python | None. `threading.RLock` per store object (in-process only). | Fixed `path.with_suffix(suffix + ".tmp")`, i.e. `store.cc.tmp`, shared by all writers | `Path.replace` (`os.replace`); no fsync | `F:\Projects\CultLib\packages\cultcache-py\src\cultcache_py\stores.py` lines 70-82, 121-126 |
Both C# and Rust name the lock `<store path>.lock`. The same-name sidecar is the only thing the two currently share. Both also hold the lock around read-modify-write of the snapshot **[src]**. A lock file is never deleted by either (no stale-file problem; the lock is the kernel object, not the file's existence) **[src, inferred from absence of delete]**.
Note re the fork text: C# does use a lock (stated in the C# code comment: "excludes other handles in this process and in others alike").
---
## 1. Language and OS primitives
### 1.1 .NET `FileStream` / `FileShare`
- **Windows**: `FileShare` maps to the `dwShareMode` of `CreateFile`. `FileShare.None` = share mode 0: "Any request to open the file (by this process or another process) will fail until the file is closed." **[primary]** https://learn.microsoft.com/en-us/dotnet/api/system.io.fileshare . Win32 side: dwShareMode 0 "cannot be opened again, either by the application that opened it or by another application, until its handle has been closed"; failure is ERROR_SHARING_VIOLATION. **[primary]** https://learn.microsoft.com/en-us/windows/win32/fileio/creating-and-opening-files . Share modes are checked at **open** time and are not byte-range locks; they do not call `LockFileEx` **[inferred]**.
- **Unix**: .NET emulates `FileShare` with advisory `flock(2)`. Source (`SafeFileHandle.Unix.cs`, runtime main): `lockOperation = (share == FileShare.None) ? LOCK_EX : LOCK_SH`, called as `FLock(handle, lockOperation | LOCK_NB)`; on `EWOULDBLOCK` an `IOException` is thrown; handle close issues `LOCK_UN`. **[primary]** https://raw.githubusercontent.com/dotnet/runtime/main/src/libraries/System.Private.CoreLib/src/Microsoft/Win32/SafeHandles/SafeFileHandle.Unix.cs
- So non-`None` opens take a **shared** flock; `None` takes an **exclusive** flock; non-blocking in all cases. A plain read with `FileShare.ReadWrite | Delete` still takes `LOCK_SH`, which would conflict with a writer holding `LOCK_EX` on the same file (only relevant if the same file is opened both ways). **[inferred from source above]**
- Disable switch: env `DOTNET_SYSTEM_IO_DISABLEFILELOCKING`; locking is off by default on Browser/WASI and iOS/tvOS (not Mac Catalyst). **[primary]**
- Because it is advisory flock, it excludes only other openers that also take flock (other .NET processes, or any program calling flock on that file). It does not stop a process that merely `open()`s and writes. **[inferred]**
- Since which .NET version: **[UNVERIFIED]**. I believe the flock-based FileShare emulation has existed since .NET Core 1.x/2.x, and .NET 6 rewrote FileStream into strategies while keeping it, but no source read this pass dates it.
- Windows `FileShare.Delete` is meaningful; the Unix code does not differentiate it. **[primary]**
- `File.Replace(src, dst, backup)` -> Win32 `ReplaceFile`; throws if volumes differ. **[primary]** https://learn.microsoft.com/en-us/dotnet/api/system.io.file.replace . Unix mapping to `rename` **[UNVERIFIED]**.
### 1.2 Rust
- `std::fs::File::lock`, `lock_shared`, `try_lock`, `try_lock_shared`, `unlock`: stable since **Rust 1.89.0**. **[primary]** https://doc.rust-lang.org/std/fs/struct.File.html
- Unix/WASI: `flock` (`LOCK_EX`, `LOCK_SH`, `+LOCK_NB`, `LOCK_UN`). Windows: `LockFileEx` (flags `LOCKFILE_EXCLUSIVE_LOCK`, `LOCKFILE_FAIL_IMMEDIATELY`) and `UnlockFile`.
- Docs: lock "may be advisory or mandatory", platform-dependent; on Windows the file must be opened for write (or read+append) for an exclusive lock; re-locking the same handle or a clone is unspecified, possibly deadlock; locks released when the file and all duplicated handles close; "this may change in the future".
- `fs2` 0.4.x (what CultCache Rust uses): Unix `flock(2)`; Windows `LockFileEx` (source `src/windows.rs`: `lock_shared` passes 0, `lock_exclusive` passes `LOCKFILE_EXCLUSIVE_LOCK`, try variants add `LOCKFILE_FAIL_IMMEDIATELY`; `unlock` uses `UnlockFile`). Docs: "advisory"; do not lock one `File` concurrently; duplicated files need great care. **[primary]** https://docs.rs/fs2/latest/fs2/trait.FileExt.html , https://raw.githubusercontent.com/danburkert/fs2-rs/master/src/windows.rs . An earlier summary of the fs2 docs page said "LockFile" for Windows; the source file says `LockFileEx`. Maintenance status of fs2: **[UNVERIFIED]**.
- `fs4`: fork of fs2 adding async, rustix instead of libc; docs say `flock`/`fcntl` on Unix, `LockFileEx` on Windows. Whether std's `File::lock` is mentioned or deprecates it: not stated on the page read. **[primary]** https://docs.rs/fs4/latest/fs4/ . Which Unix call it picks by default (flock vs fcntl): **[UNVERIFIED]**.
- Windows default share mode of Rust `OpenOptions`: `FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE`; `share_mode(0)` makes exclusive. **[primary]** https://doc.rust-lang.org/std/os/windows/fs/trait.OpenOptionsExt.html
- `std::fs::rename`: Unix `rename(2)`; Windows 10 1607+ `MoveFileExW(MOVEFILE_REPLACE_EXISTING)` or `SetFileInformationByHandle(FileRenameInfoEx)` (POSIX semantics); earlier, MoveFileExW only. "may change in future". **[primary]** https://doc.rust-lang.org/std/fs/fn.rename.html . (CultCache Rust bypasses std rename on Windows and calls MoveFileExW itself, per 0.)
### 1.3 Node
- No built-in advisory file lock in `node:fs`; **[UNVERIFIED]** by a direct docs read this pass, but nothing in the packages read contradicts it. Options seen:
- `proper-lockfile`: "mkdir strategy" - creates `<target>.lock` as a **directory**; works across local and network FS; stale detection by lock dir **mtime** (default `stale` 10 s, min 5 s; mtime refreshed every `update` = stale/2, min 1 s); `onCompromised` callback when refresh fails or is late; avoids `O_EXCL` because "O_EXCL is broken on NFS" (their README); contrasts with `lockfile` package which uses ctime for staleness, unsuitable for long-running holders. **[primary]** https://raw.githubusercontent.com/moxystudio/node-proper-lockfile/master/README.md
- `lockfile` (npm), `fs-ext` (native `flock`/`fcntl` bindings): not fetched. **[UNVERIFIED]**
- Libuv rename on Windows uses `MoveFileExW(MOVEFILE_REPLACE_EXISTING)`; reports say it fails with EPERM while any handle holds the destination open, and refuses a READONLY destination; some runtimes (Bun PR) fall back to POSIX-semantic replace. **[secondary]** search snippets: https://github.com/oven-sh/bun/pull/43997 , https://github.com/joyent/libuv/issues/283 . Not read from libuv source: **[UNVERIFIED]**.
- `npm/write-file-atomic`: temp name = `filename + "." + murmurhex(__filename, process.pid, ++invocations)` (plus worker thread id); same-process writes to one file queued and serialised; fsync on by default; no cross-process lock mentioned. **[primary]** https://raw.githubusercontent.com/npm/write-file-atomic/main/README.md
### 1.4 Python
- `fcntl.flock()` wraps `flock(2)`; docs: "On some systems, this function is emulated using fcntl()". `fcntl.lockf()` is "essentially a wrapper around the fcntl() locking calls". Unix only. "On at least some systems, LOCK_EX can only be used if the file descriptor refers to a file opened for writing." The page does not state whether flock and lockf interact. **[primary]** https://docs.python.org/3/library/fcntl.html
- `msvcrt.locking(fd, mode, nbytes)`: locks `nbytes` from the current file position (may extend past EOF); `LK_LOCK`/`LK_RLCK` retry once per second, 10 attempts, then `OSError`; `LK_NBLCK` fails immediately; regions cannot overlap; adjacent regions not merged. Underlying Win32 call not stated in docs. **[primary]** https://docs.python.org/3/library/msvcrt.html . (The CRT `_locking` is documented elsewhere as `LockFile`-based; **[UNVERIFIED]** here.)
- `filelock` (tox-dev): `FileLock` alias = `UnixFileLock` (`fcntl.flock`, described as kernel-enforced and released on crash) on Unix; `WindowsFileLock` described as `LockFileEx` byte-range locking on Windows (older versions used `msvcrt.locking`; version not checked, **[UNVERIFIED]**); `SoftFileLock` is a "cooperative file-existence marker", with same-host PID inspection and reclaim from dead processes; docs recommend verifying shared-filesystem semantics before deploying soft locks; no statement on cross-type exclusion. **[primary]** https://py-filelock.readthedocs.io/en/latest/index.html
- `portalocker`: README fetch 404; its primitives are **[UNVERIFIED]** (not read).
- `os.replace` on Windows semantics (MoveFileEx REPLACE_EXISTING): **[UNVERIFIED]**, not fetched.
### 1.5 Interoperability matrix (flock / fcntl / LockFileEx / share modes)
| Question | Finding | Source |
|---|---|---|
| Linux, local FS: does `flock` exclude `fcntl` (POSIX byte-range) locks? | No. man flock(2): on Linux "there is no interaction between the types of lock placed by flock() and fcntl(2)" (local filesystems). | **[primary]** https://man7.org/linux/man-pages/man2/flock.2.html |
| Linux over NFS (since 2.6.12) | flock is emulated as whole-file fcntl byte-range locks, so flock and fcntl **do** interact there. | same |
| Linux over CIFS (since 5.5) | flock emulated with SMB byte-range locks; flock and fcntl interact; and the lock becomes **mandatory**: I/O from other descriptors on a locked file fails with EACCES. | same |
| Duplicate fds / fork | A lock belongs to the open file description; shared by dup/fork; persists until `LOCK_UN` or **all** duplicates close. Independent `open()`s in the same process conflict with each other under flock. | same |
| Does a Rust `flock` (std or fs2) exclude a .NET-on-Unix `FileShare.None` open? | Both are `flock(LOCK_EX)` on the same inode; by the sources above they should conflict (.NET's non-blocking attempt gets EWOULDBLOCK -> IOException -> CultCache C# retries). **Not tested.** | **[inferred]**; **[UNVERIFIED]** empirically |
| Same, but .NET `FileShare.ReadWrite` opener (shared flock) vs Rust `lock_exclusive` | Conflicts (LOCK_SH vs LOCK_EX), so a reader opening the lock file with any FileShare would block/fail against a Rust holder. CultCache does not open the lock file for reading. | **[inferred]** |
| Windows: does a share-mode-0 open (.NET `FileShare.None`) exclude a Rust `LockFileEx` locker? | Order A: .NET holds the file with share mode 0. Rust's `OpenOptions::open` (share mode default RWD) then fails at `CreateFile` with ERROR_SHARING_VIOLATION, before it can lock. In `open_lock_file` that is a `with_context` error, **not a wait/retry** (looked at the source). Order B: Rust holds a handle (RWD) and `LockFileEx`; .NET `CreateFile` with share mode 0 conflicts with the existing open handle's access -> ERROR_SHARING_VIOLATION -> `IOException` -> C# retries up to 30 s. So mutual exclusion holds in both orders, but in order A Rust errors instead of waiting. | **[inferred]** from MS CreateFile share-mode table + Rust OpenOptionsExt default; **[UNVERIFIED]** empirically |
| Windows: is `LockFileEx` mandatory? | Exclusive region lock "denies all other processes both read and write access to the specified region"; shared lock denies writes; locks are not honoured through mapped views. If a process dies or closes the handle the OS releases the locks, but "the time it takes ... depends upon available system resources" (MS recommends explicit unlock). Handle needs GENERIC_READ or GENERIC_WRITE. | **[primary]** https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-lockfileex |
| Windows: second handle in same process | "If the locking process opens the file a second time, it cannot access the specified region through this second handle until it unlocks the region." | same |
| Rust-on-Windows `LockFileEx` vs Python `msvcrt.locking` (same region?) | `LockFileEx` regions conflict if they overlap; fs2 locks which byte range (offset 0, len u32::MAX..?) : **[UNVERIFIED]**; msvcrt.locking locks `nbytes` from the current position (so depends on `lseek`); whether CRT `_locking` uses LockFile/LockFileEx and which range would need to match: **[UNVERIFIED]**. |
| Python Unix `flock` vs Rust/.NET flock | Same kernel primitive on Linux/macOS (Python's flock "may be emulated using fcntl on some systems", so platform-dependent). Python `lockf` is fcntl: independent from flock on local Linux FS. | **[primary]** python docs + man page |
| macOS flock vs fcntl | **[UNVERIFIED]** (not fetched). |
| POSIX fcntl lock semantics | Per-process: `close()` of **any** fd for the file by the process drops all of that process's fcntl locks (SQLite doc 2.2 below). flock locks are per open-file-description and do not have that hazard. | **[primary]** SQLite howtocorrupt; flock(2) |
| Python `fcntl.flock` on Windows | Module is Unix only (docs). | **[primary]** |
---
## 2. Lockfile protocols in established software
### git
- Lockfile API: lock for `foo` is `foo.lock`, created with `O_CREAT|O_EXCL`; writer writes new content into it; `commit_lock_file()` renames it over `foo`; `rollback_lock_file()` removes it; writers exclude each other; readers never block and see either old or new content (relies on atomic rename); cleanup of outstanding lockfiles is registered with `atexit` and signal handlers. **[primary]** https://git-scm.com/docs/api-lockfile
- `lockfile.c`: retry with quadratic backoff for at least `timeout_ms`; PID file registered as a tempfile for signal/atexit cleanup; failure message says another git process may be running "or the lock file may be stale" and tells the user to remove it manually. **[primary]** https://raw.githubusercontent.com/git/git/master/lockfile.c
- Consequences recorded in the docs: a hard kill (SIGKILL, power loss) leaves a `.lock` file and later writers fail until it is removed; git does not time-based-reclaim index.lock **[inferred from message text, not stated; UNVERIFIED]**. Note the git design makes the lock file **the temp file** (lock name = staging name); CultCache's current design has a separate sidecar lock plus uniquely named temp.
### SQLite (see also section 4)
- Unix default VFS: POSIX advisory locks via `fcntl()`; Windows VFS: `LockFile`/`LockFileEx`/`UnlockFile`. Five states UNLOCKED/SHARED/RESERVED/PENDING/EXCLUSIVE. **[primary]** https://www.sqlite.org/lockingv3.html (byte-range layout not stated on that page).
- Built-in unix VFS variants: `unix` (POSIX advisory), `unix-dotfile` (dot-file locking), `unix-excl` (holds exclusive lock, WAL index in heap), `unix-none` (no locking), `unix-namedsem` (VxWorks). "Except for 'unix' and 'unix-excl', the various Unix VFSes all use incompatible locking implementations"; processes using different VFSes on one DB "may not see each others locks ... resulting in database corruption". **[primary]** https://www.sqlite.org/vfs.html . (`unix-flock` / `unix-afp` / `unix-nfs` appear in SQLite's source as separate io-method variants; not listed on that page: **[UNVERIFIED]**.)
### LMDB
- Uses a separate `lock.mdb` file for the reader table and writer mutex. Header warnings: stale reader slots from aborted programs make the DB grow quickly (`mdb_reader_check`); stale writer lock clears automatically on Windows, BSD with SysV semaphores, and Linux with robust POSIX mutexes, otherwise all DB users must be closed; "Do not have open an LMDB database twice in the same process at the same time. Not even from a plain open() call - close()ing it breaks flock() advisory locking."; "Do not use LMDB databases on remote filesystems, even between processes on the same host. This breaks flock() on some OSes"; `MDB_NOLOCK` disables locking entirely. **[primary]** https://raw.githubusercontent.com/LMDB/lmdb/mdb.master/libraries/liblmdb/lmdb.h
- Note: that warning is about `flock` and `close()`, which contradicts the usual statement that only fcntl locks have the close() hazard; the source of the discrepancy (older Linux/BSD semantics, or the emulated flock on some OS) is **[UNVERIFIED]**.
### Cargo / npm / pip / Gradle
- Cargo: package-cache and build-dir lock using an OS file lock, prints "Blocking waiting for file lock"; source fetch (`src/cargo/util/flock.rs`) 404; details (flock/fs4/std, handling of filesystems without locking) **[UNVERIFIED]**.
- Gradle: `DefaultFileLockManager` holds cross-process locks on cache/daemon-registry files; reports of builds hanging on locks left by crashed or killed daemons. **[secondary]** web search snippets only (https://discuss.gradle.org/t/gradle-daemon-keeps-locking-intermediate-files-preventing-builds/38004 , https://www.javathinking.com/blog/gradle-build-is-hanging-without-failure-defaultfilelockmanager-acquiring-and-releasing-lock-on-daemon-addresses-registry/ ); Gradle's own docs on lock protocol not read. **[UNVERIFIED]**
- npm: `write-file-atomic` has no cross-process lock (above); npm's cache (cacache) locking: **[UNVERIFIED]**, not fetched.
- pip: **[UNVERIFIED]**, not fetched.
### Windows rename retry (Postgres)
- `pgrename()` on Windows calls `MoveFileEx(from, to, MOVEFILE_REPLACE_EXISTING)` and retries on ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION, ERROR_LOCK_VIOLATION: up to 100 retries, 0.1 s apart (~10 s). Comment: other applications may have the file open without sharing flags; LOCK_VIOLATION seen from some anti-virus software; bounded because the caller may hold locks affecting other backends. **[primary]** https://raw.githubusercontent.com/postgres/postgres/master/src/port/dirmod.c . CultCache Rust uses the same error set with 25 x 20 ms (0.5 s); TS retries EPERM/EBUSY/EACCES 10 times with doubling from 10 ms (about 10 s worst case) **[src]**.
---
## 3. Temp-file naming and rename atomicity
- Unique temp names per writer: C# GUID, Rust uuid v4, TS pid+time+random **[src]**; npm write-file-atomic pid + counter hash **[primary]**; Python CultCache fixed `.tmp` **[src]**. Rust additionally has a startup cleanup that deletes only temps matching `<store>.<uuid>.tmp` (tests at `lib.rs` ~3420, 4667-4689 distinguish `not-a-uuid.tmp`, bare `.tmp`, neighbour stores) **[src]**. Note the naming differs between runtimes: C# `.{name}.{guid:N}.tmp` (leading dot, no dashes) vs Rust `{name}.{uuid-with-dashes}.tmp` vs TS `{name}.tmp-...`; whether the Rust stale-temp sweeper recognises C#/TS temps: it matches the Rust format only (per the test names) **[src, partial reading]**.
- Fixed temp name consequence (inferred, not tested): two processes writing `store.cc.tmp` concurrently share one file: interleaved `write_bytes` (truncate-and-write) can yield a mixed or truncated file that one process then renames into place, and the other process's `replace` can fail with source-not-found. **[inferred]**
- POSIX `rename()`: if `new` exists it "shall be removed and old renamed to new"; the rationale requires the action to be atomic; a directory entry named `new` stays visible to other threads throughout and refers to either old or new file; on failure other than I/O errors `new` is unaffected; spec does not define crash durability. **[primary]** https://pubs.opengroup.org/onlinepubs/9799919799/functions/rename.html
- Directory fsync: the usual pattern is write temp, fsync temp, rename, then fsync the parent directory so the rename survives power loss; many projects have filed bugs for omitting the directory fsync. **[secondary]** search snippets (e.g. https://github.com/npm/write-file-atomic/issues/64 titled "Rename atomicity is not enough"; https://0xkiire.com/crash-consistency-fsync-rename/). Linux ext4 `auto_da_alloc` replace-via-rename heuristic: **[UNVERIFIED]**, not fetched. Whether CultCache Rust/C# fsync the parent directory on Unix: **[UNVERIFIED]** (not seen in the excerpts read; Rust does `sync_all` on the temp, C# `Flush(true)` + `WriteThrough`; TS and Python do neither).
- Windows `MoveFileExW`:
- `MOVEFILE_REPLACE_EXISTING` replaces the destination if ACLs allow; errors if destination is an existing directory; `MOVEFILE_WRITE_THROUGH` waits for the move to be flushed (documented as guaranteeing flush of a copy+delete move). Same-volume rename is a metadata operation. The doc page does not use the word "atomic" and does not describe behavior when the destination is open by another handle. **[primary]** https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw
- Open-handle failure: reported from libuv/Bun and Postgres as ERROR_ACCESS_DENIED / SHARING_VIOLATION / LOCK_VIOLATION when another process has the destination open without FILE_SHARE_DELETE, or an anti-virus scan holds it. **[primary]** Postgres comment; **[secondary]** libuv/Bun.
- Rust's std rename (1.0+ Windows 10 1607) may use `FileRenameInfoEx` with POSIX semantics, which can replace an open destination; CultCache Rust calls `MoveFileExW` directly instead **[src; primary for std]**.
- Windows `ReplaceFileW` (used by .NET `File.Replace`): replaced file opened with GENERIC_READ|DELETE|SYNCHRONIZE and share mode `READ|WRITE|DELETE`; replacement file opened with **no sharing mode specified**; preserves creation time, short name, object id, DACLs, encryption, compression, named streams; resulting file has the replacement's file ID; all files must be on the same volume; documented failure states leave the system in partially-moved states: ERROR_UNABLE_TO_REMOVE_REPLACED (1175; names unchanged), ERROR_UNABLE_TO_MOVE_REPLACEMENT (1176; with no backup, the replaced file "no longer exists" and the replacement exists under its original name), ERROR_UNABLE_TO_MOVE_REPLACEMENT_2 (1177). `REPLACEFILE_WRITE_THROUGH` "not supported". The page makes no statement that ReplaceFile is atomic; it describes it as combining steps. **[primary]** https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-replacefilew
- So on Windows, C# (`File.Replace`) and Rust/TS/Python (MoveFileEx or equivalent) use different primitives with different failure windows. **[inferred from the two pages]**
- .NET `File.Move` with overwrite on Windows/Unix: **[UNVERIFIED]** (CultCache C# uses `File.Move(temp, path)` only when the target does not exist; there is a window in which another writer creates the target between the `File.Exists` check and the move; the lock covers it when taken **[src, inferred]**).
---
## 4. Known failures and warnings
SQLite "How To Corrupt An SQLite Database File" (https://www.sqlite.org/howtocorrupt.html) **[primary]**:
- 2.1 Filesystems with broken or missing lock implementations (network filesystems, NFS) can corrupt when multiple processes access.
- 2.2 POSIX advisory locks are cancelled by `close()`: `close()` on any descriptor for the file by any thread in the process releases the process's locks on that file; example: thread B opens, reads and closes the DB file for a backup and thread A's locks vanish silently. SQLite works around it with a mutex-protected global list of open files, which fails if two copies of SQLite are linked into one app (2.3; a commercial product shipped this bug with infrequent corruption on Linux/Mac). WAL multi-process defenses added in 3.51.0 (2025-11-04).
- 2.4 Two processes using different locking protocols (e.g. POSIX advisory vs dot-file) cannot see each other's locks; all connections must use the same protocol.
- 2.5 Unlinking or renaming the DB file while in use: processes end up with different physical files sharing one journal; SQLite warns (since 3.7.17) when the file is unlinked while open.
- 2.6 Multiple links (hard/symbolic links): same hazard; resolved by canonicalising symlinks since 3.10.0.
- 2.7 Carrying a connection across `fork()`: all locking mechanisms fail with unpredictable results.
- 1.3/1.4 Hot journal deleted, moved or mispaired with database.
- The SQLite VFS page: incompatible unix VFS locking => corruption (section 2 above).
Other recorded failure modes:
- Windows `LockFileEx` locks are released by the OS on process death but with a lag (MS doc). **[primary]**
- CIFS flock becomes mandatory (man flock(2)). **[primary]**
- Lock file left behind: git (cleanup via atexit/signal; a hard kill leaves `.lock`, message tells operator to remove) **[primary]**; Gradle hangs after killed daemons **[secondary]**; `proper-lockfile` and `filelock.SoftFileLock` provide mtime / PID-based stale reclaim as a mitigation **[primary]**.
- Lock protocols that are compatible only when everyone uses the same one: SQLite (above); `filelock` docs do not state cross-type exclusion between `UnixFileLock`/`SoftFileLock`. **[primary]**
- Lost-update by missing cross-process lock around read-modify-write: `write-file-atomic` documents only same-process serialisation; no source read this pass reports a concrete lost-update bug in it. A search-based report of corrupted/missing "settings.json" on Windows from a failed atomic write exists as an unvetted issue (https://github.com/BarganConstantin/ccdeck/issues/772, **[secondary]**, read-only destination). Reports specific to cross-language writers of one file, other than SQLite's different-VFS warning: not found this pass. **[UNVERIFIED]**
- Lock file deleted by one participant while others hold or wait on it (different inode afterward): general hazard of delete-on-unlock lock files, not sourced this pass beyond SQLite 2.5 (unlink of a live file); **[UNVERIFIED]** as a named issue for flock sidecars.
---
## 5. Open items not resolved by reading (candidates for an empirical check; nothing done)
1. Do the C# `FileShare.None` lock and Rust `fs2::lock_exclusive` actually exclude each other on Windows (both open orders) and on Linux? Only inferred above.
2. Does fs2 on Windows lock the same byte range as std 1.89 `File::lock` and as Python `msvcrt.locking`/`filelock`? Unread.
3. macOS behavior of flock vs fcntl and of .NET FileShare there. Unread.
4. .NET release that introduced Unix FileShare-via-flock. Unread.
5. Whether any runtime fsyncs the parent directory after rename on Unix. Unread.
6. Cargo, npm, pip, Gradle lock protocol primary sources. Unread.
7. `portalocker`, `fs-ext`, `lockfile`, `proper-lockfile` behavior under Windows share modes. Unread.

View file

@ -1,462 +0,0 @@
# Foreign Records: Prior Art Survey
Facts file for the single-file "foreign records" fork (lay-back versus refusing
whole-view writes while holding records the writer does not own). Eyes pass,
2026-10-02. Facts and evidence pointers only; no recommendation.
Verification marks:
- **[fetched]** the claim was read on the cited page during this pass.
- **[search]** the claim came from a search-result summary of the cited page,
not a full read.
- **[memory]** recalled from the cited source, not re-read in this pass.
Treat it as unverified.
## 0. Our design, for comparison
Source: `src/GameCult.Caching/Contracts/cultcache-persistence-format.md` (main, 614fd445).
- Store is `[header, catalog, records]`; the catalog "must embed a catalog entry
for every schema referenced by the records it contains" (Embedded Schema Catalog).
- The stated purpose of the catalog is to let another implementation "decide
whether it can: read directly, soft-migrate with warnings, or refuse the data
honestly".
- Soft migration lists "persisted schema has extra slots the local reader can
safely ignore or preserve" and the warning "field preserved but not surfaced
natively". That is slot-level preservation inside a record the reader owns. The
contract does not mention records under schema ids the reader does not own;
the lay-back ruling lives elsewhere (grep of `src/**/*.md` for "foreign" or
"lay-back" found nothing).
- Concurrency: "A plain flush and an unconditional commit are last-writer-wins
... a single file is replaced by the writer's whole view." "Conditional commit
is the only safe multi-process write." C# and Rust implement conditional
commit; TypeScript and Python do not yet (Transaction Visibility).
Granularity note used throughout: most systems below handle unknown *fields
inside a record whose type the writer knows*. Our fork is about unknown *whole
records* (schema ids) inside a container the writer rewrites. Each entry says
which granularity it covers.
## 1. Serialization formats
### 1.1 Protocol Buffers: unknown-field preservation
- **What it does.** Unknown fields are "well-formed protocol buffer serialized
data representing fields that the parser does not recognize". Proto3 messages
"preserve unknown fields and include them during parsing and in the serialized
output, which matches proto2 behavior." [fetched] https://protobuf.dev/programming-guides/proto3/ (Unknown Fields)
- **Where preservation fails, per the docs.** Serializing to JSON; iterating
fields to populate a new message. Mitigation given: use binary, use
`CopyFrom()`/`MergeFrom()`. TextFormat prints unknown fields by number but
cannot parse them back. [fetched] same page.
- **History.** Proto3 shipped dropping unknown fields. v3.5.0 release notes:
"Unknown fields are now preserved in proto3 for most of the language
implementations for proto3 by default." C++ and Python offer
`DiscardUnknownFields()`, Java a `DiscardUnknownFieldsParser`, for callers who
relied on dropping. The release notes give no rationale. [fetched]
https://github.com/protocolbuffers/protobuf/releases/tag/v3.5.0
- **Why it was restored.** No official rationale document was found. The
user-facing request is issue #272 (2015-04-07): "I know that unknown fields
have been removed from proto3, but I am trying to get an explanation about why
this change was made". The thread body was not readable in this pass. [fetched,
partial] https://github.com/protocolbuffers/protobuf/issues/272. Secondary
accounts attribute the reversal to pass-through and read-modify-write
intermediaries in uncoordinated upgrades. [search]
https://github.com/scalapb/ScalaPB/issues/406, https://kmcd.dev/posts/protobuf-unknown-fields/.
**The primary reasoning is unverified.**
- **Cost and regret.** Go: preservation through `XXX_unrecognized []byte`
broke users who compared proto3 messages with `==` or used them as map keys.
[search] https://groups.google.com/d/msg/golang-nuts/F5xFHTfwRnY/sPv5nTVXBQAJ.
JSON round trips still drop unknown fields; there is an open request to keep
them. [search] https://github.com/golang/protobuf/issues/1390. A secondary
source flags preserved unknown fields as a way to smuggle unvalidated payloads
past validation at trust boundaries. [fetched] https://kmcd.dev/posts/protobuf-unknown-fields/
- **Granularity.** Field level, inside a message the reader's code knows.
Protobuf does not preserve unknown *message types*; the closest is
`google.protobuf.Any` (type URL plus bytes), which carries an opaque typed
payload as a declared field. [memory]
- **Differs from us.** There is no in-band schema. Unknown bytes are
uninterpretable without the newer `.proto`, and only tag and wire type are
self-describing. There is no container rewrite or multi-process store
concern; protobuf is a message codec.
### 1.2 Protobuf field-number reuse
- **Rule.** "Field numbers should never be reused. Never take a field number out
of the reserved list for reuse with a new field definition." Listed
consequences: "Developer time lost to debugging, A parse/merge error (best case
scenario), Leaked PII/SPII, Data corruption." Syntax: `reserved 2, 15, 9 to 11;`
and `reserved "foo", "bar";`. [fetched] https://protobuf.dev/programming-guides/proto3/
- Best practices: "Never re-use a tag number. It messes up deserialization."
Reserve deleted numbers. "Almost never change the type of a field." Renaming
breaks JSON and text format because they key on names. [fetched]
https://protobuf.dev/best-practices/dos-donts/
- Kleppmann (2012): "you must never reuse the tag number for another field in
future, because you may still have data stored that uses that tag for the field
you deleted". [fetched] https://martin.kleppmann.com/2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html
- **Named public incidents:** none found in this pass. The docs' consequence
list is the strongest primary evidence; blog posts repeat it. **Unverified.**
- **Differs from us.** Our `schemaId` is a content hash of the canonical slot
schema, so changing slot meaning changes the id. The reuse hazard moves from
"number collides" to "a record under an old id is carried, or reinterpreted,
after an undeclared rename".
### 1.3 Avro and schema registries
- **Resolution.** The reader needs the writer's schema. Fields in the writer
schema but not the reader's are ignored. Reader fields missing from the writer
take the reader default, or raise an error when there is no default. Aliases
map renames. [fetched] https://avro.apache.org/docs/1.11.1/specification/ (Schema Resolution)
- **Object Container Files** carry the schema once in the header (`avro.schema`,
required). One file has one writer schema. [fetched] same page.
- **Kleppmann (2012):** Avro needs "the exact same version of the schema as the
writer of the data", so you either "annotate each record with its schema
version" or use "a schema registry". [fetched] blog above.
- **Confluent wire format:** byte 0 is the magic byte (0), bytes 1 to 4 are the
big-endian schema id; the deserializer fetches the writer schema by id.
[fetched] https://docs.confluent.io/platform/current/schema-registry/fundamentals/serdes-develop/index.html
- **Compatibility modes:** BACKWARD (the default), FORWARD, FULL, their
`_TRANSITIVE` forms, and NONE. BACKWARD is the default "so that you can rewind
consumers to the beginning of the topic". Upgrade order follows the mode:
consumers first for BACKWARD, producers first for FORWARD. [fetched]
https://docs.confluent.io/platform/current/schema-registry/fundamentals/schema-evolution.html
- **Failure mode.** Resolution projects writer data onto the reader schema, so
writer-only fields are gone from the decoded object. A read-modify-write by an
older reader loses them unless the application keeps the writer record.
[inference from the spec; no Avro doc states it as a warning]
- **Differs from us.** Kafka records are immutable log entries, so consumers
never rewrite the producer's records. A container file is written once by one
writer. Neither case has whole-file rewrite by several processes.
### 1.4 Thrift
- Generated readers switch on field id and call
`TProtocolUtil.skip(iprot, schemeField.type)` in the default case: unknown
fields are skipped. [search; generated-code pattern] Kleppmann (2012) says the
same of protobuf and Thrift: the parser "can figure out how many bytes it needs
to skip". [fetched]
- **Conflict.** "Thrift: The Missing Guide" says unknown fields "are not
discarded, and if the message is later serialized, the unknown fields are
serialized along with it". That text reads as copied from protobuf docs.
[fetched] https://diwakergupta.github.io/thrift-missing-guide/. Apache Thrift's
generated Java skips without retaining. **Retention is unverified and likely
false for stock Apache Thrift.**
- Rules: never change numeric tags; new fields optional; prefix removed fields
`OBSOLETE_`. [fetched] Missing Guide.
### 1.5 Cap'n Proto
- Field ordinals are append-only: "as long as each new member's number is larger
than all previous members"; "You cannot change a field, method, or enumerant's
number." [fetched] https://capnproto.org/language.html
- Unknown fields survive a copy: "If you copy the struct from one message to
another (e.g. by calling a set() method on a parent object), the extra fields
will be preserved." Caveat: "copying a struct value from another message into
an element of a list will truncate the value". [fetched] https://capnproto.org/cxx.html
- The wire format carries just enough structure (struct or list, sizes) to copy
an object recursively without its schema. [search]
https://capnproto.org/encoding.html. Preservation is a property of the encoding,
not of a per-type rule.
- **Differs from us.** The copy is opaque; nothing is reinterpreted. There is no
catalog and no store.
### 1.6 FlatBuffers
- Add new fields at the end; never remove one, mark it `deprecated` instead (no
accessors are generated); explicit `id` attributes relax ordering. Old readers
ignore unknown fields. [search] https://flatbuffers.dev/evolution/
- The object API (unpack, mutate, pack) builds a fresh buffer from native
fields. Whether unknown fields survive that round trip: **not verified in
this pass** [memory: they do not].
- Known defect: C++ object API with deprecated fields generated non-compiling
code. [search] https://github.com/google/flatbuffers/issues/5186
### 1.7 Microsoft Bond
- `bonded<T>` defers deserialization and holds a reference to the serialized
form. "Using bonded<Response> allows the intermediary to aggregate responses,
preserving their full content, even though schema of Response is not known
when the aggregator is built." Transcoding through a bonded value preserves
fields not in `T`. [fetched] https://microsoft.github.io/bond/manual/bond_cpp.html
- Untagged protocols (Simple Binary) "serialize only data and thus require that
consumers know the payload schema via some out-of-band mechanism"; Bond also
has runtime schemas (`SchemaDef`) for that. [fetched; SchemaDef from memory]
- Changing ordinals is a breaking change. [fetched]
- **Note.** Bond's preservation is an explicit opt-in wrapper type at the
declared pass-through point, not default retention of everything.
### 1.8 MessagePack-based schema systems
- MessagePack itself has no schema. MessagePack-CSharp (`[Key(n)]` int keys, the
basis of our slot layout) has no documented unknown-key or extension-data
retention for typed objects. A search found no such feature and no issue
requesting it. **Unverified absence.**
https://github.com/MessagePack-CSharp/MessagePack-CSharp
- Comparable opt-in field-level retention in other ecosystems:
Newtonsoft.Json and System.Text.Json `[JsonExtensionData]` capture unmapped
properties into a dictionary and write them back; serde `#[serde(flatten)]` into
a map does the same, and is incompatible with `deny_unknown_fields`. [search]
https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/migrate-from-newtonsoft,
https://serde.rs/attr-flatten.html. All are field level and opt in per type.
- **FIDL (Fuchsia), RFC-0033:** messages with unknown fields "MUST be validated
and parsed successfully"; the default is `flexible` (unknown values allowed and
exposed), and the `strict` keyword makes unknown fields invalid. Unknown
handles must be closed by default. The RFC frames strictness as a security tool
for protocols where misinterpreting new data is dangerous. [fetched]
https://fuchsia.dev/fuchsia-src/contribute/governance/rfcs/0033_handling_unknown_fields_strictness
## 2. Embedded stores and document databases
### 2.1 SQLite
- **Unknown tables.** The writer modifies pages through the B-tree and rollback
or WAL journal. Tables a program never touches are never rewritten, so there is
no rule about them. [memory; consistent with the file format doc]
- **Unknown schema syntax.** A library older than 3.31.0 opening a schema with
generated columns "will perceive the generated column syntax as an error and
will report that the database schema is corrupt". Older versions can read and
write newer files "as long as the database schema does not contain features
... not understood by the earlier version". [search] https://sqlite.org/gencol.html.
The whole database is refused, not just the table.
- **File format versions.** Write version > 2: "the database file must be treated
as read-only". Read version > 2: "that database cannot be read or written".
[fetched] https://www.sqlite.org/fileformat2.html (offsets 18 and 19). This is
a format-level read-only fallback for content the library cannot safely write.
- **Schema change across connections.** The schema cookie (offset 40) is bumped
on every schema change. A prepared statement checks it and reprepares or fails
with `SQLITE_SCHEMA`. [fetched] same page.
- `user_version` (offset 60) and `application_id` (offset 68) are application
owned; SQLite does not use them. [fetched]
- **Whole-file rewrite.** SQLite's own comparison: custom and wrapped
pile-of-files formats "usually require a rewrite of the entire document in
order to change a single byte", and application concurrency logic for them is
"a notorious bug-magnet". [fetched] https://www.sqlite.org/appfileformat.html
- **Differs from us.** The schema is SQL text in `sqlite_schema`, not a typed
slot catalog. Writes are partial and in place, and there is one implementation
rather than five.
### 2.2 LMDB
- Opaque byte keys and values, no schema; named sub-databases. Copy-on-write
B+tree with MVCC. One writer at a time, serialized by a mutex in the lock file;
readers do not block the writer and the writer does not block readers;
several processes may open one environment. [search; lmdb.tech/doc was not
reachable over TLS in this pass] https://en.wikipedia.org/wiki/Lightning_Memory-Mapped_Database,
https://lmdb.readthedocs.io/
- `MDB_INCOMPATIBLE`: an operation does not match a database's flags (for
example dupsort), or "the database type changed". [search]
- **Differs from us.** There are no types, so "foreign" is not a concept;
untouched keys are never rewritten.
### 2.3 Core Data (Apple)
- **Model mismatch is refused.** "The model used to open the store is
incompatible with the one used to create the store" (Cocoa error 134100)
unless a migration runs: lightweight (inferred mapping, using
`NSMigratePersistentStoresAutomaticallyOption` and
`NSInferMappingModelAutomaticallyOption`) or custom/staged. [search]
https://developer.apple.com/forums/thread/100639
- Store metadata holds per-entity version hashes (`NSStoreModelVersionHashes`).
`isConfiguration(withName:compatibleWithStoreMetadata:)` compares them with
the model. [search]
https://developer.apple.com/documentation/coredata/nsmanagedobjectmodel/isconfiguration(withname:compatiblewithstoremetadata:)
- **Atomic stores (XML, binary) are the closest analogue to our single file.**
They keep the whole object graph in memory and save the whole file: "the old
file is not deleted until the new one has been successfully written."
[fetched] https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/CoreData/PersistentStoreFeatures.html.
Combined with the refusal above, an atomic store is never rewritten by a model
that does not cover every entity in it. The page says nothing on multi-process
access to atomic stores. [fetched: absence]
- **Differs from us.** Single runtime, entity-hash catalog in metadata, a
migration-or-refuse gate at open. No foreign entity is ever carried.
### 2.4 Realm
- Opening with a changed schema requires a higher `schemaVersion`, plus a
`migrationBlock` where needed; otherwise "Migration is required". Added and
removed properties are applied to the on-disk schema automatically on
migration. [search] https://github.com/realm/realm-js/issues/4752,
https://realm.netlify.app/docs/swift/latest/
- A changelog entry allows opening old frozen versions "with a schema that
contains additional classes, but not additional properties". [search]
https://github.com/realm/realm-core/releases?page=5
- Whether a Realm opened with a class *subset* keeps the other tables untouched:
**not verified** [memory: the tables are kept, since Realm writes in place by
table and does not rewrite the whole file].
- Realm file-format upgrades are one-way. [memory]
### 2.5 CouchDB and PouchDB
- `PUT /{db}/{docid}` writes a new revision of the whole document; there is no
field merge, and omitted fields are gone in the new revision. A missing or
stale `_rev` returns `409 Conflict`: "specified revision is not latest for
target document". The revision may be given in the body, as `rev`, or in
`If-Match`. [fetched] https://docs.couchdb.org/en/stable/api/document/common.html
- Under replication, conflicting revisions are all kept; one deterministic
winner is chosen and the others are marked `_conflicts`; CouchDB does not merge.
[search] https://docs.couchdb.org/en/stable/replication/conflicts.html
- There is no schema; unknown fields survive only if the client writes them
back. Document-mapper layers drop them: Mongoose's `strict` option "ensures
that values passed to our model constructor that were not specified in our
schema do not get saved to the db", and is "enabled by default". [fetched]
https://mongoosejs.com/docs/guide.html (MongoDB, cited as the same pattern).
- **RxDB** (on PouchDB or other storage): any schema `version` > 0 requires
`migrationStrategies` for each step; a strategy returning `null` deletes the
document; writes during a running migration fail with `COL25`; in multi-tab
setups "exactly one tab is running a migration of a collection". [fetched]
https://rxdb.info/migration-schema.html. Opening storage written by a newer
RxDB major is refused ("Cannot open database state with newer RxDB version").
[search] https://github.com/pubkey/rxdb/issues/5836
- **Differs from us.** The per-document `_rev` gives optimistic concurrency at
record granularity, and documents are never rewritten as a side effect of
writing another.
### 2.6 IndexedDB
- No field schema: values are structured clones, stored by value. Schema is
object stores and indexes, versioned by an integer. [fetched] https://www.w3.org/TR/IndexedDB/
- Opening with a higher version fires `upgradeneeded` inside a versionchange
transaction. Opening with a *lower* version than the stored one fails with
`VersionError`: "An attempt was made to open a database using a lower version
than the existing version." Older code is refused outright. [fetched] same spec.
- Other open connections receive `versionchange`; if they stay open the upgrader
gets `blocked`. [fetched] same spec and
https://developer.mozilla.org/en-US/docs/Web/API/IDBOpenDBRequest/blocked_event
- **Differs from us.** The browser engine is the single writer implementation
and the schema has one integer owner per database.
### 2.7 Whole-file rewrite while preserving unknown content
Systems found that rewrite a container while keeping parts they do not
understand:
- **Automerge binary format.** Programs that edit documents must "maintain all
columns, even those that they don't understand the meaning of" and write null
into unknown columns for new rows. Implementations "must preserve operations
containing actions they don't support". Unknown value type codes: "MUST read and
store the type code and `length` bytes". Changes are SHA-256 hashed over their
binary form, so byte preservation is required for hash integrity. [fetched]
https://automerge.org/automerge-binary-format-spec/. The unit carried is an
immutable, content-addressed change; nothing preserved is ever re-decided by
the carrier.
- **Core Data atomic stores** rewrite the whole file but refuse to open under an
incompatible model (2.3), so they never carry unknown entities.
- No system was found that rewrites a whole mutable file while carrying
mutable records of unknown types that other writers may change concurrently.
**Absence of evidence, not a verified negative.**
## 3. Literature
- **Kleppmann, *Designing Data-Intensive Applications* (1st ed., O'Reilly 2017),
ch. 4 "Encoding and Evolution", section "Dataflow Through Databases".** [memory,
not re-read] The section says an older version of code may read, update and
write back a record written by newer code, and newer fields can be lost unless
the old code keeps unknown fields. Figure 4-7 shows this. The section contains
the line "data outlives code" [search; confirmed in secondary notes
https://gist.github.com/bcherny/b870a60d1650973df7e400c8603ac76d]. It also says
rewriting data into a new schema is possible but expensive, so most databases
avoid it. **The exact wording is unverified.**
- **Scherzinger, Klettke, Störl, "Managing Schema Evolution in NoSQL Data
Stores", DBPL 2013.** Contrasts eager migration with *lazy* migration (migrate
on read or touch) for schema-less stores whose data grows heterogeneous.
[search] https://arxiv.org/abs/1308.0514
- **Ringlstetter, Scherzinger, Bissyandé, "Data model evolution using
object-NoSQL mappers: folklore or state-of-the-art?", BIGDSE@ICSE 2016,
pp. 33-36.** Mapper evolution annotations (rename, remove, transform on load)
are used on GitHub, but mostly for tasks other than evolving the data model.
[search] https://dblp.org/rec/conf/icse/RinglstetterSB16.html
- **Scherzinger and Sidortschuck, "An Empirical Study on the Design and Evolution
of NoSQL Database Schemas", ER 2020.** [search, not read]
https://arxiv.org/pdf/2003.00054
- **Bancilhon and Spyratos, "Update semantics of relational views", ACM TODS
6(4), 1981, pp. 557-575.** A view update is translated by holding a chosen
*complement* constant; a complete set of view updates has a translator iff it
is translatable under constant complement. [search]
https://dl.acm.org/doi/10.1145/319628.319634. Mapping (this survey's framing,
not the paper's): a writer's owned records are a view of the store, the foreign
records are the complement, and lay-back is constant-complement translation.
- **Foster, Greenwald, Moore, Pierce, Schmitt, "Combinators for bidirectional tree
transformations", POPL 2005 / TOPLAS 2007.** A lens is `get: S -> V` and
`put: V x S -> S`. Laws: GetPut, `put(get(s), s) = s`; PutGet,
`get(put(v, s)) = v`; PutPut is the stronger optional law. `put` takes the
source `s` as an argument, so the complement is restored from whatever `s` is
supplied. [search for the paper; the law statements are from memory, and one
search summary had GetPut and PutGet swapped]
https://www.cis.upenn.edu/~bcpierce/papers/lenses-toplas-final.pdf
- **Litt, van Hardenberg, Henry, "Project Cambria" (Ink & Switch, Oct 2020).**
Bidirectional lenses between schema versions, arranged as a graph. In
Cambria-Automerge every write is tagged with its writer's schema and translated
at read time, so "old changes can be evolved using lenses that didn't even
exist at the time of the original change". Lenses travel inside the document.
Stated limits: lossy conversions (scalar to array), one-way changes
(split/merge), no external lookups, untested performance. "There are limits to
interoperability." [fetched] https://www.inkandswitch.com/cambria/
- **Local-first and CRDT, Automerge:** see 2.7. Unknown ops, columns and value
types from newer peers are preserved byte for byte and are immutable once
written. [fetched]
## 4. Concurrency facts for whole-file rewrite
- **Last-writer-wins on whole replace** loses a concurrent writer's changes
unless the write is conditional. Our contract already says this (section 0).
- **HTTP optimistic concurrency.** RFC 9110 §13.1.1 If-Match exists to prevent
the "lost update" problem; a failed precondition returns 412 Precondition
Failed (§15.5.13). [memory; the page fetch truncated before those sections]
https://www.rfc-editor.org/rfc/rfc9110.html#section-13.1.1
- **Per-record CAS:** CouchDB `_rev`, 409 (2.5).
- **Git ref update:** `git update-ref <ref> <new> <old>` stores the new value
"after verifying that the current value of the <ref> matches <old-oid>".
Transactions lock every ref (`.lock` files) at `prepare`; "If all <ref>s can be
locked with matching <old-oid>s simultaneously, all modifications are
performed. Otherwise, no modifications are performed." [fetched]
https://git-scm.com/docs/git-update-ref. The pattern: take an exclusive lock
file, compare the old value under the lock, write, rename into place.
- **SQLite** serializes writers by file lock (rollback) or WAL write lock;
schema-cookie checks catch schema drift between prepare and run (2.1).
- **LMDB:** single writer mutex across processes, MVCC readers (2.2).
- **IndexedDB:** schema change is an exclusive versionchange transaction that
waits on other connections (2.6).
- **RxDB:** one migrating tab, writes refused during migration (2.5).
- **Crash consistency of temp-plus-rename.** Pillai et al., "All File Systems Are
Not Created Equal: On the Complexity of Crafting Crash-Consistent
Applications", OSDI 2014. The ALICE tool found 60 crash vulnerabilities across
11 widely used applications; persistence properties, including how rename
orders against data writes, vary across six Linux file systems. [search]
https://research.cs.wisc.edu/adsl/Publications/alice-osdi14.pdf. Whether
`fsync` of the temp file and its directory is needed for durability of the
rename: [memory: yes on Linux ext4 and others]. Windows `MoveFileEx` with
`MOVEFILE_REPLACE_EXISTING` and `ReplaceFile` atomicity guarantees: **not
verified in this pass.**
## 5. Cross-cutting facts, as observed (not conclusions)
- Every system found that preserves unknown data does so at *field* level
inside a record whose type the code owns (protobuf, Cap'n Proto, Bond opt-in,
JSON extension data, serde flatten), or as *immutable content-addressed*
units (Automerge changes).
- Systems that hold several record types in one store either rewrite only what
they touch (SQLite, LMDB, Realm [unverified], CouchDB, IndexedDB), or rewrite
the whole file and refuse a model that does not cover the file (Core Data
atomic stores).
- Systems where older code meets a newer schema at open time do one of:
refuse to open (IndexedDB `VersionError`, Core Data 134100, RxDB newer-major,
SQLite unknown schema syntax), go read-only (SQLite write version > 2), or
require a migration before any write (Realm, RxDB, Core Data).
- Protobuf moved from dropping to preserving (3.5). Its documented residual
losses are in conversion paths (JSON, field-by-field copy), and Go's
preservation broke message comparability.
- FIDL made preserve-and-expose the default and added `strict` as an opt-in
refusal, justified on security grounds.
## Not verified in this pass
- Primary rationale for restoring proto3 unknown fields in 3.5.
- Any named incident caused by protobuf field-number reuse.
- Stock Apache Thrift retention of unknown fields (the Missing Guide says yes,
the generated-code pattern says skip).
- FlatBuffers object-API round trip of unknown fields.
- MessagePack-CSharp unknown-key retention (absence).
- Realm behaviour opening with a class subset.
- Exact DDIA wording and figure number.
- LMDB primary docs (TLS failure); RFC 9110 section text.
- Windows rename and replace atomicity semantics.

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

@ -5,7 +5,7 @@ import json
import os
import threading
from pathlib import Path
from typing import Any, Callable, TypeVar
from typing import Any
from .backing_store import (
CultCacheEnvelope,
@ -14,9 +14,6 @@ from .backing_store import (
)
STORE_FORMAT_VERSION = "cultcache.store.v1"
_STORE_FORMAT_PREFIX = "cultcache.store."
_PERSISTED_RECORD_SLOTS = 4
_Contents = TypeVar("_Contents")
class JsonLinesBackingStore:
@ -26,11 +23,10 @@ class JsonLinesBackingStore:
def pull_all(self) -> list[CultCacheEnvelope]:
with self._lock:
text = _read_store(self.path, lambda path: path.read_text(encoding="utf-8"))
if text is None:
if not self.path.exists():
return []
envelopes: list[CultCacheEnvelope] = []
for line_number, line in enumerate(text.splitlines(), start=1):
for line_number, line in enumerate(self.path.read_text(encoding="utf-8").splitlines(), start=1):
if not line.strip():
continue
raw = json.loads(line)
@ -90,9 +86,10 @@ class SingleFileMessagePackBackingStore:
def pull_all(self) -> list[CultCacheEnvelope]:
msgpack = self._msgpack()
with self._lock:
# An empty file is not a store (no writer leaves one), so it is decoded and refused.
data = _read_store(self.path, Path.read_bytes)
if data is None:
if not self.path.exists():
return []
data = self.path.read_bytes()
if not data:
return []
decoded = msgpack.unpackb(data, raw=False)
snapshot = _decode_v1_snapshot(decoded)
@ -155,46 +152,8 @@ def _encode_v1_snapshot(envelopes: list[CultCacheEnvelope]) -> list[Any]:
return [STORE_FORMAT_VERSION, catalog, records]
def _read_store(path: Path, read: Callable[[Path], _Contents]) -> _Contents | None:
"""Reads a store file, or returns None when nothing is at its path.
Only nothing is absent. A dangling symbolic link is something: reading it as
empty would let the writer replace the link and move the store off its volume.
Any other failure to reach the file stays an OSError.
"""
try:
return read(path)
except FileNotFoundError:
if path.is_symlink():
raise
return None
def _describe_identity(value: Any) -> str:
"""A record key or schema id as a refusal may show it: a string is an identity and is
named; anything else is described by its type, since the value is the store's."""
if isinstance(value, str):
return repr(value)
return f"<{type(value).__name__}>"
def _describe_header(header: str) -> str:
"""Echoes a header only in the shape cultcache.store.v<digits>; the bytes are the store's."""
version = header[len(_STORE_FORMAT_PREFIX) + 1:] if header.startswith(_STORE_FORMAT_PREFIX + "v") else ""
if version.isascii() and version.isdigit():
return repr(header)
return f"an unrecognised {_STORE_FORMAT_PREFIX}* header of {len(header.encode('utf-8'))} bytes"
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 {_describe_header(decoded[0])} is not one this runtime reads; "
f"it reads {STORE_FORMAT_VERSION!r} only"
)
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")
@ -210,13 +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 {_describe_identity(raw_record[0])} (schema {_describe_identity(raw_record[1])}) "
f"has {len(raw_record)} slots, more than "
f"the {_PERSISTED_RECORD_SLOTS} of a {STORE_FORMAT_VERSION} record, so this is not a valid store"
)
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

@ -1,6 +1,5 @@
from __future__ import annotations
import os
import tempfile
import threading
import unittest
@ -803,168 +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")
def test_single_file_refusals_say_what_was_found_without_blaming_variants(self) -> None:
with self.assertRaises(ValueError) as unsupported:
self._pull_vector("unknown-header.msgpack")
self.assertIn("not one this runtime reads", str(unsupported.exception))
self.assertNotIn("variant", str(unsupported.exception))
with self.assertRaises(ValueError) as invalid:
self._pull_vector("extra-slot-full-payload.msgpack")
self.assertIn("not a valid store", str(invalid.exception))
self.assertNotIn("variant", str(invalid.exception))
def test_single_file_only_a_missing_store_reads_as_empty(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
self.assertEqual(SingleFileMessagePackBackingStore(Path(tmp) / "store.cc").pull_all(), [])
# An existing empty file is not a store: no CultCache writer leaves one.
empty = Path(tmp) / "empty.cc"
empty.write_bytes(b"")
with self.assertRaises(ValueError):
SingleFileMessagePackBackingStore(empty).pull_all()
self.assertEqual(empty.read_bytes(), b"")
# An empty array is the legacy envelope array with no envelopes: no header, no format claimed.
legacy_empty = Path(tmp) / "legacy-empty.cc"
legacy_empty.write_bytes(b"\x90")
self.assertEqual(SingleFileMessagePackBackingStore(legacy_empty).pull_all(), [])
def test_single_file_refusals_never_echo_a_value_from_the_store(self) -> None:
import msgpack # type: ignore
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.cc"
cases = [
(["cultcache.store.v12", [], []], True),
(["cultcache.store.SECRET-HEADER", [], []], False),
(["cultcache.store.v12SECRET", [], []], False),
(["cultcache.store.v", [], []], False),
(["cultcache.store.v1", ["SECRET-CATALOG-TEXT"], []], None),
]
for value, echoed in cases:
path.write_bytes(msgpack.packb(value, use_bin_type=True))
with self.assertRaises(ValueError) as caught:
SingleFileMessagePackBackingStore(path).pull_all()
message = str(caught.exception)
if echoed:
self.assertIn(repr(value[0]), message)
else:
self.assertNotIn("SECRET", message)
if echoed is False:
self.assertNotIn(repr(value[0]), message)
self.assertIn(f"of {len(value[0])} bytes", message)
def test_single_file_record_refusal_names_only_a_string_key(self) -> None:
import msgpack # type: ignore
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.cc"
for key, schema in ((["SECRET-IN-LIST"], "s"), (b"SECRET-BYTES", "s"), ("k", {"SECRET": 1})):
path.write_bytes(msgpack.packb(
["cultcache.store.v1", [], [[key, schema, "t", b"\x90", "extra"]]], use_bin_type=True))
with self.assertRaises(ValueError) as caught:
SingleFileMessagePackBackingStore(path).pull_all()
self.assertNotIn("SECRET", str(caught.exception))
self.assertIn("not a valid store", str(caught.exception))
# What a write does through a symbolic link today. R3 decides whether a write resolves
# the link or refuses it; this pins the current behaviour so that change is deliberate.
@unittest.skipIf(os.name != "posix", "symbolic links need POSIX here")
def test_r3_decides_resolve_or_refuse_a_push_through_a_live_link_replaces_the_link(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
volume = Path(tmp) / "volume"
volume.mkdir()
target = volume / "store.cc"
envelope = CultCacheEnvelope(key="a", type="t", payload=b"\x90", stored_at="2026-09-30T00:00:00Z")
SingleFileMessagePackBackingStore(target).push(envelope)
before = target.read_bytes()
link = Path(tmp) / "store.cc"
link.symlink_to(target)
SingleFileMessagePackBackingStore(link).push(replace(envelope, key="b"))
self.assertFalse(link.is_symlink())
self.assertTrue(link.is_file())
self.assertEqual(target.read_bytes(), before)
@unittest.skipIf(os.name != "posix", "symbolic links need POSIX here")
def test_a_dangling_symlink_at_a_store_path_is_an_os_error_and_nothing_is_written(self) -> None:
for store_type in (SingleFileMessagePackBackingStore, JsonLinesBackingStore):
with tempfile.TemporaryDirectory() as tmp:
volume = Path(tmp) / "volume"
volume.mkdir()
path = Path(tmp) / "store.cc"
path.symlink_to(volume / "store.cc")
store = store_type(path)
with self.assertRaises(FileNotFoundError):
store.pull_all()
with self.assertRaises(FileNotFoundError):
store.push(CultCacheEnvelope(key="k", type="t", payload=b"\x90", stored_at="2026-09-30T00:00:00Z"))
self.assertTrue(path.is_symlink())
self.assertEqual(list(volume.iterdir()), [])
@unittest.skipIf(os.name != "posix", "symlink loops and file-as-parent errors are POSIX errno cases")
def test_json_lines_store_that_cannot_be_reached_is_an_os_error(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
self.assertEqual(JsonLinesBackingStore(Path(tmp) / "missing.jsonl").pull_all(), [])
loop = Path(tmp) / "loop.jsonl"
loop.symlink_to(loop)
parent = Path(tmp) / "body"
parent.write_bytes(b"not a directory")
for path in (loop, parent / "store.jsonl"):
with self.assertRaises(OSError) as caught:
JsonLinesBackingStore(path).pull_all()
self.assertNotIsInstance(caught.exception, FileNotFoundError)
@unittest.skipIf(os.name != "posix", "symlink loops and file-as-parent errors are POSIX errno cases")
def test_single_file_store_that_cannot_be_reached_is_an_os_error(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
loop = Path(tmp) / "loop.cc"
loop.symlink_to(loop)
parent = Path(tmp) / "body"
parent.write_bytes(b"not a directory")
for path in (loop, parent / "store.cc"):
with self.assertRaises(OSError) as caught:
SingleFileMessagePackBackingStore(path).pull_all()
self.assertNotIsInstance(caught.exception, FileNotFoundError)
if __name__ == "__main__":
unittest.main()

View file

@ -293,61 +293,6 @@ writes. That protects ordinary multi-process access to the same file. It is
still not a multi-master replication protocol, and it is not a substitute for a
coordinator when higher-level write ordering matters.
A write that fails part way, for example on a full disk, removes its staging
file and leaves the store as it was. A read that fails tells you why through
the error chain:
```rust
use cultcache_rs::{CacheBackingStore, CultCacheStoreUnreadable, CultCacheStoreUnreadableKind, SingleFileMessagePackBackingStore};
let store = SingleFileMessagePackBackingStore::new("cache.cc");
if let Err(error) = store.pull_all() {
match error.downcast_ref::<CultCacheStoreUnreadable>().map(|unreadable| unreadable.kind) {
// The bytes were read and are not a store: truncated, garbled, or invalid.
Some(CultCacheStoreUnreadableKind::Undecodable) => {}
// The header names a format this runtime does not read, such as a newer one.
Some(CultCacheStoreUnreadableKind::UnsupportedFormat) => {}
// The bytes could not be read at all: a std::io::Error is in the chain.
_ => {}
}
}
```
An I/O failure never carries `CultCacheStoreUnreadable`, and the chain under
`CultCacheStoreUnreadable` never holds a `std::io::Error`.
Only a store file that does not exist (`std::io::ErrorKind::NotFound`) reads as
an empty store. Any other failure to reach it, such as a symlink loop, a file
where a parent directory should be, or a missing permission, is an I/O error.
A store file that exists and holds zero bytes is `Undecodable`: no CultCache
writer leaves one. A symbolic link whose target is gone is not a missing store:
reading it is an I/O error, so a read-then-write (`push`, `delete`, the exchanges)
fails before writing. Writes do not yet follow links: a write through a live link,
and a `push_all` through a dangling one, replace the link with a regular file and
leave its target alone. A refusal names no
value read from the file; it names a record only by its key and schema id, and
a header only when it has the shape `cultcache.store.v<digits>`.
A write that fails says what it left on disk:
```rust
use cultcache_rs::{CultCacheStoreWriteFailed, CultCacheStoreWriteFailedKind};
fn outcome(error: &anyhow::Error) {
match error.downcast_ref::<CultCacheStoreWriteFailed>().map(|failed| failed.kind) {
// The entries cannot be stored; retrying them fails the same way.
Some(CultCacheStoreWriteFailedKind::Rejected) => {}
// Staging, fsync or rename failed: the store file holds what it held before,
// and a retry may succeed once the fault (a full disk, say) clears.
Some(CultCacheStoreWriteFailedKind::NotReplaced) => {}
// The rename landed and the directory sync failed: the new snapshot is in
// place but may not survive a crash.
Some(CultCacheStoreWriteFailedKind::ReplacedNotDurable) => {}
_ => {}
}
}
```
## Near-Term Ergonomic Improvements
1. **Derive macro**

File diff suppressed because it is too large Load diff

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

@ -1,10 +1,9 @@
import { lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
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),
@ -41,57 +42,64 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
}
async pullAll(): Promise<CultCacheEnvelope[]> {
// An empty file is not a store, since no CultCache writer leaves one, so it is
// decoded and refused. Only a read that finds nothing at the path is an empty store.
const data = await readStore(this.filePath);
if (data === undefined) {
return [];
}
const decoded = decode(data);
const snapshot = decodeSnapshot(decoded);
if (snapshot) {
return snapshot.records.map((record) => {
const catalogEntry = resolveCatalogEntryForRecord(record, snapshot.catalogBySchemaId);
if (!catalogEntry) {
throw new Error(`CultCache persisted record "${record.key}" references missing schema id "${record.schemaId}".`);
}
return {
key: record.key,
type: catalogEntry.schemaName,
schemaId: record.schemaId,
catalogEntry,
payload: record.payload,
storedAt: record.storedAt,
};
});
}
const legacy = decodeLegacyEnvelopeArray(decoded);
if (legacy) {
let repairedLegacyPayload = false;
const normalized = legacy.map((entry) => {
const payload = normalizePayload(entry.payload);
if (payload !== entry.payload) {
repairedLegacyPayload = true;
}
return {
...entry,
payload,
};
});
const parsed = envelopeArraySchema.parse(normalized) as CultCacheEnvelope[];
if (repairedLegacyPayload) {
await this.#writeAll(parsed);
try {
const data = await readFile(this.filePath);
if (data.length === 0) {
return [];
}
return parsed;
}
const decoded = decode(data);
const snapshot = decodeSnapshot(decoded);
if (snapshot) {
return snapshot.records.map((record) => {
const catalogEntry = resolveCatalogEntryForRecord(record, snapshot.catalogBySchemaId);
if (!catalogEntry) {
throw new Error(`CultCache persisted record "${record.key}" references missing schema id "${record.schemaId}".`);
}
throw new Error(`CultCache file ${this.filePath} is not a recognized CultCache MessagePack store.`);
return {
key: record.key,
type: catalogEntry.schemaName,
schemaId: record.schemaId,
catalogEntry,
payload: record.payload,
storedAt: record.storedAt,
};
});
}
const legacy = decodeLegacyEnvelopeArray(decoded);
if (legacy) {
let repairedLegacyPayload = false;
const normalized = legacy.map((entry) => {
const payload = normalizePayload(entry.payload);
if (payload !== entry.payload) {
repairedLegacyPayload = true;
}
return {
...entry,
payload,
};
});
const parsed = envelopeArraySchema.parse(normalized) as CultCacheEnvelope[];
if (repairedLegacyPayload) {
await this.#writeAll(parsed);
}
return parsed;
}
throw new Error(`CultCache file ${this.filePath} is not a recognized CultCache MessagePack store.`);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
return [];
}
throw error;
}
}
async push(entry: CultCacheEnvelope): Promise<void> {
@ -117,8 +125,16 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
async pushAll(entries: CultCacheEnvelope[], options: PushAllOptions = {}): Promise<void> {
await this.#enqueue(async () => {
if (options.soft && (await readStore(this.filePath)) !== undefined) {
return;
if (options.soft) {
try {
await readFile(this.filePath);
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
throw error;
}
}
}
await this.#writeAll(entries);
@ -158,33 +174,6 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
}
}
/**
* Reads a store file, or returns undefined when nothing is at its path. A dangling symbolic
* link is something: reading it as empty would let the writer rename a file over the link
* and move the store off its volume. Any other failure to reach the file is thrown.
*/
async function readStore(path: string): Promise<Uint8Array | undefined> {
try {
return await readFile(path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
try {
await lstat(path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return undefined;
}
throw error;
}
throw Object.assign(new Error(`CultCache store ${path} is a symbolic link whose target does not exist.`), { code: "ENOENT" });
}
async function renameWithRetry(source: string, destination: string): Promise<void> {
let delayMs = 10;
let lastError: unknown;
@ -296,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;
}
@ -447,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,61 +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 ${describeHeader(decoded[0])} is not one this runtime reads; it reads "${STORE_FORMAT_VERSION}" only.`,
);
}
return true;
}
/**
* A store header as a refusal may show it: echoed in the shape `cultcache.store.v<digits>`,
* and otherwise described only by its length, since the bytes are the store's.
*/
function describeHeader(header: string): string {
return /^cultcache\.store\.v[0-9]+$/u.test(header)
? `"${header}"`
: `an unrecognised ${STORE_FORMAT_PREFIX}* header of ${new TextEncoder().encode(header).length} bytes`;
}
/**
* A record key or schema id as a refusal may show it: a string is an identity and is named;
* anything else is described by its type, since the value is the store's.
*/
function describeIdentity(value: unknown): string {
if (typeof value === "string") {
return `"${value}"`;
}
const kind = Array.isArray(value)
? "array"
: value instanceof Uint8Array
? "bytes"
: value === null
? "nil"
: typeof value;
return `<${kind}>`;
}
/** 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 ${describeIdentity(record[0])} (schema ${describeIdentity(record[1])}) has ${record.length} slots, more than ` +
`the ${PERSISTED_RECORD_SLOTS} of a ${STORE_FORMAT_VERSION} record, so this is not a valid store.`,
);
}
}

View file

@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { exec, execFile } from "node:child_process";
import { EventEmitter } from "node:events";
import { existsSync } from "node:fs";
import { access, lstat, mkdir, mkdtemp, readdir, readFile, symlink, writeFile } from "node:fs/promises";
import { access, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import { dirname, join, resolve, delimiter } from "node:path";
import { test } from "node:test";
@ -1413,178 +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("SingleFileMessagePackBackingStore refusals say what was found without blaming variants", async () => {
await assert.rejects(
() => pullVector("unknown-header.msgpack"),
(error: Error) => error.message.includes("not one this runtime reads") && !error.message.includes("variant"),
);
await assert.rejects(
() => pullVector("extra-slot-full-payload.msgpack"),
(error: Error) => error.message.includes("not a valid store") && !error.message.includes("variant"),
);
});
test("SingleFileMessagePackBackingStore reads only a missing store as empty", async () => {
const dir = await mkdtemp(join(tmpdir(), "cultcache-absent-"));
assert.deepEqual(await new SingleFileMessagePackBackingStore(join(dir, "store.cc")).pullAll(), []);
// An existing empty file is not a store: no CultCache writer leaves one.
const empty = join(dir, "empty.cc");
await writeFile(empty, new Uint8Array());
await assert.rejects(() => new SingleFileMessagePackBackingStore(empty).pullAll());
assert.equal((await readFile(empty)).length, 0);
// An empty array is the legacy envelope array with no envelopes: no header, no format claimed.
const legacyEmpty = join(dir, "legacy-empty.cc");
await writeFile(legacyEmpty, Uint8Array.of(0x90));
assert.deepEqual(await new SingleFileMessagePackBackingStore(legacyEmpty).pullAll(), []);
});
test("SingleFileMessagePackBackingStore reports a store it cannot reach as an I/O error", { skip: process.platform === "win32" }, async () => {
const dir = await mkdtemp(join(tmpdir(), "cultcache-unreachable-"));
const loop = join(dir, "loop.cc");
await symlink(loop, loop);
const parent = join(dir, "body");
await writeFile(parent, "not a directory");
for (const [path, code] of [[loop, "ELOOP"], [join(parent, "store.cc"), "ENOTDIR"]] as const) {
await assert.rejects(
() => new SingleFileMessagePackBackingStore(path).pullAll(),
(error: NodeJS.ErrnoException) => error.code === code,
);
}
});
test("SingleFileMessagePackBackingStore refusals never echo a value from the store", async () => {
const dir = await mkdtemp(join(tmpdir(), "cultcache-canary-"));
const file = join(dir, "store.cc");
const cases: Array<[unknown[], boolean | undefined]> = [
[["cultcache.store.v12", [], []], true],
[["cultcache.store.SECRET-HEADER", [], []], false],
[["cultcache.store.v12SECRET", [], []], false],
[["cultcache.store.v", [], []], false],
[["cultcache.store.v1", ["SECRET-CATALOG-TEXT"], []], undefined],
];
for (const [value, echoed] of cases) {
await writeFile(file, encode(value));
const header = value[0] as string;
await assert.rejects(
() => new SingleFileMessagePackBackingStore(file).pullAll(),
(error: Error) => {
if (echoed) {
return error.message.includes(`"${header}"`);
}
return !error.message.includes("SECRET")
&& (echoed === undefined || (!error.message.includes(`"${header}"`) && error.message.includes(`of ${header.length} bytes`)));
},
);
}
});
test("SingleFileMessagePackBackingStore refuses a dangling symlink at its path and writes nothing", { skip: process.platform === "win32" }, async () => {
const dir = await mkdtemp(join(tmpdir(), "cultcache-dangling-"));
const volume = join(dir, "volume");
await mkdir(volume);
const file = join(dir, "store.cc");
await symlink(join(volume, "store.cc"), file);
const store = new SingleFileMessagePackBackingStore(file);
const envelope = { key: "k", type: "t", payload: Uint8Array.of(0x90), storedAt: "2026-09-30T00:00:00Z" };
await assert.rejects(() => store.pullAll(), (error: NodeJS.ErrnoException) => error.code === "ENOENT");
await assert.rejects(() => store.push(envelope), (error: NodeJS.ErrnoException) => error.code === "ENOENT");
await assert.rejects(() => store.pushAll([envelope], { soft: true }), (error: NodeJS.ErrnoException) => error.code === "ENOENT");
assert.ok((await lstat(file)).isSymbolicLink());
assert.deepEqual(await readdir(volume), []);
});
test("SingleFileMessagePackBackingStore record refusal names only a string key", async () => {
const dir = await mkdtemp(join(tmpdir(), "cultcache-record-key-"));
const file = join(dir, "store.cc");
const secret = new TextEncoder().encode("SECRET-BYTES");
for (const [key, schema] of [[["SECRET-IN-ARRAY"], "s"], [secret, "s"], ["k", { SECRET: 1 }]] as const) {
await writeFile(file, encode(["cultcache.store.v1", [], [[key, schema, "t", Uint8Array.of(0x90), "extra"]]]));
await assert.rejects(
() => new SingleFileMessagePackBackingStore(file).pullAll(),
(error: Error) => !error.message.includes("SECRET") && !error.message.includes("83,69,67") && error.message.includes("not a valid store"),
);
}
});
// What a write does through a symbolic link today. R3 decides whether a write resolves the link
// or refuses it; these pin the current behaviour so that change is deliberate.
test("R3 decides resolve-or-refuse: a push through a live link replaces the link", { skip: process.platform === "win32" }, async () => {
const dir = await mkdtemp(join(tmpdir(), "cultcache-live-link-"));
const volume = join(dir, "volume");
await mkdir(volume);
const target = join(volume, "store.cc");
const envelope = { key: "a", type: "t", payload: Uint8Array.of(0x90), storedAt: "2026-09-30T00:00:00Z" };
await new SingleFileMessagePackBackingStore(target).push(envelope);
const before = await readFile(target);
const link = join(dir, "store.cc");
await symlink(target, link);
await new SingleFileMessagePackBackingStore(link).push({ ...envelope, key: "b" });
assert.ok((await lstat(link)).isFile());
assert.deepEqual(await readFile(target), before);
});
test("R3 decides resolve-or-refuse: a pushAll through a dangling link replaces the link", { skip: process.platform === "win32" }, async () => {
const dir = await mkdtemp(join(tmpdir(), "cultcache-dangling-push-all-"));
const volume = join(dir, "volume");
await mkdir(volume);
const link = join(dir, "store.cc");
await symlink(join(volume, "store.cc"), link);
await new SingleFileMessagePackBackingStore(link).pushAll([{ key: "a", type: "t", payload: Uint8Array.of(0x90), storedAt: "2026-09-30T00:00:00Z" }]);
assert.ok((await lstat(link)).isFile());
assert.deepEqual(await readdir(volume), []);
});
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,27 +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) and
`shaders/CultMath.Interval.hlsl` from the same directory, so ship all three.
- interval arithmetic for culling empty space with a proof: `iv_*` functions
over `float2(lo, hi)` (`iv_add`, `iv_mul`, `iv_exp`, `iv_smoothstep`, ...)
and `iv_snoise_ball`/`iv_fbm_ball`, which bound `snoise` and `fbm_grad` over a
ball, and `iv_frustum_ball`, the ball around a screen tile's rays over a depth
segment, so one probe serves the whole tile (its radius carries its own
float32 rounding bound). Every one returns an interval
containing the function's value at every point of its input, so a raymarch
can skip a segment whose `hi` is below its cutoff; `cultmath_iv_*` in HLSL.
The bounds compose with a consumer's own analytic envelope (a height fog, a
carved sphere): its interval over the segment proves empty space in one probe,
and the noise ball is consulted only near the surface. See `docs/design.md`,
"Intervals".
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.
@ -73,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 and the `org.gamecult.cultmath` Unity
package both declare `MIT AND MPL-2.0` and ship this file.

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.
@ -341,230 +114,6 @@ Known C# friction: under `using static CultMath.math;` the constructor
functions (`float3(...)`, `float3x3(...)`) hide the type names in member access,
so static members need a qualified type (`CultMath.float3x3.Euler(...)`).
## Intervals
`math.Interval.cs` and `shaders/CultMath.Interval.hlsl` (included from
`CultMath.hlsl` after the Phacelle include) carry interval arithmetic for
culling empty space with a proof. Their invariant, **intervals-enclose**: every
interval function returns a `[lo, hi]` that contains `f(x)` for every `x` in
its input region, where `f(x)` is the same function evaluated pointwise in
float32. A consumer that skips a region because `hi < cutoff` has skipped
nothing that was there.
Representation: an interval is `float2(lo, hi)` with `lo <= hi`, both finite.
There is no empty interval; a caller that needs "provably empty" tests
`hi < cutoff`. `iv_point(x)` is `float2(x, x)`.
Ops: `iv_point`, `iv_add`, `iv_sub`, `iv_neg`, `iv_mul` (the least and greatest
of the four corner products), `iv_scale(iv, float)`, `iv_abs` and `iv_sqr`
(an interval straddling zero starts at 0), `iv_min`, `iv_max`, `iv_sqrt`
(`lo` clamped at 0), `iv_exp`, `iv_clamp(iv, float, float)`, `iv_saturate`,
`iv_smoothstep(float, float, iv)` (its ordered endpoints) and
`iv_lerp(iv, iv, float)`. No others until a consumer names one. Each has an
HLSL mirror named `cultmath_iv_*`, compared bit for bit by
`HlslSourceCompatibilityTests`.
The ulp-widening rule. Each bound is computed with the pointwise function's
own float32 operations. IEEE round-to-nearest is monotone, so an op that is
monotone on its region encloses its float32 evaluation exactly, with no
widening; that covers every op above except two, which widen by exactly one
ulp, in the implementation and never in the test:
- `iv_exp`: `exp` is not required to be correctly rounded, so a libm need not
be monotone. .NET's `MathF.Exp` on Linux steps down 0 times over every float
in [-87, 88.7] (`IntervalTests.MeasureMonotonicity`), so on that CPU the
widening is a margin; it is there for other libms and GPUs.
- `iv_smoothstep`: `t * t * (3 - 2t)` in float32 steps down 337,095 times over
the floats in [0, 1], each time by at most 1 ulp (`MeasureMonotonicity`).
Since `t = saturate((x - minimum) / (maximum - minimum))` is itself monotone
in `x`, that covers every `smoothstep`.
`iv_lerp` is the natural interval extension of `x + (y - x) * amount`, which is
how `lerp` evaluates; every step is monotone, so it encloses with no widening
for any `amount`, and it is tight only when `a` is a point, because `x` appears
twice.
`IntervalTests` runs every op over 10,000 seeded intervals (centres in
[-20, 20], widths log-uniform in [1e-4, 10], every eighth a point) and 16
points inside each, endpoints and zero included, with no tolerance; and for
every op it raises `lo` and lowers `hi` by one ulp and requires the harness to
catch each. A new op is one entry in that table.
Noise bounds. `iv_snoise_ball(c, r)` is `[n - L r, n + L r]` intersected with
`[-1, 1]`, where `n = snoise(c)` and `L = SNOISE_LIPSCHITZ`: one `snoise`, value
only. No interval function reads `snoise_grad`, and none has a value-only or
gradient twin. `iv_fbm_ball(c, r, octaves, lacunarity, gain)` is the octave sum
of `iv_snoise_ball(c * f_i, r * |f_i|)` scaled by `a_i`, compounding frequency
and amplitude exactly as `fbm_grad` does, octaves clamped to [0, 16]. A domain
warp that moves a point by at most `D` is enclosed by the radius `r + D`
(`NoiseBoundTests.WarpedPointsStayEnclosed`); this is what the gamecult.org
ground's march rests on.
`iv_frustum_ball(m_c, z0, z1, footprintPerDepth, warp)` is the ball one probe
serves a whole screen tile with. In a pinhole camera frame looking down `+z`,
where the ray of slope `m` is the points `(m z, z)`, it encloses every point of
every ray whose slope lies within `footprintPerDepth` of `m_c` (`N / (sqrt(2) f)`
for an `N x N` tile at focal length `f` pixels: the full pixel footprint, so
sub-pixel jitter is covered), over depths `[z0, z1]`, each point moved by a warp
of length at most `warp`. The centre is `(m_c z_m, z_m)`, `z_m` the mid-depth;
the radius is `((z1 - z0) / 2) |(m_c, 1)| + z1 footprintPerDepth + warp`. A point
of slope `m_c + e` at depth `z` is the centre plus `(z - z_m)(m_c, 1)` plus
`z (e, 0)`. The returned ball is float32, and on a degenerate (`z0 == z1`),
thin or far segment the rounding of the centre alone exceeds the gap the
triangle inequality leaves, so the radius is widened by
`(|c|_1 + radius) 2^-20`. With `u = 2^-24`, each centre component carries at most
two roundings (`z_m`, then the product), so the float centre is within
`2u |c|_1` of the exact one; the radius carries at most seven (`z1 - z0`, the
axis length at `3u`, the product, `z1 footprintPerDepth`, the sum, `+ warp`), so
the exact radius is at most `(1 + 7u)` times the float one; the widening rounds
once more. `2^-20 = 16u` covers `8u (|c|_1 + radius)` twice over, and fma
contraction only removes roundings, so HLSL and GLSL compilers are covered by
the same bound. The caller rotates and translates the centre into world space and scales
centre and radius by its noise frequency; the rounding of that transform is the
caller's. `NoiseBoundTests.TileBallEnclosesEveryRaySegment` checks 2,000 seeded
tiles x 64 points of the r2 domain and 2,000 tiles x 8 corner points of each of
seven extreme families (`z0 == z1`, `z1 - z0 = 1e-6 z1`, `|m_c|` to 1000, `z` to
`1e7`, `z0 = 0` with `z1` to `1e-3`, `f` down to 1, warp to `1e6`), corner flows
full-length and outward, distances in double, with no tolerance. It pins the ball
to the derivation both ways (`r <= ball.w <= r + 2^-19 (r + |c|_1)`), so a ball
that forgets the widening fails and one grown past it fails too. A moving camera needs nothing
more: each frame's probes use that frame's camera, and no ball, mask or probe
result is carried from one frame to the next.
The noise ball alone proves little: at `L` near 10 it bounds `snoise` to a width
of `2 L r`, which covers all of `[-1, 1]` once `L r` reaches 1. Empty space
becomes provable through an analytic envelope. The consumer's density is a
pre-distortion SDF (a height fog, a well map, a carved sphere) that noise
displaces only near its surface, and the consumer composes the bound from `iv_*`
ops. The envelope is taken over the slice's exact box (`iv_mul` of the depth
range by each slope range, then the SDF's own monotone pieces), together with its
fade. The noise is `iv_snoise_ball` over `iv_frustum_ball`, scaled by the noise
frequency. The warp moves only the noise argument, so it enlarges the ball and
the envelope's box takes none of it. Where the fade's upper end is 0 the noise
term is identically 0 and no `snoise` is evaluated. Where the envelope already
proves the slice empty with the noise at its full range, the ball is not
evaluated either. A probe is worth attempting only when the best result any
centre value could give would prove the slice, and a ball with `L r >= 2` is
`[-1, 1]` whatever `snoise(c)` is, so it costs nothing. CultMath owns none of the
envelope: the interval files have no envelope function, and each consumer composes
its own. `NoiseBoundTests.EnvelopeBoundEnclosesDensity` checks that composition,
the one the site and Aetheria use, over 2,000 seeded slices x 64 warped points
with no tolerance.
`L` has provenance, not a proof. `NoiseBoundTests.MeasureLipschitz` (slow,
explicit) takes the largest `|snoise_grad|` over 1e6 seeded points in
[-256, 256]^3 (7.1933103), refines the 1e4 largest by gradient ascent
(9.181147), and multiplies by 1.10: `L = 10.099261`. It also reaches
`|snoise| = 0.9718335` by the same ascent on the value, which is what lets the
bound intersect with `[-1, 1]`. `LipschitzConstantPinsSampledGradients`
checks 1e5 more seeded points against `L` and climbs again from the start
`MeasureLipschitz` prints as its witness, failing if `L` is not 1.10 times that
maximum to within half a percent, so a 1% move of `L` fails. The enclosure tests
over 2,000 balls x 64 points are the defence. A change to the `snoise` kernel
must re-run `MeasureLipschitz` and re-pin `L` and the witness.
What the ball bounds guarantee: enclosure of `snoise` and `fbm_grad(...).w` over
the ball, in float32. What they do not: anything about tapes, pruning, choice
tracking or affine forms; those are later steps of
`docs/cultmath-tape-target.md` (CultLib root). How loose they are is a number:
`NoiseBoundTests.TightnessReport` prints the mean interval width over the
sampled range: 5.08 for `iv_snoise_ball` at `r = 0.05`, 1.96 at 0.25 and 1.28
at 1.0; 6.64 and 2.82 for 4-octave `iv_fbm_ball` at 0.05 and 0.25.
The saving is measured where there is empty space to find.
`NoiseBoundTests.IntervalSkipHalvesEvaluations` draws screen tiles, each with
its own camera, and runs one pre-pass per tile over the dense grid's cell ranges:
it starts with the whole grid, skips and doubles a range it proves empty, halves
one it cannot, and runs to the grid's end. It then draws the tile's `N^2` ray
slopes, one per pixel, jittered inside it. Each ray is marched three times with
the same integrator and the same early-out at transmittance 0.02: over every
cell (dense), over the tile's unproven cells (the tile march), and over the
oracle mask, the cells whose mid-depth density is nonzero for at least one of
the tile's rays. The tile march and the oracle march integrate the dense march's
nonzero cells, so their transmittance agrees with it exactly in every
configuration. The oracle is a ceiling the pre-pass cannot move: dense cost over
oracle cost. Against it the test prints the efficiency (oracle cost over the
tile march's cost with its probes) and the cull fraction (the oracle-empty cells
the pre-pass proves empty). The probe overhead is the tile march's cost over that
cost plus the probes'. Cost is counted in `snoise` evaluations, with an envelope
evaluation or probe weighted 0.2. That weight is an estimate, and both counts are
printed. The flow is not counted. At `N = 8`, in Aetheria's units on its
256-cell quadratic grid out to the Main Camera's far plane, 2048:
- (a) Height fog, camera above the safety band, rays level and up: the whole
grid is one envelope probe per tile, against 256 envelope evaluations per ray,
16384x. Every cell is oracle-empty and the pre-pass proves them all.
- (b) Inside the fog: 1.15x, and 0.99x with the warp. Most cells are fog, and
the pre-pass probes each one.
- (c) The zone bowl and four wells, the camera gazing across. With no warp:
2.74x, oracle ceiling 7.26x, efficiency 0.378, cull fraction 0.952, probe
overhead 0.961. With the warp `D = 60`: 1.39x, oracle ceiling 7.17x,
efficiency 0.194, cull fraction 0.842, probe overhead 0.993. With the warp the
pre-pass cuts envelope evaluations from 177.46 to 33.49 per ray, but the
oracle march costs 0.194 of the tile march, so about four fifths of the tile
march's cost goes to cells no ray samples nonzero: the warp bound swamps the
noise ball, so the ball cannot prove those cells empty. That gap belongs to
the tape target's affine forms, not to a better pre-pass.
- (d) r1's uniform slab, which has no envelope: 1.23x, and 1.48x with no warp.
- (e) The void at the shipped point (ruling `operator-shipped-void`): `Rh = 198`,
`S = 50`, `Rc = Rh + S = 248`, `e = -ln 1.5 / ln(1 - (Rh / Rc)^2)`, the
shipped camera, 200 tiles. The fixed-step reference takes 36.34 steps per
pixel. The same cells masked by the pre-pass take 20.13, with identical
transmittance. The footprint-aware march takes 13.54: 2.68x fewer. `snoise`
per pixel goes from 32.98 to 26.69, and combined cost from 40.25 to 29.52 with
the probes, 1.36x. Every one of the 200 tiles proves a body. Before the body
both marches are measured against a converged one (midpoint quadrature at
`h = 0.5` over every cell, trusting no pre-pass): the relative optical-depth
error at the body start is at most 0.0608 for the fixed-step march and 0.08639
for the footprint march, 1.42x. Over the grid of hollow radius, camera offset
and ramp width the steps ratio runs from 1.57x (`Rh = 100`, low, `S = 150`) to
27.45x (`Rh = 1000`, centred, `S = 10`), the cost ratio from 1.26x to 3.89x,
and the depth-error ratio from 0.85 to 4.72 (`Rh = 200`, centred, `S = 150`,
where the errors are 0.01504 and 0.003188). LOD-far takes 14.19x fewer steps;
its depth errors, 0.2849 and 0.205, are not comparable, because its
fixed-step reference keeps the octaves the footprint march truncates.
The analytic body (ruling `operator-analytic-wall-body`). When a single dense
cell's own bound is a positive point, the pre-pass spends one more probe over
the rest of the grid. If that bound is a positive point too, every point of
every ray of the tile from there on has exactly that density, so the footprint
march takes one closed-form step, `exp(-BodyDepth)`, from the body start to the
grid's end and stops. `BodyDepth` is density x extinction x length x `|dir|`.
`NoiseBoundTests.AnalyticBodyMatchesFineMarch` pins it: the bound, the mask and
the pointwise densities agree exactly at the body, and `BodyDepth` equals the
summed fixed-step depth of the body cells within 1e-4 relative. The comparison
is in the depth domain, so LOD-far's deep bodies do not underflow.
The footprint-truncated void weights its octaves by `w_i(z)`, decreasing in `z`.
Its bound takes each weight over `[w_i(z1), 1]` rather than
`[w_i(z1), w_i(z0)]`, because one bound serves both the full field (`w = 1`) and
the truncated one. `[w_i(z1), 1]` contains both, and `iv_mul` is monotone in its
interval argument, so the composed interval encloses both fields.
`NoiseBoundTests.WeightBoundEnclosesOneSignedNoise` checks it on LOD-far over
2,000 segments: each weight lies in its bound, and `w(z) n` lies in
`iv_mul(bound, n)` for a one-signed `n`. A lower end of `w(z0)` fails it.
The contracts, at `N = 8`, the lower of warp 0 and `D`:
- (a) at least 2x combined cost;
- (c) (ruling `wells-overhead-plus-floor`): probe overhead, the tile march over
the tile march plus its probes, at least 0.90; and, hard, the pre-pass proves
at least half of the oracle-empty cells empty, so a pre-pass that skips
nothing fails. The oracle ceiling, the efficiency against it and the cull
fraction are printed, not asserted;
- (e) at the shipped void: the masked fixed-step march agrees with the reference
exactly; the footprint march's optical depth at the body start is off the
converged march's by at most twice the fixed-step march's error; and it takes
at most half the reference's steps per pixel.
This run meets every contract: (a) 16384x, (c) probe overhead 0.961 and cull
fraction 0.842, (e) 2.68x fewer steps and a depth error 1.42x the fixed-step
march's. The fields, the grids and the 0.2 weight are not tuned toward the
contracts.
Consumers: the gamecult.org ground shader (through the GLSL lowering) and
Aetheria's nebula raymarch at its CultMath pin bump.
## Rules
- Keep type names HLSL-shaped: `float2`, `float3`, `float4`, `float3x3`, and `math`.

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,11 +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.Interval.hlsl",
"Shaders\CultMath.Interval.hlsl.meta"
"Shaders\CultMath.hlsl.meta"
)
foreach ($relativePath in $requiredTemplateFiles) {
if (-not (Test-Path -LiteralPath (Join-Path $templateRoot $relativePath) -PathType Leaf)) {

View file

@ -1,122 +0,0 @@
#ifndef CULTMATH_INTERVAL_HLSL
#define CULTMATH_INTERVAL_HLSL
// Interval arithmetic over float2(lo, hi), lo <= hi, both finite. Every cultmath_iv_* function returns
// an interval containing f(x) for every x in its input intervals, mirroring math.Interval.cs bit for
// bit; see that file and docs/design.md ("Intervals") for each op's enclosure and its ulp widening.
// Included by CultMath.hlsl after its own definitions of cultmath_smoothstep and cultmath_snoise.
// Lipschitz constant of cultmath_snoise(float3); provenance in math.Interval.cs (SNOISE_LIPSCHITZ).
static const float CULTMATH_SNOISE_LIPSCHITZ = 10.099261;
float2 cultmath_iv_point(float value) { return float2(value, value); }
float2 cultmath_iv_add(float2 a, float2 b) { return float2(a.x + b.x, a.y + b.y); }
float2 cultmath_iv_sub(float2 a, float2 b) { return float2(a.x - b.y, a.y - b.x); }
float2 cultmath_iv_neg(float2 a) { return float2(-a.y, -a.x); }
float2 cultmath_iv_mul(float2 a, float2 b)
{
float p0 = a.x * b.x;
float p1 = a.x * b.y;
float p2 = a.y * b.x;
float p3 = a.y * b.y;
return float2(min(min(p0, p1), min(p2, p3)), max(max(p0, p1), max(p2, p3)));
}
float2 cultmath_iv_scale(float2 a, float s) { return s >= 0.0 ? float2(a.x * s, a.y * s) : float2(a.y * s, a.x * s); }
float2 cultmath_iv_abs(float2 a)
{
if (a.x >= 0.0)
return a;
if (a.y <= 0.0)
return float2(-a.y, -a.x);
return float2(0.0, max(-a.x, a.y));
}
float2 cultmath_iv_min(float2 a, float2 b) { return float2(min(a.x, b.x), min(a.y, b.y)); }
float2 cultmath_iv_max(float2 a, float2 b) { return float2(max(a.x, b.x), max(a.y, b.y)); }
float2 cultmath_iv_sqr(float2 a)
{
float l = a.x * a.x;
float h = a.y * a.y;
if (a.x >= 0.0)
return float2(l, h);
if (a.y <= 0.0)
return float2(h, l);
return float2(0.0, max(l, h));
}
float2 cultmath_iv_sqrt(float2 a) { return float2(sqrt(max(a.x, 0.0)), sqrt(max(a.y, 0.0))); }
// Widened by one ulp on each side: exp is not required to be correctly rounded.
float2 cultmath_iv_exp(float2 a)
{
float lo = exp(a.x);
float hi = exp(a.y);
return float2(lo > 0.0 ? asfloat(asuint(lo) - 1u) : 0.0, asfloat(asuint(hi) + 1u));
}
float2 cultmath_iv_clamp(float2 a, float minimum, float maximum)
{
return float2(cultmath_clamp(a.x, minimum, maximum), cultmath_clamp(a.y, minimum, maximum));
}
float2 cultmath_iv_saturate(float2 a) { return float2(cultmath_saturate(a.x), cultmath_saturate(a.y)); }
// The endpoints, ordered, widened by one ulp inside [0, 1]: the float32 evaluation of smoothstep is
// monotone only to within one ulp.
float2 cultmath_iv_smoothstep(float minimum, float maximum, float2 a)
{
float s0 = cultmath_smoothstep(minimum, maximum, a.x);
float s1 = cultmath_smoothstep(minimum, maximum, a.y);
float lo = min(s0, s1);
float hi = max(s0, s1);
return float2(lo > 0.0 ? asfloat(asuint(lo) - 1u) : 0.0, hi < 1.0 ? asfloat(asuint(hi) + 1u) : 1.0);
}
// The natural interval extension of x + (y - x) * amount, which is how cultmath_lerp evaluates.
float2 cultmath_iv_lerp(float2 a, float2 b, float amount) { return cultmath_iv_add(a, cultmath_iv_scale(cultmath_iv_sub(b, a), amount)); }
// [n - L r, n + L r] intersected with [-1, 1], n = cultmath_snoise(centre). One snoise, value only.
float2 cultmath_iv_snoise_ball(float3 centre, float radius)
{
float n = cultmath_snoise(centre);
float e = CULTMATH_SNOISE_LIPSCHITZ * radius;
return float2(max(n - e, -1.0), min(n + e, 1.0));
}
// Octave sum of cultmath_iv_snoise_ball, compounding frequency and amplitude as the value-and-gradient fBm does.
float2 cultmath_iv_fbm_ball(float3 centre, float radius, int octaves, float lacunarity, float gain)
{
octaves = clamp(octaves, 0, 16);
float amplitude = 1.0;
float frequency = 1.0;
float2 sum = float2(0.0, 0.0);
for (int i = 0; i < octaves; i++)
{
sum = cultmath_iv_add(sum, cultmath_iv_scale(cultmath_iv_snoise_ball(centre * frequency, radius * abs(frequency)), amplitude));
frequency *= lacunarity;
amplitude *= gain;
}
return sum;
}
// A ball (xyz centre, w radius) enclosing every point of every ray of a screen tile over the depth segment
// [z0, z1], warped by at most warp. Camera frame: pinhole at the origin looking down +z, ray (m z, z).
// footprintPerDepth = N / (sqrt(2) f) for an N x N tile at focal length f. The radius is widened by
// (|c|_1 + radius) 2^-20, which covers the ball's own float32 rounding. Derivation in math.Interval.cs.
float4 cultmath_iv_frustum_ball(float2 centreSlope, float z0, float z1, float footprintPerDepth, float warp)
{
float zm = (z0 + z1) * 0.5;
float axis = sqrt(centreSlope.x * centreSlope.x + centreSlope.y * centreSlope.y + 1.0);
float cx = centreSlope.x * zm;
float cy = centreSlope.y * zm;
float radius = (z1 - z0) * 0.5 * axis + z1 * footprintPerDepth + warp;
radius += (abs(cx) + abs(cy) + abs(zm) + radius) * 9.5367431640625e-7;
return float4(cx, cy, zm, radius);
}
#endif

View file

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

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,123 +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"
#include "CultMath.Interval.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.3.0</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,9 +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="..\..\shaders\CultMath.Interval.hlsl" Pack="true" PackagePath="contentFiles\any\any\shaders\CultMath.Interval.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,199 +0,0 @@
namespace CultMath;
public static partial class math
{
// Interval arithmetic over float2(lo, hi), lo <= hi, both finite (design.md, "Intervals"). Every
// iv_* function returns an interval that contains f(x) for every x in its input intervals, where
// f(x) is the same function evaluated pointwise in float32, mirrored by cultmath_iv_* in
// shaders/CultMath.Interval.hlsl. Each bound is computed with the pointwise function's own float32
// operations; because IEEE round-to-nearest is monotone, an op that is monotone on its region
// encloses its float32 evaluation with no widening. Where float32 evaluation is not monotone, or
// the libm is not required to be, the bound is widened by exactly one ulp, and the doc comment
// says so. There is no empty interval: a caller that needs "provably empty" tests hi < cutoff.
// Lipschitz constant of snoise(float3) in its input: an upper bound on the length of its analytic
// gradient. Provenance: NoiseBoundTests.MeasureLipschitz (slow, explicit) takes the largest gradient
// length over 1e6 seeded points, refines the 1e4 largest by gradient ascent, and multiplies the
// refined maximum by 1.10; NoiseBoundTests.LipschitzConstantPinsSampledGradients pins this value
// against a fast re-measure and the ascent from MeasureLipschitz's witness.
// Empirical with a margin, not a proof; the enclosure tests are the defence. A change to the
// snoise kernel must re-run MeasureLipschitz and re-pin this constant.
public const float SNOISE_LIPSCHITZ = 10.099261f;
/// <summary>The point interval [x, x].</summary>
public static float2 iv_point(float value) => new(value, value);
/// <summary>Encloses x + y for x in a, y in b. Monotone: no widening.</summary>
public static float2 iv_add(float2 a, float2 b) => new(a.x + b.x, a.y + b.y);
/// <summary>Encloses x - y for x in a, y in b. Monotone: no widening.</summary>
public static float2 iv_sub(float2 a, float2 b) => new(a.x - b.y, a.y - b.x);
/// <summary>Encloses -x for x in a. Exact.</summary>
public static float2 iv_neg(float2 a) => new(-a.y, -a.x);
/// <summary>
/// Encloses x * y for x in a, y in b: the least and greatest of the four corner products. The
/// product is monotone in each factor for a fixed sign of the other, so no widening.
/// </summary>
public static float2 iv_mul(float2 a, float2 b)
{
var p0 = a.x * b.x;
var p1 = a.x * b.y;
var p2 = a.y * b.x;
var p3 = a.y * b.y;
return new(min(min(p0, p1), min(p2, p3)), max(max(p0, p1), max(p2, p3)));
}
/// <summary>Encloses x * s for x in a. Monotone: no widening.</summary>
public static float2 iv_scale(float2 a, float s) => s >= 0.0f ? new(a.x * s, a.y * s) : new(a.y * s, a.x * s);
/// <summary>Encloses abs(x) for x in a; an interval straddling zero gives [0, max(-lo, hi)]. Exact.</summary>
public static float2 iv_abs(float2 a)
{
if (a.x >= 0.0f)
return a;
if (a.y <= 0.0f)
return new(-a.y, -a.x);
return new(0.0f, max(-a.x, a.y));
}
/// <summary>Encloses min(x, y) for x in a, y in b. Monotone: no widening.</summary>
public static float2 iv_min(float2 a, float2 b) => new(min(a.x, b.x), min(a.y, b.y));
/// <summary>Encloses max(x, y) for x in a, y in b. Monotone: no widening.</summary>
public static float2 iv_max(float2 a, float2 b) => new(max(a.x, b.x), max(a.y, b.y));
/// <summary>
/// Encloses x * x for x in a; an interval straddling zero gives [0, max(lo * lo, hi * hi)]. Tighter
/// than iv_mul(a, a), which cannot know both factors are the same x. Monotone on each side of zero:
/// no widening.
/// </summary>
public static float2 iv_sqr(float2 a)
{
var l = a.x * a.x;
var h = a.y * a.y;
if (a.x >= 0.0f)
return new(l, h);
if (a.y <= 0.0f)
return new(h, l);
return new(0.0f, max(l, h));
}
/// <summary>
/// Encloses sqrt(x) for x in a with x >= 0 (sqrt's domain): lo is clamped at 0, so [-4, 9] gives [0, 3]
/// and an interval wholly below zero gives [0, 0]. sqrt is correctly rounded, hence monotone: no
/// widening.
/// </summary>
public static float2 iv_sqrt(float2 a) => new(sqrt(max(a.x, 0.0f)), sqrt(max(a.y, 0.0f)));
/// <summary>
/// Encloses exp(x) for x in a. Widened by one ulp on each side, because exp is not required to be
/// correctly rounded, so a libm need not be monotone. Where exp(hi) overflows to infinity, hi is NaN,
/// which a cull test hi &lt; cutoff reads as "not provably below", the safe side.
/// </summary>
public static float2 iv_exp(float2 a)
{
var lo = exp(a.x);
var hi = exp(a.y);
return new(lo > 0.0f ? asfloat(asuint(lo) - 1u) : 0.0f, asfloat(asuint(hi) + 1u));
}
/// <summary>Encloses clamp(x, minimum, maximum) for x in a. Monotone: no widening.</summary>
public static float2 iv_clamp(float2 a, float minimum, float maximum) =>
new(clamp(a.x, minimum, maximum), clamp(a.y, minimum, maximum));
/// <summary>Encloses saturate(x) for x in a. Monotone: no widening.</summary>
public static float2 iv_saturate(float2 a) => new(saturate(a.x), saturate(a.y));
/// <summary>
/// Encloses smoothstep(minimum, maximum, x) for x in a. smoothstep is monotone in x (increasing when
/// minimum &lt; maximum, decreasing when minimum &gt; maximum), so the bound is its value at the two
/// endpoints, ordered. Its float32 evaluation t * t * (3 - 2t) is not monotone, by at most one ulp
/// (IntervalTests.MeasureMonotonicity), so the bound is widened by one ulp inside [0, 1].
/// </summary>
public static float2 iv_smoothstep(float minimum, float maximum, float2 a)
{
var s0 = smoothstep(minimum, maximum, a.x);
var s1 = smoothstep(minimum, maximum, a.y);
var lo = min(s0, s1);
var hi = max(s0, s1);
return new(lo > 0.0f ? asfloat(asuint(lo) - 1u) : 0.0f, hi < 1.0f ? asfloat(asuint(hi) + 1u) : 1.0f);
}
/// <summary>
/// Encloses lerp(x, y, amount) = x + (y - x) * amount for x in a, y in b, by evaluating that expression
/// with iv_sub, iv_scale and iv_add. Each step is monotone, so the result encloses the float32 lerp
/// with no widening, for any amount. It is the natural interval extension: x appears twice, so for
/// non-point a the width is (width(a) + width(b)) * |amount| + width(a), wider than the true range.
/// </summary>
public static float2 iv_lerp(float2 a, float2 b, float amount) => iv_add(a, iv_scale(iv_sub(b, a), amount));
/// <summary>
/// Encloses snoise(x) for every x within distance radius of centre: [n - L r, n + L r] intersected
/// with [-1, 1], where n = snoise(centre) and L = SNOISE_LIPSCHITZ. radius must be at least 0. One
/// snoise evaluation, value only. A domain warp that moves x by at most D is enclosed by passing
/// radius + D (NoiseBoundTests.WarpedPointsStayEnclosed).
/// </summary>
public static float2 iv_snoise_ball(float3 centre, float radius)
{
var n = snoise(centre);
var e = SNOISE_LIPSCHITZ * radius;
return new(max(n - e, -1.0f), min(n + e, 1.0f));
}
/// <summary>
/// Encloses the fBm value, sum of a_i * snoise(x * f_i) over the octaves, for every x within distance
/// radius of centre: the octave sum of iv_snoise_ball(centre * f_i, radius * |f_i|) scaled by a_i,
/// with frequency f_i and amplitude a_i compounded exactly as the value-and-gradient fBm compounds
/// them (its .w is what NoiseBoundTests.FbmBallEnclosesPoints encloses). octaves is clamped to [0, 16]
/// as there.
/// </summary>
public static float2 iv_fbm_ball(float3 centre, float radius, int octaves, float lacunarity, float gain)
{
octaves = clamp(octaves, 0, 16);
var amplitude = 1.0f;
var frequency = 1.0f;
var sum = new float2(0.0f, 0.0f);
for (var i = 0; i < octaves; i++)
{
sum = iv_add(sum, iv_scale(iv_snoise_ball(centre * frequency, radius * abs(frequency)), amplitude));
frequency *= lacunarity;
amplitude *= gain;
}
return sum;
}
/// <summary>
/// A ball (xyz centre, w radius) enclosing every point of every ray of a screen tile over the depth
/// segment [z0, z1], each point then moved by a warp of length at most warp: the ball to hand
/// iv_snoise_ball (scaled by the noise frequency) when one probe serves the whole tile. Camera frame:
/// a pinhole at the origin looking down +z, where the ray of slope m is the points (m z, z).
/// centreSlope is the tile's central slope m_c; footprintPerDepth is the largest distance of any
/// ray's slope from m_c, N / (sqrt(2) f) for an N x N pixel tile at focal length f pixels (the full
/// pixel footprint, so sub-pixel jitter is covered). The centre is (m_c z_m, z_m), z_m = (z0 + z1) / 2;
/// the radius is ((z1 - z0) / 2) |(m_c, 1)| + z1 footprintPerDepth + warp. Derivation: a point of slope
/// m_c + e (|e| &lt;= footprintPerDepth) at depth z is c + (z - z_m)(m_c, 1) + z (e, 0); the first term is
/// at most the first radius term, the second at most the second, and the warp adds at most warp.
/// The ball also carries its own float32 rounding: the radius is widened by (|c|_1 + radius) 2^-20.
/// With u = 2^-24, each centre component carries at most two roundings (z_m, then the product), so the
/// float centre is within 2u |c|_1 of the exact one; the radius carries at most seven, so the exact
/// radius is at most (1 + 7u) times the float one; the widening rounds once more. 2^-20 = 16u covers
/// 8u (|c|_1 + radius) twice over, and fma contraction only removes roundings. So every exact point of
/// every ray, at any depth and slope, lies in the returned float ball
/// (NoiseBoundTests.TileBallEnclosesEveryRaySegment, degenerate and far segments included). The caller
/// rotates and translates the centre into world space (the radius is unchanged) and scales both by its
/// noise frequency; the rounding of that transform is the caller's.
/// </summary>
public static float4 iv_frustum_ball(float2 centreSlope, float z0, float z1, float footprintPerDepth, float warp)
{
var zm = (z0 + z1) * 0.5f;
var axis = sqrt(centreSlope.x * centreSlope.x + centreSlope.y * centreSlope.y + 1.0f);
var cx = centreSlope.x * zm;
var cy = centreSlope.y * zm;
var radius = (z1 - z0) * 0.5f * axis + z1 * footprintPerDepth + warp;
radius += (abs(cx) + abs(cy) + abs(zm) + radius) * 9.5367431640625e-7f;
return new float4(cx, cy, zm, radius);
}
}

View file

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 3eb757caae684b388921e28e3c87ba3a

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,44 +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);
public static float asfloat(uint value) => BitConverter.Int32BitsToSingle((int)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));
@ -527,81 +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).
// SNOISE_LIPSCHITZ (math.Interval.cs) is measured from this kernel: a change here must re-run
// NoiseBoundTests.MeasureLipschitz and re-pin it.
// in shaders/CultMath.hlsl with the same float32 evaluation order.
public static float snoise(float3 value)
{
const float cx = 1.0f / 6.0f;
@ -648,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
@ -696,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,20 +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")]
[InlineData("CultMath.Interval.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,14 +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)));
Assert.NotNull(ShaderFunction(shader, "cultmath_iv_snoise_ball", typeof(float3), typeof(float)));
Assert.NotNull(ShaderFunction(shader, "cultmath_iv_fbm_ball", typeof(float3), typeof(float), typeof(int), typeof(float), typeof(float)));
}
// HLSL functions carry no access modifier, so they compile as private instance methods.
@ -70,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_")))
@ -121,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(53, 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)
@ -340,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

@ -1,312 +0,0 @@
using CultMath;
using Xunit;
using static CultMath.math;
namespace CultMath.Tests;
/// <summary>
/// intervals-enclose for every iv_* op (design.md, "Intervals"): for each op, intervals with seeded random
/// centres and widths and the points inside them; f(x), evaluated pointwise in float32 by the same C# math
/// the op mirrors, must lie in op(I) with no tolerance.
///
/// Stryker mutates operators, not float bounds, so the mutants that matter most here are written by hand
/// and run by <see cref="EveryOneUlpShrinkIsCaught"/>: for every op, the lo bound raised by one ulp and the
/// hi bound lowered by one ulp must each fail the harness. Two pairs are equivalent mutants on this CPU and
/// are not in that list:
/// - iv_exp's one-ulp widening, both sides: MathF.Exp is monotone over every float on the Linux runtime
/// (MeasureMonotonicity prints 0 violations), so removing the widening encloses here. It is kept because
/// exp is not required to be correctly rounded, and another libm or a GPU need not be monotone.
/// - iv_smoothstep's one-ulp widening: the generic harness never meets one of smoothstep's 1-ulp
/// non-monotone steps; <see cref="SmoothstepWideningCoversItsFloatNonMonotonicity"/> constructs one on
/// each side and kills both mutants.
/// </summary>
public sealed class IntervalTests
{
private readonly ITestOutputHelper output;
public IntervalTests(ITestOutputHelper output) => this.output = output;
private const int IntervalCount = 10000;
private const int PointsPerInterval = 16;
// One row of the shared harness: how many interval arguments the op takes, how to draw its scalar
// arguments, the op itself and the pointwise function it encloses. A new op is one entry here.
internal sealed record Op(
string Name,
int Intervals,
Func<System.Random, float[]> Scalars,
Func<float2[], float[], float2> Bound,
Func<float[], float[], float> Pointwise,
bool NonNegativeDomain = false);
private static float[] None(System.Random random) => Array.Empty<float>();
private static float Uniform(System.Random random, float lo, float hi) => lo + (hi - lo) * random.NextSingle();
internal static readonly Op[] Ops =
{
new("iv_point", 0, r => new[] { Uniform(r, -20.0f, 20.0f) }, (i, s) => iv_point(s[0]), (x, s) => s[0]),
new("iv_add", 2, None, (i, s) => iv_add(i[0], i[1]), (x, s) => x[0] + x[1]),
new("iv_sub", 2, None, (i, s) => iv_sub(i[0], i[1]), (x, s) => x[0] - x[1]),
new("iv_neg", 1, None, (i, s) => iv_neg(i[0]), (x, s) => -x[0]),
new("iv_mul", 2, None, (i, s) => iv_mul(i[0], i[1]), (x, s) => x[0] * x[1]),
new("iv_scale", 1, r => new[] { Uniform(r, -4.0f, 4.0f) }, (i, s) => iv_scale(i[0], s[0]), (x, s) => x[0] * s[0]),
new("iv_abs", 1, None, (i, s) => iv_abs(i[0]), (x, s) => abs(x[0])),
new("iv_min", 2, None, (i, s) => iv_min(i[0], i[1]), (x, s) => min(x[0], x[1])),
new("iv_max", 2, None, (i, s) => iv_max(i[0], i[1]), (x, s) => max(x[0], x[1])),
new("iv_sqr", 1, None, (i, s) => iv_sqr(i[0]), (x, s) => x[0] * x[0]),
new("iv_sqrt", 1, None, (i, s) => iv_sqrt(i[0]), (x, s) => sqrt(x[0]), NonNegativeDomain: true),
new("iv_exp", 1, None, (i, s) => iv_exp(i[0]), (x, s) => exp(x[0])),
new("iv_clamp", 1, r => new[] { Uniform(r, -25.0f, 25.0f), Uniform(r, -25.0f, 25.0f) },
(i, s) => iv_clamp(i[0], s[0], s[1]), (x, s) => clamp(x[0], s[0], s[1])),
new("iv_saturate", 1, None, (i, s) => iv_saturate(i[0]), (x, s) => saturate(x[0])),
new("iv_smoothstep", 1, r => new[] { Uniform(r, -25.0f, 25.0f), Uniform(r, -25.0f, 25.0f) },
(i, s) => iv_smoothstep(s[0], s[1], i[0]), (x, s) => smoothstep(s[0], s[1], x[0])),
new("iv_lerp", 2, r => new[] { Uniform(r, -0.5f, 1.5f) }, (i, s) => iv_lerp(i[0], i[1], s[0]), (x, s) => lerp(x[0], x[1], s[0])),
};
// Centres in [-20, 20], widths log-uniform in [1e-4, 10]. Every eighth interval is a point
// interval: a bound that is only reached when an argument is a point (iv_lerp's, where x appears
// twice) is still reached, so a one-ulp shrink of it is still caught.
private static float2 RandomInterval(System.Random random, int index)
{
var centre = Uniform(random, -20.0f, 20.0f);
if (index % 8 == 7)
return iv_point(centre);
var width = MathF.Pow(10.0f, Uniform(random, -4.0f, 1.0f));
return new float2(centre - width * 0.5f, centre + width * 0.5f);
}
// The points inside one argument: both endpoints, zero when the interval holds it, and the rest
// uniform. Endpoints and zero are where a tight bound is reached.
private static float[] PointsInside(System.Random random, float2 interval, bool nonNegative)
{
var lo = nonNegative ? max(interval.x, 0.0f) : interval.x;
var hi = nonNegative ? max(interval.y, 0.0f) : interval.y;
var points = new float[PointsPerInterval];
points[0] = lo;
points[1] = hi;
var next = 2;
if (lo < 0.0f && hi > 0.0f)
points[next++] = 0.0f;
for (; next < points.Length; next++)
points[next] = min(max(lo + (hi - lo) * random.NextSingle(), lo), hi);
return points;
}
/// <summary>Runs one op over the seeded set; returns the violations, at most a few, described.</summary>
internal static List<string> Violations(Op op, Func<float2, float2>? mutate = null, int seed = 0x1A7E)
{
var random = new System.Random(seed);
var violations = new List<string>();
var count = 0;
for (var k = 0; k < IntervalCount; k++)
{
var intervals = new float2[op.Intervals];
for (var a = 0; a < op.Intervals; a++)
intervals[a] = RandomInterval(random, k + a);
var scalars = op.Scalars(random);
var bound = op.Bound(intervals, scalars);
if (mutate is not null)
bound = mutate(bound);
var points = intervals.Select(i => PointsInside(random, i, op.NonNegativeDomain)).ToArray();
for (var p = 0; p < PointsPerInterval; p++)
{
// Binary ops visit the four endpoint corners first (p = 0..3), then pair points by index.
var x = new float[op.Intervals];
for (var a = 0; a < op.Intervals; a++)
x[a] = op.Intervals == 2 && p < 4 ? points[a][a == 0 ? p & 1 : p >> 1] : points[a][p];
var value = op.Pointwise(x, scalars);
if (!(value >= bound.x && value <= bound.y))
{
if (count++ < 4)
violations.Add($"{op.Name}({string.Join(", ", intervals)}; {string.Join(", ", scalars)}) = {bound}, f({string.Join(", ", x)}) = {value:R}");
}
}
}
if (count > violations.Count)
violations.Add($"... {count} violations in all");
return violations;
}
[Fact]
public void EveryOpEnclosesItsPoints()
{
var failures = Ops.SelectMany(op => Violations(op)).ToList();
Assert.True(failures.Count == 0, string.Join(Environment.NewLine, failures));
}
internal static float NextUp(float v) => v == 0.0f ? asfloat(1u) : asfloat(v > 0.0f ? asuint(v) + 1u : asuint(v) - 1u);
private static float NextDown(float v) => -NextUp(-v);
/// <summary>
/// The hand-mutation list: every op's lo raised by one ulp, and its hi lowered by one ulp, must each
/// fail the harness. The two equivalent pairs are named in the class comment.
/// </summary>
[Fact]
public void EveryOneUlpShrinkIsCaught()
{
var survivors = new List<string>();
foreach (var op in Ops.Where(o => o.Name is not "iv_exp" and not "iv_smoothstep"))
{
if (Violations(op, b => new float2(NextUp(b.x), b.y)).Count == 0)
survivors.Add(op.Name + " lo + 1 ulp");
if (Violations(op, b => new float2(b.x, NextDown(b.y))).Count == 0)
survivors.Add(op.Name + " hi - 1 ulp");
}
Assert.True(survivors.Count == 0, "surviving one-ulp shrinks: " + string.Join(", ", survivors));
}
[Fact]
public void MulCoversEverySignCase()
{
// Both positive, both negative, mixed both ways, and a factor straddling zero on either side.
Assert.Equal(new float2(2.0f, 12.0f), iv_mul(new float2(1.0f, 3.0f), new float2(2.0f, 4.0f)));
Assert.Equal(new float2(2.0f, 12.0f), iv_mul(new float2(-3.0f, -1.0f), new float2(-4.0f, -2.0f)));
Assert.Equal(new float2(-12.0f, -2.0f), iv_mul(new float2(1.0f, 3.0f), new float2(-4.0f, -2.0f)));
Assert.Equal(new float2(-12.0f, -2.0f), iv_mul(new float2(-3.0f, -1.0f), new float2(2.0f, 4.0f)));
Assert.Equal(new float2(-8.0f, 12.0f), iv_mul(new float2(-2.0f, 3.0f), new float2(2.0f, 4.0f)));
Assert.Equal(new float2(-12.0f, 8.0f), iv_mul(new float2(-2.0f, 3.0f), new float2(-4.0f, -2.0f)));
Assert.Equal(new float2(-15.0f, 12.0f), iv_mul(new float2(-2.0f, 3.0f), new float2(-5.0f, 4.0f)));
}
[Fact]
public void SqrtClampsANegativeLoAtZero()
{
Assert.Equal(new float2(0.0f, 3.0f), iv_sqrt(new float2(-4.0f, 9.0f)));
Assert.Equal(new float2(0.0f, 0.0f), iv_sqrt(new float2(-4.0f, -1.0f)));
Assert.Equal(new float2(2.0f, 3.0f), iv_sqrt(new float2(4.0f, 9.0f)));
}
[Fact]
public void AbsAndSqrStraddlingZeroStartAtZero()
{
Assert.Equal(new float2(0.0f, 3.0f), iv_abs(new float2(-3.0f, 2.0f)));
Assert.Equal(new float2(0.0f, 5.0f), iv_abs(new float2(-2.0f, 5.0f)));
Assert.Equal(new float2(2.0f, 5.0f), iv_abs(new float2(-5.0f, -2.0f)));
Assert.Equal(new float2(0.0f, 9.0f), iv_sqr(new float2(-3.0f, 2.0f)));
Assert.Equal(new float2(4.0f, 25.0f), iv_sqr(new float2(-5.0f, -2.0f)));
}
[Fact]
public void MinAndMaxAreThePointwiseMinAndMaxOfTheEnds()
{
var a = new float2(-1.0f, 4.0f);
var b = new float2(2.0f, 3.0f);
Assert.Equal(new float2(-1.0f, 3.0f), iv_min(a, b));
Assert.Equal(new float2(2.0f, 4.0f), iv_max(a, b));
}
[Fact]
public void SmoothstepBoundsAreItsOrderedEndpointsWidenedByOneUlp()
{
var rising = iv_smoothstep(0.0f, 1.0f, new float2(0.25f, 0.5f));
Assert.Equal(NextDown(smoothstep(0.0f, 1.0f, 0.25f)), rising.x);
Assert.Equal(NextUp(smoothstep(0.0f, 1.0f, 0.5f)), rising.y);
// minimum > maximum: smoothstep falls, so the endpoints swap.
var falling = iv_smoothstep(1.0f, 0.0f, new float2(0.25f, 0.5f));
Assert.Equal(NextDown(smoothstep(1.0f, 0.0f, 0.5f)), falling.x);
Assert.Equal(NextUp(smoothstep(1.0f, 0.0f, 0.25f)), falling.y);
// The widening never leaves [0, 1].
Assert.Equal(new float2(0.0f, 1.0f), iv_smoothstep(0.0f, 1.0f, new float2(-2.0f, 3.0f)));
}
/// <summary>
/// smoothstep's float32 evaluation steps down by one ulp in places (MeasureMonotonicity). Three
/// consecutive floats t0 &lt; t1 &lt; t2 with f(t1) below both neighbours put f(t1) outside the ordered
/// endpoints of [t0, t2]; three with f(t1) above both put it outside on the other side. The widened
/// bound encloses both; without the widening it does not.
/// </summary>
[Fact]
public void SmoothstepWideningCoversItsFloatNonMonotonicity()
{
float F(float t) => smoothstep(0.0f, 1.0f, t);
float2? dip = null, peak = null;
for (var t = 1.0e-4f; t < 0.01f && (dip is null || peak is null); t = NextUp(t))
{
var t1 = NextUp(t);
var t2 = NextUp(t1);
if (dip is null && F(t1) < F(t) && F(t1) < F(t2))
dip = new float2(t, t2);
if (peak is null && F(t1) > F(t) && F(t1) > F(t2))
peak = new float2(t, t2);
}
Assert.NotNull(dip);
Assert.NotNull(peak);
foreach (var (interval, low) in new[] { (dip!.Value, true), (peak!.Value, false) })
{
var mid = F(NextUp(interval.x));
var unwidened = new float2(min(F(interval.x), F(interval.y)), max(F(interval.x), F(interval.y)));
Assert.False(mid >= unwidened.x && mid <= unwidened.y, $"the {(low ? "dip" : "peak")} at {interval} is inside the endpoints");
var bound = iv_smoothstep(0.0f, 1.0f, interval);
Assert.True(mid >= bound.x && mid <= bound.y, $"iv_smoothstep{interval} = {bound} misses {mid:R}");
}
}
[Fact]
public void LerpIsTheNaturalExtensionAndTightForPointArguments()
{
Assert.Equal(new float2(lerp(1.0f, 3.0f, 0.25f), lerp(1.0f, 7.0f, 0.25f)), iv_lerp(iv_point(1.0f), new float2(3.0f, 7.0f), 0.25f));
Assert.Equal(new float2(-1.0f + (2.0f - 1.0f) * 0.5f, 1.0f + (4.0f + 1.0f) * 0.5f), iv_lerp(new float2(-1.0f, 1.0f), new float2(2.0f, 4.0f), 0.5f));
}
[Fact]
public void ExpIsWidenedByOneUlpOnEachSide()
{
var bound = iv_exp(new float2(-1.0f, 2.0f));
Assert.Equal(NextDown(exp(-1.0f)), bound.x);
Assert.Equal(NextUp(exp(2.0f)), bound.y);
Assert.Equal(0.0f, iv_exp(new float2(-200.0f, 0.0f)).x);
}
/// <summary>
/// The monotonicity the widening rules rest on, measured over every float: exp over [-87, 88.7], the
/// range where it neither underflows to a subnormal nor overflows, and smoothstep(0, 1, t) over [0, 1],
/// which covers every smoothstep because t = saturate((x - minimum) / (maximum - minimum)) is itself
/// monotone in x. Prints the number of steps down and the largest, in ulps. Slow: about a minute.
/// </summary>
[Fact(Explicit = true)]
[Trait("Category", "Slow")]
public void MeasureMonotonicity()
{
long expDown = 0, expWorst = 0;
var previous = exp(-87.0f);
for (var x = NextUp(-87.0f); x <= 88.7f; x = NextUp(x))
{
var e = exp(x);
if (e < previous)
{
expDown++;
expWorst = Math.Max(expWorst, (long)asuint(previous) - asuint(e));
}
previous = e;
}
long smoothDown = 0, smoothWorst = 0;
var runningMax = 0.0f;
for (var bits = 0u; bits <= asuint(1.0f); bits++)
{
var s = smoothstep(0.0f, 1.0f, asfloat(bits));
if (s < runningMax)
{
smoothDown++;
smoothWorst = Math.Max(smoothWorst, (long)asuint(runningMax) - asuint(s));
}
else
{
runningMax = s;
}
}
output.WriteLine($"IV-REPORT monotonicity: exp steps down {expDown} times, worst {expWorst} ulp; smoothstep steps down {smoothDown} times, worst {smoothWorst} ulp");
Assert.True(expWorst <= 1, $"exp steps down by {expWorst} ulp; one ulp of widening is not enough");
Assert.True(smoothWorst <= 1, $"smoothstep steps down by {smoothWorst} ulp; one ulp of widening is not enough");
}
}

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

File diff suppressed because it is too large Load diff

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,51 +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.3.0]
### Breaking
- `math.snoise(float3)` and `math.snoise(float2)`, and their HLSL mirrors
`cultmath_snoise`, return different values. The kernel now follows
stegu/webgl-noise. For 3D only, the falloff `r^2` constant is 0.5 (was 0.6)
and the output scale is 105 (was 42). For 2D, the falloff constant (0.5) and
the output scale (130) are unchanged. In both, the shared permutation offset
is now +10 (was +1), which changes the values of both. This removes the value
discontinuities the 3D kernel had at simplex cell boundaries. Any content
seeded from `snoise` changes; regenerate it when you take this version.
### Added
- `math.smin_grad`: smooth minimum with gradient, for `float4` operands
carrying the gradient in `xyz` and the value in `w`.
- `math.cellular` and the `CultCellular` result struct: cellular (Worley)
noise returning the gradient and distance to the nearest feature points.
- `math.snoise_grad`, `math.fbm_grad` and `math.ridged_grad`: simplex noise,
fractal Brownian motion and ridged multifractal with analytic gradients,
returned as `float4` with the gradient in `xyz` and the value in `w`.
- `math.phacelle` and the `CultPhasor` result struct: Phacelle noise with
gradient. `math.Phacelle.cs`, `CultPhasor.cs` and
`Shaders/CultMath.Phacelle.hlsl` are MPL-2.0 (see
`THIRD-PARTY-NOTICES.md`).
- `BoundedLeastSquares`: an allocation-free, deterministic bounded
least-squares solver (`Solve`) with a status result and the input contract
documented on the type.
- The matching `cultmath_*` HLSL mirrors of the functions above in
`Shaders/CultMath.hlsl` and `Shaders/CultMath.Phacelle.hlsl`.
## [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

@ -1,19 +1,10 @@
# CultMath License
CultMath is licensed under the MIT License (`MIT`), except for the Phacelle
files, which are licensed under the Mozilla Public License 2.0 (`MPL-2.0`):
CultMath is licensed under the Mozilla Public License 2.0 (`MPL-2.0`).
- `Shaders/CultMath.Phacelle.hlsl`
- the Phacelle sources compiled into `Runtime/Plugins/CultMath.dll`
(`math.Phacelle.cs` and `CultPhasor.cs` in the GameCult/CultLib repository)
The package license expression is therefore `MIT AND MPL-2.0`, matching the
`GameCult.Math` NuGet package. MPL-2.0 is file-level: it applies to those files
and not to the rest of CultMath.
The MPL-2.0 text is available from the Mozilla Foundation at
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`.

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