Compare commits
443 changed files with 4857 additions and 67527 deletions
29
.gitattributes
vendored
29
.gitattributes
vendored
|
|
@ -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
|
||||
|
|
|
|||
43
.github/workflows/cultmesh-portability.yml
vendored
43
.github/workflows/cultmesh-portability.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
26
.github/workflows/cultnet-interop.yml
vendored
26
.github/workflows/cultnet-interop.yml
vendored
|
|
@ -65,32 +65,6 @@ jobs:
|
|||
"KOTLIN_JAVA_HOME=$env:JAVA_HOME" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
"$kotlinHome\bin" | Out-File -FilePath $env:GITHUB_PATH -Append
|
||||
|
||||
- name: Verify CultNet schema contracts stayed consolidated
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$stray = git ls-files '*.schema.json' | Where-Object { $_ -notmatch '^contracts/cultnet/' }
|
||||
# -I skips binary files: unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll
|
||||
# still embeds the retired namespace until it is rebuilt (REC-032 D4, deferred).
|
||||
$oldNamespace = git grep -I -lE 'GameCult/cultnet-ts/(integration/)?contracts' -- ':!.voidbot'
|
||||
if ($LASTEXITCODE -eq 2) {
|
||||
throw "git grep failed"
|
||||
}
|
||||
if ($stray) {
|
||||
Write-Host "Schema files found outside contracts/cultnet/:"
|
||||
$stray | ForEach-Object { Write-Host " $_" }
|
||||
}
|
||||
if ($oldNamespace) {
|
||||
Write-Host "Files still referencing the retired GameCult/cultnet-ts contracts namespace:"
|
||||
$oldNamespace | ForEach-Object { Write-Host " $_" }
|
||||
}
|
||||
if ($stray -or $oldNamespace) {
|
||||
throw "CultNet schema contracts drifted out of contracts/cultnet/ (REC-032). See CultLib/contracts/cultnet/ and rewrite any `$id back to https://github.com/GameCult/CultLib/contracts/cultnet/."
|
||||
}
|
||||
# git grep exits 1 when nothing matches, and the runner's pwsh wrapper
|
||||
# ends the step with that $LASTEXITCODE; a clean tree must pass.
|
||||
exit 0
|
||||
|
||||
- name: Install Node dependencies
|
||||
run: npm install --no-package-lock
|
||||
|
||||
|
|
|
|||
40
.github/workflows/publish-packages.yml
vendored
40
.github/workflows/publish-packages.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
202
.github/workflows/quic-native.yml
vendored
202
.github/workflows/quic-native.yml
vendored
|
|
@ -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
3
.gitignore
vendored
|
|
@ -6,9 +6,6 @@
|
|||
# dotenv files
|
||||
.env
|
||||
|
||||
# Stryker.NET mutation-testing output
|
||||
StrykerOutput/
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
|
|
|
|||
|
|
@ -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=="
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.",
|
||||
"type": ["string", "null"],
|
||||
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported"]
|
||||
},
|
||||
"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.", "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.", "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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
|
|
@ -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"] }
|
||||
}
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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" } ]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,816 +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.
|
||||
|
|
@ -1,883 +0,0 @@
|
|||
# CultNet RUDP: Acknowledged means delivered, one watermark owns ordered delivery, and a write belongs to its session
|
||||
|
||||
Status: cut map, Imagination pass 2, 2026-09-30, committed by Self. Pass 2 re-maps every cut against
|
||||
`main` at `3bf1c0c` and applies the operator rulings below.
|
||||
|
||||
**Rulings (operator, 2026-09-30).**
|
||||
- **Q-A1 (a):** Delivered is derived at the sender from a correct receiver's cumulative watermark. The wire
|
||||
does not change.
|
||||
- **Q-A2:** "I think this is telling us we don't actually need both received and delivered."
|
||||
- There is one public meaning: Acknowledged means delivered.
|
||||
- Receipts go Pending, then Acknowledged (delivered), or Invalidated. No public Received state exists.
|
||||
- Retransmission still stops on each packet's own ack, so resending stays bounded.
|
||||
- Flush waits for Acknowledged.
|
||||
- **Scope added from the stray-packet Soul passes 3 and 4:** session generation. A restarted client on the
|
||||
same endpoint and payload is locked out. A reconnect on the same transport treats a new server session's
|
||||
frames as duplicates. The ending paths disagree about owed writes. One rule: a write belongs to the
|
||||
session it was issued in. Pass 2 maps this as **Cut 1b** (the ending rule) and **Cut 1c** (how a new
|
||||
generation is recognised), separate from Cut 1 (§8 says why).
|
||||
- **Open:** Q-A3 (expiring reliable traffic next to ordered traffic), Q-A4 (porting receipts), Q-A5
|
||||
(Kotlin), and a new **Q-A6** (how a new generation is recognised; blocks Cut 1c only).
|
||||
|
||||
- **Body.** CultLib `main` at `3bf1c0c` ("Merge RUDP stray-packet hardening across all four runtimes").
|
||||
Every `file:line` is against it unless it names another revision.
|
||||
- Since pass 1's base `d85877a7`, only `rudp.rs` and `rudp.ts` moved among the transport files.
|
||||
`CultNetTransport.cs`, `transport.py`, `CultMesh.kt`, `cultmesh-rs`, `cultmesh-ts`, `peer.ts`, the two
|
||||
TS publishers and `docs/cultnet-transport-parity.md` are byte-identical to `d85877a7`.
|
||||
- `rudp.rs` moved +6 lines from `:283` and +12 from `:596`. `rudp.ts` moved only after `:824`.
|
||||
- Odin pins `3bf1c0c` (`Odin crates/odin-core/Cargo.toml:14-17`).
|
||||
- **Base of every cut.** `main` at `3bf1c0c` or later.
|
||||
- **Scope.** The RUDP session and socket transports in all five runtimes: C#
|
||||
`src/GameCult.Networking/CultNetTransport.cs`, Rust `packages/cultnet-rs/src/rudp.rs` (plus
|
||||
`packages/cultmesh-rs`), TypeScript `packages/cultnet-ts/src/rudp.ts` (plus the two hand-rolled
|
||||
publishers), Python `packages/cultnet-py/src/cultnet_py/transport.py`, and Kotlin
|
||||
`packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt`. **Q-A5** decides whether
|
||||
Kotlin is in. Until it is ruled, every cut is mapped for four runtimes and Kotlin trails (§ Sequencing).
|
||||
- **Wire.** This map changes no byte on the wire (§1). Cut 1c changes the default initial sequence, which
|
||||
is a value, not a format.
|
||||
|
||||
|
||||
## The ends (operator, 2026-09-30)
|
||||
|
||||
- A frame the peer has buffered but not delivered must not look delivered. Today an ordered frame that
|
||||
arrives behind a gap is held and acknowledged; if the session ends it is lost, and the sender saw
|
||||
"Acknowledged".
|
||||
- Resending stays bounded. The sender stops retransmitting a packet when the peer acknowledges that packet,
|
||||
exactly as today. Delivery-only acknowledgement was rejected: "I don't like the possibility of unbounded
|
||||
resending on an unresponsive peer application. TCP sounds like it has the right idea."
|
||||
- One public meaning: **Acknowledged means delivered** to the peer's application boundary (§2). Unordered
|
||||
reliable frames are delivered on receipt. Receipts go Pending, then Acknowledged, or Invalidated.
|
||||
- A write belongs to the session it was issued in. When that session ends, a write not yet Acknowledged is
|
||||
Invalidated. It is never carried into, retransmitted in, or acknowledged by a later session.
|
||||
|
||||
|
||||
## The finding that shapes this map
|
||||
|
||||
Self's first proposal put a second acknowledgement on the wire: a per-channel "delivered through N"
|
||||
watermark beside the received ack. Probing the receivers changed the picture in two ways.
|
||||
|
||||
**1. The ordered receivers are broken in every runtime, and the break is bigger than the session-end case.**
|
||||
A held frame is not only lost at session end. In a live session it can be stranded forever, or dropped,
|
||||
while the sender sees it acknowledged. The per-channel `next` pointer (`CultNetTransport.cs:918`,
|
||||
`rudp.rs:196`, `rudp.ts:160`, `transport.py:424`, `CultMesh.kt:2643`) has been patched twice (`436654de`
|
||||
added the first-use initialisation, `4335ab10` added the skip over other channels' sequences) and is still
|
||||
wrong:
|
||||
|
||||
- **P1: a gap filled by another channel strands the ordered channel.** Ordered `o1`, reliable `u` on
|
||||
another channel (lost), `o2`, `o3`. When `u` arrives, only `u`'s channel is drained. `o2` and `o3` are
|
||||
never delivered, and the sender's pending set is empty. The same holds with two ordered channels (P1b).
|
||||
- **P6: a channel first used after other traffic loses frames.** `C1`, `C2` on a new channel, then `X` on
|
||||
another. `X` arrives, then `C2`, then `C1`. The channel initialises its `next` from
|
||||
`min(highest+1, frame)`, so `C2` is delivered first and `C1` is **dropped as old**
|
||||
(`frame.sequence < next`). It was acknowledged.
|
||||
- Both reproduce at `3bf1c0c` in C#, Rust, TS and Python (pass 2), and did at `d85877a7`, Odin's old pin
|
||||
`a8aeddac` and Muninn's pin `c2a9a6e5` (pass 1).
|
||||
|
||||
**2. Once the receiver is correct, "delivered" is not new information.** A conforming receiver holds an
|
||||
ordered frame for exactly one reason: some reliable sequence below it has not arrived. When the last
|
||||
missing sequence arrives, the same `Receive` call drains the held frames, before the acknowledgement is
|
||||
built. So:
|
||||
|
||||
> An ordered frame is delivered exactly when every reliable sequence up to its last one has been
|
||||
> acknowledged.
|
||||
|
||||
The sender already knows that. It is the sender's own **acknowledged-through** watermark: one below the
|
||||
lowest sequence that is still pending, queued or abandoned. TCP's cumulative ACK means the same thing,
|
||||
"every byte below N is in the receive buffer", and it sits beside SACK. Here the sender can compute the
|
||||
cumulative value itself, because every sequence is acknowledged individually and removed from its pending
|
||||
set. The operator ruled this (Q-A1 a), and then ruled that the per-packet ack is not a public meaning at
|
||||
all (Q-A2): it only stops retransmission.
|
||||
|
||||
The derivation is sound only if nothing the receiver acknowledged is later dropped while the session
|
||||
lives, and nothing one session acknowledges is credited to another. The probes found these places where
|
||||
that fails:
|
||||
|
||||
| # | Where an acknowledged frame is lost, or credited to the wrong session | Runtimes | Fixed by |
|
||||
|---|---|---|---|
|
||||
| P1, P1b | A cross-channel gap strands the ordered channel | all five | Cut 1 |
|
||||
| P6 | Late channel initialisation drops the earlier frame | all five | Cut 1 |
|
||||
| P5 | A reliable fragment set is evicted after its fragments were acknowledged | Rust, TS, Python (C# and Kotlin have no eviction) | Cut 2 |
|
||||
| P8 | A retransmit more than 4,096 sequences behind the receiver's highest counts as a duplicate: acknowledged and dropped | all five | Cut 3 (the sender never lets its span reach it) |
|
||||
| G2 | After `connect()` again on the same transport, a new server session's frames are duplicates of the old one's sequences: acknowledged and dropped | C#, Rust, TS, Python | Cut 1c |
|
||||
| G3 | A write left owed when a session ends is retransmitted into the next session and delivered there, and in Rust its receipt reads `Acknowledged` | Rust (local `disconnect()`, timeout); C#, TS, Python (every ending) | Cut 1b |
|
||||
|
||||
And places where the sender loses track, or is locked out, without the receiver's help:
|
||||
|
||||
| # | Sender-side lie or lockout | Runtime | Fixed by |
|
||||
|---|---|---|---|
|
||||
| G1 | A restarted client on the same endpoint and Connect payload gets an Ack, never an Accept, and stays unconnected | Rust hub, C# listener and C# server mode, TS and Python server mode (Rust server mode resets on every Connect instead) | Cut 1c |
|
||||
| G1b | C# and Python server-mode transports drop a Connect from any endpoint but the first, so a client that restarts on a new port is locked out for good | C#, Python | Cut 1c |
|
||||
| P2 | An expired reliable packet leaves a permanent hole. Ordered frames after it are held forever, and `flush_reliable` succeeds (outstanding 0) | Rust (the only runtime with reliable expiry) | Cut 4 reports it honestly; **Q-A3** decides the real fix |
|
||||
| P9 | A receipt for an expiring send reads `Acknowledged` once the packet expires unacknowledged | Rust | Cut 4 |
|
||||
| P10 | `send_reliable` with a full window queues the frame, then returns an error ("receipts require a non-empty reliable packet set"), so a caller that retries sends it twice | Rust | Cut 4 |
|
||||
|
||||
|
||||
## Probe evidence
|
||||
|
||||
Everything ran on Yggdrasil through `ygg-verify.sh`, from local-only probe branches in throwaway
|
||||
worktrees. Each probe commit survives on the Yggdrasil mirror as `refs/verify/<sha>`.
|
||||
|
||||
Pass 1 (base `d85877a7` and the pins; mirror `CultLib-imag-ack`):
|
||||
|
||||
- `de2bdfe8`, `09318d13`, `03eac6ff`: Rust session and socket probes (`packages/cultnet-rs/tests/probe_ack.rs`).
|
||||
- `b70a69ca`, `2320a4e5`, `7fe5d726`: the C#, TS and Python probes (`ProbeAckTests.cs`,
|
||||
`probe-ack.test.ts`, `probe_ack.py`).
|
||||
- `f0e7136c`: the Rust probes at Odin's old pin `a8aeddac`. `bf8fc826`: all four at Muninn's pin `c2a9a6e5`.
|
||||
- `db7d2886`: **the prototype**, the Rust receiver rebuilt on one watermark plus sender-derived receipt
|
||||
states (about +130/−125 in `rudp.rs`). Its `Received`/`Delivered` receipt states are superseded by Q-A2;
|
||||
its receiver is what Cut 1 builds.
|
||||
|
||||
Pass 2 (base `3bf1c0c`; mirror `CultLib-imag-ack2`):
|
||||
|
||||
- `a383e27d`, then `1519a20a` (the C# server-mode G1 probe acknowledges the Accept before the restart, so
|
||||
it tests a restart and not a lost Accept): the pass-1 probe files restored unchanged, plus
|
||||
`packages/cultnet-rs/tests/probe_gen.rs`, `packages/cultnet-ts/test/probe-gen.test.ts`,
|
||||
`packages/cultnet-py/tests/probe_gen.py` and `tests/GameCult.Networking.Tests/ProbeGenTests.cs`.
|
||||
- Rust and C# G1 run over real loopback UDP (hub, listener, server-mode transport), with the restarted
|
||||
client bound to the first client's exact address. Python G1 runs over UDP against the server-mode
|
||||
transport. TS G1 calls `acceptConnect` on the connected session, which is what the socket transport does
|
||||
for a same-endpoint Connect (`rudp.ts:1034-1049` calls it without a reset, and `rudp.ts:249-251` answers
|
||||
it as a repeat). G2 and G3 are session-level: the socket transports' `connect()` is `create_connect` and
|
||||
nothing else (`rudp.rs:1874-1883`, `CultNetTransport.cs:1836-1846`, `rudp.ts:891-897`,
|
||||
`transport.py:994-998`).
|
||||
|
||||
| # | Probe | Pass 1 (`d85877a7`, pins) | Pass 2 (`3bf1c0c`) | Prototype `db7d2886` |
|
||||
|---|---|---|---|---|
|
||||
| P1 | `o1`, reliable `u` lost, `o2`, `o3`; then `u`, then `o3` | Delivered `[o1, u]`; `o2`, `o3` never; sender pending `[]`. Rust, C#, TS, Python; same at both pins | Same, all four | `[o1, u, o2, o3]` |
|
||||
| P1b | `A1`, `B1` (second ordered channel) lost, `A2`, `A3` | `[A1, B1]`, all four | Same, all four | `[A1, B1, A2, A3]` |
|
||||
| P6 | `C1`, `C2` on a new channel, then `X`; arrival `X`, `C2`, `C1` | `[C2]`; `C1` dropped. All four | Same, all four | `[C1, C2, X]` |
|
||||
| P2 | Rust: reliable media with expiry 1 ms, lost; then `s1`, `s2` on `schema` | Nothing delivered. Sender outstanding 0, expired 1, no resends | Same | Unchanged (held behind the hole). Cut 4 makes the sender say so |
|
||||
| P3 | `s1` lost, `s2` arrives and is acknowledged | `s2` not delivered, but acknowledged: sender pending `[s1]` only | Same | Receipt for `s2` stays short of delivered until `s1` arrives |
|
||||
| P4 | Rust: `s0` delivered, `s1` lost, then a 5 MiB ordered frame in 60 kB fragments | `RUDP ordered hold buffer is full`: the session is refused. With no gap, delivered | Same | The prototype held every ordered frame, so a 5 MiB frame was refused **even with no gap**: hold only what is not yet deliverable (Cut 1) |
|
||||
| P5 | 3-fragment reliable frame, 2 fragments acked; 70 lossy fragment sets; last fragment arrives | Rust, TS, Python: never delivered, sender pending `[]` | Same, Rust, TS, Python | Unchanged (Cut 2) |
|
||||
| P7 | An Ack with flag bit `0x10`, a nonzero sequence field, or a 5-byte payload | Decoded and applied in Rust, C#, TS, Python | Same, all four | n/a |
|
||||
| P8 | Reliable `g` lost while 4,200+ later sequences are sent and acked; then `g` arrives | `g` not delivered, and acknowledged. All four | Same, all four | Unchanged (Cut 3) |
|
||||
| P9 | Rust socket: `send_reliable("media")` with expiry 1 ms, never read | `Pending`, then `Acknowledged` | Same | `Pending` |
|
||||
| P10 | Rust socket: 40 `schema` sends, then `send_reliable` | Error "receipts require a non-empty reliable packet set", after the frame was queued | Same (the probe's `?` fails on that error) | Not addressed (Cut 4) |
|
||||
| G1 | Client A connects, sends one ordered frame (acknowledging the Accept), and exits. Client B, a fresh session with the same initial sequence, binds A's exact address and sends the same Connect payload five times | n/a | **Locked out.** Rust hub (payload `"svc"` and empty): 5 Acks, 0 Accepts, B unconnected, the hub still holds A's generation. C# listener: same, no `PeerDisconnected`. C# server mode: 5 Acks. Python server mode: 5 Acks. TS session: the answer is an Ack. Rust server mode (control): B gets an Accept and its frame is delivered, because it resets on every Connect | Not probed |
|
||||
| G1b | C# and Python server mode: a third client on a new port sends a Connect | n/a | No reply; the datagram is counted dropped | Not probed |
|
||||
| G2 | Client connects to server 1 (initial sequence 1) and receives `old1..3`. Server 1 is gone; the client connects again on the same session to server 2 (initial sequence 1), which sends `new1`, `new2` | n/a | **`new1`, `new2` never delivered, and acknowledged** (server 2's pending set empties). C#, Rust, TS, Python | Not probed |
|
||||
| G3 | Client sends one ordered write that is lost; the session ends; the client connects to a new server; the client's resends run | n/a | Owed after the end, and **delivered in the next session**: C#, TS, Python on peer Disconnect, local disconnect and timeout; Rust on local disconnect and timeout. Rust on peer Disconnect: owed 0, nothing delivered (`5f1e883d`) | Not probed |
|
||||
| G3R | Rust socket: `send_reliable`, the write is lost, `disconnect()`, `connect()` on the same transport to a new peer | n/a | Receipt `Pending` after the disconnect; the write is delivered to the new peer; **receipt `Acknowledged`** (the Soul R1 shape) | Not probed |
|
||||
|
||||
**Suites on the prototype `db7d2886`** (pass 1; baseline `03eac6ff` was all green):
|
||||
|
||||
- `cultnet-rs`: 110 + 4 + 24 pass; `tests/cultnet.rs` 51 pass and 2 fail. Both failures are test shape,
|
||||
not mechanism:
|
||||
- `rudp_session_suppresses_duplicates_and_delivers_reliable_ordered_payloads_in_sequence`
|
||||
(`tests/cultnet.rs:1000`) hands the receiver an Accept with sequence 91 from a sender whose data starts
|
||||
at 1. That handshake is impossible. Under a watermark seeded by the Accept, every data sequence is
|
||||
"already below" it. The test must use a consistent handshake.
|
||||
- `rudp_ordered_channel_tracking_is_bounded` (`:2655`) sends 256 ordered channels with no gap. The
|
||||
prototype keeps state only for channels with held frames, so nothing is refused. The bound becomes
|
||||
"channels with held frames", and the test must hold frames behind a gap.
|
||||
- `cultmesh-rs` did not compile only because the prototype added a status variant. Under Q-A2 the status
|
||||
enum keeps its three variants, so that break does not recur.
|
||||
|
||||
**Wire compatibility (P7).** Every decoder ignores flag bits 4-7 and byte 35. Every session ignores an
|
||||
Ack's sequence field and payload (`CultNetTransport.cs:1177`, `rudp.rs:578-592`, `rudp.ts:397`,
|
||||
`transport.py:612`, `CultMesh.kt:2738`). Every decoder rejects an unknown packet type
|
||||
(`CultNetTransport.cs` `Enum.IsDefined`, `rudp.rs` `packet_type_from_code`, `rudp.ts:1313`,
|
||||
`transport.py:1212`, `CultMesh.kt` `fromCode`) and an unknown version byte. So:
|
||||
|
||||
- An additive watermark would fit in an Ack: flag bit 4 set, the value in the Ack's unused sequence field,
|
||||
zero extra bytes. Old peers apply the ack and ignore the rest. Q-A1 ruled it out; it stays the known
|
||||
place to put a FORWARD-TSN notice if Q-A3 is ever B.
|
||||
- A new packet type or a version bump is not additive: old peers refuse the datagram and count it dropped.
|
||||
- No capability negotiation is needed under this map, because nothing on the wire changes.
|
||||
|
||||
|
||||
## Authority map
|
||||
|
||||
- **Owner of ordered delivery (receiver): the session's contiguous received-through watermark `R`.**
|
||||
- Every reliable sequence up to `R` has been received since the peer state was last reset.
|
||||
- A held ordered frame whose first sequence is at most `R + 1` is delivered, in sequence order, in the
|
||||
same call that advanced `R`.
|
||||
- `R` is seeded by the handshake. `AcceptConnect` seeds it with the Connect's sequence, and receiving
|
||||
the Accept seeds it with the Accept's sequence. A session connected without a handshake (Rust
|
||||
`assume_connected`, `rudp.rs:243`, test-only today) seeds it from its first reliable packet.
|
||||
- It advances in `RememberReceived` and resets with the rest of the peer state (`ResetPeerState`).
|
||||
- **Owner of retransmission (sender): the pending and queued sets**, as today. A sequence leaves them when
|
||||
an ack names it. Retransmission stops there. This is not a public meaning.
|
||||
- **Owner of Acknowledged (sender): the acknowledged-through watermark `A`**, one below the lowest sequence
|
||||
that is pending, queued or abandoned in the current generation. An ordered send is Acknowledged when `A`
|
||||
reaches its last sequence. A reliable unordered send is Acknowledged when every one of its sequences is
|
||||
acked.
|
||||
- **Owner of the session boundary: the session's generation.** One primitive ends a generation: the
|
||||
session stops being connected, every write not yet Acknowledged is Invalidated and dropped from pending
|
||||
and queued, and the generation counter advances. Every ending path calls it: a peer's Disconnect, a
|
||||
local disconnect, a timeout and a refused packet (Cut 1b), and from Cut 1c a client's `connect()` on a
|
||||
session that already had a peer, and a server accepting a Connect that does not repeat the current one.
|
||||
- What was learned from the peer (`R`, the received set, held frames) is forgotten only where both sides
|
||||
start fresh: a refusal (as today) and, from Cut 1c, a non-repeated handshake. Forgetting it at a
|
||||
one-sided ending (a timeout the peer did not see) would let the peer's retransmits be delivered twice.
|
||||
- **Owner of "is this Connect a repeat": the session**, from the Connect's sequence (Cut 1c; Q-A6).
|
||||
- **Inputs.** Received sequences and handshake packets (receiver); acks, expiry and ending events
|
||||
(sender). Nothing else.
|
||||
- **Outputs.** Delivered frames, in order (receiver). Receipt status and flush completion (sender).
|
||||
- **Derived, not owners.**
|
||||
- The per-channel `next` map is **deleted**, not demoted: `_orderedNextSequenceByChannel`
|
||||
(`CultNetTransport.cs:918`), `ordered_next_sequence_by_channel` (`rudp.rs:196`),
|
||||
`#orderedNextSequenceByChannel` (`rudp.ts:160`), `_ordered_next_sequence_by_channel`
|
||||
(`transport.py:424`), `orderedNextSequenceByChannel` (`CultMesh.kt:2643`).
|
||||
- `expectedSequenceIfUninitialized` and its equivalents are deleted (`CultNetTransport.cs:1151`,
|
||||
`rudp.rs:540`, `rudp.ts:375`, `transport.py:595`, `CultMesh.kt:2728`).
|
||||
- `PendingOrderedFrame.NextSequence` is deleted (`rudp.rs:160-163` and equivalents).
|
||||
- `OutstandingReliablePacketCount` stays, and means "not yet acked in this generation". It is a
|
||||
retransmission count, not a delivery claim.
|
||||
- Rust `session_scope: Uuid` (`rudp.rs:135`, `:179`, `:206`, rotated at `:345` and `:601`) is no longer an
|
||||
owner. It is replaced by the generation (Cut 1b) and then by receipts resolved at their source
|
||||
(Cut 4).
|
||||
- TS `#generation`, `#endedGeneration` and `#endGeneration` in the socket transport (`rudp.ts:830-831`,
|
||||
`:937-940`) are no longer owners; the session's generation replaces them.
|
||||
- `DisconnectReason` / `disconnect_reason` / `#endedReason` are report-only: why the last generation
|
||||
ended. They no longer gate flush.
|
||||
- **Forbidden writers.**
|
||||
- No path delivers an ordered frame except the drain after `R` advances.
|
||||
- No receive path drops a frame whose sequence it acknowledged while the generation lives.
|
||||
- No sender path reports Acknowledged except through `A` (or the unordered rule).
|
||||
- No ending path leaves a write owed. No path retransmits a write into a generation after the one it was
|
||||
issued in.
|
||||
- No transport decides whether a Connect repeats: not by payload (Rust hub `rudp.rs:1637-1647`, C#
|
||||
listener `CultNetTransport.cs:2670-2688`), not by `Connected` (C# `:2226-2236`, Python
|
||||
`transport.py:1047-1053`, TS `rudp.ts:249-251`, `:1031-1049`), and not by resetting on every Connect
|
||||
(Rust server mode `rudp.rs:2105-2116`).
|
||||
- **Shared paths.** Accept, reliable data, fragments, duplicates and retransmits all advance `R` through
|
||||
`RememberReceived` and then call the drain. Direct sends, fragmented sends, queued promotion and resends
|
||||
all feed the pending and queued sets that `A` reads. Every ending path goes through the one generation
|
||||
primitive.
|
||||
- **Deletion line.** `DeliverOrdered`, `DrainOrdered` and `SkipReceivedNonChannelSequences` and their
|
||||
equivalents go before the drain is added: `CultNetTransport.cs:1628-1700`, `rudp.rs:1138-1246`,
|
||||
`rudp.ts:737-800`, `transport.py:868-920`, `CultMesh.kt:2939-2978`. The transport-level repeat decisions
|
||||
above go before the session's is added.
|
||||
|
||||
|
||||
## The design, question by question
|
||||
|
||||
### 1. Where Acknowledged comes from (Q-A1 a, ruled)
|
||||
|
||||
- Derived at the sender from `A` (§ The finding). No wire change.
|
||||
- **Why it is sound after Cuts 1-3.** A conforming receiver:
|
||||
- drops nothing it acknowledged while the session lives (P1, P6: Cut 1; P5: Cut 2; P8: made unreachable
|
||||
by Cut 3);
|
||||
- holds an ordered frame only while a sequence below it is missing (Cut 1);
|
||||
- drains held frames in the call that fills the last gap, before it acknowledges (Cut 1).
|
||||
- So every sequence up to `s` acked means `s` was delivered.
|
||||
- **And after Cuts 1b and 1c,** an ack can only be credited to the generation that issued the write
|
||||
(G3, G2).
|
||||
- **Mixed versions.** A new sender with an old receiver can be wrong only when that receiver strands a
|
||||
frame (P1, P6). That needs reliable traffic on more than one channel of one session. The C#, TS, Python
|
||||
and Kotlin socket transports send reliably only on `schema` (`CultNetTransport.cs:2372-2376`,
|
||||
`rudp.ts:1254-1262`), so they cannot trigger it. Rust sends reliable `media`/`audio` too
|
||||
(`rudp.rs:2523-2562` `channel_send_options`). The pass-1 Eyes survey found no live session that mixes
|
||||
those with `schema`: Muninn's media session carries no `schema` traffic, and neither does Sleipnir's HID
|
||||
stream. The old receiver loses such frames today in any case. Bumping the pin is the fix.
|
||||
- **Every Odin-bound publisher's receiver is Odin.** Acknowledged means delivered for Idunn health,
|
||||
presence and catalog publishing only once Odin pins a CultLib with Cuts 1-3.
|
||||
|
||||
### 2. What "delivered to the application" means
|
||||
|
||||
- **Delivered means the frame left the session's hold and was handed to the transport's application
|
||||
boundary.** That boundary is:
|
||||
- C#: enqueued in the socket transport's `_deliveredFrames` (read by `TryReceiveOnce`), or returned in
|
||||
`CultNetRudpReceiveResult.Delivered` from a bare session;
|
||||
- Rust: pushed to `delivered_frames` (`rudp.rs:2147`) or the hub's `pending_events`, or handed to the
|
||||
document server's sink (`cultmesh-rs/src/rudp_document_server.rs:343-360`), which acknowledges only
|
||||
after the sink accepted;
|
||||
- TS: the `"frame"` event was emitted (`rudp.ts:1076-1082`);
|
||||
- Python and Kotlin: the delivered queue.
|
||||
- **It is not "consumed".** The operator rejected application-driven acknowledgement, and TCP's cumulative
|
||||
ACK has the same boundary: in the receive buffer, readable, not read.
|
||||
- A listener or sink that throws or rejects has still been delivered to. The handler-rejection paths from
|
||||
the stray-packet work end the session, which is the application's answer, not the transport's.
|
||||
|
||||
### 3. The public API (Q-A2, ruled: one meaning)
|
||||
|
||||
- **Rust receipts** (the only runtime that has them):
|
||||
- `CultNetRudpReliableSendStatus` keeps its three variants, `Pending`, `Acknowledged`, `Invalidated`
|
||||
(`rudp.rs:146-150`). No variant is added, removed or renamed. `Acknowledged` changes meaning from
|
||||
"every packet was acked" to "delivered".
|
||||
- The visible difference: an ordered send behind a missing sequence reads `Pending` until the gap
|
||||
fills (P3), where today it reads `Acknowledged`.
|
||||
- Every external caller already means delivered, so none changes code: `cultmesh-rs/src/lib.rs:832`
|
||||
and `:892`, `tests/rudp_stray_packets.rs:309`, `cultmesh-rs/tests/rudp_document_server.rs:408,415`,
|
||||
CodexConnector `src/idunn_health.rs:727-745`, Ghostlight
|
||||
`crates/ghostlight-dungeon/src/idunn_health.rs:1126-1145`.
|
||||
- **A receipt is resolved once, by its session.** It becomes `Acknowledged` when `A` passes its last
|
||||
sequence (ordered) or when all its sequences are acked (unordered). It becomes `Invalidated` when its
|
||||
generation ends first. After that it never changes.
|
||||
- Mechanism: the receipt carries its sequences, whether it was ordered, and a shared status cell
|
||||
(`Arc<AtomicU8>`). The session keeps the unresolved cells, at most one per outstanding send, and
|
||||
writes each once. `reliable_send_status(&receipt)` (`rudp.rs:1931-1936`) reads the cell; its
|
||||
signature does not change.
|
||||
- This deletes the session scope (`rudp.rs:135`, `:179`, `:206`, `:283-285`, `:291-293`, `:345`,
|
||||
`:601`) and needs no history of ended generations. Today a delivered write reads `Invalidated` after
|
||||
any reset, because the scope rotates (`rudp.rs:291-293`). The pass-1 design needed a frozen snapshot
|
||||
of one ended epoch and still misreported older ones.
|
||||
- `PartialEq` on the receipt compares its sequences, which are unique within a session object because
|
||||
issued sequences survive resets.
|
||||
- `send_reliable` receipts cover queued packets (P10). The receipt lists every sequence the send
|
||||
assigned, not only the ones admitted to the wire (`send_many`, `rudp.rs:456-528`, returns only the
|
||||
admitted ones; `non_expiring_reliable_receipt`, `:305-318`, refuses an empty set).
|
||||
- `send_reliable` refuses a channel with reliable expiry (P9). The comment at `rudp.rs:1915-1916` says
|
||||
this lineage has no expiry, which is false since `bac5be88`. Restore the guard it describes.
|
||||
- New: `CultNetRudpSocketTransportConnection::wait_acknowledged(&receipt, timeout) -> Result<()>`. It is
|
||||
the ack wait: it pumps receive and resends like `flush_reliable` and returns when the receipt is
|
||||
`Acknowledged`. It fails at once when the receipt is `Invalidated`, naming the end reason. It replaces
|
||||
the hand-rolled loops at `cultmesh-rs/src/lib.rs:806-838` and `:870-900`; CodexConnector and
|
||||
Ghostlight may adopt it at their next bump, and their existing polling stays correct without it.
|
||||
- **Flush: one wait, and it means every send of this generation is Acknowledged.**
|
||||
- A flush belongs to the generation it started in. It fails when that generation ends, whatever comes
|
||||
after. Rust binds it to the scope today (`rudp.rs:2001-2014`) and TS to its transport generation
|
||||
(`rudp.ts:911-935`). C# (`CultNetTransport.cs:1914-1931`) and Python (`transport.py:1101-1124`) check
|
||||
only the disconnect reason, which the next Connect clears (`CultNetTransport.cs:1843`, `:2235`;
|
||||
`transport.py:997`, `:1054`). Once Cut 1b drops owed writes at every ending, "outstanding is 0" after a
|
||||
reconnect would read as success, so every runtime binds flush to the session's generation in Cut 1b.
|
||||
- When every sent sequence is acked, `A` is the highest sequence sent, so every send is Acknowledged.
|
||||
The only exception is behind an abandoned sequence (Rust expiry). There `flush_reliable`
|
||||
(`rudp.rs:1992-2040`) fails at once when an ordered send follows the generation's first abandoned
|
||||
sequence: that send can never be Acknowledged, and today flush reports success (P2).
|
||||
- C# `FlushReliable`, TS `flush`, Python `flush_reliable` and Kotlin `flushReliable`
|
||||
(`CultMesh.kt:3186`) keep their success condition. Their contract text becomes "every reliable frame
|
||||
sent in this session has been delivered to the peer's application". These runtimes have no expiry.
|
||||
- **Defaults.** Plain flush. No parameter. No second flush.
|
||||
- **Compatibility.** No signature changes in any runtime except additions (`wait_acknowledged`; TS
|
||||
`connectAndWait`, §4). The Rust enum is unchanged. No downstream caller has to change code at its next
|
||||
bump.
|
||||
|
||||
### 4. Consumers, each assigned
|
||||
|
||||
Surveyed across `F:\Projects` in pass 1; the rows that moved are re-checked at the revision named. Every
|
||||
consumer that waits wants delivered, which is now just Acknowledged.
|
||||
|
||||
| Consumer | What it waits on today | Change |
|
||||
|---|---|---|
|
||||
| `cultmesh-rs/src/lib.rs:781-838` `request_raw_snapshot_from_rudp_catalog` (Idunn `src/drivers.rs:5527` uses it) | a hand-rolled loop until `Acknowledged`, then the response | Cut 4: `wait_acknowledged` |
|
||||
| `cultmesh-rs/src/lib.rs:870-900` `publish_cultnet_message_to_rudp_catalog` (Odin `odin-daemon`) | a hand-rolled loop until `Acknowledged`, then disconnect. The doc at `:869` claims "application admission" | Cut 4: `wait_acknowledged`; the doc says delivered to the document server's sink (`rudp_document_server.rs:343-372`) |
|
||||
| `cultmesh-rs/src/lib.rs:918-938` `publish_cultnet_messages_to_rudp_catalog` (Ghostlight `mesh.rs:323`) | `flush_reliable`, then drop. **Several ordered frames, then close** | None; flush now means it |
|
||||
| `cultmesh-ts/src/index.ts:6070-6098` `publishRudpDocumentOnce` (Heimdall, StreamPixels, Stonks, weksa, Bifrost, VoidBot, Hermodr) | `peer.flush` (`cultnet-ts/src/peer.ts:109-110`), then close | None. Cut 5 moves its connect-and-wait helper (`index.ts:6603-6650`) into cultnet-ts |
|
||||
| `cultnet-ts/src/idunn-odin-presence-publisher.ts:89-215` `publishDocument`, behind `createIdunnRuntimePresencePublisher`. **StreamPixels' service runs this**: `apps/service/scripts/idunn-entry.mjs` calls `createIdunnRuntimePresencePublisher` from its vendored CultLib `30ee8b9`, where the file is byte-identical to `3bf1c0c` | a hand-rolled session loop (`:186-208`) until `outstandingReliablePacketCount === 0`. It does resend (`:205`). It drops `readyToSend` (`:198`), so promoted queued packets wait for the resend timer. It pins `initialSequence: 1` (`:103`) and never sends a Disconnect, so Odin's hub keeps each publish's peer until it times out (each publish binds a new port, so each is a new peer) | Cut 5: the socket transport, `connectAndWait`, `send("schema")`, `flush`, `close` (which sends the Disconnect, `rudp.ts:956-969`) |
|
||||
| `cultnet-ts/src/signed-daemon-health.ts:104-162` `publishSignedDaemonHealth` (Hermodr, Odin `idunn-rudp.cjs`) | a hand-rolled loop: sends the packets `sendMany` admitted, waits up to 500 ms for the **first** Ack packet of any content (`:146-153`), swallows the timeout, closes. Resends run inside that wait (`:303`) but promoted queued packets are never sent | Cut 5, as above. Today a write not acked within 500 ms, or any payload over 32 packets, is reported published |
|
||||
| `cultnet-ts/src/operation-service.ts` | the operation response | none (the response is the proof) |
|
||||
| C# `FlushReliable` callers: Gjallar `Program.cs:3055-3080` (two ordered frames, flush, dispose) | flush | None |
|
||||
| Heimdall `src/idunn-rudp-health.ts:55-87` (vendored CultLib `b6b1d9c`, Heimdall `d5acc94`) | the first Ack packet | Heimdall's own cut at its bump: call cultnet-ts's publisher after Cut 5 |
|
||||
| CodexConnector `idunn_health.rs:727-745`, Ghostlight `idunn_health.rs:1126-1145` | `Acknowledged` | None required. Optional `wait_acknowledged` at their next bump |
|
||||
| Muninn `main.rs:9446-9458`, Sleipnir `main.rs:880-904` | nothing (send and drop) | Follow-up: fire-and-forget health can be silently lost |
|
||||
| Kotlin `flushReliable` | no callers | Contract text only |
|
||||
|
||||
### 5. The hold buffer and the 4 MiB cap
|
||||
|
||||
- **It belongs here**, because the held frame is exactly the state this map names.
|
||||
- **Mechanism (P4).** Rust caps held ordered frames at 1,024 frames and 4 MiB (`rudp.rs:47-48`, checked
|
||||
at `:1181-1187`). A refusal ends the session. The default payload limit is 16 MiB (`rudp.rs:33`). With
|
||||
one lost packet under a large ordered transfer, a legal 5 MiB frame is refused and kills the session.
|
||||
- The sender's window counts pending packets, not span or bytes (`rudp.rs:910-942`). While one packet
|
||||
stays lost, acked packets keep freeing slots, so the held data behind the gap grows without limit.
|
||||
- **Fix: a sender-side flow window, TCP's receive window without a wire field** (Cut 3). A reliable
|
||||
packet is admitted to the wire only while:
|
||||
- its sequence is at most 1,023 above the lowest unacked sequence; and
|
||||
- the payload bytes of sequences above the lowest unacked one stay within 4 MiB, with at least one
|
||||
packet always admissible.
|
||||
- Otherwise it waits in the existing queue.
|
||||
- **What this buys:**
|
||||
- a conforming sender never makes a Rust receiver hold more than 4 MiB, so the cap stops refusing legal
|
||||
traffic;
|
||||
- a conforming sender never gets 1,024 sequences ahead of the receiver's highest, so the Rust far-ahead
|
||||
refusal (`rudp.rs:46`, checked at `:626-630`) never drops its packets;
|
||||
- a conforming sender never leaves a sequence 4,096 behind the receiver's highest, so P8 cannot happen.
|
||||
The receive-history window stays 4,096 in every runtime (`CultNetTransport.cs:884`, `rudp.rs:35`,
|
||||
`rudp.ts:20`, `transport.py:404`, `CultMesh.kt:2625`).
|
||||
- **What is kept.** The cap and the far-ahead refusal stay. They protect against hostile peers.
|
||||
- **Rejected:** raising the cap to the payload limit or above (16-64 MiB per hostile session instead of
|
||||
4); lowering the ordered payload limit to 4 MiB (an API regression for large snapshots).
|
||||
- **Cost.** Normal flow never has a span beyond the 32-packet window, so the new limits bind only while a
|
||||
packet is lost.
|
||||
- **Old senders** (the pinned and vendored ones) can still trip the Rust cap until they bump.
|
||||
- **Not in scope.** C#, TS, Python and Kotlin receivers have no hold cap. Follow-up.
|
||||
|
||||
### 6. Reliable expiry (P2)
|
||||
|
||||
- Only Rust has it (`rudp.rs:39`, `:898-908`). An expired packet's sequence never reaches the receiver.
|
||||
`R` stops below it, and so did the old `next` pointer. Ordered frames after it are held for the rest of
|
||||
the session.
|
||||
- **Today's users.** Only Muninn's reliable `audio`, and its session carries no ordered traffic.
|
||||
`hands/media-fec-cut3` (unmerged, `4b85a173`) deletes the `audio` profile entry and the `"audio"` send
|
||||
arm. The `media` channel keeps a caller-chosen reliable-with-expiry mode.
|
||||
- **This map makes it honest (Cut 4):**
|
||||
- the sender records its generation's first abandoned sequence;
|
||||
- `A` never passes it;
|
||||
- ordered receipts after it stay `Pending`, and become `Invalidated` when the generation ends;
|
||||
- `flush_reliable` fails at once.
|
||||
- **The real fix is a fork (Q-A3).** The literature's answer is PR-SCTP's FORWARD-TSN (RFC 3758): the
|
||||
sender tells the receiver to move its cumulative point past abandoned sequences.
|
||||
|
||||
### 7. Interaction with the merged stray-packet work
|
||||
|
||||
The ack cuts build on these behaviours and do not alter them:
|
||||
|
||||
- A refusing session ends, and its goodbye is built after the reset (`EndRefused`
|
||||
`CultNetTransport.cs:1315-1319`, `end_refused_session` `rudp.rs:364-367`). The refused sequence is never
|
||||
acked, so a refusal never becomes a false Acknowledged.
|
||||
- Issued sequences survive a reset in every runtime (`rudp.rs:341-343`, `CultNetTransport.cs:1292-1308`,
|
||||
`rudp.ts:214-227`, `transport.py:458-471`). Cut 1c relies on it: a client's second Connect always
|
||||
carries a new sequence.
|
||||
- One Accept per generation, and a repeated Connect answered with the Accept still owed, or an Ack. Cut 1c
|
||||
keeps the answer and moves the decision of what repeats into the session.
|
||||
- An ended session fails its flush. Cut 1b generalises the TS and Rust binding to the generation into all
|
||||
four runtimes.
|
||||
- `5f1e883d` made a peer's Disconnect end Rust receipts (`rudp.rs:596-615`) and removed the
|
||||
transport-level Pending→Invalidated override. Cut 1b makes the other ending paths agree with it.
|
||||
- The TS operation service's expiry and handler rejection are above the transport and do not change.
|
||||
|
||||
### 8. Session generation: why it is two cuts, and how a new one is recognised
|
||||
|
||||
- **Why not inside Cut 1.** Cut 1 is a receiver change with a deletion line: one watermark replaces five
|
||||
per-channel state machines. Its falsifiers are P1, P1b and P6. The generation work touches the ending
|
||||
paths, the handshake, flush and receipts, and its falsifiers are G1-G3. Both touch `ResetPeerState` and
|
||||
the Accept path, which is a merge cost, not a shared owner. Keeping them apart lets Soul falsify each
|
||||
against its own probes.
|
||||
- **Why two cuts.** The ending rule (Cut 1b) is ruled and needs no mechanism choice: every ending path
|
||||
drops what it owed. How a new generation is recognised (Cut 1c) needs one (Q-A6). The two are separable
|
||||
because 1b does not forget peer state: the peer may not know the session ended, and forgetting what was
|
||||
received would let its retransmits be delivered twice.
|
||||
- **What must hold after both.** A write is Acknowledged or Invalidated within the generation it was
|
||||
issued in. A restarted client is admitted. A new server session's frames are delivered after a
|
||||
reconnect. A retransmitted Connect is still a repeat.
|
||||
- **Recognising a new generation (Cut 1c, Q-A6 recommendation A).** The Connect's sequence identifies the
|
||||
generation, and the initial sequence is random by default, as TCP's initial sequence number is
|
||||
(RFC 9293 §3.4.1, RFC 6528).
|
||||
- A Connect repeats exactly when it carries the sequence of the Connect that started the current
|
||||
generation. Its retransmits do; a client's second `connect()` cannot, because issued sequences
|
||||
survive resets; a restarted process almost never does, because it drew a fresh random initial
|
||||
sequence.
|
||||
- A stale duplicate of the accepted Connect, delayed in the network, still repeats: same sequence.
|
||||
- Anything else ends the current generation and starts a new one, at both ends: the client forgets peer
|
||||
state when it sends a Connect on a session that had a peer, and the server when it accepts a Connect
|
||||
that does not repeat.
|
||||
- A client honours an Accept only while its Connect is pending, and only if the Accept's ack field names
|
||||
that Connect. A stale Accept from an earlier generation cannot seed `R`.
|
||||
- The initial sequence is drawn from [1, 2^31), which leaves at least 2^31 sequences before the
|
||||
exhaustion error (`rudp.rs:944-962`) in every runtime. An explicit `initial_sequence` still works, for
|
||||
tests.
|
||||
- The Connect payload stays application evidence (the provider layer's `clientSessionId`,
|
||||
`cultnet-rs/src/provider_session.rs:70-79`), carried to the application as today. It no longer decides
|
||||
anything in the transport.
|
||||
|
||||
|
||||
## Cuts
|
||||
|
||||
Every cut is on CultLib, branch `hands/rudp-delivered` from `main` at `3bf1c0c` or later, in a worktree
|
||||
Self creates. Heavy verification runs on Yggdrasil. Each cut starts by committing its probes as failing
|
||||
tests named for the rule, in every runtime it touches. The pass-1 and pass-2 probe files
|
||||
(`refs/verify/1519a20a` on the `CultLib-imag-ack2` mirror) are the starting point; they print, so each
|
||||
must become an asserting test.
|
||||
|
||||
Kotlin: every cut below is mapped for C#, Rust, TS and Python. If Q-A5 is A, a trailing Cut K applies
|
||||
Cuts 1, 1b, 1c and 3 to `CultMesh.kt` at the anchors given, on the verification host Q-A5 picks.
|
||||
|
||||
**Negative checks that stay green in every cut:**
|
||||
|
||||
- the cross-runtime reorder, drop and fragment proofs in the TS interop harness
|
||||
(`docs/cultnet-transport-parity.md:227-248`);
|
||||
- the stray-packet tests (`packages/cultnet-rs/tests/rudp_stray_packets.rs`, and their C#, TS and Python
|
||||
equivalents), except the ones each cut names as encoding the old rule;
|
||||
- the codec fixture vectors: no wire byte changes.
|
||||
|
||||
### Cut 1. One watermark owns ordered delivery (receiver) — ready
|
||||
|
||||
- **First.** Failing tests P1, P1b and P6 in each runtime.
|
||||
- **Deletes first:**
|
||||
- the per-channel `next` map and its resets: `CultNetTransport.cs:918`, `:1305`; `rudp.rs:196`, `:223`,
|
||||
`:354`; `rudp.ts:160`, `:223`; `transport.py:424`, `:468`; `CultMesh.kt:2643`;
|
||||
- `expectedSequenceIfUninitialized`: `CultNetTransport.cs:1151-1153`, `rudp.rs:540-543` (used at
|
||||
`:679`), `rudp.ts:375-377` (used at `:450`), `transport.py:595-599`, `CultMesh.kt:2728`;
|
||||
- `DeliverOrdered`, `DrainOrdered` and `SkipReceivedNonChannelSequences`: `CultNetTransport.cs:1628-1700`,
|
||||
`rudp.rs:1138-1246`, `rudp.ts:737-800`, `transport.py:868-920`, `CultMesh.kt:2939-2978`;
|
||||
- `PendingOrderedFrame.NextSequence` (`rudp.rs:160-163` and equivalents).
|
||||
- **Adds:**
|
||||
- `R`, seeded in `AcceptConnect` (`CultNetTransport.cs:1012`, `rudp.rs:383`, `rudp.ts:242`,
|
||||
`transport.py:485`, `CultMesh.kt:2667`) and on Accept receipt (`CultNetTransport.cs:1155`,
|
||||
`rudp.rs:544`, `rudp.ts:379`, `transport.py:601`, `CultMesh.kt:2730`);
|
||||
- `R` advances in `RememberReceived` (`CultNetTransport.cs:1518`, `rudp.rs:973`, `rudp.ts:619`,
|
||||
`transport.py:809`, `CultMesh.kt:2891`) and resets in `ResetPeerState`;
|
||||
- a reassembled ordered frame is delivered at once when its first sequence is at most `R + 1` and its
|
||||
channel holds nothing below it. Otherwise it is held. The caps apply only to what is held: the
|
||||
prototype held everything and so refused a 5 MiB frame with no gap;
|
||||
- after every reliable receipt (data, fragment, duplicate, Accept), drain every channel's held frames
|
||||
whose first sequence is at most `R + 1`, in sequence order;
|
||||
- Rust only: the 64-channel cap (`rudp.rs:42`) counts channels with held frames.
|
||||
- **Tests changed:** `packages/cultnet-rs/tests/cultnet.rs:1000` (consistent handshake) and `:2655`
|
||||
(hold frames behind a gap). The equivalents in the other runtimes are whatever the change breaks. Each
|
||||
must be read before it is edited, because a test that encodes `min(highest+1, frame)` encodes P6.
|
||||
- **Verification:**
|
||||
- P1, P1b and P6 pass in every runtime;
|
||||
- the full suites: `cargo test` in `cultnet-rs` and `cultmesh-rs`, `dotnet test
|
||||
tests/GameCult.Networking.Tests`, `npm test` in `cultnet-ts`, the Python suite;
|
||||
- the TS interop harness across runtimes;
|
||||
- negative grep: `rg -n "orderedNextSequence|ordered_next_sequence|expectedSequenceIfUninitialized|expected_sequence_if_uninitialized|SkipReceivedNonChannel|skip_received_non_channel" src packages`
|
||||
returns nothing;
|
||||
- mutation testing on the diff: cargo-mutants, Stryker.NET, StrykerJS, and mutmut for Python.
|
||||
- **Ledger.** Per runtime about −70 and +40. Across four, about −280 and +160.
|
||||
|
||||
### Cut 1b. A write belongs to the session it was issued in: every ending drops what it owed — ready
|
||||
|
||||
- **First.** Failing tests G3 (each ending path: peer Disconnect, local disconnect, timeout, refusal) in
|
||||
C#, TS and Python, and local disconnect and timeout in Rust; G3R in Rust; and in every runtime, a flush
|
||||
started before a timeout or disconnect fails even after a later `connect()` succeeds.
|
||||
- **The owner.** Each session gets a generation counter and one private primitive that ends the current
|
||||
generation: not connected; pending and queued dropped; generation advanced. In Rust it also invalidates
|
||||
the generation's receipts (by the scope today; Cut 4 moves that into the receipts). It does not touch
|
||||
what was received from the peer (§8).
|
||||
- **Deletes first:**
|
||||
- Rust: the inline clearing in the Disconnect branch (`rudp.rs:596-615`) and the scope rotation in
|
||||
`reset_peer_state` (`:345`), both replaced by the primitive; the receipt doc that says local endings
|
||||
carry writes (`rudp.rs:128-133`); the flush's scope capture (`:2001-2014`), replaced by the generation;
|
||||
- TS: the transport's `#generation`, `#endedGeneration`, `#endGeneration` (`rudp.ts:826-831`,
|
||||
`:937-940`) and their uses in `connect` (`:895`), `flush` (`:913-920`), `checkTimeout` (`:949`),
|
||||
`#endRefusedSession` (`:976`), the server Connect path (`:1037-1046`) and the disconnect result
|
||||
(`:1084`). `#endedReason` stays, as the report of why;
|
||||
- C# and Python: flush's reliance on the disconnect reason (`CultNetTransport.cs:1924-1929`,
|
||||
`transport.py:1110-1116`). The reason stays, as a report.
|
||||
- **Adds:**
|
||||
- the primitive, called from every ending path:
|
||||
- peer Disconnect: `CultNetTransport.cs:1190-1199`, `rudp.rs:596-615`, `rudp.ts:406-414`,
|
||||
`transport.py:619-625`;
|
||||
- local disconnect: `CreateDisconnect` `CultNetTransport.cs:1324-1328`, `create_disconnect`
|
||||
`rudp.rs:771-781`, `createDisconnect` `rudp.ts:508-515`, `create_disconnect` `transport.py:695-697`;
|
||||
- timeout, when it fires: `CultNetTransport.cs:1333-1348`, `rudp.rs:783-796`, `rudp.ts:517-526`,
|
||||
`transport.py:699-706`;
|
||||
- refusal, through `ResetPeerState` (`CultNetTransport.cs:1292-1308`, `rudp.rs:344-358`,
|
||||
`rudp.ts:214-227`, `transport.py:458-471`);
|
||||
- a read-only generation on each session, which each transport's flush captures at start and fails on
|
||||
when it changes (`CultNetTransport.cs:1914-1945`, `rudp.rs:1992-2040`, `rudp.ts:911-935`,
|
||||
`transport.py:1101-1124`).
|
||||
- **Hubs.** The Rust hub and the C# listener already discard a peer's session at its end, so its owed
|
||||
writes die with it. No change.
|
||||
- **Tests changed:** `tests/rudp_stray_packets.rs:638-690` keeps passing unchanged (peer Disconnect already
|
||||
obeys the rule). Tests that expect a write to survive a local disconnect or timeout encode the old rule;
|
||||
read each before editing.
|
||||
- **Verification:**
|
||||
- G3 in every runtime: after every ending path, owed 0 and nothing delivered to the next session;
|
||||
- G3R: the receipt reads `Invalidated` after `disconnect()`, and stays so after reconnecting;
|
||||
- the flush test above in every runtime;
|
||||
- negative: `rg -n "endedGeneration|#endGeneration" packages/cultnet-ts/src` returns nothing; a TS
|
||||
reconnect from inside a `disconnect` listener still cannot make an ended flush succeed (the case the
|
||||
TS generation pair was written for, `rudp.ts:826-829`);
|
||||
- mutation testing on the diff.
|
||||
- **Ledger.** About −40 and +45 across four runtimes. TS is net negative.
|
||||
|
||||
### Cut 1c. A Connect's sequence says whether it repeats — blocked on Q-A6
|
||||
|
||||
Mapped for Q-A6's recommended answer A. Blocked on Cut 1b (it uses the ending primitive).
|
||||
|
||||
- **First.** Failing tests G1 (the hub, the C# listener, and server mode in every runtime: a restarted
|
||||
client on the same address and payload is admitted and its frames delivered), G1b (C# and Python server
|
||||
mode admit a Connect from a new endpoint), G2 (after `connect()` on the same session, a new server
|
||||
session's frames are delivered), and a stale Accept (its ack field does not name the pending Connect) is
|
||||
ignored.
|
||||
- **Deletes first:**
|
||||
- the transport-level repeat decisions: Rust server mode's reset on every Connect
|
||||
(`rudp.rs:2105-2116`); the Rust hub's payload comparison (`rudp.rs:1637-1647`); C# server mode's
|
||||
`_session.Connected` branch (`CultNetTransport.cs:2226-2236`); the C# listener's payload comparison
|
||||
(`CultNetTransport.cs:2670-2688`); TS's `movedEndpoint`/`repeated` computation (`rudp.ts:1011-1049`)
|
||||
and the session's own `connected` shortcut (`rudp.ts:249-251`); Python's `connected` branch
|
||||
(`transport.py:1047-1053`);
|
||||
- C# and Python server mode's refusal of a Connect from a new endpoint (`CultNetTransport.cs:2212-2216`,
|
||||
`transport.py:1042-1044`), for Connect packets only. Rust and TS already admit it.
|
||||
- **Adds:**
|
||||
- the session records the sequence of the Connect that started its generation, and decides: a Connect
|
||||
repeats exactly when it is connected and the Connect carries that sequence. `AcceptConnect` answers a
|
||||
repeat as `AnswerRepeatedConnect` does today, and otherwise ends the generation, resets peer state and
|
||||
accepts (`CultNetTransport.cs:1012-1059`, `rudp.rs:383-435`, `rudp.ts:242-290`,
|
||||
`transport.py:485-525`). `AnswerRepeatedConnect` stops being public; a public predicate
|
||||
(`ConnectRepeats(packet)`) serves the hubs, which keep building a fresh session for a new generation and
|
||||
emit Disconnected for the old one (`rudp.rs:1648-1690`, `CultNetTransport.cs:2690-2710`);
|
||||
- `CreateConnect` on a session that has had a peer ends the generation and resets peer state first
|
||||
(`CultNetTransport.cs:1001-1010`, `rudp.rs:369-381`, `rudp.ts:229-240`, `transport.py:473-483`);
|
||||
- on Accept receipt, the session honours it only while its Connect is pending and only if the Accept's
|
||||
ack field names that Connect (`CultNetTransport.cs:1155-1160`, `rudp.rs:544-557`, `rudp.ts:379-390`,
|
||||
`transport.py:601-604`);
|
||||
- the default initial sequence is random in [1, 2^31): Rust `CultNetRudpSessionOptions::default`
|
||||
(`rudp.rs:104-113`), the transport constructors (`:1311`, `:1332`) and hub options (`:1377`); C#
|
||||
`CultNetTransport.cs:773`, `:850`, `:2404`; TS `rudp.ts:172`; Python `transport.py:298`, `:323`;
|
||||
- CultLib's own fixed `initial_sequence: 1` call sites take the default: `cultmesh-rs/src/lib.rs:964`,
|
||||
`:1409`, `cultmesh-rs/src/rudp_document_server.rs:432`, `cultnet-rs/src/cultmesh.rs:259`,
|
||||
`cultnet-ts/src/signed-daemon-health.ts:116`.
|
||||
- **Tests changed** (they encode payload identity or a fixed initial sequence):
|
||||
- `packages/cultnet-rs/tests/rudp_server_hub.rs:171-240` calls `connect()` again with the same payload
|
||||
and expects no replacement. Under this rule that is a new generation. Its retransmit half is already
|
||||
covered by `rudp_stray_packets.rs:487` (the same Connect packet twenty times), which stays;
|
||||
- `tests/GameCult.Networking.Tests/NetworkingTests.cs:1507` replaces a peer by payload with two sessions
|
||||
at the default initial sequence; it passes under a random default, but must say it relies on distinct
|
||||
sequences;
|
||||
- `packages/cultnet-ts/test/cultnet.test.ts:2225` (another endpoint replaces the peer) stays;
|
||||
- any test that asserts a sequence value with the default initial sequence.
|
||||
- **Verification:**
|
||||
- G1, G1b, G2 and the stale-Accept test in every runtime;
|
||||
- the retransmit tests stay green: `rudp_stray_packets.rs:487`, `:511`, `NetworkingTests.cs:1483`,
|
||||
`cultnet.test.ts:2119`, `:2134`, `test_cultnet.py:1134`;
|
||||
- negative grep: `rg -n "SequenceEqual\(connectPayload\)|connect_payload == packet.payload|movedEndpoint" src packages`
|
||||
returns nothing;
|
||||
- mutation testing on the diff.
|
||||
- **Ledger.** About −60 and +70 across four runtimes. Ten call sites lose an explicit initial sequence.
|
||||
|
||||
### Cut 2. A reliable fragment set is never evicted (receiver: Rust, TS, Python) — ready
|
||||
|
||||
- **First.** Failing test P5 in each of the three.
|
||||
- **Change:**
|
||||
- eviction picks the stalest set that holds no reliable fragment (`rudp.rs:1027-1047`,
|
||||
`rudp.ts:679-692`, `transport.py:836-842`);
|
||||
- if every pending set holds reliable fragments, the packet is refused and the session ends, as every
|
||||
other refusal does;
|
||||
- a conforming sender cannot reach that bound: its window keeps at most 32 reliable packets in flight,
|
||||
and the bound is 64 sets.
|
||||
- **Verification:** P5 delivers the reliable frame. `d3bc6e66`'s test (lossy fragment sets keep being
|
||||
evicted) stays green. `fragment_sets_evicted` still counts evictions.
|
||||
- **Ledger.** About −3 and +12 per runtime.
|
||||
|
||||
### Cut 3. The sender's flow window: 1,023 sequences and 4 MiB above the lowest unacked — ready
|
||||
|
||||
- **First.** Failing tests P8 (all runtimes) and P4 (Rust receiver, with a sender from each runtime).
|
||||
- **Change.** Admission (`AdmitReliablePackets` / `PromoteQueuedReliable` at
|
||||
`CultNetTransport.cs:1439-1490`, `rudp.rs:910-942`, `rudp.ts:586-600`, `transport.py:776-796`,
|
||||
`CultMesh.kt:2856-2872`) admits a packet only while:
|
||||
- the window has a slot (as today);
|
||||
- its sequence is at most the lowest unacked sequence plus 1,023;
|
||||
- the admitted payload bytes above the lowest unacked sequence stay within 4 MiB.
|
||||
- The first packet is always admissible. Expired packets (Rust) do not count as unacked here: they are
|
||||
gone from the wire.
|
||||
- **Contract.** `docs/cultnet-transport-parity.md` (after `:263`) states the receive window: a receiver
|
||||
must hold 1,023 sequences and 4 MiB beyond its contiguous watermark, and a conforming sender never
|
||||
exceeds that.
|
||||
- **Verification:**
|
||||
- P8: `g` is delivered;
|
||||
- P4: the 5 MiB frame is delivered after the gap fills, and the session is never refused;
|
||||
- the existing large-snapshot tests (`c398bad1`) and the fragment interop stay green;
|
||||
- the Rust benchmark or media probe shows no throughput change on a lossless link.
|
||||
- **Ledger.** About +25 per runtime, and nothing removed.
|
||||
|
||||
### Cut 4. Rust receipts: Acknowledged means delivered, resolved once; honest expiry and queueing — blocked on Cuts 1 and 1b, and on Q-A3
|
||||
|
||||
- **First.** Failing tests: P3 (the receipt for `s2` reads `Pending` until `s1` arrives, then
|
||||
`Acknowledged`), P9, P10, P2 (`flush_reliable` must fail), and: a receipt `Acknowledged` before any
|
||||
ending stays `Acknowledged` after the ending and after a reconnect; a `Pending` one becomes
|
||||
`Invalidated`.
|
||||
- **Deletes first:**
|
||||
- the session scope (`rudp.rs:135`, `:179`, `:206`, `:283-285`, the check at `:291-293`, and what
|
||||
Cut 1b left of the rotations);
|
||||
- the stale comment and the half guard at `rudp.rs:1915-1920`;
|
||||
- the hand-rolled status loops at `cultmesh-rs/src/lib.rs:806-838` and `:870-900`.
|
||||
- **Adds:**
|
||||
- the receipt's status cell and ordered flag; the session's unresolved-receipt list, resolved once
|
||||
(Acknowledged through `A`, Invalidated by the ending primitive);
|
||||
- `acked_through` (`A`), reading pending, queued and first-abandoned;
|
||||
- first-abandoned, recorded in `purge_expired_reliable` (`rudp.rs:898-908`);
|
||||
- the receipt covers queued sequences: `send_many` (`:456`) reports every sequence it assigned;
|
||||
- `send_reliable` (`:1903-1930`) refuses a channel with expiry;
|
||||
- `wait_acknowledged`;
|
||||
- `flush_reliable` fails fast when an ordered send follows the first abandoned sequence.
|
||||
- **Unchanged:** `CultNetRudpReliableSendStatus` (`:146-150`), `reliable_send_status`'s signature
|
||||
(`:1931`), and every caller of `Acknowledged`: `tests/rudp_stray_packets.rs:309`,
|
||||
`cultmesh-rs/tests/rudp_document_server.rs:408,415`, CodexConnector, Ghostlight.
|
||||
- **Changes:** `cultmesh-rs/src/lib.rs:832` and `:892` become `wait_acknowledged`; the doc at `:869` says
|
||||
delivered to the document server's sink.
|
||||
- **Verification:**
|
||||
- the new tests pass;
|
||||
- `rg -n "session_scope" packages/cultnet-rs` has no hit;
|
||||
- both suites are green;
|
||||
- cargo-mutants on the diff.
|
||||
- **Ledger.** About −90 (two loops of about 30 each, the scope) and +90 (of which `wait_acknowledged` is
|
||||
about 35).
|
||||
|
||||
### Cut 5. CultLib-internal TS publishers and the contract text (TS, docs) — blocked on Cut 3, and on Cut 1b in TS
|
||||
|
||||
- **Changes:**
|
||||
- `idunn-odin-presence-publisher.ts:89-215` and `signed-daemon-health.ts:104-162` (with its helpers
|
||||
`:244-252`, `:273-389`, except the exported `parseEndpoint` `:254-271`) send through
|
||||
`CultNetRudpSocketTransportConnection`: `connectAndWait`, `send("schema")`, `flush`, `close`. Their
|
||||
hand-rolled session loops are deleted. The presence publisher keeps rejecting a `cultnet.error.v0`
|
||||
response, from the transport's `frame` events during the flush.
|
||||
- `connectAndWait(payload, timeoutMs)` on the TS socket transport, matching C# `ConnectAndWait`
|
||||
(`CultNetTransport.cs:1859-1880`). It is `cultmesh-ts/src/index.ts:6603-6650` `waitForRudpConnected` moved
|
||||
down; `createRudpPeer` (`index.ts:6220-6245`) uses it and the cultmesh-ts copy is deleted.
|
||||
- Flush contract text in all runtimes: "every reliable frame sent in this session has been delivered to
|
||||
the peer's application".
|
||||
- `docs/cultnet-transport-parity.md:236-240` (the control-packet guard paragraph) is replaced by the
|
||||
watermark rule, and `:261-272` gains: Acknowledged means delivered; a write belongs to the session it
|
||||
was issued in; how a repeated Connect is recognised (after Cut 1c); the receive window (after Cut 3).
|
||||
- **Verification:**
|
||||
- `runtime-presence-health.test.ts` and `signed-daemon-health.test.ts` stay green;
|
||||
- a new test drops the first datagram and still publishes;
|
||||
- a new test publishes a payload over 32 packets whole;
|
||||
- a new test shows the publisher's peer receives a Disconnect.
|
||||
- **Ledger.** About −270 and +50.
|
||||
|
||||
|
||||
## Subtraction ledger (estimate, four runtimes)
|
||||
|
||||
| Cut | Removed | Added | Other |
|
||||
|---|---|---|---|
|
||||
| 1 | ~280 | ~160 | four per-channel state machines become one watermark |
|
||||
| 1b | ~40 | ~45 | one ending primitive; TS's transport generation pair goes |
|
||||
| 1c | ~60 | ~70 | four transport-level repeat decisions become one session predicate |
|
||||
| 2 | ~10 | ~35 | none |
|
||||
| 3 | 0 | ~100 | a documented receive-window contract |
|
||||
| 4 | ~90 | ~90 | one public method (`wait_acknowledged`); the session scope goes; no enum change |
|
||||
| 5 | ~270 | ~50 | two hand-rolled RUDP loops and cultmesh-ts's connect wait go |
|
||||
|
||||
- **Source, net:** about −195. Tests are extra: about 4 × 10 rule tests.
|
||||
- **Wire:** zero bytes.
|
||||
- **Downstream:** no caller has to change code at its next bump. Callers that fixed `initial_sequence`
|
||||
keep a deterministic Connect sequence, and with it the restart lockout, until they drop the value.
|
||||
- **Kotlin (Q-A5 A):** Cuts 1, 1b, 1c and 3 again, about the same size as one runtime each.
|
||||
|
||||
|
||||
## Operator questions
|
||||
|
||||
Each question stands alone.
|
||||
|
||||
**Ruled (2026-09-30), recorded so they are not re-asked:**
|
||||
|
||||
- **Q-A1. Where does delivered come from?** (a): derived at the sender from the cumulative watermark. No
|
||||
wire change.
|
||||
- **Q-A2. One flush, or a "received" flush beside it?** One meaning: Acknowledged means delivered.
|
||||
Receipts are Pending, Acknowledged or Invalidated. Retransmission stops on each packet's own ack. Flush
|
||||
waits for Acknowledged.
|
||||
|
||||
**Open:**
|
||||
|
||||
- **Q-A3. Reliable expiry next to ordered traffic (P2).** An expired reliable packet leaves a permanent
|
||||
hole, and every ordered frame after it on the same session is held until the session ends.
|
||||
- **A.** Report it honestly (Cut 4): ordered receipts after the hole stay `Pending` and become
|
||||
`Invalidated` when the session ends, and flush fails fast. Document "do not send ordered traffic on a
|
||||
session that uses reliable expiry". No live consumer does.
|
||||
- **B.** PR-SCTP's FORWARD-TSN (RFC 3758). On expiry the sender sends an abandonment notice, which rides
|
||||
an Ack-typed packet with flag `0x10` so that old peers ignore it (P7). New receivers treat the sequence
|
||||
as received with no frame, and acknowledge it. The sender resends the notice until it is acknowledged.
|
||||
That is a new cut in every receiver. Old receivers never acknowledge it, so the resend needs its own
|
||||
bound.
|
||||
- **C.** Refuse ordered sends on a session after its first expiry.
|
||||
- **D.** Delete reliable expiry. Muninn's `audio` is its only user and `hands/media-fec-cut3` retires it.
|
||||
This subtracts, but CultLib would lose partial reliability, which a reasonable consumer can expect.
|
||||
- **Recommended: A now.** Record B as the complete answer for when a consumer mixes the two.
|
||||
- **Blocks:** Cut 4 only. A keeps Cut 4 as mapped (`first_abandoned` alone). B adds a wire cut in every
|
||||
receiver before Cut 4. C changes Cut 4's `send_many` guard. D replaces Cut 4's expiry half with a
|
||||
deletion. Cuts 1, 1b, 1c, 2, 3 and 5 are unaffected.
|
||||
|
||||
- **Q-A4. Receipts in C#, TypeScript, Python and Kotlin?**
|
||||
- Only Rust has per-send receipts. The other four offer flush and an outstanding count.
|
||||
- **A.** Port receipts with the same three states (Pending, Acknowledged, Invalidated) and a
|
||||
`WaitAcknowledged`, in this campaign.
|
||||
- **B.** Record it as a parity gap. Flush covers every surveyed consumer.
|
||||
- **Recommended: B.** No consumer outside Rust waits per send. A reasonable C# consumer might want it, but
|
||||
it is additive later, changes no wire byte, and after Cut 4 the Rust design (a status cell resolved
|
||||
once by the session) ports without a second design.
|
||||
- **Blocks:** nothing mapped. A adds a cut of about +80 per runtime after Cut 4.
|
||||
|
||||
- **Q-A5. Is Kotlin in the campaign?**
|
||||
- `cultmesh-kotlin` carries a fifth RUDP runtime with P1 and P6 (the same code, `CultMesh.kt:2939-2978`).
|
||||
Its flush has no callers. The stray-packet work did not touch it, so none of those fixes reached it
|
||||
(not probed in pass 2). Its only build path is
|
||||
`build.ps1`, which needs Android Studio's `kotlinc` and JBR on Windows. Yggdrasil has no Kotlin image in
|
||||
the verify list.
|
||||
- **A.** Include it, as a trailing Cut K that applies Cuts 1, 1b, 1c and 3, verified on a Yggdrasil
|
||||
Kotlin image added first (or on Starfire as a Windows-only job).
|
||||
- **B.** Record it as parity debt, with the stray-packet gaps. Mark the parity doc so that nobody reads
|
||||
Kotlin as conforming.
|
||||
- **Recommended: A, with a Yggdrasil Kotlin image.** Wire parity is the library's promise. A runtime
|
||||
that strands acknowledged frames should not stay listed as conforming.
|
||||
- **Blocks:** Cut K only. Cuts 1, 1b, 1c and 3 proceed in four runtimes either way; pass 1 had Q-A5
|
||||
blocking their scope, and pass 2 removes that by making Kotlin trail.
|
||||
|
||||
- **Q-A6. How does a session recognise a new generation? (new in pass 2)** Today a server treats a
|
||||
Connect from a connected endpoint as a repeat by payload (Rust hub, C# listener) or unconditionally (C#,
|
||||
TS and Python server mode), so a restarted client with the same payload is locked out (G1), and a
|
||||
client's second `connect()` is not seen as new, so the client cannot safely forget the old server
|
||||
session's sequences (G2). The Connect's bytes are identical for a retransmit and for a restarted process
|
||||
that starts at the same initial sequence, so something has to differ.
|
||||
- **A.** The Connect's sequence identifies the generation, and the default initial sequence is random in
|
||||
[1, 2^31), as TCP's initial sequence number is (RFC 9293 §3.4.1, RFC 6528). A Connect repeats exactly
|
||||
when it carries the current generation's Connect sequence. No wire change. The default of a public
|
||||
option changes (from 1, or 100 for the C# listener); an explicit value still works for tests, and a
|
||||
caller that fixes it keeps the lockout.
|
||||
- **B.** Proof of receipt. A byte-identical Connect repeats until the server has seen the client
|
||||
acknowledge the Accept; after that any Connect is a new generation. No default changes. But a delayed
|
||||
duplicate of the original Connect that arrives after that ack replaces a live session and forgets what
|
||||
it received, so its retransmits are delivered twice.
|
||||
- **C.** The application's Connect payload identifies the generation (today's hub rule), and every
|
||||
client must put a per-process nonce in it, as the provider layer's `clientSessionId` does. Server mode
|
||||
adopts the same comparison. This leaves G1 as a documented obligation on every caller, including the
|
||||
ones that send an empty payload (`cultmesh-rs/src/lib.rs:973`, the TS publishers).
|
||||
- **Recommended: A.** It is the literature's answer, it is robust to delayed duplicates (they carry the
|
||||
accepted sequence), and it moves the decision into the session, deleting four transport-level
|
||||
decisions.
|
||||
- **Blocks:** Cut 1c only. Cut 1b (the ending rule) is ruled and does not depend on it.
|
||||
|
||||
**Not forks, recorded so they are not re-asked:**
|
||||
|
||||
- `Acknowledged` keeps its name and now means delivered. No variant is added or removed.
|
||||
- Delivered means handed to the transport's application boundary, not consumed.
|
||||
- The 4 MiB cap stays. The sender's window keeps legal traffic under it (§5).
|
||||
- The receiver's 4,096 receive history stays. The sender's window keeps legal traffic inside it.
|
||||
- A receipt is resolved once. `Acknowledged` before a session ends stays `Acknowledged`; anything else
|
||||
becomes `Invalidated` at the end.
|
||||
- Session generation is its own two cuts, not part of Cut 1 (§8).
|
||||
|
||||
|
||||
## Sequencing and blockers
|
||||
|
||||
1. **Done.** `hands/rudp-stray-packets` merged to `main` at `3bf1c0c`. Odin pins `3bf1c0c`.
|
||||
2. **Ready for Hands now** (base `main` at `3bf1c0c` or later; four runtimes; Kotlin trails per Q-A5):
|
||||
- **Cut 1** (the watermark).
|
||||
- **Cut 1b** (the ending rule). Same files as Cut 1 in `ResetPeerState` and the Disconnect branches:
|
||||
land it after Cut 1 on the same branch, or in parallel and rebase the second.
|
||||
- **Cut 2** and **Cut 3**, independent of Cut 1 and of each other, in parallel worktrees. Merge Cut 1
|
||||
before Cut 3 so that Cut 3's P4 test exercises the new hold path.
|
||||
3. **Cut 1c** is blocked on Q-A6 and on Cut 1b.
|
||||
4. **Cut 4** is blocked on Cut 1 (Acknowledged is sound only against a correct receiver), on Cut 1b (it
|
||||
resolves receipts through the ending primitive), and on Q-A3.
|
||||
5. **Cut 5** is blocked on Cut 3 in TS (the socket transport it routes through must carry the window) and
|
||||
on Cut 1b in TS (its flush must be bound to the generation). It does not need Cut 1c, but Cut 5's
|
||||
parity-doc text for repeated Connects waits for it.
|
||||
6. **Odin's next pin bump** follows Cuts 1-3 (and 1b). Odin is the receiver for every Idunn health,
|
||||
presence and catalog publisher, so this bump is what makes their Acknowledged mean delivered. Odin uses
|
||||
`publish_cultnet_message_to_rudp_catalog` and no receipt type, so Cut 4 never breaks its build.
|
||||
7. **Parallel campaigns:**
|
||||
- **`hands/media-fec-cut3`** (unmerged, `4b85a173`) touches `rudp.rs` only in the imports, the
|
||||
profile's `audio` entry (`~2374` at `3bf1c0c`) and the `"media" | "audio"` arm of
|
||||
`channel_send_options` (`~2547`). None overlaps Cuts 1-4. Either order; the second rebases trivially.
|
||||
Once it lands, reliable expiry is only for `media` when the caller chooses Reliable.
|
||||
- **Muninn's own cuts** use lossy media plus parity. Its reliable `audio` session has no ordered
|
||||
traffic, so P2 does not bite it. Nothing here blocks Muninn, and nothing in Muninn blocks this.
|
||||
- **CultCache publication Cut 4** (`docs/cultcache-publication-cut.md` §4) touches `CultNetDatabase.cs`
|
||||
only, and needs in-order, exactly-once delivery on `schema`. The C# socket transports carry one
|
||||
reliable channel (`CultNetTransport.cs:2372-2376`), so P1 and P6 cannot occur on their sessions. No
|
||||
blocking relation in either direction.
|
||||
8. **Vendored TS bumps come after Cut 5**, so they pick up honest flushes, the TS window and the rewritten
|
||||
publishers at once:
|
||||
- **StreamPixels** vendors CultLib at `30ee8b9` (StreamPixels `origin/main`, gitlink bumped in
|
||||
`44a8945`). Its service runs `createIdunnRuntimePresencePublisher`
|
||||
(`apps/service/scripts/idunn-entry.mjs`), whose file is byte-identical at `30ee8b9` and `3bf1c0c`;
|
||||
`30ee8b9` also predates the TS stray-packet hardening in `rudp.ts`.
|
||||
- **Heimdall** vendors `b6b1d9c` and hand-rolls its own publisher (`src/idunn-rudp-health.ts:55-87`,
|
||||
first-ack wait). At its bump it calls cultnet-ts's publisher instead.
|
||||
- Bumping either after Cut 1c as well also gives them the restart fix; neither needs it, because each
|
||||
publish binds a new port.
|
||||
9. **CodexConnector** and **Ghostlight** need no code change at their next bump. They may switch their
|
||||
status loops to `wait_acknowledged` after Cut 4.
|
||||
|
||||
|
||||
## Follow-ups outside this campaign
|
||||
|
||||
- **Hold caps in C#, TS, Python and Kotlin.** Their ordered hold buffers and per-channel maps are unbounded
|
||||
against a hostile peer. Rust has caps.
|
||||
- **The document server's per-session payload budget never shrinks** (`rudp_document_server.rs:321`,
|
||||
`:584`). After 4 MiB in one session it drops data without acknowledging it, and the sender retransmits
|
||||
until it gives up.
|
||||
- **Fire-and-forget health.** Muninn `main.rs:9446`, Sleipnir `main.rs:880-904` and Heimdall's first-ack
|
||||
wait can lose a health record silently.
|
||||
- **FORWARD-TSN** if Q-A3 is A and a consumer later mixes expiry with ordered traffic.
|
||||
- **Callers outside CultLib that fix `initial_sequence`** keep the restart lockout after Cut 1c until they
|
||||
drop the value. Record it in their pin-bump notes.
|
||||
- **The P4 probe at the old pins** predates its fix, so it proves nothing there. If anyone needs
|
||||
old-sender-to-new-receiver evidence for the cap, rerun `09318d13`'s P4 against a pinned sender.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,520 +0,0 @@
|
|||
# CultCache document variants: cut map (draft)
|
||||
|
||||
Date 2026-09-29. Imagination output for `docs/document-variants-target.md` (branch
|
||||
`codex/document-variants`). Body read at `origin/main` `268e0ef`. This map owns means only;
|
||||
the target owns ends and rulings 1-7. Nothing here is decided that the batch in section 4
|
||||
asks the operator to decide.
|
||||
|
||||
## 1. Probe facts (run, not inferred)
|
||||
|
||||
Probe: scratch clone `scratchpad/variant-probe` (commit `7b6de8e`, probe files only), run on
|
||||
Yggdrasil via `ygg-verify.sh` with images `dotnet`, `rust`, `python:3.12-slim`, `node:22`.
|
||||
Each runtime's current reader wrote a normal store, the bytes were altered four ways, and
|
||||
the store was reopened. Output in `scratchpad/probe-out/*.txt`.
|
||||
|
||||
| Altered store | C# | Python | TypeScript | Rust |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| A. v1 header, record gains slot 4, payload empty | refuses, "read past end of stream" | refuses, "incomplete input" | refuses, "Offset outside DataView" | refuses (strict tuple), misleading legacy-envelope message |
|
||||
| B. v1, slot 4 plus a full payload | **loads the payload silently** | **loads silently** | **loads silently** | refuses |
|
||||
| C. header `cultcache.store.v2` | **ignores the header**; refused only by the empty payload | refuses, misleading "legacy envelopes" message | refuses | refuses |
|
||||
| D. payload holds the delta as a sparse array | **loads `Name=''`, `Max=99`** | **loads `name=None`** | refuses | loads; `get` of that key fails |
|
||||
|
||||
Consequences:
|
||||
- The **empty payload is the only tripwire every shipped reader trips**, pinned builds
|
||||
included (Aetheria pins `caching-unity-v1.4.0`). A variant record's payload must be empty,
|
||||
and the delta must live outside it.
|
||||
- A payload-as-delta encoding is ruled out: C# and Python misread it silently.
|
||||
- C# never checks `formatVersion` (`CultDocumentMessagePackSerialization.DeserializeSnapshot`
|
||||
defaults a missing one to v1). C#, Python and TypeScript skip extra record slots silently.
|
||||
These are latent defects today and become misreads when the format grows.
|
||||
- Every refusal is loud but none of them names the record. The target requires named refusals,
|
||||
so current readers need a small fix (C0).
|
||||
|
||||
Other Body facts that shape the map:
|
||||
- **CultNet has no member-path grammar.** `docs/cultnet-selection-cut.md` D2: "Declared
|
||||
aliases, not a field-path language". It reaches top-level declared `index`/`role` aliases
|
||||
only (`CultDocumentMemberView`). Nothing exists to reuse. See section 3.
|
||||
- **The schema catalog describes top-level members only.** A nested type (`BehaviorData`,
|
||||
`PerformanceStat`, `HardpointData`) appears only as a CLR type name. Soft drift and hard
|
||||
rejection cannot see below slot level.
|
||||
- **Resolution needs a codec, and the core has none.** `GameCult.Caching` holds objects. The
|
||||
MessagePack codec lives in `GameCult.Caching.MessagePack`. `CultInspectorModel` already
|
||||
takes the codec as two injected functions (`CultCacheMessagePack.CreateInspectorModel`).
|
||||
- **Same concrete type implies same home store.** `Home(type)` routes by type, so ruling 3
|
||||
("same store") falls out of ruling 6. No separate check is needed.
|
||||
- **The name index shadows silently.** `Index()` does `MapOf(_names, type)[name] = key`, so a
|
||||
second record with the same name overwrites the entry. A variant that inherits its base's
|
||||
name would make `GetByName` return one of the two, depending on apply order.
|
||||
- **Studio edits by whole-document upsert.** `CultInspectorEdit.Commit` calls
|
||||
`UpsertAsync(type, copy, key)`, and `Duplicate` clones through the codec.
|
||||
- **Raw readers bypass the cache.** In CultLib: `CultMesh.ReadSingleFileDocument`
|
||||
(`CultMeshSingleFileDocuments.cs:112`), plus its legacy snapshot decoder. In consumers:
|
||||
AetheriaEve `AetheriaRuntimeCatalogStore` (directory-store pages).
|
||||
- **Kotlin reads no store files.** It sees CultNet raw snapshots only.
|
||||
|
||||
## 2. Model page (step 0b)
|
||||
|
||||
| Kind | Named by | Over time | Decided by |
|
||||
| --- | --- | --- | --- |
|
||||
| Variant record | its own `CultRecordKey`, minted like any record; same `schemaId` as its type | created, re-overridden, rebased, removed; never converted to or from a plain record implicitly (Q1) | author through Studio or the variant write API; the cache validates |
|
||||
| Base reference | the base's key, in the record's variant slot | changed only by an explicit rebase; a base cannot be deleted while it has variants, unless the same batch rebases or removes them | author; the cache refuses a cycle, a missing base, a different concrete type, a global type |
|
||||
| Override entry | `(op, path, id, value)`; op is Set, Insert or Remove | added by editing; removed by clearing; survives base edits while its path resolves | author; the cache refuses entries a base edit strands (Q2); an entry whose member left the type soft-drifts with a warning (Q4 c) |
|
||||
| Member path | a sequence of `(slot, elementId)` steps; `slot` is the MessagePack `[Key]`; `elementId` is `""` unless the step enters an object-list element | a type change can invalidate it | the type's owner (slot authority); ids by the element's creator |
|
||||
| Element id | a `[CultElementId]` string member on an object-list element type; `""` means unset | minted by the cache on write when unset; copied unchanged into variants; a moved element is a new element | cache mints it; it is never retargeted |
|
||||
| Resolved view | `CultStoredDocument.Document` for a variant key | recomputed inside the hold that admits the variant, or any base on its chain | the cache only; never persisted, never written back (Q1) |
|
||||
| Index and name entries | as today, from the resolved view | a dependent's entries are dropped and re-added in the same `Apply` as its base change | the cache; a variant must override the `[CultName]` member (rule R6) |
|
||||
| Watches | as today; each re-resolved dependent emits `Updated` with its own `Sequence` | published in the same hold as the base change | the cache |
|
||||
| Compare-exchange | `(schemaId, storedAt)` of the stored delta (ruling 2) | a base change does not stamp its variants | the cache; the resolved view is not an identity |
|
||||
| Persisted format version | store header `formatVersion` | `cultcache.store.v2` when the store holds at least one variant; `v1` otherwise (Q5) | the writing store, from content |
|
||||
| Variant slot on the wire (CultNet) | **empty cell**: no CultNet message carries a delta today | C5 | ruling 4 decides deltas; the message shape is C5's design |
|
||||
| Dictionary entries, scalar lists, arrays | **empty cell**: no stable identity exists | whole-member Set only (Q7) | Q7 |
|
||||
| Template (non-item) bases | **empty cell**: parked by ruling 6 | none | not in these cuts |
|
||||
|
||||
## 3. Where the Body contradicts the target
|
||||
|
||||
1. **"A member path in CultNet's grammar"** (ruling 1 and fork 1 text) has nothing to reuse.
|
||||
CultNet selection deliberately has no path language. This map specifies a minimal slot
|
||||
path (section 2) owned by `GameCult.Caching`. If CultNet ever needs paths, it can adopt
|
||||
this one. Nothing is shared today, and nothing is invented twice.
|
||||
2. **"Overrides are subject to the same soft drift rules as full records"** conflicts with
|
||||
the ends list: "Loading refuses an override naming a member that does not exist on the
|
||||
type". Under soft drift, a persisted slot missing locally is ignored with a warning, so
|
||||
the two rules disagree for a dropped member. Below top level, the catalog has no shape to
|
||||
drift against at all. See Q4.
|
||||
3. **"Deletes first"** has little to delete: the feature is additive. The honest deletions
|
||||
are the silent extra-slot skips and C#'s ignored header. C0 puts them first.
|
||||
|
||||
## 4. Operator questions (one batch)
|
||||
|
||||
**Operator rulings, 2026-09-29, each asked on its own:**
|
||||
|
||||
- **Q1: (a)**, a plain `Upsert` at a variant's key is refused loudly.
|
||||
- **Q2: (a)**, a base edit removing an overridden element is refused unless the same batch
|
||||
resolves it. Operator's reading: "the very existence of that element in the child collection
|
||||
must become the new override state... you can't change the parent without also changing the
|
||||
child... that's the resolve part of (a)."
|
||||
- **Q3: ids everywhere, not opt-in.** Operator: "Opt-in sounds nice but consider the UX. We're
|
||||
planting a footgun if we don't require ids everywhere." Rollout: **required, minted on load**.
|
||||
The C# registry requires an id member on object-list element types. A store written before ids
|
||||
existed loads, and ids are minted once on its first write (C2's rewrite). Other runtimes carry
|
||||
the id as an ordinary member and enforce the rule when they gain variants. Each consumer's type
|
||||
edit is a sweep in its own repo after C2.
|
||||
- **Q4: (c) soft drift everywhere. This reverses the recommendation and supersedes the target's
|
||||
end "loading refuses ... an override naming a member that does not exist on the type".**
|
||||
Operator: "Soft drift everywhere aligns with the vision... the rationale for adopting
|
||||
migrationless changes is still valid for variants. Any member dropped in a type change is a
|
||||
design change, the reason for it to apply to the parent also applies to the variant." A
|
||||
dangling override, at any depth, is ignored with a warning naming the variant and the member,
|
||||
and the variant inherits the base value. The other broken-variant refusals (a base cycle, a
|
||||
missing base, an incompatible base type) stand.
|
||||
- **Q5: (a)**, the v2 header is written only when the store holds a variant.
|
||||
- **Q6: yes**, the directory store and CultNet refuse variants loudly until their own cuts.
|
||||
- **Q7: yes**, object-list elements are addressable by id, and dictionaries, scalar lists and
|
||||
arrays are replaced whole. The operator asked first whether design roles and quality levels
|
||||
use dictionaries. They do not: `CraftedItemData.Roles` is a `List<ItemRole>` and
|
||||
`FactionProduct.Roles` is a `List<ProductRole>` (Aetheria `ItemData.cs:316`,
|
||||
`FactionProduct.cs:30`). A market-segment product becomes a variant overriding one
|
||||
`ProductRole` by id.
|
||||
|
||||
*The questions as asked:*
|
||||
|
||||
**Q1. A plain `Upsert` of a whole document at a variant's key.** It happens when Studio's
|
||||
current edit commits, when AetherDb migrations do `batch.Upsert(type, doc, key)`, and on a
|
||||
CultNet remote write.
|
||||
- (a) Refuse it loudly. A variant changes only through the variant API. Flattening is an
|
||||
explicit, separate operation.
|
||||
- (b) Diff the document against the base and store the difference. Ids make the diff
|
||||
deterministic, but a value set equal to the base stops being an override without anyone
|
||||
saying so.
|
||||
- (c) Flatten silently (today's behaviour by default).
|
||||
- **Recommend (a).** Affects C1's write path, C3's edit commit, and every AetherDb migration
|
||||
(each skips variants or uses the variant API).
|
||||
|
||||
**Q2. A base edit that removes an element some variant overrides.**
|
||||
- (a) Refuse the commit unless the same batch clears or retargets those overrides. Studio
|
||||
offers keep, retarget or clear at edit time, and "keep" means the base edit is not
|
||||
committed. The store never holds a conflict, and loading refuses one.
|
||||
- (b) Persist the conflict. The override goes inert, readers get the resolved document
|
||||
without it, and Studio lists it.
|
||||
- **Recommend (a).** Option (b) makes a reader's resolved document silently differ from what
|
||||
the author wrote. Affects C2's validation, C3's rebase UI, and migrations that remove
|
||||
behaviours.
|
||||
|
||||
**Q3. Scope of "ids everywhere".**
|
||||
- (a) Every object-list element in every registered document type, in all 28 consumers. The
|
||||
registry refuses a type whose element types lack an id.
|
||||
- (b) Only document types that opt in with `[CultVariants]`. For those, the registry requires
|
||||
`[CultElementId]` on every object element type reachable through their lists, including
|
||||
union bases.
|
||||
- **Recommend (b).** Option (a) breaks 28 registries for a feature that one consumer uses.
|
||||
Affects C2 and Aetheria's adoption cut. A `BehaviorData` id needs a base slot above the
|
||||
subclasses' highest key (32), which adds about 30 nil bytes per behaviour unless Aetheria
|
||||
renumbers.
|
||||
|
||||
**Q4. Schema drift under overrides** (contradiction 2).
|
||||
- (a) Refuse loading when an override names a member missing at any depth, or holds a value
|
||||
that will not decode into the member's current type. Record this as a stated exception to
|
||||
soft drift in `cultcache-schema-compatibility.md`.
|
||||
- (b) Top-level overrides follow catalog soft drift (ignore with a warning); nested ones are
|
||||
refused.
|
||||
- **Recommend (a).** It follows the target's ends and is one rule. The cost: after a type
|
||||
change drops an overridden member, the catalog must be rewritten before the new build opens
|
||||
it.
|
||||
|
||||
**Q5. Format version.**
|
||||
- (a) Write `cultcache.store.v2` only when the store holds a variant. Stores without variants
|
||||
stay byte-identical `v1` for every consumer.
|
||||
- (b) Always write v2 after the upgrade.
|
||||
- **Recommend (a).** The empty payload remains the tripwire for pinned old C#, which ignores
|
||||
the header. Affects C0 and C1.
|
||||
|
||||
**Q6. Slice scope.** Should the directory store and CultNet refuse variants loudly until
|
||||
their own cuts? The directory store's only consumer is AetheriaEve's raw reader. CultNet
|
||||
must carry deltas (ruling 4), and no message can yet.
|
||||
- **Recommend yes.** It is two refusals. Otherwise C1 must design CultNet messages first.
|
||||
Affects C1's size and the order of C5.
|
||||
|
||||
**Q7. What is addressable below a member.** The proposal: object-list elements by id only.
|
||||
Dictionary entries, scalar lists and arrays are replaced whole.
|
||||
- **Recommend yes.** Aetheria's cases (`Hardpoints` whole; `Behaviors[id]...Max`) fit. Keyed
|
||||
dictionary addressing can be added later without a format change. Affects C2.
|
||||
|
||||
## 5. Cuts, in order
|
||||
|
||||
All cuts are in `F:\Projects\CultLib`. Rebase `codex/document-variants` (docs only) onto
|
||||
`origin/main`. Each cut is one Hands pass on that branch and merges to main after its Soul
|
||||
pass. Probes show that a C1 writer can merge safely before other runtimes change, because
|
||||
every reader refuses it. Verification uses `ygg-verify.sh /f/Projects/CultLib <rev> <image> '<cmd>'`.
|
||||
The command strings below are the `<cmd>`. Keep the job's exit status last.
|
||||
|
||||
Overlap: BP-1 (`cultmesh/rust-content-plane`) edits `docs/runtime-parity-scope.md`, which
|
||||
C0 also edits. Rebase after BP-1 merges. `codex/fix-node24-ajv-esm` has no diff against main.
|
||||
|
||||
### C0. Tripwires: every reader refuses what C1 will write, and names the record
|
||||
|
||||
Deletes:
|
||||
- The silent extra-slot skip in the C# record decoder
|
||||
(`CultDocumentMessagePackSerialization.ReadPersistedRecord`'s trailing `Skip` loop) and
|
||||
its copy in `CultMeshSingleFileDocuments.ReadLegacyPersistedRecord`.
|
||||
- The `?? "cultcache.store.v1"` defaulting of the header.
|
||||
- Python `raw_record[:4]` slicing.
|
||||
- The TypeScript and Python fallthrough to the legacy envelope decoder when the header is a
|
||||
`cultcache.store.*` string they do not know.
|
||||
|
||||
Adds, one rule in each runtime: a snapshot whose header is not `v1`, or whose record has more
|
||||
than 4 slots, is refused with a message that names the version or the record key, and says
|
||||
the store needs a runtime that resolves variants. C# accepts `v1` only, until C1 adds `v2`.
|
||||
|
||||
Keeps: every v1 byte and every v1 test.
|
||||
|
||||
Verification (rule: "unknown header or extra record slot is refused by name"). Each runtime
|
||||
gets one test, and each test's negative is fixture B from section 1 (full payload plus slot
|
||||
4) loading:
|
||||
- `dotnet`: `dotnet test tests/GameCult.Caching.Tests --filter FullyQualifiedName~PreCut2StoreFormat`
|
||||
- `node:22`: `npm install --no-audit --no-fund && cd packages/cultcache-ts && npm test`
|
||||
- `python:3.12-slim`: `pip install -q msgpack -e packages/cultcache-py && python -m unittest discover packages/cultcache-py/tests`
|
||||
- `rust`: `cd packages/cultcache-rs && cargo test` (message only; Rust already refuses)
|
||||
|
||||
Subtraction: about −20 lines of skip and fallthrough; +4 small refusals and 4 tests. Build:
|
||||
the 4 cache packages only.
|
||||
|
||||
### C1. C# core: variant records, top-level Set overrides, resolution in the admitting hold
|
||||
|
||||
Adds:
|
||||
- **`CultPersistedRecord.Variant`**, record slot 4: `[baseKey, overrides[]]`. Payload slot 3
|
||||
is empty for a variant. An override is `[op, path[], id, value]`.
|
||||
- Owner: `GameCult.Caching`.
|
||||
- Consumer: both file stores and Studio.
|
||||
- Invariant: an old reader refuses rather than misreads (section 1).
|
||||
- Why no existing owner serves: the payload cannot hold the delta (probe D).
|
||||
- **`CultStoredDocument.Variant`** (base key and overrides; null for plain records).
|
||||
`Document` stays the resolved view.
|
||||
- **A codec port on `CultCache`**: the `(serialize, deserialize)` pair `CultInspectorModel`
|
||||
already takes, lifted.
|
||||
- Owner: `GameCult.Caching.MessagePack`, supplied by `CultCacheMessagePack.Create`.
|
||||
- Consumers: resolution and the inspector model.
|
||||
- Invariant: one codec per cache; clones and override values use the store's encoding.
|
||||
- `CultInspectorModel`'s constructor takes the port instead of its own pair, so there is one
|
||||
pair, not two.
|
||||
- A cache without a codec refuses variant writes.
|
||||
- **Write API** on `CultCacheBatch`, with single-call equivalents: `UpsertVariant(key, baseKey,
|
||||
overrides)`, `Flatten(key)` (Q1a), `Remove` unchanged. Rebase is `UpsertVariant` with a new
|
||||
base.
|
||||
- **`Validate` resolves** every admitted variant and every dependent of an admitted or
|
||||
evicted base, in base-first order, against the post-batch set. It refuses, naming keys:
|
||||
a cycle; a missing base; a different concrete type; a global type; an evicted base with
|
||||
surviving variants; a variant whose resolved `[CultName]` equals its base's (R6); a plain
|
||||
`Upsert` at a variant key (Q1a). **Not refused (Q4 c, operator 2026-09-29):** an override
|
||||
naming a slot the type no longer has, or holding a value that no longer decodes into the
|
||||
member's type, soft-drifts exactly as the same member would on a plain record under
|
||||
`cultcache-schema-compatibility.md`. It is ignored with a warning naming the variant and
|
||||
the member, and the variant inherits the base value. One rule, the existing soft-drift
|
||||
path, never a variant-specific one.
|
||||
- **`Apply` re-resolves dependents** in the same hold. Each emits `Updated` and is re-indexed.
|
||||
A base change stamps no dependent `storedAt`.
|
||||
- **Header**: the store writes `v2` iff it holds a variant (Q5a) and reads `v1` and `v2`.
|
||||
- **Refusals** (Q6): `DirectoryMessagePackBackingStore` push and commit of a variant;
|
||||
`CultNetDocumentRegistry` row building for a variant; `CultMesh.ReadSingleFileDocument` of
|
||||
a variant key.
|
||||
- **Contracts**: the variant record section in `cultcache-persistence-format.md`; one
|
||||
sentence in `cultcache-schema-compatibility.md` saying overrides soft-drift like members
|
||||
(Q4 c, no exception); a "variants: C# resolves; others refuse" row in
|
||||
`runtime-parity-scope.md`.
|
||||
|
||||
Authority map:
|
||||
- Owner: `CultCache.Validate`/`Apply` owns the resolved view.
|
||||
- Inputs: stored deltas, base records, the codec.
|
||||
- Outputs: the resolved `Document`, index entries, changes.
|
||||
- Demoted: the resolved `Document` is no longer a writable owner for a variant key (Q1a).
|
||||
`storedAt` is the delta's identity only.
|
||||
- Forbidden writers: store `Loaded` callbacks never resolve; Studio never writes a resolved
|
||||
document to a variant key; CultNet never emits a variant row.
|
||||
- Shared paths: a load (`Admit` with a source), a single write, a batch and a remove all pass
|
||||
the same `Validate` and `Apply`. There is no second resolution path.
|
||||
|
||||
Verification. New `VariantTests.cs`; each rule gets a positive and a named negative:
|
||||
- a load with a variant resolves;
|
||||
- a base edit updates the variant's `Get`, `GetByName` and `GetByIndex`, and `Watch` sees both
|
||||
changes inside one publication, with no gap in `Sequence` between them. Negative: a
|
||||
subscriber observing the base change must already read the new variant.
|
||||
- `Expect` on the variant survives a base change;
|
||||
- each refusal case, and the refusal message names both keys;
|
||||
- the header is `v1` without variants and `v2` with them;
|
||||
- a read-only store resolves;
|
||||
- a variant round-trips byte-identically through flush.
|
||||
|
||||
Cross-runtime vectors: the Caching interop peer gains `write-variant --file`. A test in
|
||||
`packages/cultcache-ts/test/cult-cache.test.ts` asserts that TypeScript refuses that file by
|
||||
name; Python and Rust tests read the same fixture from `contracts/cultcache/variant-store.cc`.
|
||||
|
||||
Command, `dotnet`:
|
||||
`dotnet test tests/GameCult.Caching.Tests && dotnet test tests/GameCult.Networking.Tests --filter FullyQualifiedName~Registry`,
|
||||
then the C0 commands for `node:22`, `python` and `rust` against the new fixture.
|
||||
|
||||
Subtraction: the inspector's own codec pair collapses into the port; otherwise net additive,
|
||||
estimated +350 to 450 lines of core and +400 of tests. Build: the Caching and MessagePack
|
||||
projects, Caching.Tests, the Networking test filter, the interop peer.
|
||||
|
||||
**C1 status, 2026-09-29 (Self).** C1 landed on `hands/variants-c1` (`136adce`). Soul (Opus) found it
|
||||
not closeable, and a fix batch is in Hands:
|
||||
- **F1:** CultNet's live-change paths leak variants as plain records, which breaks Q6.
|
||||
- **F3:** an undecodable override soft-drifted where a plain record refuses to load.
|
||||
- **F4:** invalid overrides were accepted at write.
|
||||
- **F2:** an inherited unique index value shadows the base, and the winner depends on order.
|
||||
|
||||
**Self's readings and defaults, not new operator rulings:**
|
||||
- **Q4 (c) read literally:** an override follows the plain-record outcome. A missing slot
|
||||
soft-drifts; a value that no longer decodes refuses loudly, as a plain record does.
|
||||
- **Invalid overrides are refused at write.** Drift covers type changes after the fact, not
|
||||
bad writes.
|
||||
- **F2 extends R6 to indexes.** A commit that leaves a variant sharing an indexed value
|
||||
with another record is refused.
|
||||
- **One override per slot.**
|
||||
|
||||
**Open for the operator:** plain records with duplicate unique-index values are silently
|
||||
overwritten today. That predates variants. Fixing it could refuse existing stores.
|
||||
|
||||
**C2a status, 2026-09-30.** Hands pushed `hands/variants-c2a` at `640ad9a`.
|
||||
- Element ids are 12 hex characters. Writes mint them at random. Load mints them deterministically.
|
||||
Derived ids use `[CultElementId(nameof(Member))]`.
|
||||
- The CDN chunk ref's id is its offset. The witness types carry ids, and the ids cross C#, TS, Python and
|
||||
Rust.
|
||||
|
||||
Soul held it. Fixes are in Hands:
|
||||
- ids are never minted inside variant overrides;
|
||||
- interface unions are never minted;
|
||||
- a null derived source leaves an empty id;
|
||||
- load minting is culture-dependent;
|
||||
- the minted-on-load list goes stale;
|
||||
- a refused batch leaves minted ids behind;
|
||||
- the id format is unchecked;
|
||||
- late union-slot reuse passes registration.
|
||||
|
||||
**C2a rulings (operator, 2026-09-30):**
|
||||
- **Old readers are tripwired.** A store that holds element ids carries a marker that every pre-C2a reader
|
||||
refuses under C0's unknown-header rule. An old C# reader can therefore never silently strip nested ids.
|
||||
MessagePack's generated formatters skip unknown slots inside elements.
|
||||
- **Duplicate element ids refuse at load,** naming the record, the list and the id. This matches the
|
||||
duplicate-index ruling.
|
||||
- **C2b precondition (Soul F7).** Whether a pre-id element keeps its load-time id depends on the write path.
|
||||
C2b must refuse an override that targets a record whose ids exist only in memory, or mint on open.
|
||||
|
||||
**C2a Soul pass 2 (`e85b201b`), 2026-09-30: hold.** The tripwire only works against unreleased readers.
|
||||
Every published release (`cultlib-unity-v1.0.60`, `caching-unity-v1.4.0`, `cultcache-ts-v0.14.0`,
|
||||
`cultcache-py-v0.3.0`) predates C0. The 1.0.60 C# reader loads a v3 store, and its next write is v1 with the ids
|
||||
stripped (probe). Other findings:
|
||||
- F2: the same element object twice in one list commits, then the store refuses to load.
|
||||
- F3: CultMesh single-file writes bypass the marker.
|
||||
- F4: TS `pushAll` writes the header without reading it first.
|
||||
|
||||
**C2a rulings, 2026-09-30 (operator):**
|
||||
- **Rollout order closes the released-reader hole.** Ship a CultLib release that refuses v3, move every C#
|
||||
reader of a store onto it, then let a C2a writer touch that store. There is no format change for this.
|
||||
This supersedes the claim above that old readers are tripwired: that claim holds only for post-C0 readers.
|
||||
- **Mark by content.** A store is written as v3 (v5 for directory stores) only when a record actually holds an
|
||||
element id. An existing v3 header on disk stays.
|
||||
- Fix batch 3 in Hands: F2, marking by content in every runtime, F3 routed through the cache's header
|
||||
decision and load checks, F4.
|
||||
|
||||
**C2a Soul pass 3 (`9ed61dd2`), 2026-09-30: fix first.**
|
||||
- A flattened record's `HoldsIds` goes stale when its base changes in the same batch.
|
||||
- A conditional commit onto disk ignores content.
|
||||
- Rust `push_all` clobbers headers it cannot read.
|
||||
- TS keeps a stale header after the file goes away.
|
||||
- Self's claim that "rollout order makes the raw-writer hole benign" was wrong. The reader that strips ids
|
||||
from a v1 store is a post-C0, pre-C2a build. That is exactly the reader the v3 marker targets, and rollout
|
||||
order does not keep it away.
|
||||
|
||||
**Ruling, 2026-09-30 (operator): C0 is released only together with C2a.** The first CultLib release that
|
||||
refuses v3 also contains C2a, so no released reader strips ids. C0 and C1 are never released on their own.
|
||||
Raw and cross-runtime writers that cannot see ids stay sticky-only. Fix batch 4 is in Hands.
|
||||
|
||||
**Operator rulings, 2026-09-30:**
|
||||
- **Duplicate unique-index values do not load** ("Duplicate indices should not load"). This covers
|
||||
plain records too, so R6-for-indexes becomes one general rule:
|
||||
- a commit that would leave two records holding one indexed value is refused;
|
||||
- a store holding duplicates refuses to load, and the refusal names both keys and the index.
|
||||
- The variant-specific rule `RefuseVariantIndexSharing` collapses into this rule.
|
||||
- Before it ships, every real store is scanned, so no store stops loading by surprise.
|
||||
- **Scan result (Eyes, 2026-09-30):** `[CultIndex]` had been recycled as a grouping and
|
||||
selection-filter declaration: `Category`, `Kind`, `HardpointType`, `ShardId`, and others in
|
||||
CultMesh, Geometry and docs. AetheriaEve's `aetheria-world.cc` (taxidermy) is the only store
|
||||
holding duplicates. The only single-result lookups in real code are unique:
|
||||
`PlayerData.PlayerId` and `Email` in `Server.cs`. The operator: "Pretty sure indexes are
|
||||
supposed to be unique, it was not my decision to recycle that mechanism for grouping
|
||||
purposes" and "Grouping was a desirable feature from my old DatabaseListView that didn't
|
||||
quite make it into the initial CultCache Studio port".
|
||||
- **Resolution (Self, from those answers):** `[CultIndex]` means unique. Grouping belongs to
|
||||
the Studio record-grouping campaign's own attribute (`docs/studio-grouping-cut.md` on
|
||||
`codex/studio-grouping`, attribute-based per the 2026-09-17 ruling). CultNet selection
|
||||
filters any declared member by its catalog name, so no index is needed for filtering. The
|
||||
grouping-only `[CultIndex]` declarations are deleted. This is one CultLib cut, queued
|
||||
behind C2a because both edit `CultCache.cs`.
|
||||
- The TS/Python last-writer-wins parity gap closes by the same rule when those runtimes
|
||||
adopt it.
|
||||
- **CultNet invisibility is a defect** ("looks like a defect to me"): `cache.Commit` and
|
||||
`cache.UpsertAsync` on a database's cache must publish to subscribers and to the mutation
|
||||
log (`CultCache.Publish`'s `if (!loaded) continue;`).
|
||||
- **Hop subscriptions keep refusing variants** until CultNet carries deltas (C5): "keep refusing,
|
||||
I guess, CultNet will have deltas by the time we're done here anyway".
|
||||
|
||||
### C2. C# element identity and nested paths (Set, Insert, Remove)
|
||||
|
||||
Adds:
|
||||
- **`[CultElementId]`** (a string member). **Ids everywhere, not opt-in (Q3, operator
|
||||
2026-09-29): there is no `[CultVariants]` attribute.** The C# registry requires an id member
|
||||
on every object element type reachable through a list in any registered document type,
|
||||
union bases included, and refuses a type that lacks one.
|
||||
- **Minting**: on write, the cache mints ids for unset ids in every type, and refuses a
|
||||
duplicate id within one list. A store written before ids existed loads, and its ids are
|
||||
minted on its first write (minted on load, per the operator's rollout ruling).
|
||||
- **Path steps below top level.**
|
||||
- **Insert** anchored after an id, where `""` means the head. An inserted run sits
|
||||
immediately after its anchor and before base elements added there later.
|
||||
- **Remove** by id.
|
||||
- **Canonical order**: removes, then inserts in list order, then sets by path.
|
||||
- **Q2a refusal**: a base commit that orphans an override is refused.
|
||||
- **Q7**: whole-member Set for dictionaries, scalar lists and arrays.
|
||||
- **`CultCache.MintElementIds()`**: the one-shot rewrite for existing stores (ruling 1). It
|
||||
upserts every record that has an unset id through the normal path.
|
||||
- **The consumer sweep (Q3 rollout).** About 28 consumers carry list-element types, and each
|
||||
needs an id member before it can take the CultLib that requires one. C2 ships the
|
||||
requirement. Self schedules the per-repo sweep, one small type edit per consumer, before
|
||||
those consumers bump. C2 lists the element types it finds per consumer, as data for that
|
||||
sweep.
|
||||
|
||||
Keeps: the C1 format unchanged. C2 fills path shapes that C1 refused.
|
||||
|
||||
Verification:
|
||||
- Two look-alike elements (two of one union subtype) get different overrides, and a base
|
||||
insert before them retargets nothing.
|
||||
- Rebase re-applies overrides by id. A missing id is refused, and the message names the
|
||||
variant, path and id.
|
||||
- A Set on a removed element is refused.
|
||||
- Minting is idempotent.
|
||||
- A registered type with an id-less list element type is refused, naming the type and member.
|
||||
- A store written before ids loads, and its ids are minted exactly once on first write
|
||||
(idempotent).
|
||||
- A C#-written nested-override fixture joins `contracts/cultcache/`.
|
||||
|
||||
Command: `dotnet test tests/GameCult.Caching.Tests`.
|
||||
|
||||
Estimate +300 lines and +350 of tests. Build: as C1, without Networking.
|
||||
|
||||
### C3. Studio: edit the delta
|
||||
|
||||
Adds, in `CultInspectorModel` (engine-free; the lowering stays a renderer):
|
||||
- For a variant edit, each member path is marked inherited or overridden.
|
||||
- `Commit` writes `UpsertVariant` with overrides equal to the prior overrides plus a
|
||||
structural diff by id. It is deterministic because ids exist.
|
||||
- An explicit "override" pins a value equal to its base's.
|
||||
- `ClearOverride(path)`.
|
||||
- `CreateVariant(record)` next to `Duplicate`; it prompts for a name (R6).
|
||||
- `Rebase(record, base)` returns a conflict list for Q2a before commit.
|
||||
|
||||
Lowering, in `CultCacheStudioWindow`/`Drawers`: an overridden marker and a revert control per
|
||||
member, a base link, and variant creation and rebase controls.
|
||||
|
||||
Verification:
|
||||
- `CultInspectorModelTests`: the diff produces exactly the touched paths; clear removes one
|
||||
entry; the pin survives an equal value; rebase conflicts are listed.
|
||||
- Negative: a Studio commit never calls a plain `Upsert` at a variant key.
|
||||
|
||||
Only the operator can check these in Unity against a copy of `Aetheria.cc`:
|
||||
- the markers read correctly;
|
||||
- clearing reverts the value live;
|
||||
- a hull variant replaces `Hardpoints`;
|
||||
- a laser variant changes one behaviour's `PerformanceStat.Max`;
|
||||
- a base edit shows in the open variant.
|
||||
|
||||
Then tag `caching-unity-v1.5.0`. **This is the point where Aetheria is unblocked.**
|
||||
|
||||
Estimate +250 model, +150 lowering, +250 tests. Build: Caching.Tests. The Unity package is
|
||||
checked by the operator in the editor.
|
||||
|
||||
### C4. Aetheria adoption (Aetheria repo, its own cut, after the release)
|
||||
|
||||
- Bump the package to `v1.5.0`.
|
||||
- Add `[CultElementId]` to every list-element type Aetheria's documents carry (ids everywhere, Q3). There is no per-type opt-in.
|
||||
- Add `[CultElementId]` to `BehaviorData` (base) and `HardpointData`.
|
||||
- Run `MintElementIds` once over `GameData/Aetheria.cc`.
|
||||
- AetherDb migrations skip variants or use the variant API (Q1a).
|
||||
- Re-author the families listed in section 6.
|
||||
|
||||
### C5. CultNet deltas (C# reference)
|
||||
|
||||
Adds a new raw-record message version that carries the variant slot, following selection D3:
|
||||
a v1 message string, so peers not in this cut refuse through their own dispatch. The C1
|
||||
refusal in `CultNetDocumentRegistry` is deleted. Scheduled when a CultNet consumer needs
|
||||
variants; none does today.
|
||||
|
||||
### Parked: TypeScript, Python and Rust resolution
|
||||
|
||||
Each runtime gets its own cut when a consumer that reads a variant-bearing store exists
|
||||
(ruling 5). None does. `Aetheria.cc` is read by C# only.
|
||||
|
||||
## 6. First payoff in `Aetheria.cc`
|
||||
|
||||
Scan of the live file (206 records, `v1`): records of one schema whose leaves differ by at
|
||||
most 20 percent, name excluded. This is a heuristic; the operator confirms the real families.
|
||||
|
||||
| Type | Records | Families that collapse | Records becoming variants |
|
||||
| --- | --- | --- | --- |
|
||||
| geardata | 32 | Refinery / Assembly Line / Deep Ore / Surface Ore / Shipyard (1 to 3 leaves differ); Small / Medium / Large Drive; RevvITup 2.0 / deep space burnout / Talaria; Skiron / Iapyx; Arctica / cold like my heart | 10 |
|
||||
| weaponitemdata | 18 | ChargeBlast SG / ChargeBlast+- (14 of 253 leaves differ); pswarm / scorched void policy; Earp / 6k Shooter; Spectra / ColdFire | 4 |
|
||||
| hulldata | 5 | Longinus / LonginusX (only `Behaviors`, `Hardpoints` and two cosmetic members differ) | 1 |
|
||||
|
||||
That is 15 of 55 equipment records. In most of these families the difference lies inside
|
||||
`Behaviors`, so element-level overrides (C2) are what pay. Top-level overrides alone would
|
||||
still copy each behaviour list.
|
||||
|
||||
## 7. Consumers
|
||||
|
||||
There are 28 projects: C# 13, Rust 9, TypeScript 10, Python 1, Kotlin 0 (Explore survey;
|
||||
dependency-level for most, read in detail for Aetheria and AetheriaEve).
|
||||
- **See variants:** Aetheria through C# `Get`, `GetByName` and `CultRecordRef` (resolved
|
||||
documents only), CultCache Studio (deltas), and AetherDb tools (must honour Q1).
|
||||
- **Must stay oblivious or refuse:** every other consumer. No other project reads
|
||||
`Aetheria.cc`. Raw readers refuse through the empty payload plus C0/C1: `CultMesh`
|
||||
single-file reads, AetheriaEve's directory-page reader (the directory store refuses
|
||||
variants, Q6), and Mimir (writer only).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -1,8 +1,5 @@
|
|||
# NuGet Packaging
|
||||
|
||||
See `docs/semver-policy.md` for what a version bump on these packages is
|
||||
required to mean and how a release is checked against that before it ships.
|
||||
|
||||
CultLib publishes the managed dependency graph as separate packages. The leaf
|
||||
package is `GameCult.Mesh`; its NuGet dependencies preserve the CultMesh,
|
||||
CultNet, and CultCache ownership boundaries.
|
||||
|
|
@ -22,5 +19,5 @@ closure through UPM rather than NuGet restore.
|
|||
Install the Unity package from the immutable release tag:
|
||||
|
||||
```text
|
||||
https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.57
|
||||
https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.46
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
|
@ -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.
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-stryker": {
|
||||
"version": "5.0.0",
|
||||
"commands": [
|
||||
"dotnet-stryker"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 */
|
||||
File diff suppressed because it is too large
Load diff
355
package-lock.json
generated
355
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@ from .backing_store import (
|
|||
)
|
||||
|
||||
STORE_FORMAT_VERSION = "cultcache.store.v1"
|
||||
_STORE_FORMAT_PREFIX = "cultcache.store."
|
||||
_PERSISTED_RECORD_SLOTS = 4
|
||||
|
||||
|
||||
class JsonLinesBackingStore:
|
||||
|
|
@ -155,14 +153,7 @@ def _encode_v1_snapshot(envelopes: list[CultCacheEnvelope]) -> list[Any]:
|
|||
|
||||
|
||||
def _decode_v1_snapshot(decoded: Any) -> list[CultCacheEnvelope] | None:
|
||||
if not isinstance(decoded, list) or not decoded:
|
||||
return None
|
||||
if isinstance(decoded[0], str) and decoded[0].startswith(_STORE_FORMAT_PREFIX) and decoded[0] != STORE_FORMAT_VERSION:
|
||||
raise ValueError(
|
||||
f"CultCache store format {decoded[0]!r} is not readable; this runtime reads {STORE_FORMAT_VERSION!r} only. "
|
||||
"The store needs a runtime that resolves document variants."
|
||||
)
|
||||
if decoded[0] != STORE_FORMAT_VERSION:
|
||||
if not isinstance(decoded, list) or not decoded or decoded[0] != STORE_FORMAT_VERSION:
|
||||
return None
|
||||
if len(decoded) < 3 or not isinstance(decoded[1], list) or not isinstance(decoded[2], list):
|
||||
raise ValueError("CultCache v1 snapshot must contain a schema catalog and record array")
|
||||
|
|
@ -178,12 +169,7 @@ def _decode_v1_snapshot(decoded: Any) -> list[CultCacheEnvelope] | None:
|
|||
for raw_record in decoded[2]:
|
||||
if not isinstance(raw_record, list) or len(raw_record) < 4:
|
||||
raise ValueError("CultCache persisted records must be MessagePack arrays")
|
||||
if len(raw_record) > _PERSISTED_RECORD_SLOTS:
|
||||
raise ValueError(
|
||||
f"CultCache record {raw_record[0]!r} (schema {raw_record[1]!r}) has {len(raw_record)} slots; "
|
||||
f"this runtime reads {_PERSISTED_RECORD_SLOTS}. The store needs a runtime that resolves document variants."
|
||||
)
|
||||
key, schema_id, stored_at, payload = raw_record
|
||||
key, schema_id, stored_at, payload = raw_record[:4]
|
||||
if not isinstance(key, str) or not key:
|
||||
raise ValueError("CultCache persisted records must declare a key")
|
||||
if not isinstance(schema_id, str) or not schema_id:
|
||||
|
|
|
|||
|
|
@ -802,45 +802,5 @@ class CultCacheTests(unittest.TestCase):
|
|||
self.assertEqual(cache.get_required_envelope(document, "item:one").key, "item:one")
|
||||
|
||||
|
||||
# Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests.
|
||||
_VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "document-variants-c0"
|
||||
_ITEM_SCHEMA_ID = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788"
|
||||
|
||||
def _pull_vector(self, name: str) -> list[CultCacheEnvelope]:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store_path = Path(tmp) / "store.msgpack"
|
||||
store_path.write_bytes((self._VECTORS / name).read_bytes())
|
||||
return SingleFileMessagePackBackingStore(store_path).pull_all()
|
||||
|
||||
def test_single_file_refuses_unknown_header_by_name(self) -> None:
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
self._pull_vector("unknown-header.msgpack")
|
||||
self.assertIn("cultcache.store.v9", str(caught.exception))
|
||||
|
||||
def test_single_file_refuses_extra_record_slot_naming_the_record(self) -> None:
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
self._pull_vector("extra-slot-full-payload.msgpack")
|
||||
self.assertIn("item:anvil", str(caught.exception))
|
||||
self.assertIn(self._ITEM_SCHEMA_ID, str(caught.exception))
|
||||
|
||||
def test_single_file_refuses_variant_store_by_version_or_record(self) -> None:
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
self._pull_vector("variant-v2.msgpack")
|
||||
message = str(caught.exception)
|
||||
self.assertTrue("cultcache.store.v2" in message or "item:anvil-big" in message, message)
|
||||
|
||||
def test_single_file_refuses_the_csharp_written_variant_store_by_version_or_record(self) -> None:
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
self._pull_vector("../document-variants-c1/variant-store.msgpack")
|
||||
message = str(caught.exception)
|
||||
self.assertTrue("cultcache.store.v2" in message or "laser-big" in message, message)
|
||||
|
||||
def test_v1_store_written_at_the_base_commit_still_reads_byte_for_byte(self) -> None:
|
||||
envelopes = self._pull_vector("v1-base.msgpack")
|
||||
self.assertEqual([(e.key, e.type) for e in envelopes], [("alpha", "vectors.item"), ("beta", "vectors.item")])
|
||||
self.assertEqual(envelopes[0].payload, b"\x92\xa5alpha\x01")
|
||||
self.assertEqual(envelopes[1].payload, b"\x92\xa4beta\x02")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -271,7 +271,7 @@ struct PersistedSchemaCatalogMember(
|
|||
Option<String>,
|
||||
);
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
struct PersistedRecord(
|
||||
String,
|
||||
String,
|
||||
|
|
@ -279,67 +279,6 @@ struct PersistedRecord(
|
|||
#[serde(with = "serde_bytes")] Vec<u8>,
|
||||
);
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for PersistedRecord {
|
||||
/// Reads the four v1 slots and refuses a fifth, naming the record. A record with more
|
||||
/// slots was written by a runtime that resolves document variants.
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
|
||||
use serde::de::{Error, IgnoredAny, SeqAccess, Visitor};
|
||||
|
||||
struct RecordVisitor;
|
||||
impl<'de> Visitor<'de> for RecordVisitor {
|
||||
type Value = PersistedRecord;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("a CultCache persisted record of 4 slots")
|
||||
}
|
||||
|
||||
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> std::result::Result<PersistedRecord, A::Error> {
|
||||
let key: String = seq.next_element()?.ok_or_else(|| A::Error::invalid_length(0, &self))?;
|
||||
let schema_id: String = seq.next_element()?.ok_or_else(|| A::Error::invalid_length(1, &self))?;
|
||||
let stored_at: String = seq.next_element()?.ok_or_else(|| A::Error::invalid_length(2, &self))?;
|
||||
let payload: serde_bytes::ByteBuf =
|
||||
seq.next_element()?.ok_or_else(|| A::Error::invalid_length(3, &self))?;
|
||||
if seq.next_element::<IgnoredAny>()?.is_some() {
|
||||
return Err(A::Error::custom(format!(
|
||||
"CultCache record {key:?} (schema {schema_id:?}) has more than 4 slots; this runtime reads 4. The store needs a runtime that resolves document variants."
|
||||
)));
|
||||
}
|
||||
Ok(PersistedRecord(key, schema_id, stored_at, payload.into_vec()))
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_seq(RecordVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
/// The store header string, or `None` when the bytes are not a snapshot-shaped array
|
||||
/// (the legacy envelope array starts with a map).
|
||||
fn store_header(bytes: &[u8]) -> Option<String> {
|
||||
struct Header(String);
|
||||
impl<'de> serde::Deserialize<'de> for Header {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
|
||||
struct HeaderVisitor;
|
||||
impl<'de> serde::de::Visitor<'de> for HeaderVisitor {
|
||||
type Value = Header;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("an array whose first slot is the store header")
|
||||
}
|
||||
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> std::result::Result<Header, A::Error> {
|
||||
let header: String = seq
|
||||
.next_element()?
|
||||
.ok_or_else(|| <A::Error as serde::de::Error>::invalid_length(0, &self))?;
|
||||
while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
|
||||
Ok(Header(header))
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_seq(HeaderVisitor)
|
||||
}
|
||||
}
|
||||
rmp_serde::from_slice::<Header>(bytes).ok().map(|header| header.0)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct PushAllOptions {
|
||||
pub soft: bool,
|
||||
|
|
@ -719,11 +658,9 @@ impl SingleFileMessagePackBackingStore {
|
|||
if bytes.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
match store_header(&bytes) {
|
||||
Some(header) if header.starts_with("cultcache.store.") => decode_store_snapshot(&bytes),
|
||||
_ => rmp_serde::from_slice(&bytes).map_err(anyhow::Error::from),
|
||||
}
|
||||
.map_err(|error| anyhow!("failed to decode MessagePack {}: {error:#}", self.path.display()))
|
||||
decode_store_snapshot(&bytes)
|
||||
.or_else(|_| rmp_serde::from_slice(&bytes))
|
||||
.with_context(|| format!("failed to decode MessagePack {}", self.path.display()))
|
||||
}
|
||||
|
||||
/// Reads one filesystem snapshot without creating or opening the sibling
|
||||
|
|
@ -2581,15 +2518,11 @@ fn encode_store_snapshot(entries: &[CultCacheEnvelope]) -> Result<PersistedStore
|
|||
}
|
||||
|
||||
fn decode_store_snapshot(bytes: &[u8]) -> Result<Vec<CultCacheEnvelope>> {
|
||||
if let Some(header) = store_header(bytes) {
|
||||
if header != "cultcache.store.v1" {
|
||||
return Err(anyhow!(
|
||||
"CultCache store format {header:?} is not readable; this runtime reads \"cultcache.store.v1\" only. The store needs a runtime that resolves document variants."
|
||||
));
|
||||
}
|
||||
}
|
||||
let snapshot: PersistedStoreSnapshot =
|
||||
rmp_serde::from_slice(bytes).context("failed to decode CultCache v1 snapshot")?;
|
||||
if snapshot.0 != "cultcache.store.v1" {
|
||||
return Err(anyhow!("unsupported CultCache snapshot {}", snapshot.0));
|
||||
}
|
||||
|
||||
let catalog = snapshot
|
||||
.1
|
||||
|
|
@ -4640,62 +4573,4 @@ mod tests {
|
|||
assert_eq!(store.pull_all_read_only_snapshot()?, vec![admitted]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests.
|
||||
const ITEM_SCHEMA_ID: &str = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788";
|
||||
|
||||
fn pull_vector(name: &str) -> Result<Vec<CultCacheEnvelope>> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let path = temp.path().join("store.msgpack");
|
||||
let vector = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../tests/vectors/document-variants-c0")
|
||||
.join(name);
|
||||
std::fs::copy(vector, &path)?;
|
||||
SingleFileMessagePackBackingStore::new(&path).pull_all()
|
||||
}
|
||||
|
||||
fn refusal(name: &str) -> String {
|
||||
format!("{:#}", pull_vector(name).unwrap_err())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_file_refuses_unknown_header_by_name() {
|
||||
assert!(refusal("unknown-header.msgpack").contains("cultcache.store.v9"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_file_refuses_extra_record_slot_naming_the_record() {
|
||||
let message = refusal("extra-slot-full-payload.msgpack");
|
||||
assert!(message.contains("item:anvil"), "{message}");
|
||||
assert!(message.contains(ITEM_SCHEMA_ID), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_file_refuses_variant_store_by_version_or_record() {
|
||||
let message = refusal("variant-v2.msgpack");
|
||||
assert!(
|
||||
message.contains("cultcache.store.v2") || message.contains("item:anvil-big"),
|
||||
"{message}"
|
||||
);
|
||||
}
|
||||
|
||||
// The store the C# runtime wrote through its own variant API (tests/vectors/document-variants-c1).
|
||||
#[test]
|
||||
fn single_file_refuses_the_csharp_written_variant_store_by_version_or_record() {
|
||||
let message = refusal("../document-variants-c1/variant-store.msgpack");
|
||||
assert!(
|
||||
message.contains("cultcache.store.v2") || message.contains("laser-big"),
|
||||
"{message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_store_written_at_the_base_commit_still_reads_byte_for_byte() -> Result<()> {
|
||||
let envelopes = pull_vector("v1-base.msgpack")?;
|
||||
let rows: Vec<_> = envelopes.iter().map(|e| (e.key.as_str(), e.r#type.as_str())).collect();
|
||||
assert_eq!(rows, vec![("alpha", "vectors.item"), ("beta", "vectors.item")]);
|
||||
assert_eq!(envelopes[0].payload, b"\x92\xa5alpha\x01");
|
||||
assert_eq!(envelopes[1].payload, b"\x92\xa4beta\x02");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { dirname, resolve } from "node:path";
|
|||
import { decode, encode } from "@msgpack/msgpack";
|
||||
import { z } from "zod";
|
||||
|
||||
import { STORE_FORMAT_VERSION, isV1Snapshot, requireV1RecordSlots } from "./store-format";
|
||||
import type {
|
||||
CacheBackingStore,
|
||||
CultCacheEnvelope,
|
||||
|
|
@ -13,6 +12,8 @@ import type {
|
|||
PushAllOptions,
|
||||
} from "./types";
|
||||
|
||||
const STORE_FORMAT_VERSION = "cultcache.store.v1";
|
||||
|
||||
const envelopeSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
type: z.string().min(1),
|
||||
|
|
@ -284,7 +285,7 @@ function encodeCatalogMember(member: CultCacheSchemaCatalogMember): unknown[] {
|
|||
}
|
||||
|
||||
function decodeSnapshot(decoded: unknown): DecodedSnapshot | undefined {
|
||||
if (!isV1Snapshot(decoded)) {
|
||||
if (!Array.isArray(decoded) || decoded.length === 0 || decoded[0] !== STORE_FORMAT_VERSION) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
|
@ -435,7 +436,6 @@ function decodeRecord(value: unknown): PersistedRecord {
|
|||
throw new Error("CultCache persisted records must be MessagePack arrays.");
|
||||
}
|
||||
|
||||
requireV1RecordSlots(value);
|
||||
const [key = "", schemaId = "", storedAt = "", payload = new Uint8Array()] = value;
|
||||
if (!isNonEmptyString(key) || !isNonEmptyString(schemaId) || !isNonEmptyString(storedAt)) {
|
||||
throw new Error("CultCache persisted records must declare key, schemaId, and storedAt.");
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
export const STORE_FORMAT_VERSION = "cultcache.store.v1";
|
||||
|
||||
const STORE_FORMAT_PREFIX = "cultcache.store.";
|
||||
const PERSISTED_RECORD_SLOTS = 4;
|
||||
|
||||
/**
|
||||
* True for a v1 snapshot, false for anything that is not a CultCache store header (the legacy
|
||||
* envelope array). Any other `cultcache.store.*` header is refused by name.
|
||||
*/
|
||||
export function isV1Snapshot(decoded: unknown): decoded is unknown[] {
|
||||
if (!Array.isArray(decoded) || typeof decoded[0] !== "string" || !decoded[0].startsWith(STORE_FORMAT_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (decoded[0] !== STORE_FORMAT_VERSION) {
|
||||
throw new Error(
|
||||
`CultCache store format "${decoded[0]}" is not readable; this runtime reads "${STORE_FORMAT_VERSION}" only. ` +
|
||||
"The store needs a runtime that resolves document variants.",
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Refuses a persisted record with more slots than v1 defines, naming its key and schema id. */
|
||||
export function requireV1RecordSlots(record: unknown[]): void {
|
||||
if (record.length > PERSISTED_RECORD_SLOTS) {
|
||||
throw new Error(
|
||||
`CultCache record "${String(record[0])}" (schema "${String(record[1])}") has ${record.length} slots; ` +
|
||||
`this runtime reads ${PERSISTED_RECORD_SLOTS}. The store needs a runtime that resolves document variants.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1413,55 +1413,3 @@ async function runJsonCommand(
|
|||
|
||||
return JSON.parse(trimmed.split(/\r?\n/).at(-1) as string);
|
||||
}
|
||||
|
||||
// Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests.
|
||||
const variantVectors = resolve(cultLibRoot, "tests", "vectors", "document-variants-c0");
|
||||
const itemSchemaId = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788";
|
||||
|
||||
async function pullVector(name: string): Promise<CultCacheEnvelope[]> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "cultcache-vector-"));
|
||||
const file = join(dir, "store.msgpack");
|
||||
await writeFile(file, await readFile(join(variantVectors, name)));
|
||||
return new SingleFileMessagePackBackingStore(file).pullAll();
|
||||
}
|
||||
|
||||
test("SingleFileMessagePackBackingStore refuses an unknown store header by name", async () => {
|
||||
await assert.rejects(() => pullVector("unknown-header.msgpack"), /cultcache\.store\.v9/u);
|
||||
});
|
||||
|
||||
test("SingleFileMessagePackBackingStore refuses an extra record slot naming the record", async () => {
|
||||
await assert.rejects(
|
||||
() => pullVector("extra-slot-full-payload.msgpack"),
|
||||
(error: Error) => error.message.includes("item:anvil") && error.message.includes(itemSchemaId),
|
||||
);
|
||||
});
|
||||
|
||||
test("SingleFileMessagePackBackingStore refuses a variant store by version or record", async () => {
|
||||
await assert.rejects(
|
||||
() => pullVector("variant-v2.msgpack"),
|
||||
(error: Error) => error.message.includes("cultcache.store.v2") || error.message.includes("item:anvil-big"),
|
||||
);
|
||||
});
|
||||
|
||||
test("SingleFileMessagePackBackingStore refuses the C#-written variant store by version or record", async () => {
|
||||
await assert.rejects(
|
||||
() => pullVector("../document-variants-c1/variant-store.msgpack"),
|
||||
(error: Error) => error.message.includes("cultcache.store.v2") || error.message.includes("laser-big"),
|
||||
);
|
||||
});
|
||||
|
||||
test("CultCache inspector refuses the same vectors by name", async () => {
|
||||
const inspect = async (name: string) => inspectCultCacheBytes(name, await readFile(join(variantVectors, name)));
|
||||
await assert.rejects(() => inspect("unknown-header.msgpack"), /cultcache\.store\.v9/u);
|
||||
await assert.rejects(
|
||||
() => inspect("extra-slot-full-payload.msgpack"),
|
||||
(error: Error) => error.message.includes("item:anvil") && error.message.includes(itemSchemaId),
|
||||
);
|
||||
});
|
||||
|
||||
test("a v1 store written at the base commit still reads byte for byte", async () => {
|
||||
const envelopes = await pullVector("v1-base.msgpack");
|
||||
assert.deepEqual(envelopes.map((entry) => [entry.key, entry.type]), [["alpha", "vectors.item"], ["beta", "vectors.item"]]);
|
||||
assert.deepEqual([...envelopes[0]!.payload], [0x92, 0xa5, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x01]);
|
||||
assert.deepEqual([...envelopes[1]!.payload], [0x92, 0xa4, 0x62, 0x65, 0x74, 0x61, 0x02]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,13 +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.
|
||||
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.
|
||||
|
|
@ -59,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));
|
||||
|
|
|
|||
|
|
@ -6,16 +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.
|
||||
|
|
|
|||
|
|
@ -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,181 +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`) is the first and, for
|
||||
now, only consumer. 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.
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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 `
|
||||
|
|
|
|||
|
|
@ -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,120 +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;
|
||||
}
|
||||
|
||||
float cultmath_value_noise(float2 position)
|
||||
{
|
||||
float2 cell = floor(position);
|
||||
|
|
|
|||
|
|
@ -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 <= x <= 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 <= 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 <= x <= 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><= 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 4ba991e1b6c54e099bc7ea16541734c5
|
||||
|
|
@ -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>(∇F1, F1): the gradient and distance to the nearest feature point.</summary>
|
||||
public float4 nearest;
|
||||
|
||||
/// <summary>(∇F2 - ∇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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 76be9498b63e410891715b3ec965caf8
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
<RootNamespace>CultMath</RootNamespace>
|
||||
<AssemblyName>CultMath</AssemblyName>
|
||||
<PackageId>GameCult.Math</PackageId>
|
||||
<Version>0.2.4</Version>
|
||||
<Version>0.2.0</Version>
|
||||
<Description>Clean-room C# math shaped for HLSL-style renderer code.</Description>
|
||||
<Authors>GameCult</Authors>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
using System;
|
||||
|
||||
// Cut 2a-i fix batch 1: cellular_unit's [0,1) mapping bug (F7) is only reachable from cellular's
|
||||
// private call sites; this lets CultMath.Tests probe it directly instead of hunting for a hash
|
||||
// input that happens to land on the bit pattern under test.
|
||||
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("CultMath.Tests")]
|
||||
|
||||
namespace CultMath;
|
||||
|
||||
public static partial class math
|
||||
|
|
@ -110,9 +105,6 @@ public static partial class math
|
|||
public static float2 frac(float2 value) => value - floor(value);
|
||||
public static float3 frac(float3 value) => value - floor(value);
|
||||
public static float4 frac(float4 value) => value - floor(value);
|
||||
// dxc has no double Frc: it demotes to float, as it does for exp and lerp. CultMath's double
|
||||
// overloads keep double precision instead, so frac(double) is x - floor(x) in double.
|
||||
public static double frac(double value) => value - Math.Floor(value);
|
||||
|
||||
// dxc lowers float min/max/clamp to DXIL FMin/FMax (saturate to Saturate, defined as FMin(1, FMax(0, x))).
|
||||
// DXIL.rst: FMin(a, b) is a < b ? a : b, FMax(a, b) is a >= b ? a : b, and a NaN operand returns the other.
|
||||
|
|
@ -138,11 +130,6 @@ public static partial class math
|
|||
public static float2 clamp(float2 value, float2 minimum, float2 maximum) => min(max(value, minimum), maximum);
|
||||
public static float3 clamp(float3 value, float3 minimum, float3 maximum) => min(max(value, minimum), maximum);
|
||||
public static float4 clamp(float4 value, float4 minimum, float4 maximum) => min(max(value, minimum), maximum);
|
||||
// dxc lowers int clamp to IMin(IMax(x, a), b): inverted bounds return b rather than throwing like Math.Clamp.
|
||||
public static int clamp(int value, int minimum, int maximum) => min(max(value, minimum), maximum);
|
||||
public static int2 clamp(int2 value, int2 minimum, int2 maximum) => min(max(value, minimum), maximum);
|
||||
public static int3 clamp(int3 value, int3 minimum, int3 maximum) => min(max(value, minimum), maximum);
|
||||
public static int4 clamp(int4 value, int4 minimum, int4 maximum) => min(max(value, minimum), maximum);
|
||||
|
||||
// DXIL Saturate is FMin(1, FMax(0, x)); that operand order, unlike clamp(x, 0, 1), maps -0 to +0.
|
||||
public static float saturate(float value) => min(1.0f, max(0.0f, value));
|
||||
|
|
@ -218,10 +205,9 @@ public static partial class math
|
|||
public static float distance(float3 left, float3 right) => length(left - right);
|
||||
public static float distance(float4 left, float4 right) => length(left - right);
|
||||
|
||||
// dxc lowers normalize(x) to x * Rsqrt(Dot(x, x)), and DXIL Rsqrt is 1 / sqrt(src): a zero vector is 0 * inf = NaN.
|
||||
public static float2 normalize(float2 value) => value * (1.0f / MathF.Sqrt(dot(value, value)));
|
||||
public static float3 normalize(float3 value) => value * (1.0f / MathF.Sqrt(dot(value, value)));
|
||||
public static float4 normalize(float4 value) => value * (1.0f / MathF.Sqrt(dot(value, value)));
|
||||
public static float2 normalize(float2 value) => value / max(length(value), 1.0e-20f);
|
||||
public static float3 normalize(float3 value) => value / max(length(value), 1.0e-20f);
|
||||
public static float4 normalize(float4 value) => value / max(length(value), 1.0e-20f);
|
||||
public static quaternion normalize(quaternion value)
|
||||
{
|
||||
var length = MathF.Sqrt(value.x * value.x + value.y * value.y + value.z * value.z + value.w * value.w);
|
||||
|
|
@ -387,43 +373,6 @@ public static partial class math
|
|||
public static float hash(float2 value) => hash(dot(value, new float2(127.1f, 311.7f)));
|
||||
public static float hash(float3 value) => hash(dot(value, new float3(127.1f, 311.7f, 74.7f)));
|
||||
|
||||
public static uint asuint(float value) => (uint)BitConverter.SingleToInt32Bits(value);
|
||||
|
||||
// PCG hashes from Jarzynski and Olano, "Hash Functions for GPU Rendering" (JCGT 9(3), 2020). Integer-only, so
|
||||
// dxc and C# agree bit for bit. pcg3d/pcg4d carry uint bit patterns in int3/int4; float overloads hash IEEE bits.
|
||||
public static uint pcg(uint value)
|
||||
{
|
||||
var state = value * 747796405u + 2891336453u;
|
||||
var word = ((state >> (int)((state >> 28) + 4)) ^ state) * 277803737u;
|
||||
return (word >> 22) ^ word;
|
||||
}
|
||||
|
||||
public static int3 pcg3d(int3 value)
|
||||
{
|
||||
uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u, z = (uint)value.z * 1664525u + 1013904223u;
|
||||
x += y * z; y += z * x; z += x * y;
|
||||
x ^= x >> 16; y ^= y >> 16; z ^= z >> 16;
|
||||
x += y * z; y += z * x; z += x * y;
|
||||
return new int3((int)x, (int)y, (int)z);
|
||||
}
|
||||
|
||||
// The paper hashes 2 -> N inputs through pcg3d with the unused component held constant.
|
||||
public static int3 pcg3d(float2 value) => pcg3d(new float3(value, 0.0f));
|
||||
public static int3 pcg3d(float3 value) => pcg3d(new int3((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z)));
|
||||
|
||||
public static int4 pcg4d(int4 value)
|
||||
{
|
||||
uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u;
|
||||
uint z = (uint)value.z * 1664525u + 1013904223u, w = (uint)value.w * 1664525u + 1013904223u;
|
||||
x += y * w; y += z * x; z += x * y; w += y * z;
|
||||
x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; w ^= w >> 16;
|
||||
x += y * w; y += z * x; z += x * y; w += y * z;
|
||||
return new int4((int)x, (int)y, (int)z, (int)w);
|
||||
}
|
||||
|
||||
public static int4 pcg4d(float4 value) =>
|
||||
pcg4d(new int4((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z), (int)asuint(value.w)));
|
||||
|
||||
public static float log(float value) => MathF.Log(value);
|
||||
public static float2 log(float2 value) => new(log(value.x), log(value.y));
|
||||
public static float3 log(float3 value) => new(log(value.x), log(value.y), log(value.z));
|
||||
|
|
@ -526,79 +475,8 @@ public static partial class math
|
|||
a._m20 * b._m01 + a._m21 * b._m11 + a._m22 * b._m21,
|
||||
a._m20 * b._m02 + a._m21 * b._m12 + a._m22 * b._m22);
|
||||
|
||||
// Abramowitz & Stegun 7.1.26, a single-precision rational/exponential fit with a stated
|
||||
// max absolute error of 1.5e-7. erf is odd, so only x >= 0 is fit and the sign is restored.
|
||||
// Measured against an independent reference: worst absolute error 6.621e-7, inside the
|
||||
// 2e-6 bar this package's tests pin.
|
||||
public static float erf(float value)
|
||||
{
|
||||
const float p = 0.3275911f;
|
||||
const float a1 = 0.254829592f;
|
||||
const float a2 = -0.284496736f;
|
||||
const float a3 = 1.421413741f;
|
||||
const float a4 = -1.453152027f;
|
||||
const float a5 = 1.061405429f;
|
||||
|
||||
var s = sign(value);
|
||||
var x = abs(value);
|
||||
var t = 1.0f / (1.0f + p * x);
|
||||
var poly = ((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t;
|
||||
var result = 1.0f - poly * exp(-x * x);
|
||||
return s * result;
|
||||
}
|
||||
|
||||
// Giles, "Approximating the erfinv function" (GPU Computing Gems, 2010): a single-precision
|
||||
// minimax polynomial in two ranges of w = -ln((1-x)(1+x)), central and tail.
|
||||
// Domain is [-1, 1]. Outside it, including +-infinity, the result is NaN (there is no real
|
||||
// inverse). At the edges the mathematical limit is infinite: erfinv(1) = +infinity and
|
||||
// erfinv(-1) = -infinity. The unguarded polynomial hits log(0) = -infinity at those edges and
|
||||
// the tail branch's leading coefficient is negative, so it delivers the wrong sign; guard the
|
||||
// edges explicitly instead of trusting the fit through the singularity.
|
||||
// Measured against an independent reference: worst absolute error 5.066e-7, and the
|
||||
// erf(erfinv(y)) round trip 6.109e-7 worst case, both inside the 2e-6 bar this package's
|
||||
// tests pin.
|
||||
public static float erfinv(float value)
|
||||
{
|
||||
if (value >= 1.0f) return value > 1.0f ? float.NaN : float.PositiveInfinity;
|
||||
if (value <= -1.0f) return value < -1.0f ? float.NaN : float.NegativeInfinity;
|
||||
|
||||
var w = -log((1.0f - value) * (1.0f + value));
|
||||
float p;
|
||||
if (w < 5.0f)
|
||||
{
|
||||
w -= 2.5f;
|
||||
p = 2.81022636e-08f;
|
||||
p = 3.43273939e-07f + p * w;
|
||||
p = -3.5233877e-06f + p * w;
|
||||
p = -4.39150654e-06f + p * w;
|
||||
p = 0.00021858087f + p * w;
|
||||
p = -0.00125372503f + p * w;
|
||||
p = -0.00417768164f + p * w;
|
||||
p = 0.246640727f + p * w;
|
||||
p = 1.50140941f + p * w;
|
||||
}
|
||||
else
|
||||
{
|
||||
w = sqrt(w) - 3.0f;
|
||||
p = -0.000200214257f;
|
||||
p = 0.000100950558f + p * w;
|
||||
p = 0.00134934322f + p * w;
|
||||
p = -0.00367342844f + p * w;
|
||||
p = 0.00573950773f + p * w;
|
||||
p = -0.0076224613f + p * w;
|
||||
p = 0.00943887047f + p * w;
|
||||
p = 1.00167406f + p * w;
|
||||
p = 2.83297682f + p * w;
|
||||
}
|
||||
|
||||
return p * value;
|
||||
}
|
||||
|
||||
// Ashima Arts / Ian McEwan 3D simplex noise (MIT), mirrored by cultmath_snoise(float3)
|
||||
// in shaders/CultMath.hlsl with the same float32 evaluation order. Follows stegu/webgl-noise
|
||||
// src/noise3D.glsl at 22434e04d7 (kernel radius^2 0.5, scale 105, permute (34x+10)x); see
|
||||
// THIRD-PARTY-NOTICES.md. Continuous across simplex-cell boundaries
|
||||
// (NoiseGradTests.SnoiseAndItsGradientAreContinuousAcrossSimplexCellBoundaries).
|
||||
// in shaders/CultMath.hlsl with the same float32 evaluation order.
|
||||
public static float snoise(float3 value)
|
||||
{
|
||||
const float cx = 1.0f / 6.0f;
|
||||
|
|
@ -645,9 +523,9 @@ public static partial class math
|
|||
p2 *= norm.z;
|
||||
p3 *= norm.w;
|
||||
|
||||
var m = max(0.5f - new float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0f);
|
||||
var m = max(0.6f - new float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0f);
|
||||
m = m * m;
|
||||
return 105.0f * dot(m * m, new float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
|
||||
return 42.0f * dot(m * m, new float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
|
||||
}
|
||||
|
||||
// Ashima Arts / Ian McEwan 2D simplex noise, kept component-explicit so
|
||||
|
|
@ -693,280 +571,8 @@ public static partial class math
|
|||
private static float2 snoise_mod289(float2 value) => value - floor(value * (1.0f / 289.0f)) * 289.0f;
|
||||
private static float3 snoise_mod289(float3 value) => value - floor(value * (1.0f / 289.0f)) * 289.0f;
|
||||
private static float4 snoise_mod289(float4 value) => value - floor(value * (1.0f / 289.0f)) * 289.0f;
|
||||
private static float3 snoise_permute(float3 value) => snoise_mod289(((value * 34.0f) + 10.0f) * value);
|
||||
private static float4 snoise_permute(float4 value) => snoise_mod289(((value * 34.0f) + 10.0f) * value);
|
||||
|
||||
// Analytic gradient of snoise(float3) (invariant 8), following the differentiation in
|
||||
// webgl-noise src/noise3Dgrad.glsl (Ashima Arts / McEwan, MIT), which uses the same kernel
|
||||
// radius^2 0.5, permutation (34x+10)x and scale 105 as snoise(float3) (stegu/webgl-noise
|
||||
// 22434e04d7). Every local up through p0..p3 and x0..x3 below is snoise(float3)'s own derivation
|
||||
// verbatim, so .w is that same field (NoiseGradTests.SnoiseGradValueIsBitEqualToSnoise pins the
|
||||
// two bit-equal over a random sweep). Per corner, m0 = max(0.5 - dot(x,x), 0), so dm0/dx = -2x,
|
||||
// and d(m0^4 * dot(p,x))/dx = 4*m0^3*(-2x)*dot(p,x) + m0^4*p = -8*m0^3*dot(p,x)*x + m0^4*p (p is
|
||||
// that corner's own gradient constant, independent of x); summed over the four corners and
|
||||
// scaled by the same 105 as the value.
|
||||
public static float4 snoise_grad(float3 value)
|
||||
{
|
||||
const float cx = 1.0f / 6.0f;
|
||||
const float cy = 1.0f / 3.0f;
|
||||
var i = floor(value + dot(value, new float3(cy, cy, cy)));
|
||||
var x0 = value - i + dot(i, new float3(cx, cx, cx));
|
||||
var g = step(new float3(x0.y, x0.z, x0.x), x0);
|
||||
var l = 1.0f - g;
|
||||
var lzxy = new float3(l.z, l.x, l.y);
|
||||
var i1 = min(g, lzxy);
|
||||
var i2 = max(g, lzxy);
|
||||
var x1 = x0 - i1 + cx;
|
||||
var x2 = x0 - i2 + cy;
|
||||
var x3 = x0 - 0.5f;
|
||||
|
||||
i = snoise_mod289(i);
|
||||
var p = snoise_permute(snoise_permute(snoise_permute(
|
||||
i.z + new float4(0.0f, i1.z, i2.z, 1.0f)) + i.y + new float4(0.0f, i1.y, i2.y, 1.0f)) + i.x + new float4(0.0f, i1.x, i2.x, 1.0f));
|
||||
|
||||
const float n = 0.142857142857f;
|
||||
var ns = new float3(2.0f * n, 0.5f * n - 1.0f, n);
|
||||
var j = p - 49.0f * floor(p * ns.z * ns.z);
|
||||
var xs = floor(j * ns.z);
|
||||
var ys = floor(j - 7.0f * xs);
|
||||
var x = xs * ns.x + ns.y;
|
||||
var y = ys * ns.x + ns.y;
|
||||
var h = 1.0f - abs(x) - abs(y);
|
||||
|
||||
var b0 = new float4(x.x, x.y, y.x, y.y);
|
||||
var b1 = new float4(x.z, x.w, y.z, y.w);
|
||||
var s0 = floor(b0) * 2.0f + 1.0f;
|
||||
var s1 = floor(b1) * 2.0f + 1.0f;
|
||||
var sh = -step(h, 0.0f);
|
||||
var a0 = new float4(b0.x, b0.z, b0.y, b0.w) + new float4(s0.x, s0.z, s0.y, s0.w) * new float4(sh.x, sh.x, sh.y, sh.y);
|
||||
var a1 = new float4(b1.x, b1.z, b1.y, b1.w) + new float4(s1.x, s1.z, s1.y, s1.w) * new float4(sh.z, sh.z, sh.w, sh.w);
|
||||
|
||||
var p0 = new float3(a0.x, a0.y, h.x);
|
||||
var p1 = new float3(a0.z, a0.w, h.y);
|
||||
var p2 = new float3(a1.x, a1.y, h.z);
|
||||
var p3 = new float3(a1.z, a1.w, h.w);
|
||||
var norm = 1.79284291400159f - 0.85373472095314f * new float4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3));
|
||||
p0 *= norm.x;
|
||||
p1 *= norm.y;
|
||||
p2 *= norm.z;
|
||||
p3 *= norm.w;
|
||||
|
||||
var m0 = max(0.5f - new float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0f);
|
||||
var m2 = m0 * m0;
|
||||
var m3 = m2 * m0;
|
||||
var m4 = m2 * m2;
|
||||
|
||||
var px = new float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3));
|
||||
var value2 = 105.0f * dot(m4, px);
|
||||
var grad = -8.0f * m3.x * x0 * px.x + m4.x * p0
|
||||
+ -8.0f * m3.y * x1 * px.y + m4.y * p1
|
||||
+ -8.0f * m3.z * x2 * px.z + m4.z * p2
|
||||
+ -8.0f * m3.w * x3 * px.w + m4.w * p3;
|
||||
grad *= 105.0f;
|
||||
|
||||
return new float4(grad, value2);
|
||||
}
|
||||
|
||||
// Octave sum of snoise_grad (invariant 8). Each octave samples snoise_grad at p scaled by the
|
||||
// running frequency, so by the chain rule its gradient scales by that same frequency
|
||||
// (d/dp[n(f*p)] = f * grad_n(f*p)); amplitude and frequency both compound per octave (gain,
|
||||
// lacunarity respectively), not applied once for the whole sum, so with 1 octave this is exactly
|
||||
// snoise_grad (NoiseGradTests.FbmGradWithOneOctaveEqualsSnoiseGrad). octaves is clamped to
|
||||
// [0, 16]: HlslSourceCompatibilityTests' bit-parity oracle drives every mirrored int parameter
|
||||
// across the full int32 range, which is the right domain for pcg3d/pcg4d's O(1) hash inputs but
|
||||
// would turn an unclamped octave count into a multi-billion-iteration loop for this mirror.
|
||||
public static float4 fbm_grad(float3 p, int octaves, float lacunarity, float gain)
|
||||
{
|
||||
octaves = clamp(octaves, 0, 16);
|
||||
var amplitude = 1.0f;
|
||||
var frequency = 1.0f;
|
||||
var value = 0.0f;
|
||||
var gradient = new float3(0.0f, 0.0f, 0.0f);
|
||||
for (var i = 0; i < octaves; i++)
|
||||
{
|
||||
var n = snoise_grad(p * frequency);
|
||||
value += amplitude * n.w;
|
||||
gradient += amplitude * frequency * new float3(n.x, n.y, n.z);
|
||||
frequency *= lacunarity;
|
||||
amplitude *= gain;
|
||||
}
|
||||
|
||||
return new float4(gradient, value);
|
||||
}
|
||||
|
||||
// Ridged multifractal noise (Musgrave, Texturing and Modeling, ch. 16; the operator's dune term,
|
||||
// 2026-09-25): each octave folds snoise_grad about zero, value = Sum a_i*(1 - |n_i|), gradient =
|
||||
// -Sum a_i*f_i*sign(n_i)*grad(n_i), the same per-octave frequency scaling as fbm_grad's chain
|
||||
// rule plus the fold's sign flip (d|n|/dp = sign(n)*dn/dp). The crease at n_i = 0 is real and
|
||||
// deliberate, not a bug: NoiseGradTests excludes a band around it, the same way cellular excludes
|
||||
// its F1 = F2 seam. sign follows this codebase's HLSL-matching convention (design.md: sign
|
||||
// returns int, 0 at exactly 0), so the crease itself contributes no gradient rather than an
|
||||
// arbitrary one. Same octave clamp as fbm_grad, for the same bit-parity-oracle reason.
|
||||
public static float4 ridged_grad(float3 p, int octaves, float lacunarity, float gain)
|
||||
{
|
||||
octaves = clamp(octaves, 0, 16);
|
||||
var amplitude = 1.0f;
|
||||
var frequency = 1.0f;
|
||||
var value = 0.0f;
|
||||
var gradient = new float3(0.0f, 0.0f, 0.0f);
|
||||
for (var i = 0; i < octaves; i++)
|
||||
{
|
||||
var n = snoise_grad(p * frequency);
|
||||
var s = (float)sign(n.w);
|
||||
value += amplitude * (1.0f - abs(n.w));
|
||||
gradient += -amplitude * frequency * s * new float3(n.x, n.y, n.z);
|
||||
frequency *= lacunarity;
|
||||
amplitude *= gain;
|
||||
}
|
||||
|
||||
return new float4(gradient, value);
|
||||
}
|
||||
|
||||
// Inigo Quilez, "smooth minimum" (https://iquilezles.org/articles/smin/): the quadratic-polynomial
|
||||
// smin, carried to value-and-gradient form (invariant 8). h is affine in (b.w - a.w) inside the
|
||||
// smoothing band, so differentiating value = lerp(b.w, a.w, h) - k*h*(1-h) with respect to
|
||||
// position, the terms carrying dh/dp cancel exactly (dh/dp * [(a.w-b.w) - k + 2*k*h] and the
|
||||
// bracket is identically zero given how h is built from a.w-b.w). What is left is exactly
|
||||
// lerp(∇b, ∇a, h): the gradient blend is not an approximation, it is the analytic gradient.
|
||||
// Outside the band (|a.w - b.w| >= k), h saturates to 0 or 1 and this is exactly min(a, b) with
|
||||
// that input's own gradient, continuously (h and the correction term both reach the boundary at
|
||||
// the same value from the smooth side, so there is no kink to exclude here the way cellular's
|
||||
// F1 = F2 seam needs one).
|
||||
public static float4 smin_grad(float4 a, float4 b, float k)
|
||||
{
|
||||
var h = saturate(0.5f + 0.5f * (b.w - a.w) / k);
|
||||
var value = lerp(b.w, a.w, h) - k * h * (1.0f - h);
|
||||
var gradient = lerp(new float3(b.x, b.y, b.z), new float3(a.x, a.y, a.z), h);
|
||||
return new float4(gradient, value);
|
||||
}
|
||||
|
||||
// Worley, "A Cellular Texture Basis Function" (SIGGRAPH 1996): F1/F2 and their analytic
|
||||
// gradients, searched over the jittered neighbourhood of feature points. Feature points are
|
||||
// selected with pcg3d over each neighbour's integer cell coordinate (design.md, "Integer
|
||||
// hashing"), never the float32 bit pattern of that coordinate and never the sin-based hash.
|
||||
// Hashing the integer cell coordinate directly is exact and runtime-independent, and it is what
|
||||
// the HLSL mirror does, so C# and shader stay bit-for-bit aligned. What pins this is the oracle
|
||||
// and mirror tests, not a uniformity measurement: reverting to hashing the float32 bit pattern
|
||||
// (or the sin-based hash) is caught by ProductionSearchMatchesTheBruteForceOracleExactly,
|
||||
// EveryMirrorFunctionMatchesCSharpMath, F1NeverExceedsF2, and the other tests that key off the
|
||||
// integer-hash fixtures. The
|
||||
// gradient of a distance field, del|p - c|, is the unit vector (p - c)/|p - c|; that is undefined exactly at
|
||||
// a feature point (F1 = 0 or F2 = 0), so cellular follows the repo's existing degenerate-normal
|
||||
// convention (GameCult.Geometry.CultGeometryIsoSurface.EmitOrientedTriangle: guard the zero-length
|
||||
// case and return the zero vector instead of the NaN a bare normalize would produce there).
|
||||
//
|
||||
// The search radius is 2 cells on every axis (5x5x5), not the classic 1-cell (3x3x3) radius,
|
||||
// because 3x3x3 is not exact: measured 9 of 400,000 points in [-50,50]^3 land on the wrong F2
|
||||
// (worst error 0.088), and past |p| of about 2^23 the float32 jitter can put two distinct cells'
|
||||
// features on the same value, which 3x3x3 has no chance of noticing either way. 5x5x5 is exact,
|
||||
// not merely safer, by the following argument:
|
||||
//
|
||||
// Let u = p - cell be the query's position inside its own cell (u in [0,1)^3 per component), and
|
||||
// let a candidate feature at integer cell offset d = (dx, dy, dz) sit at d + j for some jitter j
|
||||
// in [0,1)^3. Per axis, the displacement from p to that feature is (u_i - j_i) - d_i; since
|
||||
// u_i - j_i is always strictly inside (-1, 1) regardless of the actual u_i and j_i values,
|
||||
// |displacement_i| is always strictly greater than max(0, |d_i| - 1). So every cell with |d_i| >=
|
||||
// 3 on some axis is strictly farther than 2 from p (that axis alone already gives max(0, 3-1) =
|
||||
// 2). Now bound F1 and F2 from above using two real, always-present candidates: the query's own
|
||||
// cell (d = 0), whose distance is always strictly less than sqrt(3) (every axis strictly under
|
||||
// 1); and the near-side neighbour on whichever single axis's u_i sits closest to a cell
|
||||
// boundary. Let m = min(u_i, 1 - u_i) on that chosen axis (m is in [0, 1/2] by construction,
|
||||
// since it is the smaller of the two). Stepping one cell towards that nearer boundary puts the
|
||||
// query within (1 + m) of the far face on the offset axis and within (1 - m) of the near face on
|
||||
// each of the other two axes, so that neighbour's squared distance is at most
|
||||
// (1 + m)^2 + 2*(1 - m)^2 = 3 - 2m + 3m^2, which is <= 3 for every m in [0, 1/2] (3 at m = 0,
|
||||
// dipping to a minimum of 8/3 ~= 2.667 at m = 1/3, then rising back to 2.75 at m = 1/2), and
|
||||
// strictly below 3 because the jitter never reaches exactly 0 or
|
||||
// 1 (u_i - j_i stays strictly inside (-1, 1), so this bound, like the own-cell one, is never
|
||||
// tight). F2 is at most the larger of these two real candidates (adding candidates to a set can
|
||||
// only lower or hold its 2nd-smallest value), so F1 < sqrt(3) and F2 < sqrt(3) always: roughly
|
||||
// 1.732 < 2, margin 2 - sqrt(3) ~= 0.268 (Soul's adversarial check, cut 2a-i: the worst
|
||||
// realizable in-box F2 is exactly sqrt(3), reached only in the unattainable limit at a cell
|
||||
// corner). Every cell outside the 5x5x5 block is farther than 2, hence farther than either F1 or
|
||||
// F2, so the true global F1 and F2 are always inside it. (The matching argument for 3x3x3 only
|
||||
// reaches radius 1, whose excluded cells start at distance > 1, which is less than the 1.732
|
||||
// ceiling — the gap 3x3x3's failures live in.)
|
||||
//
|
||||
// An earlier version of this proof picked the near-side neighbour on a fixed axis rather than
|
||||
// whichever axis sits closest to its boundary, and that version is wrong (Soul, cut 2a-i):
|
||||
// u = (0.5, 0, 0) with the x-neighbour's jittered feature at (-1, 1 - 2^-24, 1 - 2^-24) gives a
|
||||
// distance of about 2.06, outside the radius-2 exclusion the proof needs. Choosing the axis by
|
||||
// proximity to its own boundary is what keeps m <= 1/2 and the bound at sqrt(3); x was the wrong
|
||||
// axis to fix there because u.x = 0.5 is the point on the whole cube farthest from any boundary.
|
||||
//
|
||||
// The loop below still visits every one of the 125 cells in a fixed order, but skips the
|
||||
// pcg3d/jitter/distance work for a cell whose distance cannot possibly beat the current f2: that
|
||||
// per-cell lower bound (the max(0, |d_i| - 1) term above, combined across axes) depends only on
|
||||
// the integer offset, never on p or the hash, so pruning a cell the unpruned loop would also
|
||||
// have rejected changes no output bit (CellularExactSearchTests pins the pruned/unpruned
|
||||
// equivalence and the exactness against a 7x7x7 brute-force oracle).
|
||||
public static CultCellular cellular(float3 p)
|
||||
{
|
||||
var cell = floor(p);
|
||||
var f1 = 1.0e30f;
|
||||
var f2 = 1.0e30f;
|
||||
var c1 = new float3(0.0f, 0.0f, 0.0f);
|
||||
var c2 = new float3(0.0f, 0.0f, 0.0f);
|
||||
var cellId = new float3(0.0f, 0.0f, 0.0f);
|
||||
|
||||
for (var dz = -2; dz <= 2; dz++)
|
||||
{
|
||||
var lz = max(0.0f, abs((float)dz) - 1.0f);
|
||||
for (var dy = -2; dy <= 2; dy++)
|
||||
{
|
||||
var ly = max(0.0f, abs((float)dy) - 1.0f);
|
||||
for (var dx = -2; dx <= 2; dx++)
|
||||
{
|
||||
var lx = max(0.0f, abs((float)dx) - 1.0f);
|
||||
var lowerBound = sqrt(lx * lx + ly * ly + lz * lz);
|
||||
if (lowerBound >= f2)
|
||||
continue;
|
||||
|
||||
var neighbor = cell + new float3(dx, dy, dz);
|
||||
var hash = pcg3d(int3(neighbor));
|
||||
var jitter = new float3(cellular_unit(hash.x), cellular_unit(hash.y), cellular_unit(hash.z));
|
||||
var feature = neighbor + jitter;
|
||||
var d = distance(p, feature);
|
||||
|
||||
if (d < f1)
|
||||
{
|
||||
f2 = f1; c2 = c1;
|
||||
f1 = d; c1 = feature; cellId = neighbor;
|
||||
}
|
||||
else if (d < f2)
|
||||
{
|
||||
f2 = d; c2 = feature;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// F1 = 0 is reachable (p sits exactly on a feature point). F2 = 0 needs two distinct cells'
|
||||
// pcg3d-jittered features to land on the same float32 value in every component: unreachable
|
||||
// at ordinary magnitudes, but confirmed reachable past roughly |p| = 2^23 (design.md,
|
||||
// "Precision domain"), so both share the same degenerate-gradient guard.
|
||||
var grad1 = f1 > 0.0f ? (p - c1) / f1 : new float3(0.0f, 0.0f, 0.0f);
|
||||
var grad2 = f2 > 0.0f ? (p - c2) / f2 : new float3(0.0f, 0.0f, 0.0f);
|
||||
|
||||
// id comes from pcg4d over the winning cell's integer coordinate alone (same int-hashing
|
||||
// rule as the jitter above), never from the pcg3d hash that produced its jitter: sharing a
|
||||
// hash between id and jitter would make id a deterministic function of the jitter it is
|
||||
// supposed to be independent from, correlated across every cell (F4).
|
||||
var idHash = pcg4d(new int4(int3(cellId), 0));
|
||||
|
||||
return new CultCellular(
|
||||
new float4(grad1, f1),
|
||||
new float4(grad2 - grad1, f2 - f1),
|
||||
cellular_unit(idHash.w));
|
||||
}
|
||||
|
||||
// Maps a hash component's uint bit pattern to [0, 1) using the top 24 bits, so the result is
|
||||
// always strictly less than 1: a plain divide-by-2^32 rounds 0xFFFFFF80..0xFFFFFFFF up to
|
||||
// exactly 1.0f in float32, outside the documented range. (uint >> 8) has at most 24 significant
|
||||
// bits, which float32's 24-bit mantissa represents exactly, and the division is by an exact power
|
||||
// of two, so both the shift-and-convert and the divide are the same IEEE-754 round-to-nearest on
|
||||
// dxc and C#, bit-exact on both sides.
|
||||
internal static float cellular_unit(int bits) => ((uint)bits >> 8) * (1.0f / 16777216.0f);
|
||||
private static float3 snoise_permute(float3 value) => snoise_mod289(((value * 34.0f) + 1.0f) * value);
|
||||
private static float4 snoise_permute(float4 value) => snoise_mod289(((value * 34.0f) + 1.0f) * value);
|
||||
|
||||
public static float value_noise(float2 position)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,8 +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);
|
||||
Assert.Contains("CULTMATH_NORMALIZE_EPSILON", include);
|
||||
Assert.Contains("max(length(value), CULTMATH_NORMALIZE_EPSILON)", include);
|
||||
}
|
||||
|
||||
private static string GetCultMathRoot()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,11 +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)));
|
||||
}
|
||||
|
||||
// HLSL functions carry no access modifier, so they compile as private instance methods.
|
||||
|
|
@ -67,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_")))
|
||||
|
|
@ -118,140 +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(33, compared.Count);
|
||||
Assert.Equal(26, compared.Count);
|
||||
}
|
||||
|
||||
/// <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; }
|
||||
private static int Components(Type t) => t.GetFields().Count(f => f.FieldType == typeof(float) && !f.IsStatic);
|
||||
|
||||
[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)));
|
||||
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)
|
||||
|
|
@ -269,13 +115,7 @@ 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";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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++) {
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this package are documented in this file.
|
||||
|
||||
Earlier releases (0.2.0-0.2.3) predate this package's changelog and are not
|
||||
backfilled here; see `docs/semver-policy.md` for why.
|
||||
|
||||
## [0.2.4]
|
||||
|
||||
### Added
|
||||
|
||||
- `math.erf` and `math.erfinv`: single-precision error function and its
|
||||
inverse. `erf` is the Abramowitz & Stegun 7.1.26 rational/exponential fit
|
||||
(max absolute error 1.5e-7 by that source, 6.621e-7 measured here against
|
||||
an independent reference). `erfinv` is Giles' minimax polynomial
|
||||
("Approximating the erfinv function", GPU Computing Gems, 2010), guarded
|
||||
explicitly at the domain edges (`erfinv(1) = +Infinity`,
|
||||
`erfinv(-1) = -Infinity`, outside `[-1, 1]` is `NaN`); measured worst
|
||||
absolute error 5.066e-7, worst `erf(erfinv(y))` round trip 6.109e-7.
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 0a6d25c5f7894c0d8e83f2b9d69358a4
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -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,120 +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;
|
||||
}
|
||||
|
||||
float cultmath_value_noise(float2 position)
|
||||
{
|
||||
float2 cell = floor(position);
|
||||
|
|
|
|||
|
|
@ -6,16 +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.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "org.gamecult.cultmath",
|
||||
"displayName": "CultMath",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.0",
|
||||
"unity": "2021.3",
|
||||
"description": "Precompiled CultMath numeric primitives and matching HLSL semantics for GameCult Unity runtimes.",
|
||||
"license": "MPL-2.0",
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
"directory": "packages/cultmesh-browser"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@msgpack/msgpack": "^3.1.3",
|
||||
|
|
|
|||
|
|
@ -15,28 +15,42 @@ import {
|
|||
type CultMeshSessionAcceptedMessage,
|
||||
type CultMeshSessionOpenMessage,
|
||||
} from "cultnet-ts/contracts";
|
||||
import {
|
||||
base64ToBytes,
|
||||
bytesToBase64,
|
||||
isLoopbackEndpoint,
|
||||
isUnsignedCertificate,
|
||||
routeCertificateView,
|
||||
verifyAuthorityRoute,
|
||||
verifyProviderSessionProof,
|
||||
type CultMeshAuthorityIdentity,
|
||||
type CultMeshAuthorityRouteCertificate,
|
||||
type CultMeshAuthorityRouteView,
|
||||
type CultMeshAuthorityTrustMode,
|
||||
type CultMeshAuthorityTrustPolicy,
|
||||
type CultMeshP256PublicKey,
|
||||
} from "cultnet-ts/authority";
|
||||
|
||||
export type CultMeshBrowserIdentity = CultMeshAuthorityIdentity;
|
||||
export type CultMeshBrowserRoute = CultMeshAuthorityRouteView;
|
||||
export type CultMeshBrowserP256PublicKey = CultMeshP256PublicKey;
|
||||
export type CultMeshBrowserRouteCertificate = CultMeshAuthorityRouteCertificate;
|
||||
export type CultMeshBrowserAuthorityTrustMode = CultMeshAuthorityTrustMode;
|
||||
export type CultMeshBrowserAuthorityTrustPolicy = CultMeshAuthorityTrustPolicy;
|
||||
export interface CultMeshBrowserIdentity {
|
||||
verseId: string;
|
||||
authorityRuntimeId: string;
|
||||
}
|
||||
|
||||
export interface CultMeshBrowserRoute extends CultMeshBrowserIdentity {
|
||||
endpoint: string;
|
||||
protocolId?: string;
|
||||
protocolIds?: readonly string[];
|
||||
priority?: number;
|
||||
generation: string;
|
||||
certificate?: CultMeshBrowserRouteCertificate;
|
||||
}
|
||||
|
||||
export interface CultMeshBrowserP256PublicKey {
|
||||
keyId: string;
|
||||
x: string;
|
||||
y: string;
|
||||
}
|
||||
|
||||
export interface CultMeshBrowserRouteCertificate {
|
||||
providerKey: CultMeshBrowserP256PublicKey;
|
||||
odinKeyId: string;
|
||||
issuedAtUnixMilliseconds: number;
|
||||
expiresAtUnixMilliseconds: number;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export type CultMeshBrowserAuthorityTrustMode = "authenticated-remote" | "local-development";
|
||||
|
||||
export interface CultMeshBrowserAuthorityTrustPolicy {
|
||||
mode: CultMeshBrowserAuthorityTrustMode;
|
||||
odinRoots?: readonly CultMeshBrowserP256PublicKey[];
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface CultMeshBrowserRendezvous {
|
||||
resolve(
|
||||
|
|
@ -780,6 +794,19 @@ export function decodeCultNetOperationPayload<T>(response: CultNetOperationRespo
|
|||
return decode(base64ToBytes(response.payload)) as T;
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToBytes(value: string): Uint8Array {
|
||||
const binary = atob(value);
|
||||
return Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
function requireWebSocketEndpoint(value: string, field: string): URL {
|
||||
let endpoint: URL;
|
||||
try {
|
||||
|
|
@ -871,7 +898,17 @@ async function selectOdinRoute(
|
|||
protocolIds: route.protocolIds,
|
||||
priority: route.priority,
|
||||
generation: route.generation,
|
||||
...(route.certificate ? { certificate: routeCertificateView(route.certificate) } : {}),
|
||||
...(route.certificate ? { certificate: {
|
||||
providerKey: {
|
||||
keyId: route.certificate.providerKeyId,
|
||||
x: route.certificate.providerPublicKeyX,
|
||||
y: route.certificate.providerPublicKeyY,
|
||||
},
|
||||
odinKeyId: route.certificate.odinKeyId,
|
||||
issuedAtUnixMilliseconds: route.certificate.issuedAtUnixMilliseconds,
|
||||
expiresAtUnixMilliseconds: route.certificate.expiresAtUnixMilliseconds,
|
||||
signature: route.certificate.signature,
|
||||
} } : {}),
|
||||
};
|
||||
try {
|
||||
await verifyAuthorityRoute(candidate, trust);
|
||||
|
|
@ -904,26 +941,136 @@ async function validateSessionAcceptance(
|
|||
`expected '${request.verseId}/${request.authorityRuntimeId}/${protocolId}/${route.generation}'.`,
|
||||
);
|
||||
}
|
||||
// `CultMeshVerifiedAuthorityRoute.IsLocalDevelopment` in C#: an unsigned
|
||||
// loopback route under local-development needs no provider session proof.
|
||||
if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint) && isUnsignedCertificate(route.certificate)) {
|
||||
return undefined;
|
||||
}
|
||||
if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint) && !route.certificate) return undefined;
|
||||
const providerKey = route.certificate?.providerKey;
|
||||
if (!providerKey || message.providerKeyId !== providerKey.keyId || !message.providerSignature) {
|
||||
return new Error("CultMesh authority did not prove possession of the Odin-certified provider key.");
|
||||
}
|
||||
if (!await verifyProviderSessionProof(request, route.endpoint, providerKey, message.providerSignature)) {
|
||||
if (!await verifyP256(providerKey, canonicalSession(request, route.endpoint), message.providerSignature)) {
|
||||
return new Error("CultMesh provider session proof is invalid.");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function verifyAuthorityRoute(
|
||||
route: CultMeshBrowserRoute,
|
||||
trust: CultMeshBrowserAuthorityTrustPolicy,
|
||||
): Promise<void> {
|
||||
const certificate = route.certificate;
|
||||
if (!certificate) {
|
||||
if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint)) return;
|
||||
throw new Error("Remote CultMesh routes require an Odin-signed authority certificate.");
|
||||
}
|
||||
const endpoint = new URL(route.endpoint);
|
||||
if (endpoint.protocol !== "wss:" && !(trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint))) {
|
||||
throw new Error("Authenticated remote CultMesh browser routes require wss:// channel protection.");
|
||||
}
|
||||
const now = trust.now?.() ?? Date.now();
|
||||
if (now < certificate.issuedAtUnixMilliseconds || now >= certificate.expiresAtUnixMilliseconds) {
|
||||
throw new Error("The Odin route certificate is not currently valid.");
|
||||
}
|
||||
const root = trust.odinRoots?.find(candidate => candidate.keyId === certificate.odinKeyId);
|
||||
if (!root) throw new Error(`Odin key '${certificate.odinKeyId}' is not trusted by this consumer.`);
|
||||
if (!await verifyP256(root, canonicalRoute(route), certificate.signature)) {
|
||||
throw new Error("The Odin route certificate signature is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalRoute(route: CultMeshBrowserRoute): Uint8Array {
|
||||
const certificate = route.certificate!;
|
||||
return canonicalFields(
|
||||
"gamecult.cultmesh.route-certificate.v1",
|
||||
route.verseId,
|
||||
route.authorityRuntimeId,
|
||||
route.endpoint,
|
||||
[...(route.protocolIds ?? [route.protocolId ?? "cultmesh.documents.v1"])].sort().join("\u001f"),
|
||||
String(route.priority ?? 0),
|
||||
route.generation,
|
||||
certificate.providerKey.keyId,
|
||||
certificate.providerKey.x,
|
||||
certificate.providerKey.y,
|
||||
certificate.odinKeyId,
|
||||
String(certificate.issuedAtUnixMilliseconds),
|
||||
String(certificate.expiresAtUnixMilliseconds),
|
||||
);
|
||||
}
|
||||
|
||||
function canonicalSession(request: CultMeshSessionOpenMessage, endpoint: string): Uint8Array {
|
||||
return canonicalFields(
|
||||
"gamecult.cultmesh.session-proof.v1",
|
||||
request.clientNonce,
|
||||
request.messageId,
|
||||
request.sourceRuntimeId,
|
||||
request.verseId,
|
||||
request.authorityRuntimeId,
|
||||
request.protocolId,
|
||||
endpoint,
|
||||
request.routeGeneration,
|
||||
);
|
||||
}
|
||||
|
||||
function canonicalFields(...values: string[]): Uint8Array {
|
||||
const encoder = new TextEncoder();
|
||||
const encoded = values.map(value => encoder.encode(value));
|
||||
const total = encoded.reduce((sum, value) => sum + 4 + value.byteLength, 0);
|
||||
const result = new Uint8Array(total);
|
||||
const view = new DataView(result.buffer);
|
||||
let offset = 0;
|
||||
for (const value of encoded) {
|
||||
view.setUint32(offset, value.byteLength, false);
|
||||
offset += 4;
|
||||
result.set(value, offset);
|
||||
offset += value.byteLength;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function verifyP256(
|
||||
key: CultMeshBrowserP256PublicKey,
|
||||
payload: Uint8Array,
|
||||
signatureBase64: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const x = base64ToBytes(key.x);
|
||||
const y = base64ToBytes(key.y);
|
||||
const signature = base64ToBytes(signatureBase64);
|
||||
if (x.byteLength !== 32 || y.byteLength !== 32 || signature.byteLength !== 64) return false;
|
||||
const raw = new Uint8Array(65);
|
||||
raw[0] = 4;
|
||||
raw.set(x, 1);
|
||||
raw.set(y, 33);
|
||||
const publicKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
raw.slice().buffer as ArrayBuffer,
|
||||
{ name: "ECDSA", namedCurve: "P-256" },
|
||||
false,
|
||||
["verify"],
|
||||
);
|
||||
return await crypto.subtle.verify(
|
||||
{ name: "ECDSA", hash: "SHA-256" },
|
||||
publicKey,
|
||||
signature.slice().buffer as ArrayBuffer,
|
||||
payload.slice().buffer as ArrayBuffer,
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function randomNonce(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
return bytesToBase64(bytes);
|
||||
}
|
||||
|
||||
function isLoopbackEndpoint(value: string): boolean {
|
||||
try {
|
||||
const host = new URL(value).hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
return host === "localhost" || host === "127.0.0.1" || host === "::1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function requireText(value: string, field: string): void {
|
||||
if (!value || value.trim().length === 0) throw new Error(`CultMesh browser ${field} is required.`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -236,54 +236,6 @@ test("browser client rejects a credential-free authority impersonator", async ()
|
|||
);
|
||||
});
|
||||
|
||||
test("browser client opens an unsigned-certificate loopback session under local-development without a provider proof, as in C#", async () => {
|
||||
const connect = (route: CultMeshBrowserRoute, trust: { mode: "local-development" | "authenticated-remote"; odinRoots: CultMeshBrowserP256PublicKey[] }) =>
|
||||
CultMeshBrowserClient.connect({
|
||||
...route,
|
||||
runtimeId: "browser-unsigned-certificate-test",
|
||||
rendezvous: { resolve: async () => route },
|
||||
trust,
|
||||
socketFactory: () => handshakeOnlySocket(route),
|
||||
});
|
||||
const blank = (route: CultMeshBrowserRoute, signature: string) =>
|
||||
({ ...route, certificate: { ...route.certificate!, signature } });
|
||||
|
||||
const loopback = await createSignedRoute("ws://127.0.0.1:4501/mesh");
|
||||
const localTrust = { mode: "local-development" as const, odinRoots: [loopback.odinPublic] };
|
||||
const remote = await createSignedRoute("wss://provider.example/mesh");
|
||||
|
||||
// Empty is the easy case. The whitespace signatures are the ones that catch a
|
||||
// local re-reading of "unsigned" here: an inline `signature === ""` passes
|
||||
// every other test in this file and fails these, and U+0085 also separates
|
||||
// `String.prototype.trim` from the C# `char.IsWhiteSpace` the shared
|
||||
// `isUnsignedCertificate` uses. Both sides of the session check have to read
|
||||
// "unsigned" the same way, or the route verifier accepts a session the
|
||||
// acceptance check then demands a provider proof for.
|
||||
for (const signature of ["", " ", "\u0085", "\u00a0\t\n"]) {
|
||||
const label = JSON.stringify(signature);
|
||||
const client = await connect(blank(loopback.route, signature), localTrust);
|
||||
assert.equal(client.state, "connected", label);
|
||||
await client.dispose();
|
||||
await assert.rejects(
|
||||
connect(blank(remote.route, signature), { mode: "local-development", odinRoots: [remote.odinPublic] }),
|
||||
/Odin-signed authority certificate/,
|
||||
label,
|
||||
);
|
||||
await assert.rejects(
|
||||
connect(blank(remote.route, signature), { mode: "authenticated-remote", odinRoots: [remote.odinPublic] }),
|
||||
/Odin-signed authority certificate/,
|
||||
label,
|
||||
);
|
||||
}
|
||||
// U+FEFF is whitespace to JavaScript and not to C#, so it is a signature, not
|
||||
// an unsigned route, and the loopback route is refused for its bytes.
|
||||
await assert.rejects(connect(blank(loopback.route, "\ufeff"), localTrust), /not base64|not IEEE P1363/);
|
||||
|
||||
// A signed loopback certificate is an authenticated route even under
|
||||
// local-development; the provider must still prove its key.
|
||||
await assert.rejects(connect(loopback.route, localTrust), /did not prove possession/);
|
||||
});
|
||||
|
||||
test("browser client rejects a mutated or expired signed route before opening a provider socket", async () => {
|
||||
const fixture = await createSignedRoute("wss://provider.example/mesh");
|
||||
let opened = false;
|
||||
|
|
|
|||
|
|
@ -1359,7 +1359,7 @@ fun defineCultNetSchemaDescriptor(
|
|||
schemaJson = schemaJson,
|
||||
)
|
||||
|
||||
private const val cultNetSchemaBase = "https://github.com/GameCult/CultLib/contracts/cultnet"
|
||||
private const val cultNetSchemaBase = "https://github.com/GameCult/cultnet-ts/contracts"
|
||||
|
||||
private data class CultNetBuiltInSchemaSpec(
|
||||
val schemaVersion: String,
|
||||
|
|
@ -1377,58 +1377,58 @@ private val cultNetBuiltInSchemaSpecs = listOf(
|
|||
title = "CultNet Hello Message",
|
||||
schemaId = "$cultNetSchemaBase/cultnet.hello.schema.json",
|
||||
requiredFields = listOf("schemaVersion", "runtimeId", "runtimeKind"),
|
||||
contentHash = "f660ef803f293e2882650b3981c6eed835bd8e595c9b880d6c6271e0dcbd79e4",
|
||||
contentHash = "1d2114bf0c92a2dd5e07dd735778ceeb939402c6424550d6d281d9db423f56b3",
|
||||
),
|
||||
CultNetBuiltInSchemaSpec(
|
||||
schemaVersion = "cultnet.document_delete.v0",
|
||||
title = "CultNet Document Delete Message",
|
||||
schemaId = "$cultNetSchemaBase/cultnet.document-delete.schema.json",
|
||||
requiredFields = listOf("schemaVersion", "messageId", "schemaId", "recordKey"),
|
||||
contentHash = "ed7dc202e4f1ebe0d88c5d60c81c53005b44a2557698d3d3c5d2126c01b40f08",
|
||||
contentHash = "037f7cf1b30756904e0bfada6bcff486269378997afbe3c4b6fc5c573fc8a4df",
|
||||
),
|
||||
CultNetBuiltInSchemaSpec(
|
||||
schemaVersion = "cultnet.document_put_raw.v0",
|
||||
title = "CultNet Raw Document Put Message",
|
||||
schemaId = "$cultNetSchemaBase/cultnet.document-put-raw.schema.json",
|
||||
contentHash = "34b61b14b49cfc887728673f08aa0f33df7ccd401e79c77d3bc324c72e547177",
|
||||
contentHash = "b61e7e6cce8508653fbeccc4d956ef211c853a174d67c7e1903ce294e15985a2",
|
||||
),
|
||||
CultNetBuiltInSchemaSpec(
|
||||
schemaVersion = "cultnet.snapshot_request.v0",
|
||||
title = "CultNet Snapshot Request Message",
|
||||
schemaId = "$cultNetSchemaBase/cultnet.snapshot-request.schema.json",
|
||||
contentHash = "05267d9b5eb450d4aa8979179f74ba3aec2f3a82fead7ba4a16ab5462c7750fa",
|
||||
contentHash = "9f6546f51a1aab85978554e6908d7a59e96b3329feff7b35022be210495dd721",
|
||||
),
|
||||
CultNetBuiltInSchemaSpec(
|
||||
schemaVersion = "cultnet.snapshot_response_raw.v0",
|
||||
title = "CultNet Raw Snapshot Response Message",
|
||||
schemaId = "$cultNetSchemaBase/cultnet.snapshot-response-raw.schema.json",
|
||||
contentHash = "fdefbdf5d6b0844dd0eabe2c7430d054295d78e41289f96b227dc39f4aab78b9",
|
||||
contentHash = "0dbca3338a3ae5d4011aed2c973c75b9b47b6b9dfc0874459ac56da01fef4582",
|
||||
),
|
||||
CultNetBuiltInSchemaSpec(
|
||||
schemaVersion = "cultnet.schema_catalog_request.v0",
|
||||
title = "CultNet Schema Catalog Request Message",
|
||||
schemaId = "$cultNetSchemaBase/cultnet.schema-catalog-request.schema.json",
|
||||
wireContracts = listOf("cultnet.schema.v0", "gamecult.networking.v0"),
|
||||
contentHash = "ed6d63e29de2d492bb831f030b26ff49ba6883cd2be791ec4936c385f765c76a",
|
||||
contentHash = "bdd49b44428daf24b514c521e53027ec944aecfebb39ca0d10967661c303e096",
|
||||
),
|
||||
CultNetBuiltInSchemaSpec(
|
||||
schemaVersion = "cultnet.schema_catalog_response.v0",
|
||||
title = "CultNet Schema Catalog Response Message",
|
||||
schemaId = "$cultNetSchemaBase/cultnet.schema-catalog-response.schema.json",
|
||||
wireContracts = listOf("cultnet.schema.v0", "gamecult.networking.v0"),
|
||||
contentHash = "10b4835d33181fe4b93a33e39b63886c5514899e93682de786e298f7e4a213e9",
|
||||
contentHash = "b158de53d1ca614eacf2e05767122fb4b9e06504c037ad76c649cffdf71dd648",
|
||||
),
|
||||
CultNetBuiltInSchemaSpec(
|
||||
schemaVersion = "cultnet.shard_catalog_request.v0",
|
||||
title = "CultNet Shard Catalog Request Message",
|
||||
schemaId = "$cultNetSchemaBase/cultnet.shard-catalog-request.schema.json",
|
||||
contentHash = "b4edecd8a24f68ebccf0aca9b429d234618ff7e8858a10123d0b328aeae7eb3f",
|
||||
schemaId = "cultnet.shard_catalog_request.v0",
|
||||
contentHash = "0abd131a1db12f6731010333b86e104d550160bd58f539b18413a2b61198fa38",
|
||||
),
|
||||
CultNetBuiltInSchemaSpec(
|
||||
schemaVersion = "cultnet.shard_catalog_response.v0",
|
||||
title = "CultNet Shard Catalog Response Message",
|
||||
schemaId = "$cultNetSchemaBase/cultnet.shard-catalog-response.schema.json",
|
||||
contentHash = "d86a94c57d7820483912b8b5cef6214ac951d34cb2641d2d21dcb1f1bc0263c0",
|
||||
schemaId = "cultnet.shard_catalog_response.v0",
|
||||
contentHash = "93a0e90ac81bc6ab21b68925ad25e381edfac8773232333679d3275141cff37b",
|
||||
),
|
||||
CultNetBuiltInSchemaSpec(
|
||||
schemaVersion = "cultnet.shard_log_request.v0",
|
||||
|
|
@ -1468,7 +1468,7 @@ private val cultNetBuiltInSchemaSpecs = listOf(
|
|||
schemaId = "$cultNetSchemaBase/cultnet.transport-profile.schema.json",
|
||||
kind = "shared_contract",
|
||||
requiredFields = listOf("schemaVersion", "runtimeId", "transports"),
|
||||
contentHash = "63dfd484e1fd332c167caadf52cb5b4fe12d5ffea4415206c3ef111c196507aa",
|
||||
contentHash = "84473264f543ca3443267999e43d72b7993b61b7b2ac53c642c9cbe8b6de0217",
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -3717,16 +3717,16 @@ private fun cultNetReconnectControllerSchedulesAttemptsAndReset() {
|
|||
private const val interopDocumentType = "cultnet.interop-note"
|
||||
private const val interopSchemaVersion = "cultnet.interop_note.v0"
|
||||
private const val interopMutationIntentType = "cultnet.interop-note-mutation-intent"
|
||||
private const val interopMutationIntentSchemaId = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-note-mutation-intent.schema.json"
|
||||
private const val interopMutationIntentSchemaId = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-note-mutation-intent.schema.json"
|
||||
private const val interopMutationIntentSchemaVersion = "cultnet.interop_note_mutation_intent.v0"
|
||||
private const val interopMutationReceiptType = "cultnet.interop-note-mutation-receipt"
|
||||
private const val interopMutationReceiptSchemaId = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-note-mutation-receipt.schema.json"
|
||||
private const val interopMutationReceiptSchemaId = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-note-mutation-receipt.schema.json"
|
||||
private const val interopMutationReceiptSchemaVersion = "cultnet.interop_note_mutation_receipt.v0"
|
||||
private const val interopFireCommandType = "cultnet.interop-fire-weapon-command"
|
||||
private const val interopFireCommandSchemaId = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-fire-weapon-command.schema.json"
|
||||
private const val interopFireCommandSchemaId = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-fire-weapon-command.schema.json"
|
||||
private const val interopFireCommandSchemaVersion = "cultnet.interop_fire_weapon_command.v0"
|
||||
private const val interopFireReceiptType = "cultnet.interop-fire-weapon-receipt"
|
||||
private const val interopFireReceiptSchemaId = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-fire-weapon-receipt.schema.json"
|
||||
private const val interopFireReceiptSchemaId = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-fire-weapon-receipt.schema.json"
|
||||
private const val interopFireReceiptSchemaVersion = "cultnet.interop_fire_weapon_receipt.v0"
|
||||
private const val rudpInteropConnectionId = 0x43554c54L
|
||||
private const val rudpInteropMaxFragmentBytes = 1024
|
||||
|
|
@ -4627,11 +4627,11 @@ private fun cultNetBuiltInSchemaCatalogAdvertisesWireContracts() {
|
|||
check(schemaCatalogRequest["wireContracts"] == listOf("cultnet.schema.v0", "gamecult.networking.v0"))
|
||||
check(!schemaCatalogRequest.containsKey("schemaJson"))
|
||||
val shardCatalogRequest = schemas.single { it["schemaVersion"] == "cultnet.shard_catalog_request.v0" }
|
||||
check(shardCatalogRequest["schemaId"] == "$cultNetSchemaBase/cultnet.shard-catalog-request.schema.json")
|
||||
check(shardCatalogRequest["schemaId"] == "cultnet.shard_catalog_request.v0")
|
||||
check(shardCatalogRequest["wireContracts"] == listOf("cultnet.schema.v0"))
|
||||
check(shardCatalogRequest["contentHash"] == "b4edecd8a24f68ebccf0aca9b429d234618ff7e8858a10123d0b328aeae7eb3f")
|
||||
check(shardCatalogRequest["contentHash"] == "0abd131a1db12f6731010333b86e104d550160bd58f539b18413a2b61198fa38")
|
||||
val shardCatalogResponse = schemas.single { it["schemaVersion"] == "cultnet.shard_catalog_response.v0" }
|
||||
check(shardCatalogResponse["schemaId"] == "$cultNetSchemaBase/cultnet.shard-catalog-response.schema.json")
|
||||
check(shardCatalogResponse["schemaId"] == "cultnet.shard_catalog_response.v0")
|
||||
check(shardCatalogResponse["wireContracts"] == listOf("cultnet.schema.v0"))
|
||||
|
||||
val inlineCatalog = cultNetBuiltInSchemaCatalog(includeSchemaJson = true)
|
||||
|
|
@ -4644,7 +4644,7 @@ private fun cultNetBuiltInSchemaCatalogAdvertisesWireContracts() {
|
|||
)
|
||||
val transportProfile = mapList(transportResponse.body["schemas"]).single()
|
||||
check(transportProfile["kind"] == "shared_contract")
|
||||
check(transportProfile["contentHash"] == "63dfd484e1fd332c167caadf52cb5b4fe12d5ffea4415206c3ef111c196507aa")
|
||||
check(transportProfile["contentHash"] == "84473264f543ca3443267999e43d72b7993b61b7b2ac53c642c9cbe8b6de0217")
|
||||
check(!transportProfile.containsKey("schemaJson"))
|
||||
|
||||
val filteredTransportCatalog = CultMesh.createBuiltInSchemaCatalog(
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ class CultMesh:
|
|||
bind_host: str = "127.0.0.1",
|
||||
bind_port: int = 0,
|
||||
socket: socket_module.socket | None = None,
|
||||
initial_sequence: int | None = None,
|
||||
initial_sequence: int = 1,
|
||||
resend_delay_ms: int = 250,
|
||||
transport_id: str = "rudp",
|
||||
max_payload_bytes: int | None = None,
|
||||
|
|
@ -191,7 +191,7 @@ class CultMesh:
|
|||
bind_host: str = "127.0.0.1",
|
||||
bind_port: int = 0,
|
||||
socket: socket_module.socket | None = None,
|
||||
initial_sequence: int | None = None,
|
||||
initial_sequence: int = 1,
|
||||
resend_delay_ms: int = 250,
|
||||
transport_id: str = "rudp",
|
||||
max_payload_bytes: int | None = None,
|
||||
|
|
|
|||
|
|
@ -224,19 +224,15 @@ class CultMeshLocalServer:
|
|||
now_ms = _now_ms()
|
||||
peer = peers.get(remote_addr)
|
||||
if packet.packet_type == CultNetRudpPacketType.CONNECT:
|
||||
# The session decides whether this Connect repeats the one it
|
||||
# accepted. Any other Connect is a new client session, with new
|
||||
# subscriptions.
|
||||
if peer is None or not peer.session.connect_repeats(packet):
|
||||
peer = _RudpPeerConnection(
|
||||
CultNetRudpSession(
|
||||
CultNetRudpSessionOptions(
|
||||
connection_id=self.rudp_connection_id,
|
||||
resend_delay_ms=self.rudp_resend_delay_ms,
|
||||
)
|
||||
peer = _RudpPeerConnection(
|
||||
CultNetRudpSession(
|
||||
CultNetRudpSessionOptions(
|
||||
connection_id=self.rudp_connection_id,
|
||||
resend_delay_ms=self.rudp_resend_delay_ms,
|
||||
)
|
||||
)
|
||||
peers[remote_addr] = peer
|
||||
)
|
||||
peers[remote_addr] = peer
|
||||
self._send_rudp_packet(
|
||||
rudp_socket,
|
||||
remote_addr,
|
||||
|
|
|
|||
|
|
@ -1759,11 +1759,11 @@ class CultMeshTests(unittest.TestCase):
|
|||
)
|
||||
self.assertEqual(
|
||||
builtins.list()[0].schema_id,
|
||||
"https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.transport-profile.schema.json",
|
||||
"https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json",
|
||||
)
|
||||
self.assertIsNone(builtins.list()[0].schema_json)
|
||||
peer_exchange_schema_id = (
|
||||
"https://github.com/GameCult/CultLib/contracts/cultnet/cultmesh.peer-exchange-request.schema.json"
|
||||
"https://github.com/GameCult/cultnet-ts/contracts/cultmesh.peer-exchange-request.schema.json"
|
||||
)
|
||||
filtered_builtin = CultMesh.create_builtin_schema_catalog(
|
||||
include_schema_json=True,
|
||||
|
|
@ -4033,7 +4033,7 @@ class CultMeshTests(unittest.TestCase):
|
|||
self.assertIn("cultmesh.peer_exchange_response.v0", wire_descriptors)
|
||||
self.assertIn("cultnet.error.v0", {descriptor.schema_version for descriptor in synced_wire_descriptors})
|
||||
self.assertIn("cultnet.document_put_raw.v0", {descriptor.schema_version for descriptor in synced_wire_descriptors})
|
||||
self.assertIsNotNone(synced_schema_catalog.get("https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.document-put-raw.schema.json"))
|
||||
self.assertIsNotNone(synced_schema_catalog.get("https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put-raw.schema.json"))
|
||||
self.assertEqual(snapshot_response["documents"][0]["recordKey"], "note:1")
|
||||
self.assertEqual(typed_snapshot_response.documents[0].record_key, "note:1")
|
||||
self.assertEqual(typed_snapshot_response.documents[0].schema_id, "mesh.server_note.v1")
|
||||
|
|
@ -4317,14 +4317,6 @@ class CultMeshTests(unittest.TestCase):
|
|||
{"schemaVersion": "cultnet.document_delete.v0", "messageId": "bad-delete", "schemaId": "mesh.error_note.v1"},
|
||||
expected_schema_version="cultnet.error.v0",
|
||||
)
|
||||
# R-2, owed by Cut 1 (docs/cultnet-selection-cut.md, section 9): this v0-only Mesh server
|
||||
# refuses a cultnet.selection.v1 carrier message through the same delegating path
|
||||
# (_handle_connection_message -> handle_message) as any other unsupported schema, not a
|
||||
# silent empty response.
|
||||
unsupported_v1 = raw_client.request(
|
||||
{"schemaVersion": "cultnet.snapshot_request.v1", "messageId": "bad-v1-selection"},
|
||||
expected_schema_version="cultnet.error.v0",
|
||||
)
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
|
|
@ -4336,9 +4328,6 @@ class CultMeshTests(unittest.TestCase):
|
|||
self.assertEqual(malformed_put["code"], "malformed_document_put")
|
||||
self.assertEqual(malformed_delete["messageId"], "bad-delete")
|
||||
self.assertEqual(malformed_delete["code"], "malformed_document_delete")
|
||||
self.assertEqual(unsupported_v1["messageId"], "bad-v1-selection")
|
||||
self.assertEqual(unsupported_v1["code"], "unsupported_schema_version")
|
||||
self.assertEqual(unsupported_v1["details"]["schemaVersion"], "cultnet.snapshot_request.v1")
|
||||
|
||||
def test_cultmesh_authority_lease_requires_live_matching_lease(self) -> None:
|
||||
peer = CultMeshPeerCard(
|
||||
|
|
|
|||
|
|
@ -1,159 +0,0 @@
|
|||
"""The CultMesh local server's RUDP loop asks each peer's session whether a Connect
|
||||
repeats. A retransmitted Connect keeps the peer; a restarted client on the same
|
||||
address is a new one, with none of the old one's subscriptions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import time
|
||||
import unittest
|
||||
from dataclasses import dataclass
|
||||
|
||||
import msgpack
|
||||
from cultcache_py import define_database_entry_type
|
||||
from cultmesh_py import create_node
|
||||
from cultmesh_py.server import CultMeshLocalServer
|
||||
from cultnet_py import (
|
||||
CultNetRudpSendOptions,
|
||||
CultNetRudpSession,
|
||||
CultNetRudpSessionOptions,
|
||||
document_put_raw,
|
||||
)
|
||||
from cultnet_py.transport import CultNetRudpPacketType, decode_rudp_packet, encode_rudp_packet
|
||||
|
||||
CONNECTION_ID = 0x10203091
|
||||
SCHEMA_ID = "mesh.rudp_generation_note.v1"
|
||||
RELIABLE = CultNetRudpSendOptions(reliable=True, ordered=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Note:
|
||||
body: str
|
||||
|
||||
|
||||
NOTE = define_database_entry_type(
|
||||
"mesh.rudp_generation_note",
|
||||
[("body", 0)],
|
||||
cls=Note,
|
||||
schema_id=SCHEMA_ID,
|
||||
schema_name="mesh.rudp_generation_note",
|
||||
schema_version=SCHEMA_ID,
|
||||
)
|
||||
|
||||
|
||||
def wire(message: dict) -> bytes:
|
||||
return msgpack.packb(message, use_bin_type=True)
|
||||
|
||||
|
||||
class RudpServerGenerationTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
node = create_node(runtime_id="rudp-generation")
|
||||
node.database.register_document(NOTE)
|
||||
self.server = CultMeshLocalServer(node=node, rudp_connection_id=CONNECTION_ID)
|
||||
self.server.start()
|
||||
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self.sock.bind(("127.0.0.1", 0))
|
||||
self.sock.settimeout(0.02)
|
||||
self.target = ("127.0.0.1", self.server.port)
|
||||
# Every schema data packet the server sent, by sequence, whether or not the receiving
|
||||
# session delivered it: a new server-side session numbers its packets afresh.
|
||||
self.sent: dict[int, str] = {}
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.sock.close()
|
||||
self.server.stop()
|
||||
|
||||
def to_server(self, packet) -> None:
|
||||
self.sock.sendto(encode_rudp_packet(packet), self.target)
|
||||
|
||||
def pump(self, session: CultNetRudpSession, until, seconds: float = 2.0) -> bool:
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
if until():
|
||||
return True
|
||||
try:
|
||||
data, _ = self.sock.recvfrom(65535)
|
||||
except TimeoutError:
|
||||
continue
|
||||
packet = decode_rudp_packet(data)
|
||||
session.receive(packet, int(time.monotonic() * 1000))
|
||||
if packet.reliable or packet.packet_type == CultNetRudpPacketType.ACCEPT:
|
||||
self.to_server(session.create_ack_for_received(packet.sequence))
|
||||
if packet.packet_type == CultNetRudpPacketType.DATA and packet.channel_id == "schema":
|
||||
self.sent[packet.sequence] = msgpack.unpackb(packet.payload, raw=False).get("schemaVersion")
|
||||
return until()
|
||||
|
||||
def count(self, schema_version: str) -> int:
|
||||
return sum(1 for value in self.sent.values() if value == schema_version)
|
||||
|
||||
def new_session(self, initial_sequence: int) -> CultNetRudpSession:
|
||||
return CultNetRudpSession(CultNetRudpSessionOptions(connection_id=CONNECTION_ID, initial_sequence=initial_sequence))
|
||||
|
||||
def test_a_retransmitted_connect_keeps_the_peer_and_a_restarted_client_is_a_new_one(self) -> None:
|
||||
first = self.new_session(50_000)
|
||||
connect = first.create_connect(0, b"same")
|
||||
self.to_server(connect)
|
||||
self.assertTrue(self.pump(first, lambda: first.connected), "the first Accept")
|
||||
hello = first.send(
|
||||
"schema", wire({"schemaVersion": "cultnet.hello.v0", "runtimeId": "probe", "runtimeKind": "test"}), RELIABLE
|
||||
)
|
||||
self.to_server(hello)
|
||||
self.assertTrue(self.pump(first, lambda: self.count("cultnet.hello.v0") == 1), "the first answer")
|
||||
self.to_server(
|
||||
first.send(
|
||||
"schema",
|
||||
wire({
|
||||
"schemaVersion": "cultnet.database_subscribe.v0",
|
||||
"messageId": "subscribe",
|
||||
"subscriptionId": "old-incarnation",
|
||||
"schemaIds": [SCHEMA_ID],
|
||||
"includeSnapshot": False,
|
||||
}),
|
||||
RELIABLE,
|
||||
)
|
||||
)
|
||||
self.pump(first, lambda: False, 0.2)
|
||||
first_put = document_put_raw(
|
||||
message_id="first-put",
|
||||
key="note:a",
|
||||
schema_id=SCHEMA_ID,
|
||||
stored_at="2026-09-30T00:00:00Z",
|
||||
payload=NOTE.encode_payload(Note("first")),
|
||||
)
|
||||
self.to_server(first.send("schema", first_put.to_bytes(), RELIABLE))
|
||||
self.assertTrue(
|
||||
self.pump(first, lambda: self.count("cultnet.database_change_raw.v0") == 1),
|
||||
"the subscription did not notify its own connection",
|
||||
)
|
||||
|
||||
# A retransmitted Connect repeats: the data already delivered is not delivered, or answered, again.
|
||||
self.to_server(connect)
|
||||
self.to_server(hello)
|
||||
self.pump(first, lambda: False, 0.4)
|
||||
self.assertEqual(self.count("cultnet.hello.v0"), 1, "the retransmitted Connect reset the peer")
|
||||
|
||||
# The same client restarted on the same address, connection id and payload: a new peer.
|
||||
second = self.new_session(7)
|
||||
self.to_server(second.create_connect(0, b"same"))
|
||||
self.assertTrue(self.pump(second, lambda: second.connected), "the restarted client's Accept")
|
||||
self.to_server(
|
||||
second.send(
|
||||
"schema", wire({"schemaVersion": "cultnet.hello.v0", "runtimeId": "probe", "runtimeKind": "test"}), RELIABLE
|
||||
)
|
||||
)
|
||||
self.assertTrue(self.pump(second, lambda: self.count("cultnet.hello.v0") == 2), "the restarted client's answer")
|
||||
|
||||
# It inherited none of the old incarnation's subscriptions.
|
||||
put = document_put_raw(
|
||||
message_id="second-put",
|
||||
key="note:a",
|
||||
schema_id=SCHEMA_ID,
|
||||
stored_at="2026-09-30T00:00:01Z",
|
||||
payload=NOTE.encode_payload(Note("second")),
|
||||
)
|
||||
self.to_server(second.send("schema", put.to_bytes(), RELIABLE))
|
||||
self.pump(second, lambda: False, 0.4)
|
||||
self.assertEqual(self.count("cultnet.database_change_raw.v0"), 1, "the new peer inherited a subscription")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -820,7 +820,7 @@ pub fn request_raw_snapshot_from_rudp_catalog(
|
|||
} if received == &message_id => {
|
||||
snapshot_response = Some(message);
|
||||
}
|
||||
cultnet_rs::CultNetMessage::Error { error, .. } => {
|
||||
cultnet_rs::CultNetMessage::Error { error } => {
|
||||
let _ = client.disconnect(b"snapshot-error".to_vec());
|
||||
anyhow::bail!("CultMesh RUDP snapshot failed: {error}");
|
||||
}
|
||||
|
|
@ -961,7 +961,7 @@ fn connect_rudp_client(
|
|||
mode: cultnet_rs::CultNetRudpSocketMode::Client,
|
||||
remote_addr: Some(target),
|
||||
connection_id: fresh_rudp_connection_epoch(),
|
||||
initial_sequence: None,
|
||||
initial_sequence: 1,
|
||||
resend_delay_ms,
|
||||
transport_id: Some(transport_id.to_string()),
|
||||
max_payload_bytes: None,
|
||||
|
|
@ -1406,7 +1406,7 @@ mod tests {
|
|||
let mut session =
|
||||
cultnet_rs::CultNetRudpSession::new(cultnet_rs::CultNetRudpSessionOptions {
|
||||
connection_id: connect.connection_id,
|
||||
initial_sequence: Some(1),
|
||||
initial_sequence: 1,
|
||||
resend_delay_ms: 5,
|
||||
max_pending_reliable_packets: Some(64),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -207,7 +207,6 @@ pub struct CultMeshRudpDocumentServer<S, Q, C> {
|
|||
snapshot_source: Q,
|
||||
clock: C,
|
||||
options: CultMeshRudpDocumentServerOptions,
|
||||
packets_dropped: u64,
|
||||
}
|
||||
|
||||
impl<S, Q, C> CultMeshRudpDocumentServer<S, Q, C>
|
||||
|
|
@ -235,18 +234,9 @@ where
|
|||
snapshot_source,
|
||||
clock,
|
||||
options,
|
||||
packets_dropped: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Datagrams read and discarded because they belong to no admissible
|
||||
/// session: malformed frames, unknown sessions, a Connect the session cap
|
||||
/// refuses, a packet that fails its session. Never an
|
||||
/// error: a moved flow or a scanner must not end the daemon loop.
|
||||
pub fn packets_dropped(&self) -> u64 {
|
||||
self.packets_dropped
|
||||
}
|
||||
|
||||
pub fn local_addr(&self) -> Result<SocketAddr> {
|
||||
Ok(self.socket.local_addr()?)
|
||||
}
|
||||
|
|
@ -274,10 +264,7 @@ where
|
|||
|
||||
let packet = match decode_rudp_packet(&wire) {
|
||||
Ok(packet) => packet,
|
||||
Err(_) => {
|
||||
self.packets_dropped += 1;
|
||||
return Ok(CultMeshRudpPollOutcome::Handled);
|
||||
}
|
||||
Err(_) => return Ok(CultMeshRudpPollOutcome::Handled),
|
||||
};
|
||||
let now_unix = self.clock.now_unix_millis();
|
||||
let now_monotonic = self.clock.now_monotonic_millis();
|
||||
|
|
@ -287,14 +274,11 @@ where
|
|||
};
|
||||
|
||||
if packet.packet_type == CultNetRudpPacketType::Connect {
|
||||
if !self.accept_connection(key, &packet, now_monotonic)? {
|
||||
self.packets_dropped += 1;
|
||||
}
|
||||
self.accept_connection(key, &packet, now_monotonic)?;
|
||||
return Ok(CultMeshRudpPollOutcome::Handled);
|
||||
}
|
||||
|
||||
if !self.sessions.contains_key(&key) {
|
||||
self.packets_dropped += 1;
|
||||
return Ok(CultMeshRudpPollOutcome::Handled);
|
||||
}
|
||||
if packet.packet_type == CultNetRudpPacketType::Data
|
||||
|
|
@ -325,8 +309,7 @@ where
|
|||
let result = match result {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
self.end_refused_session(key)?;
|
||||
self.packets_dropped += 1;
|
||||
self.sessions.remove(&key);
|
||||
return Ok(CultMeshRudpPollOutcome::Handled);
|
||||
}
|
||||
};
|
||||
|
|
@ -402,62 +385,45 @@ where
|
|||
})
|
||||
}
|
||||
|
||||
/// `Ok(false)` when the Connect was refused because the session cap is full;
|
||||
/// only a socket failure is an `Err`.
|
||||
fn accept_connection(
|
||||
&mut self,
|
||||
key: CultMeshRudpSessionKey,
|
||||
packet: &CultNetRudpPacket,
|
||||
now: u64,
|
||||
) -> Result<bool> {
|
||||
// The session decides whether a Connect repeats the one it accepted. Any
|
||||
// other Connect from this key is a new client session: the old one ends
|
||||
// with its budget, and the new one starts fresh.
|
||||
if self
|
||||
.sessions
|
||||
.get(&key)
|
||||
.is_some_and(|entry| !entry.session.connect_repeats(packet))
|
||||
{
|
||||
self.sessions.remove(&key);
|
||||
) -> Result<()> {
|
||||
if !self.sessions.contains_key(&key) {
|
||||
if self.sessions.len() >= self.options.max_sessions {
|
||||
return Ok(());
|
||||
}
|
||||
self.sessions.insert(
|
||||
key,
|
||||
SessionEntry {
|
||||
session: CultNetRudpSession::new(CultNetRudpSessionOptions {
|
||||
connection_id: key.connection_id,
|
||||
initial_sequence: 1,
|
||||
resend_delay_ms: duration_millis(self.options.resend_delay),
|
||||
max_pending_reliable_packets: Some(
|
||||
self.options.max_pending_reliable_packets_per_session,
|
||||
),
|
||||
}),
|
||||
created_at_monotonic_millis: now,
|
||||
last_activity_monotonic_millis: now,
|
||||
admitted_payload_bytes: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
let reply = match self.sessions.get_mut(&key) {
|
||||
Some(entry) => {
|
||||
entry.last_activity_monotonic_millis = now;
|
||||
match entry.session.accept_connect(packet, now, Vec::new()) {
|
||||
Ok(reply) => reply,
|
||||
Err(_) => {
|
||||
self.end_refused_session(key)?;
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if self.sessions.len() >= self.options.max_sessions {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut session = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
||||
connection_id: key.connection_id,
|
||||
initial_sequence: None,
|
||||
resend_delay_ms: duration_millis(self.options.resend_delay),
|
||||
max_pending_reliable_packets: Some(
|
||||
self.options.max_pending_reliable_packets_per_session,
|
||||
),
|
||||
});
|
||||
let accept = session.accept_connect(packet, now, Vec::new())?;
|
||||
self.sessions.insert(
|
||||
key,
|
||||
SessionEntry {
|
||||
session,
|
||||
created_at_monotonic_millis: now,
|
||||
last_activity_monotonic_millis: now,
|
||||
admitted_payload_bytes: 0,
|
||||
},
|
||||
);
|
||||
accept
|
||||
}
|
||||
|
||||
let accept = {
|
||||
let entry = self
|
||||
.sessions
|
||||
.get_mut(&key)
|
||||
.expect("accepted session must be present");
|
||||
// The same key is a retransmitted Connect for the existing epoch.
|
||||
// A genuinely fresh client incarnation must choose a fresh id.
|
||||
entry.last_activity_monotonic_millis = now;
|
||||
entry.session.accept_connect(packet, now, Vec::new())?
|
||||
};
|
||||
self.send_packet(key.remote_addr, &reply)?;
|
||||
Ok(true)
|
||||
self.send_packet(key.remote_addr, &accept)
|
||||
}
|
||||
|
||||
fn deliver_application_message(
|
||||
|
|
@ -600,16 +566,6 @@ where
|
|||
Ok(None)
|
||||
}
|
||||
|
||||
/// The session refused a packet: it ends, and the client is told so it does
|
||||
/// not wait on a session the server no longer holds.
|
||||
fn end_refused_session(&mut self, key: CultMeshRudpSessionKey) -> Result<()> {
|
||||
let Some(mut entry) = self.sessions.remove(&key) else {
|
||||
return Ok(());
|
||||
};
|
||||
let goodbye = entry.session.end_refused_session();
|
||||
self.send_packet(key.remote_addr, &goodbye)
|
||||
}
|
||||
|
||||
fn payload_budget_allows(&self, key: CultMeshRudpSessionKey, bytes: usize) -> bool {
|
||||
let Some(session_bytes) = self
|
||||
.sessions
|
||||
|
|
|
|||
|
|
@ -160,15 +160,7 @@ fn server(
|
|||
}
|
||||
|
||||
fn client(target: SocketAddr, id: u32) -> Result<CultNetRudpSocketTransportConnection> {
|
||||
client_on(UdpSocket::bind("127.0.0.1:0")?, target, id, 1)
|
||||
}
|
||||
|
||||
fn client_on(
|
||||
socket: UdpSocket,
|
||||
target: SocketAddr,
|
||||
id: u32,
|
||||
initial_sequence: u32,
|
||||
) -> Result<CultNetRudpSocketTransportConnection> {
|
||||
let socket = UdpSocket::bind("127.0.0.1:0")?;
|
||||
socket.set_nonblocking(true)?;
|
||||
CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions {
|
||||
media_delivery: None,
|
||||
|
|
@ -177,7 +169,7 @@ fn client_on(
|
|||
mode: CultNetRudpSocketMode::Client,
|
||||
remote_addr: Some(target),
|
||||
connection_id: id,
|
||||
initial_sequence: Some(initial_sequence),
|
||||
initial_sequence: 1,
|
||||
resend_delay_ms: 10,
|
||||
transport_id: None,
|
||||
max_payload_bytes: None,
|
||||
|
|
@ -299,7 +291,7 @@ fn same_connection_id_is_peer_scoped_and_raw_bytes_are_untouched() -> Result<()>
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_connect_from_a_client_replaces_its_session_and_a_fresh_epoch_is_separate() -> Result<()> {
|
||||
fn duplicate_connect_preserves_epoch_and_fresh_epoch_is_separate() -> Result<()> {
|
||||
let clock = Clock::new(45_000);
|
||||
let sink = Sink::default();
|
||||
let mut server = server(Default::default(), clock, sink.clone(), Source::default())?;
|
||||
|
|
@ -333,97 +325,6 @@ fn a_second_connect_from_a_client_replaces_its_session_and_a_fresh_epoch_is_sepa
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// A client that restarts on the same address and connection id starts a new
|
||||
/// session: its Connect carries a sequence the accepted one did not, so the
|
||||
/// server does not mistake it for a retransmit, and what it sends is delivered.
|
||||
#[test]
|
||||
fn a_restarted_client_on_the_same_address_and_connection_id_is_admitted() -> Result<()> {
|
||||
let sink = Sink::default();
|
||||
let mut server = server(Default::default(), Clock::new(46_000), sink.clone(), Source::default())?;
|
||||
let target = server.local_addr()?;
|
||||
let shared = UdpSocket::bind("127.0.0.1:0")?;
|
||||
let twin = shared.try_clone()?;
|
||||
let mut first = client_on(shared, target, 93, 50_000)?;
|
||||
connect(&mut server, &mut [&mut first])?;
|
||||
for index in 0..3 {
|
||||
send(
|
||||
&mut first,
|
||||
&CultNetMessage::DocumentPutRaw {
|
||||
message_id: format!("before-{index}"),
|
||||
document: document("before", vec![index]),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
for _ in 0..40 {
|
||||
server.poll_once()?;
|
||||
first.receive_once()?;
|
||||
}
|
||||
assert_eq!(sink.0.lock().unwrap().receipts.len(), 3);
|
||||
|
||||
let mut restarted = client_on(twin, target, 93, 7)?;
|
||||
connect(&mut server, &mut [&mut restarted])?;
|
||||
assert_eq!(server.session_count(), 1);
|
||||
send(
|
||||
&mut restarted,
|
||||
&CultNetMessage::DocumentPutRaw {
|
||||
message_id: "after-restart".into(),
|
||||
document: document("after", vec![9]),
|
||||
},
|
||||
)?;
|
||||
for _ in 0..40 {
|
||||
server.poll_once()?;
|
||||
restarted.receive_once()?;
|
||||
}
|
||||
let receipts = sink.0.lock().unwrap();
|
||||
assert!(
|
||||
receipts.receipts.iter().any(|r| r.message_id == "after-restart"),
|
||||
"the restarted client's frame was not delivered"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A restarted client is a new session, and starts with a new payload budget:
|
||||
/// what the old one spent is not charged to it.
|
||||
#[test]
|
||||
fn a_restarted_client_starts_with_a_fresh_payload_budget() -> Result<()> {
|
||||
let put = |id: &str| CultNetMessage::DocumentPutRaw {
|
||||
message_id: id.into(),
|
||||
document: document("budget", vec![7; 64]),
|
||||
};
|
||||
let encoded = encode_cultnet_message_to_vec(&put("put-a"), CultNetWireContract::CultNetSchemaV0)?;
|
||||
let options = CultMeshRudpDocumentServerOptions {
|
||||
max_admitted_payload_bytes: encoded.len() * 2,
|
||||
max_admitted_payload_bytes_per_session: encoded.len(),
|
||||
max_snapshot_response_bytes: encoded.len(),
|
||||
..Default::default()
|
||||
};
|
||||
let sink = Sink::default();
|
||||
let mut server = server(options, Clock::new(47_000), sink.clone(), Source::default())?;
|
||||
let target = server.local_addr()?;
|
||||
let shared = UdpSocket::bind("127.0.0.1:0")?;
|
||||
let twin = shared.try_clone()?;
|
||||
let mut first = client_on(shared, target, 94, 50_000)?;
|
||||
connect(&mut server, &mut [&mut first])?;
|
||||
send(&mut first, &put("put-a"))?;
|
||||
for _ in 0..20 {
|
||||
server.poll_once()?;
|
||||
}
|
||||
assert_eq!(sink.0.lock().unwrap().receipts.len(), 1);
|
||||
|
||||
let mut restarted = client_on(twin, target, 94, 7)?;
|
||||
connect(&mut server, &mut [&mut restarted])?;
|
||||
send(&mut restarted, &put("put-b"))?;
|
||||
for _ in 0..20 {
|
||||
server.poll_once()?;
|
||||
}
|
||||
assert_eq!(
|
||||
sink.0.lock().unwrap().receipts.len(),
|
||||
2,
|
||||
"the restarted client was charged what the old session spent"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_rejection_is_nonfatal_peer_scoped_and_unacknowledged() -> Result<()> {
|
||||
let clock = Clock::new(48_000);
|
||||
|
|
@ -652,103 +553,3 @@ fn session_cap_and_expiry_use_monotonic_time() -> Result<()> {
|
|||
assert_eq!(server.session_count(), 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_connect_storm_and_stray_frames_are_dropped_not_fatal() -> Result<()> {
|
||||
use cultnet_rs::{
|
||||
CultNetRudpSendOptions, CultNetRudpSession, CultNetRudpSessionOptions,
|
||||
encode_rudp_packet,
|
||||
};
|
||||
let clock = Clock::new(60_000);
|
||||
let sink = Sink::default();
|
||||
let options = CultMeshRudpDocumentServerOptions {
|
||||
max_pending_reliable_packets_per_session: 4,
|
||||
..Default::default()
|
||||
};
|
||||
let mut server = server(options, clock.clone(), sink.clone(), Source::default())?;
|
||||
let target = server.local_addr()?;
|
||||
let mut real = client(target, 101)?;
|
||||
connect(&mut server, &mut [&mut real])?;
|
||||
assert_eq!(server.packets_dropped(), 0);
|
||||
|
||||
let raw = UdpSocket::bind("127.0.0.1:0")?;
|
||||
let session = |id| {
|
||||
CultNetRudpSession::new(CultNetRudpSessionOptions {
|
||||
connection_id: id,
|
||||
initial_sequence: Some(1),
|
||||
resend_delay_ms: 10,
|
||||
max_pending_reliable_packets: None,
|
||||
})
|
||||
};
|
||||
let send_raw = |server: &mut Server, packet: &cultnet_rs::CultNetRudpPacket| -> Result<()> {
|
||||
raw.send_to(&encode_rudp_packet(packet)?, target)?;
|
||||
server.poll_once()?;
|
||||
Ok(())
|
||||
};
|
||||
|
||||
// A malformed frame.
|
||||
raw.send_to(b"not a rudp packet", target)?;
|
||||
server.poll_once()?;
|
||||
assert_eq!(server.packets_dropped(), 1);
|
||||
|
||||
// Data for a session no Connect admitted.
|
||||
let mut unadmitted = session(300);
|
||||
unadmitted.assume_connected(0);
|
||||
let reliable = CultNetRudpSendOptions {
|
||||
reliable: true,
|
||||
..Default::default()
|
||||
};
|
||||
send_raw(&mut server, &unadmitted.send("schema", vec![1], reliable.clone())?)?;
|
||||
assert_eq!(server.packets_dropped(), 2);
|
||||
|
||||
// A moved flow re-sends Connect from a socket that never hears the Accept.
|
||||
// Each repeat re-sends the Accept still awaiting acknowledgement and queues
|
||||
// nothing, so the storm neither errors nor grows the session's queue.
|
||||
let mut stormer = session(102);
|
||||
let connect_packet = stormer.create_connect(0, Vec::new())?;
|
||||
for _ in 0..200 {
|
||||
send_raw(&mut server, &connect_packet)?;
|
||||
}
|
||||
assert_eq!(server.packets_dropped(), 2);
|
||||
assert_eq!(server.session_count(), 2);
|
||||
clock.set(60_000 + 1_000);
|
||||
assert_eq!(
|
||||
server.maintain()?.packets_resent,
|
||||
1,
|
||||
"one Accept awaits acknowledgement, however many Connects repeated"
|
||||
);
|
||||
|
||||
// A packet its own admitted session refuses ends that session, not the loop.
|
||||
stormer.assume_connected(0);
|
||||
let mut poison = stormer.send("schema", vec![1], reliable)?;
|
||||
poison.fragment_count = 2;
|
||||
poison.fragment_id = 0;
|
||||
send_raw(&mut server, &poison)?;
|
||||
assert_eq!(server.packets_dropped(), 3);
|
||||
assert_eq!(server.session_count(), 1);
|
||||
// ...and the refused client is told, after the Accepts its storm drew.
|
||||
raw.set_read_timeout(Some(Duration::from_millis(200)))?;
|
||||
let mut buffer = vec![0_u8; 65_535];
|
||||
let mut told = false;
|
||||
while let Ok(received) = raw.recv(&mut buffer) {
|
||||
told |= cultnet_rs::decode_rudp_packet(&buffer[..received])?.packet_type
|
||||
== cultnet_rs::CultNetRudpPacketType::Disconnect;
|
||||
}
|
||||
assert!(told, "the refused client must be sent a goodbye");
|
||||
|
||||
send(
|
||||
&mut real,
|
||||
&CultNetMessage::DocumentPutRaw {
|
||||
message_id: "after-storm".into(),
|
||||
document: document("real", vec![9]),
|
||||
},
|
||||
)?;
|
||||
for _ in 0..20 {
|
||||
server.poll_once()?;
|
||||
if sink.0.lock().unwrap().receipts.len() == 1 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(sink.0.lock().unwrap().receipts.len(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
msquic.version=2.5.9
|
||||
msquic.windows.source=https://www.nuget.org/api/v2/package/Microsoft.Native.Quic.MsQuic.OpenSSL/2.5.9 sha256=FEE9A664C0052DEEE66D40CED2E19BFE20BC423DE5AE5234CFC0CEBC22CA3F5E
|
||||
msquic.linux.source=https://packages.microsoft.com/debian/13/prod/pool/main/libm/libmsquic/libmsquic_2.5.9_amd64.deb sha256=1baa61ade0b7b4a99f6dcb6b00d9aedb12b5566d00918a325be7425e878e51ba
|
||||
bridge.commit=e5bf1c9243acb6b76de72c7864e3b79214177199
|
||||
build.workflow_run=https://github.com/GameCult/CultLib/actions/runs/35912386052
|
||||
build.release=none (TEMPORARY push-to-quic-cut6 trigger, no tag; see quic-native.yml history for the tagged release path)
|
||||
build.win32-x64.host=windows-2025-vs2026, cl.exe (MSVC) 19.51.36256.0
|
||||
build.linux-x64.host=ubuntu-24.04, container debian:13@sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9ffc4b1, g++ (Debian 14.2.0-19) 14.2.0
|
||||
date=2026-09-23T19:54:08Z
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
9dae391a44d59f12e19baa08a44d86c7e8b4916ca5d40007a42d65913ce0896f *win32-x64/gamecult_mesh_quic_native.dll
|
||||
c17c65813fe007eb486a5270d244f3659261355795b27abb99bd19ff77539263 *win32-x64/msquic.dll
|
||||
0ee3d28324c79a5b0135368187e7bd75c1969ad94fc6985755e81de4ec96e430 *linux-x64/libgamecult_mesh_quic_native.so
|
||||
c09a72f9b3bfd9119044b305c6064731c1e1879411e6f54276474fb58b524b68 *linux-x64/libmsquic.so.2
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -12,14 +12,13 @@
|
|||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"native"
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json",
|
||||
"prepack": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json",
|
||||
"typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --pretty false",
|
||||
"test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --expose-gc --test dist-test/test/*.test.js"
|
||||
"test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js"
|
||||
},
|
||||
"keywords": [
|
||||
"cultmesh",
|
||||
|
|
@ -40,13 +39,12 @@
|
|||
},
|
||||
"homepage": "https://github.com/GameCult/CultLib/tree/main/packages/cultmesh-ts#readme",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@msgpack/msgpack": "^3.1.2",
|
||||
"@gamecult/cultcache-ts": "^0.14.0",
|
||||
"cultnet-ts": "^0.2.0",
|
||||
"koffi": "3.3.0"
|
||||
"cultnet-ts": "^0.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
|
|
|
|||
|
|
@ -34,18 +34,6 @@ import {
|
|||
type CultNetWireContract,
|
||||
} from "cultnet-ts";
|
||||
|
||||
import {
|
||||
CultMeshQuicRealtimeConnector,
|
||||
CultMeshQuicRealtimeProvider,
|
||||
CultMeshQuicRealtimeSessionManager,
|
||||
parseQuicRealtimeEndpoint,
|
||||
type CultMeshQuicRealtimeConnectorOptions,
|
||||
type CultMeshQuicRealtimeProviderOptions,
|
||||
type CultMeshQuicRealtimeSessionManagerOptions,
|
||||
} from "./realtime-quic";
|
||||
|
||||
export * from "./realtime-wire";
|
||||
export * from "./realtime-quic";
|
||||
export * from "./provider-session";
|
||||
export * from "./provider-session-wire";
|
||||
export * from "./provider-rudp-transport";
|
||||
|
|
@ -5793,7 +5781,7 @@ export class CultMesh {
|
|||
|
||||
if (packet.packetType === "connect") {
|
||||
const connectPayload = Uint8Array.from(packet.payload ?? []);
|
||||
if (record?.session.connectRepeats(packet)) {
|
||||
if (record && byteArraysEqual(record.connectPayload, connectPayload)) {
|
||||
socket.send(
|
||||
encodeRudpPacket(record.session.acceptConnect(packet, nowMs)),
|
||||
remote.port,
|
||||
|
|
@ -5846,7 +5834,7 @@ export class CultMesh {
|
|||
}
|
||||
if (packet.reliable) {
|
||||
socket.send(
|
||||
encodeRudpPacket(record.session.createAckForReceived(packet.sequence)),
|
||||
encodeRudpPacket(record.session.createAckFor(packet.sequence)),
|
||||
record.remote.port,
|
||||
record.remote.address,
|
||||
);
|
||||
|
|
@ -6280,28 +6268,6 @@ export class CultMesh {
|
|||
}
|
||||
return CultMesh.createRudpPeerForPeer(runtimeId, connectionId, peer, options);
|
||||
}
|
||||
|
||||
public static parseQuicRealtimeEndpoint(endpoint: string): ReturnType<typeof parseQuicRealtimeEndpoint> {
|
||||
return parseQuicRealtimeEndpoint(endpoint);
|
||||
}
|
||||
|
||||
public static createQuicRealtimeConnector(
|
||||
options: CultMeshQuicRealtimeConnectorOptions = {},
|
||||
): CultMeshQuicRealtimeConnector {
|
||||
return new CultMeshQuicRealtimeConnector(options);
|
||||
}
|
||||
|
||||
public static createQuicRealtimeSessionManager(
|
||||
options: CultMeshQuicRealtimeSessionManagerOptions,
|
||||
): CultMeshQuicRealtimeSessionManager {
|
||||
return new CultMeshQuicRealtimeSessionManager(options);
|
||||
}
|
||||
|
||||
public static createQuicRealtimeProvider(
|
||||
options: CultMeshQuicRealtimeProviderOptions,
|
||||
): Promise<CultMeshQuicRealtimeProvider> {
|
||||
return CultMeshQuicRealtimeProvider.listen(options);
|
||||
}
|
||||
}
|
||||
|
||||
function cultMeshSchemaFromDefinition(
|
||||
|
|
@ -6671,6 +6637,14 @@ function nonBlankOr(value?: string, fallback = ""): string {
|
|||
return value && value.trim() ? value : fallback;
|
||||
}
|
||||
|
||||
function byteArraysEqual(left: Uint8Array, right: Uint8Array): boolean {
|
||||
if (left.byteLength !== right.byteLength) return false;
|
||||
for (let index = 0; index < left.byteLength; index += 1) {
|
||||
if (left[index] !== right[index]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseCultMeshLocalityKind(
|
||||
value: string | undefined,
|
||||
fallback: CultMeshLocalityKind,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue