Compare commits
545 changed files with 5925 additions and 110497 deletions
38
.gitattributes
vendored
38
.gitattributes
vendored
|
|
@ -1,40 +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
|
||||
|
||||
# CultMath source keeps CRLF in the working tree on every checkout. The release
|
||||
# gate compares the CultMath.dll it builds with the one org.gamecult.cultmath
|
||||
# tracks, byte for byte, and the bytes follow the checkout's line endings: the
|
||||
# pdb's document checksums feed the DLL's pdb id, timestamp and MVID. The
|
||||
# tracked DLL was built from CRLF source, so CRLF here keeps it valid, and an
|
||||
# LF-configured or Linux clone builds the same bytes as a Windows one. The index
|
||||
# stays LF; only the checkout changes.
|
||||
packages/cultmath/src/** text eol=crlf
|
||||
|
|
|
|||
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"
|
||||
|
|
|
|||
37
.github/workflows/cultnet-interop.yml
vendored
37
.github/workflows/cultnet-interop.yml
vendored
|
|
@ -12,13 +12,6 @@ jobs:
|
|||
name: C# / TypeScript / Rust / Python / Kotlin
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 60
|
||||
# Windows runners default to pwsh, which does not stop when a native command fails: a
|
||||
# multi-command step reported only its last command, and a failing cultnet-py suite
|
||||
# went green that way. Git Bash runs as `bash -e -o pipefail`, so every command counts.
|
||||
# Steps that name `shell: pwsh` check $LASTEXITCODE themselves.
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
DOTNET: dotnet
|
||||
|
|
@ -72,36 +65,6 @@ jobs:
|
|||
"KOTLIN_JAVA_HOME=$env:JAVA_HOME" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
"$kotlinHome\bin" | Out-File -FilePath $env:GITHUB_PATH -Append
|
||||
|
||||
- name: Verify CultNet schema contracts stayed consolidated
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
# packages/cultnet-rs/contracts/cultnet/ holds R-AB's vendored byte copies (the crate must build
|
||||
# when copied alone). Their drift guard is packages/cultnet-rs/tests/schema_discovery_contracts.rs:74-99.
|
||||
$tracked = git ls-files '*.schema.json'
|
||||
if ($LASTEXITCODE -ne 0) { throw "git ls-files failed" }
|
||||
$stray = $tracked | Where-Object { $_ -notmatch '^(packages/cultnet-rs/)?contracts/cultnet/' }
|
||||
# -I skips binary files: unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll
|
||||
# still embeds the retired namespace until it is rebuilt (REC-032 D4, deferred).
|
||||
$oldNamespace = git grep -I -lE 'GameCult/cultnet-ts/(integration/)?contracts' -- ':!.voidbot'
|
||||
if ($LASTEXITCODE -eq 2) {
|
||||
throw "git grep failed"
|
||||
}
|
||||
if ($stray) {
|
||||
Write-Host "Schema files found outside contracts/cultnet/:"
|
||||
$stray | ForEach-Object { Write-Host " $_" }
|
||||
}
|
||||
if ($oldNamespace) {
|
||||
Write-Host "Files still referencing the retired GameCult/cultnet-ts contracts namespace:"
|
||||
$oldNamespace | ForEach-Object { Write-Host " $_" }
|
||||
}
|
||||
if ($stray -or $oldNamespace) {
|
||||
throw "CultNet schema contracts drifted out of contracts/cultnet/ (REC-032). See CultLib/contracts/cultnet/ and rewrite any `$id back to https://github.com/GameCult/CultLib/contracts/cultnet/."
|
||||
}
|
||||
# git grep exits 1 when nothing matches, and the runner's pwsh wrapper
|
||||
# ends the step with that $LASTEXITCODE; a clean tree must pass.
|
||||
exit 0
|
||||
|
||||
- name: Install Node dependencies
|
||||
run: npm install --no-package-lock
|
||||
|
||||
|
|
|
|||
77
.github/workflows/publish-packages.yml
vendored
77
.github/workflows/publish-packages.yml
vendored
|
|
@ -10,8 +10,8 @@ name: Publish packages
|
|||
#
|
||||
# Tag a release to publish, for example `cultcache-ts-v0.13.6` or
|
||||
# `cultnet-py-v0.1.0`. The tag's version must match the version in the package
|
||||
# manifest; scripts/release-after-suite.mjs refuses the release otherwise rather
|
||||
# than shipping a mislabelled artifact.
|
||||
# manifest; the workflow refuses the publish otherwise rather than shipping a
|
||||
# mislabelled artifact.
|
||||
|
||||
on:
|
||||
push:
|
||||
|
|
@ -42,12 +42,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
# fetch-depth: 0 so the semver check in the release step 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
|
||||
|
|
@ -55,30 +51,29 @@ jobs:
|
|||
node-version: 24.x
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
# The release-checks suite, which Publish runs, needs .NET for the API gate.
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Install workspace dependencies
|
||||
run: npm ci
|
||||
|
||||
# Manual runs publish nothing, so they build and test here. A tag run's
|
||||
# Publish runs these tests itself.
|
||||
- name: Verify the tag matches the manifest version
|
||||
if: startsWith(github.ref, 'refs/tags/cultcache-ts-v')
|
||||
run: |
|
||||
manifest="$(node -p "require('./packages/cultcache-ts/package.json').version")"
|
||||
tagged="${GITHUB_REF_NAME#cultcache-ts-v}"
|
||||
if [ "$manifest" != "$tagged" ]; then
|
||||
echo "tag $GITHUB_REF_NAME does not match manifest version $manifest" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build and test
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
working-directory: packages/cultcache-ts
|
||||
run: npm test
|
||||
|
||||
# One process checks that the tag is the manifest version, runs the
|
||||
# release-checks suite, the semver policy check (docs/semver-policy.md)
|
||||
# and npm test, and only then runs npm publish --access public. Only
|
||||
# npm publish receives the token. It starts without NODE_OPTIONS, so no
|
||||
# preloaded module runs inside it.
|
||||
# Scoped packages default to restricted even on public npm, and this is
|
||||
# MIT like the rest of the repo, so say public explicitly.
|
||||
- name: Publish
|
||||
if: startsWith(github.ref, 'refs/tags/cultcache-ts-v')
|
||||
run: env -u NODE_OPTIONS node scripts/release-after-suite.mjs npm-publish packages/cultcache-ts "$GITHUB_REF"
|
||||
working-directory: packages/cultcache-ts
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
|
|
@ -110,13 +105,9 @@ jobs:
|
|||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# fetch-depth: 0 so the semver check in the release step 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'
|
||||
|
|
@ -124,33 +115,29 @@ 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: Set up .NET
|
||||
if: steps.selected.outputs.run == 'true'
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
- name: Verify the tag matches the manifest version
|
||||
if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/')
|
||||
run: |
|
||||
manifest="$(python -c "import tomllib,pathlib;print(tomllib.loads(pathlib.Path('packages/${{ matrix.package }}/pyproject.toml').read_text())['project']['version'])")"
|
||||
tagged="${GITHUB_REF_NAME#${{ matrix.package }}-v}"
|
||||
if [ "$manifest" != "$tagged" ]; then
|
||||
echo "tag $GITHUB_REF_NAME does not match manifest version $manifest" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Lower layers install from source so a release of one package is tested
|
||||
# against the sibling revision it was cut with, not whatever PyPI has.
|
||||
- name: Install
|
||||
- name: Install and test
|
||||
if: steps.selected.outputs.run == 'true'
|
||||
run: python -m pip install --upgrade build -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py
|
||||
run: |
|
||||
python -m pip install -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py
|
||||
python -m unittest discover -s packages/${{ matrix.package }}/tests
|
||||
|
||||
# One process checks, on a tag, that the tag is the manifest version, runs
|
||||
# the release-checks suite, on a tag the semver policy check
|
||||
# (docs/semver-policy.md), and the package's unit tests, and only then
|
||||
# builds the sdist and wheel. Publish uploads only what this step built,
|
||||
# so it has nothing to upload unless every check passed. It starts without
|
||||
# NODE_OPTIONS, so no preloaded module runs inside it.
|
||||
- name: Build sdist and wheel
|
||||
if: steps.selected.outputs.run == 'true'
|
||||
run: env -u NODE_OPTIONS node scripts/release-after-suite.mjs python-build packages/${{ matrix.package }} "$GITHUB_REF"
|
||||
run: |
|
||||
python -m pip install --upgrade build
|
||||
python -m build packages/${{ matrix.package }}
|
||||
|
||||
- name: Publish
|
||||
if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/')
|
||||
|
|
|
|||
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. The set is a closed enum: Rust and TypeScript refuse a message whose code is not listed, so adding a code is one coordinated change across every runtime (this schema, the C# factory, the Rust CultNetErrorCode, and a captured-bytes case for each).",
|
||||
"type": ["string", "null"],
|
||||
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema"]
|
||||
},
|
||||
"details": {
|
||||
"description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"required": ["field", "value", "asOf", "current"],
|
||||
"properties": {
|
||||
"field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\".", "type": ["string", "null"] },
|
||||
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns.", "type": ["string", "null"] },
|
||||
"asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 },
|
||||
"current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,840 +0,0 @@
|
|||
# CultCache publication: the cache owns "published once", and order stays data
|
||||
|
||||
Status, 2026-09-29: Cuts 1-3 landed on `main`. Cut 3 merged at `ae8576d`. Imagination pass 4 re-took Cuts 4-6
|
||||
against `3bf1c0c`. Q-P5 is ruled (A). Cut 4 is ready for Hands.
|
||||
- F4, pinning `Held`'s publish-on-throw guard, is done: Cut 3 pinned it.
|
||||
- F3 (observer removal on unsubscribe) and F1 (the false "getters run once" claim for conditional commits on variant
|
||||
stores) were Cut 3 follow-ups; confirm against the Cut 3 merge.
|
||||
|
||||
Recorded:
|
||||
- F2, the Q-P2 hazard. A rethrown observer exception makes a committed write look cancelled or refused. The
|
||||
only live typed catch around a cache write is AetheriaEve `Aetheria.State.Daemon/Program.cs:3722`. It needs a
|
||||
fail-fast handler, and no production code installs one.
|
||||
- F5: subscribe and unsubscribe cost O(n).
|
||||
- F6: writes after `Dispose` are dropped silently.
|
||||
|
||||
Map written by Imagination pass 1, 2026-09-29. The ends are the
|
||||
operator ruling of 2026-09-30: every committed cache change reaches subscribers
|
||||
exactly once. This document owns the means.
|
||||
|
||||
- **Body.** CultLib `main` `65311c9ad770828adafe40c2c14b2d3da517628e`. Every
|
||||
`file:line` is against it unless it names the branch.
|
||||
- **Superseded attempt.** `origin/hands/cultnet-publish-commits`, tip
|
||||
`28248eb`, base `f9f107a`. Soul pass 3 said do not merge it, and that stands.
|
||||
- Main has not touched `CultCache.cs`, `CultNetDatabase.cs`, the composition
|
||||
doc or the networking tests since `f9f107a`: `git diff --stat` is empty.
|
||||
- `git merge-tree 65311c9 28248eb` is clean.
|
||||
- **Scope.** The C# cache's publication and CultNetDatabase's use of it. There
|
||||
is no wire-format change and no other runtime changes (§6).
|
||||
|
||||
|
||||
## Rulings (operator, 2026-09-30)
|
||||
|
||||
- **Q-P1 A: order as data.** Each change is published exactly once, on its writer's thread, before the write
|
||||
returns, and carries its `Sequence`. The shard log is written in cache order by the gate-held journal. Wire
|
||||
peers drop changes that are stale by `Sequence`. The turnstile (`bcae483`/`4562340`) and the drainer are
|
||||
both rejected.
|
||||
- **Q-P2 A:** a throwing observer does not stop the others. Every observer runs, the exception is rethrown to
|
||||
the writer afterwards, and the observer stays subscribed. The commit stands.
|
||||
- **Q-P3 A:** a public `CultCache.AddJournal(...)`, with reentry guarded.
|
||||
- **Q-P4 A:** Cut 6, which drops stale changes per key on the wire, lands in this campaign.
|
||||
- **Q-P5 A (2026-09-29):** a primary that cannot log a committed change throws a typed `CultNetShardLogException`
|
||||
naming the shard and the burned sequence, after publication, not derived from `InvalidOperationException`.
|
||||
|
||||
## The finding that shapes this map
|
||||
|
||||
The direction Imagination was handed was: "ordered delivery belongs in the
|
||||
cache's own publication, on the writer's thread, so a writer delivers its own
|
||||
changes before returning". That design **has been built before, and it was
|
||||
deleted**.
|
||||
|
||||
- `bcae483` (2026-09-13 20:09) made the cache a delivery scheduler: tickets, a
|
||||
delivery gate, turn waits and per-thread queues for reentrant writes.
|
||||
- `4562340`, 34 minutes later, deleted it: "It deadlocked inside CultLib: a
|
||||
load's delivery reaching a CultNetDatabaseSubscriptionServer observer that
|
||||
needs _lifecycleGate, while a subscribe's demand handler holding that gate
|
||||
writes the cache and waits for the delivery's turn."
|
||||
- It replaced it with **order as data**: a per-cache `Sequence`, and no
|
||||
delivery scheduler.
|
||||
- The regression tests it left, `ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries`
|
||||
(`ConditionalCommitTests.cs:763`) and
|
||||
`SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock`
|
||||
(`NetworkingTests.cs:2244`), are live on main.
|
||||
|
||||
This pass rebuilt that scheduler as a prototype. It is the per-cache turnstile
|
||||
with same-thread trampolining at `79f4639`, and it reproduces the deletion:
|
||||
|
||||
- Caching tests: 278 of 279 pass, and `ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries`
|
||||
fails.
|
||||
- The networking suite hangs, and Yggdrasil's idle watchdog killed it at 900 s.
|
||||
The blame-hang run that names the hung test is recorded below.
|
||||
|
||||
The reason is structural, not an implementation slip. Once a writer waits for
|
||||
another thread's delivery, any lock that an observer takes and a writer holds
|
||||
becomes a deadlock:
|
||||
|
||||
- total cross-thread order;
|
||||
- a writer delivering before it returns;
|
||||
- no writer waiting on another thread.
|
||||
|
||||
Pick two. The branch's drainer chose order plus no-wait, and lost
|
||||
before-return (F3 and F4). The turnstile chose order plus before-return, and
|
||||
lost no-wait (the `bcae483` deadlock).
|
||||
|
||||
**So this map keeps order as data (the `4562340` ruling) and moves the one
|
||||
order-sensitive consumer, the CultNet mutation log, to the place order is
|
||||
decided: under the cache gate.**
|
||||
|
||||
- Every writer keeps delivering its own changes, on its own thread, before it
|
||||
returns, exactly as main does. F3 is satisfied by not regressing it.
|
||||
- The log becomes a **journal** written in the admitting hold, in Sequence
|
||||
order.
|
||||
- The reorder buffer and drainer die, and no scheduler replaces them. This
|
||||
departs from Soul's stated direction on purpose, and **Q-P1** puts it to the
|
||||
operator.
|
||||
|
||||
## Probe evidence
|
||||
|
||||
All of this ran on Yggdrasil via `ygg-verify.sh`, from scratch commits in a
|
||||
throwaway worktree that no branch holds.
|
||||
|
||||
- Probe file: `tests/GameCult.Caching.Tests/PublicationProbeTests.cs` in scratch commit `79f4639`. The worktree has been removed. The commit is unreferenced in the local object store, where gc can collect it; it also survives on the Yggdrasil mirror as `refs/verify/79f4639f8f5b95db7ce16f244a081c1ec101a468`.
|
||||
- `9c5abf5` is the probes on main. `79f4639` is the same probes plus the
|
||||
turnstile prototype in `CultCache.cs`.
|
||||
|
||||
| # | Probe | main `65311c9` | Turnstile prototype `79f4639` |
|
||||
|---|---|---|---|
|
||||
| Q1 | A batch `{ok, boom}` where boom's `[CultName]` getter throws | `TargetInvocationException`. `_sequence` goes from 0 to 1, because ok was minted. **Both ok and boom are in memory**, and `GetByName("ok")` finds ok. **Nothing is published**, and the next write gets Sequence 2. F1 is worse than stated: memory is corrupt and unpublished. In a store-backed cache, `land` (`:2325`) already ran, so the store holds it too. | No stall, but memory is still corrupt. |
|
||||
| Q2 | Two `Watch` observers, the first throws | With the default R3 handler, the second observer sees 2 of 2 and the writer sees nothing. **With a rethrowing handler, the second observer sees 0 of 2 and every writer gets the exception.** The comment at `CultCache.cs:2374`, "OnNext does not throw", is false under fail-fast. | With its own observer array and a per-observer catch, the second observer sees 2 of 2 and the writer gets the exception afterwards. |
|
||||
| Q3 | Writer A's observer blocks on `a`, then B writes `b` | The recorder sees `b(2)` before `a(1)`. There is no cross-thread order, by design since `4562340`. | Sees `a` then `b`, because B waited for A. |
|
||||
| Q4 | An observer on `x` writes `y` | `y(2)` before `x(1)`: nested, depth-first, and the nested write returns after `y` is published. | `x` then `y`, and the nested write returns before `y` is published. |
|
||||
| Q5 | `OnUpdate` on Upsert and on Commit | 0 fires, so loads only. `Watch` sees 2. | Unchanged. |
|
||||
| Q6 | 8 threads × 1000 writes, plus reentrant writes | 67 ms, 2645 of 8800 cross-thread inversions, and 0 writes returned undelivered. | 516 ms, 0 inversions, 0 undelivered. |
|
||||
| Q7 | Soul's P2 shape: a handler waits 1 s for another thread's write | Observed in time. | Not observed in time. |
|
||||
| Suites | Caching, Networking and Mesh | (green on main) | Caching fails `ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries`, and Networking **hangs** until the 900 s idle kill. Rerun with `--blame-hang-timeout 90s`: 286 passed, then the test host hung and was dumped. The blame sequence file was not extracted, so **the hung test is not named**. It is consistent with, but not proven to be, `SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock`, whose own comment says "disposal would hang too". |
|
||||
|
||||
## Imagination pass 4: Cut 4 re-taken on the landed journal
|
||||
|
||||
Cut 3 landed at `ae8576d`. Its Soul left two questions that Cut 4 had to settle before relying on the
|
||||
journal: (a) what a throwing journal means for the log, and (b) whether a journal needs the change's origin.
|
||||
Both have one coherent answer. The one fork, Q-P5, was about who hears of a log failure; the operator ruled it A (2026-09-29).
|
||||
|
||||
### Probe evidence (pass 4)
|
||||
|
||||
- Probe file: `Pub4ProbeTests.cs` (Networking tests). It was run on Yggdrasil from scratch commits:
|
||||
- `0d7397d`: main plus the probes;
|
||||
- `6ec2f73`: the same, merged with the branch `28248eb`;
|
||||
- `376cd74`: main plus probe C.
|
||||
- The commits survive on the Yggdrasil mirror. The scratch worktree is gone.
|
||||
- The probes report facts and assert nothing. `FlakyLogStore` is an in-memory `ICultNetShardMutationLogStore`
|
||||
that can refuse `Append` or run a hook inside `GetCompactedThrough`.
|
||||
|
||||
| # | Probe | main `3bf1c0c` | branch `28248eb` (queue) |
|
||||
|---|---|---|---|
|
||||
| A1 | Primary with a durable log. Put 1, Put 2 (its `Append` throws), Put 3. A replica pulls, then retries. | Writer gets `IOException`. The cache holds 1, 2 and 3, but **2 is never published** (2 of 3). Memory log `[1,2,3]`, durable `[1,3]`. The replica pull throws "log has a gap. Expected 2, received 3", and so does **every retry**: the replica is stuck for good. | **Writer sees nothing**: R3 swallowed the drainer's exception. 2 is not published. The replica is stuck in the same way. |
|
||||
| A2 | The same failure at the tail, then the primary restarts over the same cache and log store. | The replica applied 1. After restart, note 3 is **minted sequence 2**. The replica takes it, reports applied 2, and holds `1,3` against the primary's `1,2,3`: **silent divergence**, a reused sequence. | Same. |
|
||||
| A3 | A replica whose log `Append` throws once. | The pull throws and the cursor stays at 0. The retry converges (`1,2`), but the memory log is `[1,1,2]`: a duplicate, because memory is written before disk and kept as a `List`. | The pull "succeeds": the exception was swallowed and the cursor went to 2. The durable log is `[2]`, so **entry 1 is lost**, and a chained replica sees a gap. |
|
||||
| B1 | Primary with a file store. Another writer puts `pulled` into the file, the primary runs `PullAll`, and a replica pulls the log. | `pulled` is published but **not logged**. The replica gets nothing. | Logged. The replica converges on `pulled`. |
|
||||
| B2 | A store attached after the database exists. | Nothing is logged. | All three hydrated records are logged. |
|
||||
| S | `CreateShardSnapshotResponse` with a write landing between its document read and its sequence read (hooked in `GetCompactedThrough`). | The snapshot holds `[1]` at log sequence 2. The replica applies it and pulls, and still lacks 2: **divergence**. | Same. |
|
||||
| C | Compact through 2, restart, then Put 3. | Note 3 is **minted sequence 1**, and `GetLatestMutationLogSequence` returns 1, below the floor of 2. | (not run; the code is unchanged) |
|
||||
|
||||
What the probes establish:
|
||||
- A1, A2, S and C are pre-existing defects on main. Cut 4 inherits them and must not build on them: the
|
||||
resync path it relies on for (a) is itself broken by S and C.
|
||||
- On a replica, the pull loop already retries. The cursor advances only after an entry applies
|
||||
(`CultNetDatabase.cs:1038-1083`), so a replica needs no new mechanism. It needs only to keep the exception
|
||||
reaching that loop, which the branch's queue broke (A3).
|
||||
- Loads on a primary must be logged, or replicas diverge (B1).
|
||||
|
||||
### (a) A journal that cannot log a committed change: one answer
|
||||
|
||||
The facts:
|
||||
- The log store is a second durable store written after the cache's store. Without an atomic commit across
|
||||
the two, the log can lag the store. It must never lead: a replica would then apply a change the primary
|
||||
refused.
|
||||
- So lag is unavoidable, and it has to be detectable and repaired.
|
||||
- The protocol already has a repair, and nothing else needs to be invented.
|
||||
- "History the primary cannot serve" is `compacted_history`: `CultNetDatabaseServer.cs:189-201` answers
|
||||
`ResyncRequired`.
|
||||
- `CultNetShardReplication.cs:199-214` then snapshot-resyncs.
|
||||
|
||||
**Rule: a primary that cannot log a committed change burns that change's sequence and compacts through it.**
|
||||
|
||||
- In the database journal, on a primary, for each change that is not `NoLog`:
|
||||
1. Mint `S`.
|
||||
2. Build the entry: the wire message from the door, or `ToLogEntryMessage`.
|
||||
3. `Append` durably, **then** record it in memory. Disk goes first, so memory never serves what disk lacks.
|
||||
- If step 2 or 3 throws:
|
||||
- record nothing;
|
||||
- raise the database's in-memory floor for the shard to `S`;
|
||||
- try `store.CompactThrough(shard, S)`;
|
||||
- still set `_lastWriteSequence` to `S`, because the row was written at `S`;
|
||||
- carry on with the rest of the admission;
|
||||
- throw `CultNetShardLogException` once at the end of the journal call (Q-P5 A).
|
||||
- Every replica behind `S` is then told `compacted_history` and resyncs by snapshot. The sequence gap at `S`
|
||||
is never served, because the floor covers it.
|
||||
- This **replaces** the old §4 step 3 claim, "a serialization failure then mints nothing, so the log can never
|
||||
hold a gap". Minting nothing hides a replication gap behind a dense sequence. That is exactly A2's silent
|
||||
divergence.
|
||||
- **Replica:** there is no floor. The journal's exception reaches `ApplyShardLogResponseAsync` and the cursor
|
||||
does not advance. The next pull re-applies the entry: a fresh instance, so the door matches again, and the
|
||||
entry is re-recorded under the primary's sequence. The branch's absent-key direct log follows the same
|
||||
disk-first order.
|
||||
- **Required fixes** that the rule depends on, all in Cut 4:
|
||||
- `GetCompactedMutationLogSequence` (`CultNetDatabase.cs:597-602`) returns
|
||||
`max(store floor, in-memory floor)`.
|
||||
- `InitializeLogSequencesFromStore` (`:1363-1395`) resumes at
|
||||
`max(highest retained, store.GetCompactedThrough(shard)) + 1`. This is probe C.
|
||||
- `GetLatestMutationLogSequence` (`:607-616`) never reports below the floor.
|
||||
- `CreateShardSnapshotResponse` (`:624-670`) reads `GetLatestMutationLogSequence` **before** `SelectAll`, on
|
||||
both returns, `:645` and `:668`. The documents then reflect at least that sequence, and replaying later
|
||||
entries converges: they are whole-document puts and deletes, applied in order. This is probe S.
|
||||
- `RecordMutationLogEntry` (`:1331-1352`) writes disk first. The in-memory log becomes
|
||||
`SortedList<long, entry>` per shard, replacing by sequence as the durable store does. This fixes A3's
|
||||
duplicate.
|
||||
- **Residual, recorded rather than forked.** Suppose the store refuses both `Append` and `CompactThrough`, and
|
||||
then the process restarts.
|
||||
- The durable log does not know about the hole, so A2's tail reuse can recur.
|
||||
- Fixing that needs a durable write per mint, a sequence reservation, which doubles log I/O under the gate.
|
||||
- The thrown exception names both failures, so the outage is loud.
|
||||
- Refusing later writes ("fail closed") is not coherent. The database can refuse only its own doors, while
|
||||
bare cache writes are journaled regardless: that would be split authority.
|
||||
|
||||
### (b) A pulled or loaded change on a primary: no origin needed
|
||||
|
||||
- On a primary the log is the shard's **state history**, whoever wrote the state. B1 shows that leaving loads
|
||||
out of the log leaves replicas permanently diverged.
|
||||
- The only origins that change what is logged are already carried by the door table, keyed by instance
|
||||
identity and matched by shape. They are:
|
||||
- `Replica` (log the primary's entry);
|
||||
- `NoLog` (prediction, snapshot resync);
|
||||
- `Put`/`Delete` (the wire message);
|
||||
- `Predicts`/`Authoritative`.
|
||||
- A load or pull has no door, so on a primary it is logged like any unattributed commit. On a replica it logs
|
||||
nothing, like any local write. That is what the branch does, and B1 converges under it.
|
||||
- Cost:
|
||||
- B2: a store attached **after** the database logs its hydration.
|
||||
- `CultNetHost` opens the cache before building the database (`CultNetLocal.cs:146-152`), so production
|
||||
hydration is not journaled.
|
||||
- A re-pull admits only the records that changed. In B1 the seed was not re-logged.
|
||||
- `AddJournal` keeps its signature. If a future consumer (audit, say) needs "load or commit", the fact goes on
|
||||
`CultCacheDocumentChange` as an additive property, as `Sequence` did. The cache already knows it: `Admit`'s
|
||||
`source != null` at `CultCache.cs:2420`. It never needs a new `AddJournal` shape.
|
||||
- This is recorded, not forked: no consumer needs it today.
|
||||
|
||||
### The two recorded hazards, settled for Cut 4
|
||||
|
||||
- **Q-P2 typed-exception hazard.** Cut 4 makes it frequent. Every cache write on a primary with a durable log
|
||||
now does log I/O inside its admission.
|
||||
- AetheriaEve opts into durable logs (`Aetheria.State/AetheriaStateNode.cs:144`).
|
||||
- Its `AetheriaHangarCommandJournal.ValidateAsync` writes an envelope (`Program.cs:6509`) inside the `catch
|
||||
(InvalidOperationException)` at `Program.cs:3722`.
|
||||
- A log failure surfacing as `InvalidOperationException` would therefore produce a "denied" receipt for a
|
||||
committed envelope.
|
||||
- Q-P5 A (ruled): the writer gets a typed `CultNetShardLogException` that **does not derive from
|
||||
`InvalidOperationException`**.
|
||||
- **Journal deadlock (the Cut 3 note).** The database journal waits only on `_logGate` and on the log store's
|
||||
own lock.
|
||||
- Lock order: cache gate, then `_logGate`, then the log store's lock.
|
||||
- Invariant: **no code holding `_logGate` calls the cache, and no log store calls the cache or waits on a
|
||||
thread that does.**
|
||||
- Readers that take `_logGate` read only log state: `GetMutationLog`, `LastWriteSequence`, `CurrentAsOf`.
|
||||
Readers already inside a cache read, such as the selection evaluator's `ordinalOf`, nest `_logGate` under
|
||||
the cache gate, which is the same order.
|
||||
- The store rule goes into the `ICultNetShardMutationLogStore` XML doc (`CultNetShardMutationLogStorage.cs:13-34`),
|
||||
because a user store runs under the cache's gate.
|
||||
- A log store that writes the cache already throws, through Cut 3's guard. Cut 4 pins it with a test.
|
||||
|
||||
## Authority map
|
||||
|
||||
- **Owner.** `CultCache`. The hold that mints a Sequence range also:
|
||||
1. journals the range under the gate, in Sequence order (new);
|
||||
2. publishes the range to every observer, isolated, on the minting writer's
|
||||
thread, after it releases the gate and before it returns (main's model,
|
||||
now isolated).
|
||||
|
||||
Nothing waits on another call's delivery.
|
||||
- **Inputs.**
|
||||
- The changes returned by `Apply` (`CultCache.cs:2707`), the single mutation
|
||||
site.
|
||||
- The pre-mint projection of names and indexes (Cut 1).
|
||||
- The journal list, the observer array and `OnUpdate`.
|
||||
- **Outputs.**
|
||||
- Journal calls: one per admission, under the gate, in Sequence order.
|
||||
- `Watch` / `WatchRecord` notifications that carry `Sequence`.
|
||||
- `OnUpdate` for loads.
|
||||
- Observer and journal exceptions, rethrown to the writer.
|
||||
- **Derived state.**
|
||||
- `Sequence` is order as data.
|
||||
- CultNet's primary log sequence is minted in the journal, so log order is
|
||||
cache order.
|
||||
- A replica's log sequence is copied from the primary entry.
|
||||
- The database change kind (`Predicted`, `Reconciled`, `Added`, `Updated`,
|
||||
`Removed`) is decided in the journal.
|
||||
- `CultNetDatabaseChange.Sequence` (new) is copied from the cache change.
|
||||
- Wire projections drop stale by it (Cut 6).
|
||||
- **Forbidden writers.**
|
||||
- Nothing re-sequences or schedules cache changes. The branch's `_parked`,
|
||||
`_ready`, `_nextSequence`, `_draining`, `PendingChange` and the drain body
|
||||
of `OnCacheChange` die, and so do the turnstile, `bcae483` and the
|
||||
prototype.
|
||||
- No R3 `Subject` fans out cache changes (Q2).
|
||||
- `Apply` and `Index` run no user code.
|
||||
- Doors never read or write prediction state: the branch's `HasPrediction`
|
||||
and `_predictionGate` die.
|
||||
- The database's Watch observer never logs; only the journal logs.
|
||||
- A journal never enters the cache.
|
||||
- **Shared paths.** Every path goes `Held` → `Admit` → `Resolve` → **Project**
|
||||
→ `land` → `Apply` → **journal** → leave gate → **publish**:
|
||||
- loads: `AddBackingStore` `:1909`, `Loaded` `:1940`, `PullAll*` `:1959` and
|
||||
`:3052`;
|
||||
- `Commit` / `TryCommit` (`Land`, `:2272`);
|
||||
- `UpsertAsync`, `UpsertVariantAsync`, `FlattenAsync` and `Remove`
|
||||
(`:2004-2056`, `Write` `:2252`);
|
||||
- a variant dependent's re-resolution (`VariantPlan.Dependents`, in the same
|
||||
`Apply`);
|
||||
- direct calls on an attached store (`:2924`);
|
||||
- `Dispose` (`:2142`);
|
||||
- the database doors, replica apply and snapshot resync, which only write
|
||||
the cache and hand context to the journal.
|
||||
|
||||
The single exception is the replica's absent-key delete, which has no cache
|
||||
change. It logs directly (§4).
|
||||
- **Deletion line.**
|
||||
- In the cache: `Subject<Change> _changes` (`:1809`), the `_changes.OnNext`
|
||||
call and its false comment (`:2374-2375`), `_changes.Dispose()`
|
||||
(`:2154`), and the accessor calls in `Index` (`:2798-2804`).
|
||||
- In the database, on the salvaged branch: the reorder buffer and drainer,
|
||||
`_predictionGate` and `HasPrediction`, the logging in `Deliver`, and the
|
||||
`Subject<object> _changes` fan-out.
|
||||
|
||||
## The design, question by question
|
||||
|
||||
### 1. Validation before minting: a refused change mints nothing
|
||||
|
||||
- **Where.** Add **Project** to `Admit` (`:2313`), between `Resolve` (`:2324`)
|
||||
and `land` (`:2325`).
|
||||
- **What.** Over `plan.Admitted ∪ plan.Dependents`, it evaluates once per
|
||||
record: `NameAccessor(document)` and every `IndexAccessors[alias](document)`.
|
||||
It keeps `(Name, (Alias, Value)[])` per key on the plan.
|
||||
- **A throwing getter refuses the whole admission.** It raises an
|
||||
`InvalidOperationException` that names the key, the member and the unwrapped
|
||||
message; today the caller sees a bare `TargetInvocationException`. The
|
||||
refusal comes before `land`, so the admission writes no store, touches no
|
||||
memory and mints nothing.
|
||||
- **Readers take the projection, not the document.**
|
||||
- `Index` (`:2793-2806`).
|
||||
- `RefuseVariantIndexSharing` (`:2659-2660`).
|
||||
- The variant-name rule in `ResolveOne` (`:2616`). Its ancestor read at
|
||||
`:2621` reads held documents, pre-land, and may stay.
|
||||
|
||||
`Resolve` currently runs the refusal rules before any projection exists.
|
||||
Hands makes Project the first pass over the resolved plan and moves both
|
||||
rules after it, so rules read the projection and only the projection reads
|
||||
documents.
|
||||
- **Apply becomes user-code-free.** It then does only dictionary and
|
||||
`ConditionalWeakTable` work on precomputed data.
|
||||
- **Loads refuse too.** A store whose record's getter throws fails to open,
|
||||
loudly. That is the rule "hydration failure on open is loud" (composition doc
|
||||
`:88-92`). Today it half-indexes silently.
|
||||
|
||||
### 2. Observer isolation
|
||||
|
||||
- **Replace the `Subject<Change>` with the cache's own observer array**,
|
||||
copy-on-write under a small lock.
|
||||
- `Watch<T>` (`:1887`) becomes `Observable.Create<Change>(register)` followed
|
||||
by the existing `Where` and `Select`.
|
||||
- `Dispose` calls `OnCompleted` on each observer, which is what
|
||||
`Subject.Dispose` did.
|
||||
- **Publish loops change → observer, and wraps each `OnNext` in its own
|
||||
try/catch.**
|
||||
- R3's `Observer<T>` already catches handler exceptions (`OnErrorResume`, then
|
||||
the unhandled handler). So an exception reaches the cache only when fail-fast
|
||||
was asked for: a rethrowing `ObservableSystem` handler, or a custom observer.
|
||||
- Escaped exceptions join the `OnUpdate` failures. They are rethrown to the
|
||||
writer after every observer has received every change of the hold. That is
|
||||
the existing `OnUpdate` policy (composition doc `:77-82`).
|
||||
- The observer stays subscribed (**Q-P2**). The commit stands either way.
|
||||
- **Prototype.** At `79f4639` (Q2) this took about 15 lines. Hands keeps that
|
||||
part and drops the turnstile around it.
|
||||
|
||||
### 3. Delivery thread, order and reentrancy
|
||||
|
||||
- **Unchanged from main, on purpose.** Each outermost hold publishes exactly its
|
||||
own changes, on its thread, after it leaves the gate and before it returns
|
||||
(composition doc `:59-65`, `Held` `:2339`).
|
||||
- A write from inside a handler is its own outermost hold. It delivers
|
||||
depth-first before returning, as Q4 shows on main.
|
||||
- There is no cross-thread order. Every change carries `Sequence`, and
|
||||
latest-state consumers drop stale changes by it (doc `:68-73`, and the Mesh
|
||||
mirrors at `CultMesh.cs:1534-1540`).
|
||||
- **Why this is not a regression of the ruling.** The ruling asks for
|
||||
exactly-once, not for a total delivery order.
|
||||
- Exactly-once in the cache comes from the single mint site plus isolation
|
||||
plus Cut 1 (§1-2).
|
||||
- Order is needed by exactly one consumer, the shard log. It gets that order
|
||||
from the journal (§4), not from delivery.
|
||||
- **The journal is the new mechanism.**
|
||||
- Signature: `IDisposable CultCache.AddJournal(Action<IReadOnlyList<CultCacheDocumentChange<object>>>)`.
|
||||
Its name and shape are **Q-P3**.
|
||||
- It is called in `Admit` right after `Apply` (`:2328-2329`), under the gate,
|
||||
once per admission, with that admission's changes in Sequence order.
|
||||
Journals run in registration order.
|
||||
- A journal that enters the cache, meaning any hold on this cache while a
|
||||
journal runs, throws `InvalidOperationException`. It is guarded by a flag
|
||||
checked in `Held`'s `Monitor.IsEntered` branch (`:2341`). This is the
|
||||
store rule ("a store call must not wait for a write to its own cache"),
|
||||
enforced.
|
||||
- Journal exceptions follow the observer policy: the admission stands, it is
|
||||
still published, and the exception is rethrown to the writer after
|
||||
publication.
|
||||
- Adding or removing a journal takes the gate, so registration is atomic with
|
||||
respect to admissions.
|
||||
- **Lock order.** Cache gate, then journal-internal leaf locks (the database's
|
||||
`_logGate`). Observers still run outside the gate, so the `bcae483` cycle
|
||||
(an observer needing `_lifecycleGate` while a gate holder writes the cache)
|
||||
cannot form. No journal takes `_lifecycleGate`, and no writer waits on a
|
||||
delivery.
|
||||
- **Soul's P2 passes.** A handler waiting on another thread's write sees it,
|
||||
because nothing orders deliveries (Q7 on main).
|
||||
|
||||
### 4. What CultNetDatabase keeps
|
||||
|
||||
The database is rebuilt on the journal. It has no queue.
|
||||
|
||||
- **Journal (under the cache gate, per admitted change, in Sequence order):**
|
||||
1. `TakeDoor(change)`, with the shape rule salvaged from the branch
|
||||
(`:1219-1234`). A put door matches a change whose new document is its
|
||||
instance and whose previous document is not. A removal door matches a
|
||||
change whose previous document is its instance and which has no new one.
|
||||
- The consumption now happens inside the door's own hold, before any
|
||||
observer runs. So an observer's later write to the door's key or
|
||||
instance can never wear the context (the `f3343c8` defect), and
|
||||
unconditional `Dispose`-removal stays correct.
|
||||
2. Predictions. A `Predicts` door adds the prediction. On an `Authoritative`
|
||||
door (`ApplyPutAsync`, replica put), if a prediction exists for the key,
|
||||
the kind is `Reconciled` and the prediction is removed.
|
||||
- The gate serializes this, so `_predictionGate` and `HasPrediction` are
|
||||
deleted (F5).
|
||||
- Soul's P6 cannot happen: the prediction is recorded before
|
||||
`PutPredictedAsync` returns.
|
||||
3. Primacy.
|
||||
- On a shard primary, where the door is not `NoLog`: mint, build, append disk-first and record, with a failed
|
||||
append burning the sequence and compacting through it. See "(a) A journal that cannot log a committed
|
||||
change" in Imagination pass 4; the earlier claim that a serialization failure mints nothing is withdrawn.
|
||||
- On a replica, record the primary's entry from the `Replica` door,
|
||||
under its own sequence.
|
||||
- For a local write on a replica, log nothing.
|
||||
4. Stash `(cache Sequence → CultNetDatabaseChange)` under `_logGate`, for the
|
||||
publisher.
|
||||
- **Watch observer (after the gate, on the writer's thread, before it
|
||||
returns):** it removes the stash entry for `change.Sequence` and publishes it.
|
||||
- A change with no stash was admitted before the database existed and is
|
||||
ignored.
|
||||
- Register the observer first and the journal second, in the constructor, so
|
||||
a stash can never be orphaned.
|
||||
- **Log-before-publish** is structural: the journal runs before the gate opens.
|
||||
- **The replica's absent-key delete (F6)** logs directly under `_logGate`. It
|
||||
is in order because every earlier entry of the same pull loop was journaled
|
||||
inside its own hold, before that `UpsertAsync`/`Remove` returned. This holds
|
||||
even when a pull runs inside a handler, since nested holds journal
|
||||
synchronously.
|
||||
- **The in-memory log is keyed and ordered by sequence.** It becomes a
|
||||
`SortedList<long, entry>` per shard, to match the durable store
|
||||
(`CultNetShardMutationLogStorage.cs:55-64, 75-89`: that store replaces by
|
||||
sequence and orders by sequence).
|
||||
- **Kept from the branch:**
|
||||
- the door table keyed by instance identity, and the shape rule;
|
||||
- "only a primary mints; replicas publish";
|
||||
- log-before-publish;
|
||||
- the snapshot resync "logs nothing" rule. Its removal door (branch `:733`)
|
||||
must set `Removal = true`, or the shape rule never matches it.
|
||||
- **Database fan-out isolation.** The database's `Subject<object> _changes`
|
||||
(main `:487`) has the Q2 defect one layer up: the subscription server's
|
||||
per-peer handlers sit on it. Replace it with the same isolated
|
||||
observer-array pattern as §2 (about 20 lines). Its exceptions reach the
|
||||
writer through the cache's own per-observer catch.
|
||||
- **`CultNetDatabaseChange<T>` gains `long Sequence`**, the cache change's
|
||||
Sequence, as an optional constructor argument, exactly as
|
||||
`CultCacheDocumentChange` did (composition doc `:57-58`). Without it,
|
||||
in-process latest-state consumers cannot drop stale changes.
|
||||
- **Gone:** F3 (no deferred delivery), F4 (no drainer), F7 (no queue to
|
||||
strand) and F8 (no guard).
|
||||
|
||||
### 5. Backpressure and memory bounds
|
||||
|
||||
- **No buffer exists.** A writer delivers its own changes, and they are held
|
||||
only in its `mine` list for the length of its own call.
|
||||
- **The journal stash** holds at most the changes of holds between their
|
||||
journal and their publication. That is one hold per writing thread, and each
|
||||
entry is removed by the same thread that added it.
|
||||
- **A slow observer slows only the writer whose change it is handling.** That
|
||||
is main's behaviour. It does not slow other writers, and it does not grow
|
||||
memory. This replaces the branch's unbounded drainer backlog (F4, Soul P5).
|
||||
- **The journal runs under the gate, so a slow journal slows every writer.**
|
||||
- The database journal does in-memory work plus one durable
|
||||
`mutationLogStore.Append` per logged change. `Append` rewrites the shard
|
||||
file (`CultNetShardMutationLogStorage.cs:83-89`).
|
||||
- That is I/O under the gate, which the locking doctrine already allows for
|
||||
stores ("A store doing I/O on behalf of its cache ... holds the gate",
|
||||
composition doc `:98-101`). It is still a new cost, so Cut 4's
|
||||
verification measures commit latency with a file log store.
|
||||
- The whole-file rewrite is a pre-existing monolith. It is recorded as a
|
||||
follow-up, not fixed here.
|
||||
|
||||
### 6. Cross-runtime impact
|
||||
|
||||
The Eyes survey was run at `65311c9`.
|
||||
|
||||
- **`cultcache-rs`, `cultcache-ts` and `cultcache-py` have no publication
|
||||
surface:** no watch, no listener and no Sequence.
|
||||
- Rust: `lib.rs:1970-1975, 2213-2420`.
|
||||
- TypeScript: `cult-cache.ts:164-181`, "Observer delivery is not scheduled
|
||||
here".
|
||||
- Python: `cache.py:30-45`, which refuses reentrant mutation.
|
||||
- **`cultnet-rs`, `cultnet-ts` and `cultnet-py` have no CultNetDatabase
|
||||
equivalent and no cache watcher.** The only server-side shard log is the
|
||||
Python interop test peer (`interop_peer.py:565, 583`), which mints
|
||||
`len(log)+1` under its own lock, inline with the write. That is already the
|
||||
journal's shape.
|
||||
- **Wire parity is unaffected.**
|
||||
- Cache `Sequence` never goes on the wire (composition doc `:47-49`).
|
||||
- `CultNetShardLogEntryMessage` does not change.
|
||||
- `CultNetDatabaseChangeRawMessage` (`CultNetSchemaMessages.cs:1016`) carries
|
||||
no sequence, and this map adds none: stale protection for wire
|
||||
subscriptions is server-side (Cut 6).
|
||||
- No vector in `tests/vectors/` encodes publication order.
|
||||
- **No other runtime must change.**
|
||||
- **Follow-up.** `cultmesh-py`'s `CultMeshDatabase` watch (`node.py:421-505,
|
||||
1059-1065`) has F2 (a throwing callback skips the rest and reaches the
|
||||
writer), and it has no Sequence. When Python grows cache publication, it
|
||||
takes this contract.
|
||||
|
||||
## Cuts
|
||||
|
||||
Every cut is on CultLib, branch `hands/cultcache-publication` from `main`
|
||||
`65311c9`, in a worktree Self creates. Cuts 1-3 touch only the cache and land
|
||||
first. Cuts 4-6 depend on Cut 3. Heavy verification runs on Yggdrasil.
|
||||
|
||||
**The negative checks that stay green in every cut** are the `4562340` tests:
|
||||
|
||||
- `ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries`;
|
||||
- `SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock`;
|
||||
- `SequenceIncreasesInAdmissionOrder`;
|
||||
- `WatchRecordIgnoresStaleSequence`.
|
||||
|
||||
They are the proof that no scheduler has come back.
|
||||
|
||||
### Cut 1. Project before mint (cache, behaviour)
|
||||
|
||||
- **First.** Commit Q1 as a failing test named for the rule.
|
||||
- **Deletes first.**
|
||||
- Accessor calls in `Index` (`:2798-2804`).
|
||||
- Direct accessor reads in `RefuseVariantIndexSharing` (`:2659-2660`) and
|
||||
`ResolveOne` (`:2616`).
|
||||
- **Adds.**
|
||||
- `Project` in `Admit` (`:2324-2325`).
|
||||
- A per-key projection on `VariantPlan` (`:2456`).
|
||||
- The early returns in `Resolve` (`:2477-2478`, `:2530-2534`) still pass
|
||||
through `Project`, because it is called from `Admit`.
|
||||
- **Authority.** Project owns "this admission's name and index values", and
|
||||
the rules and `Index` read them. `Apply` runs no user code.
|
||||
- **Verification.**
|
||||
- Tests:
|
||||
- **A refused getter mints nothing and changes nothing.** `GetWithSequence`
|
||||
is unchanged, both keys are absent, the name lookup misses, nothing is
|
||||
published, and the next write is previous+1.
|
||||
- **Store-backed batch:** the store file is byte-identical, and a reopen
|
||||
holds neither key.
|
||||
- **A load whose getter throws** refuses the open and leaves the file
|
||||
byte-identical.
|
||||
- **Once per landed record:** a counting getter runs exactly once per
|
||||
landed record, including a variant dependent re-resolved by a base edit.
|
||||
- **The refusal names** the key and the member.
|
||||
- Soul P4.
|
||||
- Negative grep: `rg -n "NameAccessor\?\.Invoke|IndexAccessors" src/GameCult.Caching/CultCache.cs`
|
||||
has no hit in `Apply`, `Index` or `Unindex`. `:315` is store-side and
|
||||
legitimate.
|
||||
- Stryker.NET on the diff.
|
||||
- **Ledger.** About −15 and +45.
|
||||
|
||||
### Cut 2. Observer isolation (cache, behaviour)
|
||||
|
||||
- **Deletes first.**
|
||||
- `Subject<Change> _changes` (`:1809`).
|
||||
- `_changes.OnNext` and the false comment (`:2374-2375`).
|
||||
- `_changes.Dispose()` (`:2154`).
|
||||
- **Adds.**
|
||||
- The observer array and `Observable.Create` in `Watch<T>` (`:1887-1897`).
|
||||
- The per-observer catch in `Publish` (`:2369`), with escapes joining
|
||||
`failures`.
|
||||
- `OnCompleted` on `Dispose`.
|
||||
- **Docs.** Composition doc `:77-82`: escaped observer exceptions follow the
|
||||
`OnUpdate` policy.
|
||||
- **Verification.**
|
||||
- Tests:
|
||||
- **Q2 with a rethrowing handler:** the second observer receives every
|
||||
change. The writer gets the escaped exception after delivery, and the
|
||||
thrower is still subscribed (per Q-P2).
|
||||
- **Subscription order:** subscribing and unsubscribing during a delivery
|
||||
leaves every other observer's stream complete.
|
||||
- **Dispose** completes observers.
|
||||
- Soul P3.
|
||||
- The four `4562340` tests stay green.
|
||||
- Stryker on the diff, especially removing the catch and breaking out of
|
||||
the loop on the first failure.
|
||||
- **Ledger.** About −5 and +30.
|
||||
|
||||
### Cut 3. The journal (cache, behaviour)
|
||||
|
||||
- **Adds.**
|
||||
- `AddJournal` (Q-P3), with registration under the gate.
|
||||
- The invocation in `Admit` after `Apply` (`:2328`).
|
||||
- The no-reentry guard in `Held` (`:2341`).
|
||||
- Exceptions routed like observer failures.
|
||||
- **Docs.** A composition-doc paragraph in "Store composition" and in
|
||||
"Locking": journals run under the gate, in Sequence order, and must not
|
||||
enter the cache or wait for anything that needs it.
|
||||
- **Verification.**
|
||||
- Tests:
|
||||
- **Order:** 8 threads × 1000 writes. The journal sees contiguous,
|
||||
strictly increasing Sequences, and each call's changes arrive together.
|
||||
- **Timing:** the journal runs before any observer of the same change, and
|
||||
after `Apply` (`Get` inside the journal sees the new document).
|
||||
- **Reentry:** a journal that writes the cache throws, and the admission
|
||||
stands.
|
||||
- **Refusal:** a refused admission journals nothing.
|
||||
- **Dependents:** a variant dependent is journaled in the same call as its
|
||||
base edit.
|
||||
- **Failure:** a throwing journal still lets the change publish, and the
|
||||
writer gets the exception.
|
||||
- The `4562340` tests stay green.
|
||||
- Stryker on the diff.
|
||||
- **Ledger.** About +50.
|
||||
|
||||
### Cut 4. Salvage the branch and rebuild CultNetDatabase on the journal (networking)
|
||||
|
||||
- **4a (history).** `git merge origin/hands/cultnet-publish-commits` into the cut branch from main
|
||||
`3bf1c0c`.
|
||||
- It is clean: the probe merge `6ec2f73` auto-merged `NetworkingTests.cs` and the composition doc, then
|
||||
built and ran.
|
||||
- The merge brings the tests. The queue dies in 4b.
|
||||
- Self may squash-salvage instead, with the same content.
|
||||
- **4b (subtraction first).** Delete at branch `28248eb` `CultNetDatabase.cs`:
|
||||
- `_parked`, `_ready`, `_nextSequence` and `_draining` (`:502-505`);
|
||||
- `_predictionGate` (`:506-507`);
|
||||
- the Watch subscription and handshake (`:529-531`);
|
||||
- `OnCacheChange` (`:1080-1135`);
|
||||
- `PendingChange` (`:1265-1275`);
|
||||
- `HasPrediction` (`:1209-1213`) and its calls (`:916`, `:1574`);
|
||||
- the logging and prediction bookkeeping in `Deliver` (`:1137-1201`).
|
||||
|
||||
Main's `PublishCacheUpdate` / `OnUpdate +=` (`:505`, `:1157`, `:1161-1191`) is already gone on the branch.
|
||||
Hands confirms it is absent after the merge.
|
||||
- **4c (the journal).** Register the Watch observer first and the journal second (`AddJournal`), in the
|
||||
constructor. Per admission, under the cache gate, per change in Sequence order:
|
||||
1. `TakeDoor(change)`, keeping the branch's shape rule (`:1219-1234`).
|
||||
2. Resolve the shard and kind. Predictions: a `Predicts` door adds its key. An `Authoritative` door
|
||||
(`ApplyPutAsync`, replica put) holding a prediction for the key makes the kind `Reconciled` and removes
|
||||
the key.
|
||||
3. Log, per §(a):
|
||||
- a replica door records the primary's entry, disk first;
|
||||
- a primary change that is not `NoLog` mints, builds, appends and records, with failure going to the
|
||||
floor;
|
||||
- anything else logs nothing.
|
||||
4. Stash `(cache Sequence → CultNetDatabaseChange)` under `_logGate`.
|
||||
5. If step 3 failed, throw once at the end of the call, after all changes are handled (Q-P5).
|
||||
- **4c, publication.** The Watch observer removes the stash entry for `change.Sequence` and publishes it. A
|
||||
missing stash means the change was admitted before this database existed, so it is ignored. `Dispose`
|
||||
removes the journal and the observer and clears the stash.
|
||||
- **4d (the §(a) fixes).**
|
||||
- The floor: `GetCompactedMutationLogSequence`, `GetLatestMutationLogSequence`, and resume in
|
||||
`InitializeLogSequencesFromStore`.
|
||||
- Disk-first `RecordMutationLogEntry`.
|
||||
- A `SortedList` per shard.
|
||||
- The snapshot reads its sequence first (`:660` moves below `:668`'s read).
|
||||
- `_logGate` around `_lastWriteSequence`/`_nextLogSequences` readers (`:1302`, `:1314`, `:1325`).
|
||||
- **Door changes.**
|
||||
- Flags: `Predicts` and `Authoritative`.
|
||||
- The snapshot removal door (branch `:733`) sets `Removal = true`.
|
||||
- The replica absent-key delete logs directly and publishes nothing (branch `:1612-1621`).
|
||||
- Doors neither log nor publish. They register context, write the cache, and dispose the registration.
|
||||
- **Verification.**
|
||||
- Every salvaged test passes: `CultNetPublishesCommitsTests` (13) and `CultNetPublicationOrderTests` (S1,
|
||||
S1b, S2, S3, S4, S6, S7, log-before-publish, in-place re-upsert).
|
||||
- Soul pass 3's P1-P7 are committed and passing.
|
||||
- The pass 4 probes become tests named for the rule:
|
||||
- **A1:** a refused primary `Append` publishes the change. The writer gets `CultNetShardLogException`,
|
||||
which names the shard and `S`. A replica pull answers `compacted_history`, and a snapshot resync
|
||||
converges.
|
||||
- **A2:** the same at the tail with a restart: the replica converges after resync, and no sequence is
|
||||
reused. The store's `CompactThrough` succeeded.
|
||||
- **A3:** a refused replica `Append` leaves the cursor unchanged. The retry converges, and the memory log
|
||||
holds `[1,2]`.
|
||||
- **B1:** a pull on a primary is logged, and the replica converges.
|
||||
- **S:** a snapshot racing a write converges.
|
||||
- **C:** after compacting through 2 and restarting, the next mint is 3.
|
||||
- New tests:
|
||||
- a reentrant `database.PutAsync` from inside a database handler is logged with its context and published
|
||||
once;
|
||||
- a reentrant `ApplyShardLogResponseAsync` from inside a handler keeps the replica log in primary order;
|
||||
- a log store whose `Append` writes the cache fails with Cut 3's reentry guard, wrapped, and the admission
|
||||
stands.
|
||||
- Commit latency with `CultNetFileShardMutationLogStore`, main against Cut 4. Also measure a `CultMesh` node
|
||||
with `EnableDurableShardLogs`, where every bookkeeping write is now logged. Report it; do not gate on it.
|
||||
- Negative grep: `rg -n "_parked|_ready|_draining|_nextSequence|_predictionGate|HasPrediction|PublishCacheUpdate" src/GameCult.Networking`
|
||||
returns nothing.
|
||||
- Review check: no `_cache.` call inside a `lock (_logGate)` block.
|
||||
- The `4562340` tests stay green.
|
||||
- Test runs use `--blame-hang-timeout 2m`.
|
||||
- Stryker on the diff: dropping the floor raise, memory-before-disk, and the snapshot read order must each
|
||||
kill a test.
|
||||
- **Docs.**
|
||||
- Composition doc `:50-53`: replace the "streams derive from `OnUpdate`" sentence. CultNet derives from
|
||||
`Watch` plus one journal, and the log is written in cache order.
|
||||
- A new paragraph: a primary that cannot log a change compacts through it.
|
||||
- The log-store rule under Locking.
|
||||
- **Blockers.** None. Q-P5 is ruled A.
|
||||
- **Ledger.** Against the branch, about −130 and +95. The extra over the old estimate is the floor, the
|
||||
disk-first order, the `SortedList`, the snapshot order, and the exception type.
|
||||
|
||||
### Cut 5. Database fan-out isolation and Sequence (networking, behaviour)
|
||||
|
||||
- **Deletes first.** The database's `Subject<object> _changes` (main `:485`, branch `:487`) and its
|
||||
`Dispose` (`:1158`).
|
||||
- **Adds.**
|
||||
- The isolated observer array, the same pattern as the cache's `Register`/`Publish`
|
||||
(`CultCache.cs:1929-1944`, `:2470-2503`), behind `Watch<T>`, `WatchAllChanges` and the rest (`:1089-1145`).
|
||||
- `CultNetDatabaseChange<T>.Sequence` (`:319-371`), as an optional constructor argument. The journal's stash
|
||||
sets it from the cache change. A change with no cache change behind it carries 0.
|
||||
- **Docs.** The binary-signature note, as the composition doc gave for `CultCacheDocumentChange` (`:57-58`).
|
||||
- **Verification.**
|
||||
- With a rethrowing handler and one throwing database subscriber, the other subscriber and a live
|
||||
subscription-server peer receive every change. The writer gets the exception after delivery (Q-P2).
|
||||
- The disposed-database test.
|
||||
- `Sequence` equals the cache change's.
|
||||
- **Blockers.** Cut 4.
|
||||
- **Ledger.** About −10 and +35.
|
||||
|
||||
### Cut 6. Stale protection on wire projections (networking, behaviour; Q-P4 A)
|
||||
|
||||
- **The rule.** A projection applies a change to a key only if its `Sequence` is above what the projection
|
||||
last applied for that key. A snapshot or reconcile applies everything at or below the cache sequence it read
|
||||
**before** reading documents.
|
||||
- That read is `Cache.GetWithSequence(<any key>).Sequence`, the branch's idiom (branch `:531`). No new API
|
||||
is needed.
|
||||
- **Subscription server** (`CultNetDatabaseSubscriptionServer.cs`):
|
||||
- `SubscriptionProjectionState` (`:630`) gains `long Floor` and `Dictionary<string, long> Applied`.
|
||||
- The single-row fast path in `Watch` (`:296-340`) reads the change's `Sequence` (reflection, like `Key`).
|
||||
Under `_lifecycleGate` it drops the change if `Sequence <= max(Floor, Applied[key])`. Otherwise it applies
|
||||
the change and records it. Removals are included.
|
||||
- `Reconcile` (`:347`) and the subscribe snapshot (`:168-181`) read `seq0` before `CreateProjectedSnapshot`
|
||||
(`:402`), set `Floor = seq0`, and clear `Applied`.
|
||||
- This is sound: every change applied earlier was applied under the same gate, after its admission, so
|
||||
its `Sequence` is at most `seq0`.
|
||||
- Hop and unauthorized paths already reconcile from current state, and they set the floor too.
|
||||
- **`CultNetDatabaseServer.CreateSubscription`** (`:501-513`):
|
||||
- A per-subscription `(Floor, Applied)` under a per-subscription lock, held across check, record and send.
|
||||
- `HandleSubscribeAsync`'s snapshot (`:362-369`) takes `seq0` first and sets the floor.
|
||||
- **Verification.**
|
||||
- Two threads write one key, with a blocking cache observer that forces the inverted delivery (Q3 shape).
|
||||
On both servers, the peer's last state for the key equals the cache's.
|
||||
- A removal overtaken by an older upsert leaves the key absent at the peer.
|
||||
- The negative check: without the drop, both tests fail.
|
||||
- **Blockers.** Cut 5, which provides `Sequence` on database changes.
|
||||
- **Ledger.** About +45.
|
||||
|
||||
### Salvage from `hands/cultnet-publish-commits` (revised)
|
||||
|
||||
- **Keep:**
|
||||
- both test files;
|
||||
- the `NetworkingTests.cs` edits;
|
||||
- the door table and the shape rule;
|
||||
- "replicas never mint";
|
||||
- log-before-publish;
|
||||
- "the snapshot logs nothing";
|
||||
- "loads and pulls on a primary are logged" (B1);
|
||||
- the composition-doc paragraph, rewritten to name the journal.
|
||||
- **Drop:**
|
||||
- the reorder buffer and the drainer;
|
||||
- `_predictionGate`;
|
||||
- logging from the observer;
|
||||
- the swallowed exception path (A1 and A3 on the branch).
|
||||
|
||||
## Subtraction ledger (estimate)
|
||||
|
||||
| Cut | Removed | Added | Other |
|
||||
|---|---|---|---|
|
||||
| 1 | ~15 | ~45 | none |
|
||||
| 2 | ~5 | ~30 | the cache no longer uses R3 `Subject` |
|
||||
| 3 | 0 | ~50 | one public method (Q-P3) |
|
||||
| 4 | ~130 vs branch | ~95 | the merge brings +1109/-243, of which about 780 are tests; the floor, disk-first order, `SortedList`, snapshot order and `CultNetShardLogException` are the extra over the first estimate |
|
||||
| 5 | ~10 | ~35 | the database `Subject` goes, and one public property |
|
||||
| 6 | 0 | ~45 | none |
|
||||
|
||||
- **Source, net against main:** about +245.
|
||||
- **Source against the branch:** no scheduler anywhere.
|
||||
- The budget is pressure, not a metric.
|
||||
|
||||
## Operator questions
|
||||
|
||||
- Q-P1..Q-P4 are ruled (see Rulings). Their option analysis is in the map's git history.
|
||||
|
||||
- **Q-P5 A (ruled 2026-09-29): a primary that cannot log a committed change throws a typed
|
||||
`CultNetShardLogException` naming the shard and the burned sequence, after publication, not derived from
|
||||
`InvalidOperationException`.**
|
||||
- The commit stands, the change is published, and replicas behind it snapshot-resync (Imagination pass 4 (a)).
|
||||
- The writer hears of it. AetheriaEve `Program.cs:3722` cannot mistake it for a denial.
|
||||
|
||||
Recorded, not forked:
|
||||
- (b): no origin on the journal. Loads and pulls are logged on primaries.
|
||||
- (a)'s restart residual when the store refuses both writes.
|
||||
- The replica keeps retry-by-cursor.
|
||||
- A throwing getter refuses loads too.
|
||||
- Reentrant writes keep main's depth-first delivery. Order is data.
|
||||
- Journal I/O under the gate follows the store precedent.
|
||||
|
||||
## Follow-ups outside this campaign
|
||||
|
||||
- **`cultmesh-py` watch.** It has no isolation and no Sequence
|
||||
(`node.py:1059-1065`).
|
||||
- **Durable log whole-file rewrite.** `CultNetShardMutationLogStorage.cs:83-89`
|
||||
rewrites the whole file per `Append`. It is a monolith, and under the gate
|
||||
its cost is now every writer's.
|
||||
- **Mesh database-backed handles.** `CultMesh.cs:2362` and `:3396` can drop
|
||||
stale changes once `CultNetDatabaseChange.Sequence` exists.
|
||||
- **Skill scar.** The Eureka SKILL.md already says "a ticket system that
|
||||
serialized delivery was built, deadlocked, and was deleted". The Soul pass 3
|
||||
direction re-proposed it anyway. The Soul brief template should tell Soul to
|
||||
check `git log -S` for a proposed mechanism before recommending it.
|
||||
- **A chained replica after a snapshot.** A replica that snapshot-resyncs has no log entries below the snapshot
|
||||
sequence. It should raise its own floor to that sequence, so a replica chained on it resyncs instead of seeing
|
||||
a gap (`ApplyShardSnapshotResponseAsync` `:675-736`). It predates this campaign.
|
||||
- **AetheriaEve `Program.cs:3722`.** It reads a committed write's `InvalidOperationException` as a denial. Under
|
||||
Q-P5 A, Cut 4's log failures cannot reach it. Observer and `OnUpdate` exceptions still can, so the Cuts 1-2
|
||||
recording stands.- **Cut 4 Soul, recorded 2026-09-30 (Self), not fixed in Cut 4:**
|
||||
- **File log store is not crash-safe (hard prerequisite).** `CultNetFileShardMutationLogStore.Append` and
|
||||
`CompactThrough` use `File.WriteAllBytes` (truncate, rewrite, no temp+rename, no fsync). A failure mid-write
|
||||
truncates the log; every later append burns a sequence and a restart cannot construct the database. It also
|
||||
rewrites the whole file per append, which Cut 4 now does under the cache gate (2.2 ms per write at 100
|
||||
entries, 33 ms at 950, a 2.49 s stall). An atomic, append-only store must land before any live daemon
|
||||
enables durable shard logs. Today only AetheriaEve's offline tools do; the live Aetheria daemon passes
|
||||
`enableDurableShardLogs: false`.
|
||||
- **A slow log append blocks cache readers** (`CultCache.Get` takes the gate). Accepted with a note in the
|
||||
store's XML doc.
|
||||
- **Crash between the store write and the log append** leaves the log lagging the store, and nothing detects
|
||||
it; a restarted primary reuses that sequence (A2's divergence reached by a plain kill). The spec called this
|
||||
lag detectable; for a crash it is not.
|
||||
- **Cut 4 final Soul (4d), recorded 2026-09-30 (Self), not fixed in Cut 4:**
|
||||
- **F2, read cost.** `CultNetDatabase.Serves` (`:1980-1989`) runs per row in `GetAll`/`GetByName`/`GetByIndex` for every
|
||||
layout except a single owns-everything shard: a reflection `GetTrackedKey`, a gate lock and a shard re-scan per row.
|
||||
`database.GetAll` measured 24.8x `cache.GetAll` on 20,000 rows under a key-prefixed shard. Fix: filter by key inside
|
||||
the cache enumeration (the `rowFilter` path already has keys) and hoist the owns-everything check.
|
||||
- **F4, dispose drain edges.** An observer that removed its stash entry before the drain can publish after `Dispose`
|
||||
returns (dropped); a throwing `_cacheJournal.Dispose()` skips the drain. Neither double-publishes or deadlocks.
|
||||
- **F5, refusal naming.** A write whose schema is owned but whose key is outside the shard's prefix also answers
|
||||
`unowned_schema` with details `{field:"schemaId"}`; the peer cannot tell a key refusal from a schema refusal.
|
||||
- **Pre-existing, separate task.** Python emits error code `unsupported_schema_version`
|
||||
(`cultnet-py/.../interop_peer.py:415`, `cultmesh-py/.../server.py:161`), which is not in the error-code enum; TS and
|
||||
Rust refuse the whole message.
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
{
|
||||
"campaign": "cultmath-tapes",
|
||||
"cut": "glsl-lowering",
|
||||
"revision": 2,
|
||||
"title": "GLSL lowering r2: Phacelle as its own MPL-2.0 file so CultMath.glsl is MIT, notices corrected, iv_exp in the fixture, fixture regenerated, glslang and dxc run",
|
||||
"repo": "GameCult/CultLib",
|
||||
"branch": "hands/cultmath-glsl-lowering",
|
||||
"base": "3ac7250a",
|
||||
"depends_on": [
|
||||
"interval-ops"
|
||||
],
|
||||
"first": [
|
||||
"Follow-up on cut report cultmath-tapes:cut_report:cut-glsl-lowering.h1 (range c9bd003b..5ae207e8, no verdict yet; worktree F:\\Projects\\CultLib-glsl). Rulings in force: operator-split-phacelle, operator-compiler-downloads, self-exp-measure-in-site. Map: docs/cultmath-interval-ground-cut.md, 'Pass 3'. First commit: MERGE 3ac7250a (interval-ops r3's head, which Soul s2 found holds for everything this cut lowers) into hands/cultmath-glsl-lowering. Do not rebase: the branch is pushed, and the git rules forbid force-pushing a shared branch. Probed by Imagination in a scratch clone: the merge conflicts only in packages/cultmath/README.md. Resolve it by keeping r3's Intervals bullet (iv_frustum_ball, its rounding bound, the envelope), then r1's GLSL bullet, so the GLSL bullet sits at :48-53. design.md and HlslSourceCompatibilityTests.cs merge clean.",
|
||||
"Second commit: regeneration alone (CULTMATH_WRITE_GLSL=1). Probed on the merge: CultMath.glsl gains 15 lines (cultmath_iv_frustum_ball with the r3 widening); the fixture does not change until this revision adds its families; GlslMirrorTests 4/4 and HlslSourceCompatibilityTests green (16 tests). Soul's range is 3ac7250a..HEAD, which carries r1's four commits through the merge, so the whole lowering gets its first verdict. Runs in parallel with interval-ops r4: r4 changes no src, HLSL or README, only NoiseBoundTests.cs and design.md's saving section (:475-512 at 3ac7250a, :533-570 after this merge), disjoint from this cut's :136-193. Do not merge r4's head into this branch.",
|
||||
"The split (operator-split-phacelle). Transformation step 1 becomes: each #include is inlined in place, except an include whose file is under its own licence, which is lowered on its own into its own output file. The list of such includes is one table in GlslLowering, SeparateFiles = { \"CultMath.Phacelle.hlsl\" -> \"CultMath.Phacelle.glsl\" }, with the reason beside the entry (file-level MPL-2.0). That is an enumerated rule of the lowering, documented as step 1 in design.md; it is not a per-function special case, and nothing else is keyed by name. Lower(root) returns the two texts; Lower(string) stays for the inlined body if that is simpler, but there is one resolver (ReadShaderSource, generalised to accept the include predicate), never a second.",
|
||||
"shaders/CultMath.glsl: the provenance line says it is generated from CultMath.hlsl and CultMath.Interval.hlsl, MIT, and that cultmath_phacelle is in CultMath.Phacelle.glsl, to be concatenated after this file by consumers that call it. The dropped include line leaves nothing behind. shaders/CultMath.Phacelle.glsl: its own provenance line (generated from CultMath.Phacelle.hlsl, MPL-2.0, requires CultMath.glsl before it), the MPL-2.0 header and attribution exactly as the HLSL carries them, the CultPhasor struct and cultmath_phacelle, guard CULTMATH_PHACELLE_GLSL. Both files pinned by CommittedGlslEqualsLowering; CULTMATH_WRITE_GLSL=1 writes both.",
|
||||
"Pin tests over both files: NoHlslOnlyTokensSurvive runs on each; EveryPublicFunctionIsLowered takes the union of both files' definitions against the HLSL (minus texture samplers) and additionally asserts cultmath_phacelle and CultPhasor appear only in the Phacelle file; a new PhacelleFileCompilesOnlyAfterTheLibrary is not needed, the compile check below proves it.",
|
||||
"The fixture (self-exp-measure-in-site). The site measures WebGL2 exp against the fixture, so iv_exp joins it; its C# bits come from the platform exp and differ by OS, so its entry carries tolerance \"ulp-bounded, platform exp\" and names the OS that generated it. GoldenFixtureMatchesCSharp parses the committed JSON (System.Text.Json, BCL) and compares family by family: text-equal for every family except those whose tolerance names the platform, which compare case by case within 1 ulp and print the maximum observed. One fixture, one writer, one tolerance field already in the format; no second fixture. The phacelle family stays in the fixture; the site's parity harness concatenates both GLSL files for that run (the site ships only CultMath.glsl), say so in design.md.",
|
||||
"Notices: packages/cultmath/THIRD-PARTY-NOTICES.md :35-43 lists the MPL-2.0 files as math.Phacelle.cs, CultPhasor.cs, CultMath.Phacelle.hlsl with its Unity copy, and shaders/CultMath.Phacelle.glsl; CultMath.glsl is MIT and says so in its header. The package licence expressions (MIT AND MPL-2.0) do not change: the NuGet still ships the HLSL include. README :36-37 and :48-53 (after the merge) name both GLSL files and when a consumer needs the second. design.md :136-192: step 1 rewritten, the second file, the wrapper runs, the fixture's platform tolerance. Unity ships neither GLSL file.",
|
||||
"Compile checks on Starfire (operator-compiler-downloads), one job each, detached with a log, polled in the foreground: tools/get-glslang.ps1 (16.6.0, the pinned SHA already in the script; if the published digest differs, stop and report, do not edit the pin to match); tools/compile-glsl.ps1 gains a -Phacelle switch and runs twice: the MIT-only wrapper over CultMath.glsl calling every function in it, and the full wrapper over both files calling every function including cultmath_phacelle. Negative controls, both must fail: -Body 'float x = lerp(0.0, 1.0, 0.5);' and the MIT-only wrapper with -Body calling cultmath_phacelle (proves Phacelle is not in CultMath.glsl). Then tools/get-dxc.ps1 and dxc -T lib_6_3 -HV 2021 on both HLSL copies, exit 0. The browser-pane WebGL2 probe of r1 is repeated on the two-file wrapper.",
|
||||
"Carried from r1 and still out of scope, name them in the report: the fixture's ulp bounds on WebGL2 are measured by the site cut; Stryker is not re-run unless packages/cultmath/src changes in 3ac7250a..HEAD (it should not; the lowering lives in tests; interval-ops r3's Stryker evidence, Soul s2, stands). Deviations r1 reported that stand: the anchored token regex; the iv_exp absence is now resolved by this revision; the HLSL edits (damp splat, pcg3d order) stay."
|
||||
],
|
||||
"deletes": [
|
||||
{
|
||||
"path": "packages/cultmath/shaders/CultMath.glsl",
|
||||
"lines": 100,
|
||||
"note": "the inlined Phacelle block (:510-612 at 5ae207e8: MPL header, CultPhasor, cultmath_phacelle) leaves this file; it is regenerated, not edited."
|
||||
},
|
||||
{
|
||||
"path": "packages/cultmath/THIRD-PARTY-NOTICES.md",
|
||||
"lines": 3,
|
||||
"note": ":39-41 the sentence making CultMath.glsl MPL-2.0 as a whole."
|
||||
}
|
||||
],
|
||||
"keeps_moves": [
|
||||
"Transformation steps 2-9 and their order: unchanged; the cast scanner, saturate lowering, the guard and provenance mechanism stay.",
|
||||
"Both tracked CultMath.hlsl copies stay one blob; no HLSL edit is expected in this revision (a dialect gap found by the two-file compile is fixed in the HLSL in the common subset, bit-identical, listed in the report).",
|
||||
"HlslSourceCompatibilityTests' mirror and FindCultMathRoot: the resolver generalises in place; no second include resolver.",
|
||||
"tests/CultMath.Tests/fixtures/glsl-parity.json: the same file, regenerated; families keep their order so the diff shows only the two additions."
|
||||
],
|
||||
"adds": [
|
||||
"packages/cultmath/shaders/CultMath.Phacelle.glsl: generated, MPL-2.0, guard CULTMATH_PHACELLE_GLSL.",
|
||||
"GlslLowering: the SeparateFiles table and the two-text Lower; GlslMirrorTests: the second file in CommittedGlslEqualsLowering, NoHlslOnlyTokensSurvive and EveryPublicFunctionIsLowered; GoldenFixtureMatchesCSharp's per-family comparison with the platform tolerance.",
|
||||
"fixture families: cultmath_iv_frustum_ball (exact) and cultmath_iv_exp (ulp-bounded, platform exp).",
|
||||
"tools/compile-glsl.ps1: -Phacelle switch (appends CultMath.Phacelle.glsl after the library and includes cultmath_phacelle in the calls).",
|
||||
"design.md and README: the two files, the concatenation order, the fixture tolerance."
|
||||
],
|
||||
"file_changes": [
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/GlslLowering.cs",
|
||||
"line": 24
|
||||
},
|
||||
"change": "Lower takes the root (or the resolver's two texts) and returns both outputs; SeparateFiles table with the MPL reason; ProvenanceLine per file."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs",
|
||||
"line": 320
|
||||
},
|
||||
"change": "ReadShaderSource accepts a predicate (or returns the separated include's text beside the inlined body); the C# mirror keeps inlining everything."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/GlslMirrorTests.cs",
|
||||
"line": 30
|
||||
},
|
||||
"change": "CommittedGlslEqualsLowering over both files; :45 NoHlslOnlyTokensSurvive over both; :54 EveryPublicFunctionIsLowered union plus the phacelle-only-in-its-file assertion; :64 GoldenFixtureMatchesCSharp per family with the platform tolerance."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tools/compile-glsl.ps1",
|
||||
"line": 1
|
||||
},
|
||||
"change": "-Phacelle switch; the library text is CultMath.glsl, plus CultMath.Phacelle.glsl when set; the call list covers whichever functions are present."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/THIRD-PARTY-NOTICES.md",
|
||||
"line": 35
|
||||
},
|
||||
"change": "MPL-2.0 file list: the two C# files, the HLSL include and its Unity copy, shaders/CultMath.Phacelle.glsl. CultMath.glsl is MIT."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/README.md",
|
||||
"line": 48
|
||||
},
|
||||
"change": "both GLSL files; a consumer concatenates CultMath.Phacelle.glsl after CultMath.glsl only when it calls cultmath_phacelle."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/docs/design.md",
|
||||
"line": 150
|
||||
},
|
||||
"change": "step 1 rewritten for the separated include; :182-192 the two wrapper runs and the fixture's platform tolerance; the site's parity run concatenates both files."
|
||||
}
|
||||
],
|
||||
"authority_map": {
|
||||
"owner": "GlslLowering owns the HLSL -> GLSL transformation and now emits two derived files, one per licence; both are pinned by GlslMirrorTests and never edited. The fixture stays owned by GoldenFixtureMatchesCSharp with one tolerance field per family. THIRD-PARTY-NOTICES.md states which files are MPL-2.0 and is the one place that does.",
|
||||
"inputs": [
|
||||
"shaders/CultMath.hlsl, CultMath.Interval.hlsl and CultMath.Phacelle.hlsl text at the merged head (3ac7250a merged into the branch)",
|
||||
"C# math for the fixture values, including the platform exp for iv_exp"
|
||||
],
|
||||
"outputs": [
|
||||
"shaders/CultMath.glsl (MIT) and shaders/CultMath.Phacelle.glsl (MPL-2.0)",
|
||||
"tests/CultMath.Tests/fixtures/glsl-parity.json with cultmath_iv_frustum_ball and cultmath_iv_exp families"
|
||||
],
|
||||
"derived_state": [
|
||||
"both GLSL files are derived; a hand edit to either fails CommittedGlslEqualsLowering",
|
||||
"the fixture is derived from C#; the iv_exp family is derived from the generating platform's exp and compared within 1 ulp, every other family bit for bit"
|
||||
],
|
||||
"forbidden_writers": [
|
||||
"no per-function special case in the lowering; the only name-keyed rule is the SeparateFiles table, one entry, licence-justified",
|
||||
"no second include resolver, no PowerShell or JS copy of the transformation",
|
||||
"no GLSL in the Unity package; no precision statement in either library file",
|
||||
"no edit to the pinned glslang or dxc hashes to make a download pass"
|
||||
],
|
||||
"shared_paths": [
|
||||
"the same dotnet test run pins the C# mirror, both GLSL files and the fixture",
|
||||
"compile-glsl.ps1 is the one wrapper writer for the MIT-only run, the two-file run and both negative controls"
|
||||
],
|
||||
"deletion_line": "The Phacelle block leaves CultMath.glsl by regeneration and the MPL sentence leaves the notices before the new file is added. Net: one more generated file and one switch; what it buys is an MIT CultMath.glsl the site can vendor without carrying MPL text, and the compile checks r1 could not run."
|
||||
},
|
||||
"verification": {
|
||||
"builds": [
|
||||
"Yggdrasil: dotnet test packages/cultmath/tests/CultMath.Tests green on the merged branch; paste counts; GlslMirrorTests 4/4 with the per-family fixture line printed",
|
||||
"Starfire: tools/get-glslang.ps1 installs 16.6.0 with the pinned SHA matching; tools/compile-glsl.ps1 exits 0 on the MIT-only wrapper and on -Phacelle; both negative controls fail with the error quoted",
|
||||
"Starfire: tools/get-dxc.ps1 then dxc -T lib_6_3 -HV 2021 on both HLSL copies, exit 0; FXC cs_5_0 still green",
|
||||
"Browser pane WebGL2: the two-file wrapper compiles with an empty log"
|
||||
],
|
||||
"tests": [
|
||||
{
|
||||
"name": "GlslMirrorTests.CommittedGlslEqualsLowering",
|
||||
"pins": "CultMath.glsl and CultMath.Phacelle.glsl are exactly the lowering of the committed HLSL; the regeneration path is the same code."
|
||||
},
|
||||
{
|
||||
"name": "GlslMirrorTests.EveryPublicFunctionIsLowered",
|
||||
"pins": "the union of both files equals the HLSL's cultmath_* set minus texture samplers; cultmath_phacelle and CultPhasor live only in the Phacelle file."
|
||||
},
|
||||
{
|
||||
"name": "GlslMirrorTests.NoHlslOnlyTokensSurvive",
|
||||
"pins": "no HLSL-only token in either file."
|
||||
},
|
||||
{
|
||||
"name": "GlslMirrorTests.GoldenFixtureMatchesCSharp",
|
||||
"pins": "every family is C#'s current output bit for bit, except iv_exp within 1 ulp of the platform exp; iv_frustum_ball is present and exact."
|
||||
},
|
||||
{
|
||||
"name": "HlslSourceCompatibilityTests.EveryMirrorFunctionMatchesCSharpMath (existing)",
|
||||
"pins": "no HLSL edit in this range changed a bit."
|
||||
}
|
||||
],
|
||||
"negative": [
|
||||
{
|
||||
"pattern": "Mozilla|MPL|Johansen|CultPhasor|cultmath_phacelle\\(",
|
||||
"scope": "packages/cultmath/shaders/CultMath.glsl: 0 matches; the header's file-name pointer to CultMath.Phacelle.glsl is the only mention of Phacelle."
|
||||
},
|
||||
{
|
||||
"pattern": "^CultPhasor cultmath_phacelle\\(",
|
||||
"scope": "packages/cultmath/shaders/CultMath.Phacelle.glsl: exactly 1 match."
|
||||
},
|
||||
{
|
||||
"pattern": "CultMath\\.glsl|Phacelle\\.glsl",
|
||||
"scope": "packages/cultmath/unity/**: 0 matches."
|
||||
},
|
||||
{
|
||||
"pattern": "\\b(float|u?int|bool)[234]\\b|\\blerp\\(|\\bfrac\\(|\\bsaturate\\(|\\brsqrt\\(|\\basuint\\(|\\basfloat\\(|static const|#include|precision (high|medium|low)p",
|
||||
"scope": "both GLSL files: 0 matches (the anchored regex r1 settled on)."
|
||||
},
|
||||
{
|
||||
"pattern": "CultMath\\.glsl",
|
||||
"scope": "packages/cultmath/THIRD-PARTY-NOTICES.md: 0 matches in the MPL-2.0 file list; CultMath.Phacelle.glsl appears there once."
|
||||
},
|
||||
{
|
||||
"pattern": "git diff 3ac7250a..HEAD -- packages/cultmath/src",
|
||||
"scope": "empty; the lowering and the fixture live in tests, so interval-ops r3's Stryker evidence (verdict s2) stands for this range."
|
||||
}
|
||||
],
|
||||
"operator": []
|
||||
},
|
||||
"estimate": {
|
||||
"lines_added": 320,
|
||||
"lines_removed": 130,
|
||||
"dependencies_added": [],
|
||||
"dependencies_removed": [],
|
||||
"formats_added": [
|
||||
"GLSL ES 3.00 library file (generated, MPL-2.0): shaders/CultMath.Phacelle.glsl"
|
||||
],
|
||||
"formats_removed": [],
|
||||
"targets_added": [],
|
||||
"targets_removed": []
|
||||
},
|
||||
"rulings": [
|
||||
"cultmath-tapes:ruling:operator-split-phacelle",
|
||||
"cultmath-tapes:ruling:operator-compiler-downloads",
|
||||
"cultmath-tapes:ruling:self-exp-measure-in-site",
|
||||
"cultmath-tapes:ruling:operator-campaign-home"
|
||||
],
|
||||
"questions": []
|
||||
}
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
{
|
||||
"campaign": "cultmath-tapes",
|
||||
"cut": "glsl-lowering",
|
||||
"revision": 1,
|
||||
"title": "GLSL lowering: shaders/CultMath.glsl is generated from CultMath.hlsl by one enumerated textual transformation, pinned by a test, compiled by glslang, with a committed golden fixture for WebGL2 parity",
|
||||
"repo": "GameCult/CultLib",
|
||||
"branch": "hands/cultmath-glsl-lowering",
|
||||
"base": "c49c76ee",
|
||||
"depends_on": [],
|
||||
"first": [
|
||||
"Map: docs/cultmath-interval-ground-cut.md, sections 'Where the interval math lives' (route C) and 'The authority map'. Model: the HLSL -> C# mirror in tests/CultMath.Tests/HlslSourceCompatibilityTests.cs (wrapper at :348) and its complete transformation list in packages/cultmath/docs/design.md:92-134. The GLSL lowering follows the same discipline: a complete, enumerated list; anything outside it fails the test rather than being patched around.",
|
||||
"Read how HlslSourceCompatibilityTests resolves #include \"CultMath.Phacelle.hlsl\" (CultMath.hlsl:506); the GLSL lowering inlines the included file at that line, because GLSL has no #include.",
|
||||
"Target dialect: GLSL ES 3.00 (#version 300 es), WebGL2. No implicit int/uint -> float conversion; step(vec4, float) and max(vec4, float) differ (max(genType, float) exists, step(genType, float) does not); C-style casts do not exist; lerp/frac/saturate/rsqrt/asuint are mix/fract/clamp(x,0.0,1.0)/inversesqrt/floatBitsToUint; floatN/intN are vecN/ivecN; static const is const. Mixed-signedness shifts are allowed.",
|
||||
"Where the lowered text does not compile, fix the HLSL into the common subset with a legal, bit-identical HLSL edit (e.g. CultMath.hlsl:114 and :200 step(h, 0.0) -> step(h, float4(0.0, 0.0, 0.0, 0.0)); :471-473 and :502 (float)((uint)hash.x >> 8) before the float multiply). The HLSL -> C# mirror test and the FXC/dxc compiles keep those edits honest. Never special-case the lowering for one function.",
|
||||
"Casts need a balanced-paren scanner, not a regex: (int)((state >> 28) + 4) at CultMath.hlsl:396 is the case that breaks a regex.",
|
||||
"Dropped, as the C# mirror drops them: include guards (replaced by CULTMATH_GLSL guards), and the Texture2D/SamplerState functions at CultMath.hlsl:535-573. The GLSL declares no precision; the host shader does (precision highp float; precision highp int; the pcg hashes need 32-bit int).",
|
||||
"Regeneration: the pin test writes shaders/CultMath.glsl when the environment variable CULTMATH_WRITE_GLSL=1 is set, then asserts equality. No new project, no PowerShell copy of the transformation.",
|
||||
"Golden fixture: tests export 256 seeded points per function family (snoise float3/float2, snoise_grad, fbm_grad 4 octaves, ridged_grad, cellular, pcg3d/pcg4d, iv_* if present on main by then) with outputs as float32 bit patterns in hex, to tests/CultMath.Tests/fixtures/glsl-parity.json. JSON is tolerated here: the consumer is a browser-pane WebGL2 readback, a xenos boundary. The site cut evaluates it.",
|
||||
"glslang: tools/get-glslang.ps1 fetches a pinned glslangValidator release the way tools/get-dxc.ps1 does; tools/compile-glsl.ps1 compiles a #version 300 es fragment wrapper that includes shaders/CultMath.glsl and calls every public function once. Run on Starfire, one job, detached with a log, polled in the foreground.",
|
||||
"Verification on Yggdrasil: dotnet test from packages/cultmath/tests/CultMath.Tests; Stryker --since:c49c76ee from a plain clone (not a linked worktree: Stryker resolves --since paths against the main checkout). Never run verification as a background shell task."
|
||||
],
|
||||
"deletes": [],
|
||||
"keeps_moves": [
|
||||
"Both tracked CultMath.hlsl copies (packages/cultmath/shaders and unity/org.gamecult.cultmath/Shaders) stay one blob; the common-subset edits land in both.",
|
||||
"The C# mirror test, the FXC probe and dxc -T lib_6_3 -HV 2021 all still pass on the edited HLSL; the Unity package does not ship the GLSL file.",
|
||||
"design.md's HLSL -> C# transformation list (92-134) is unchanged; the GLSL list is a new sibling section."
|
||||
],
|
||||
"adds": [
|
||||
"tests/CultMath.Tests/GlslLowering.cs: the HLSL -> GLSL ES 3.00 transformation, one static function string -> string, with the list as named steps in order: inline includes; drop guards and texture functions; type names; static const; casts via the balanced scanner; intrinsic renames (lerp, frac, saturate, rsqrt, asuint/asfloat); float literal passthrough; guards CULTMATH_GLSL added; a provenance header line naming the source file and that the file is generated.",
|
||||
"tests/CultMath.Tests/GlslMirrorTests.cs: CommittedGlslEqualsLowering (writes when CULTMATH_WRITE_GLSL=1, prints a unified diff on failure); NoHlslOnlyTokensSurvive (regex over the output for float[234]\\(|int[234]\\(|lerp\\(|frac\\(|saturate\\(|static const|\\((float|int|uint)\\)|Texture2D|SamplerState -> 0 matches); EveryPublicFunctionIsLowered (the set of cultmath_* function names in the HLSL minus the texture ones equals the set in the GLSL); GoldenFixtureMatchesCSharp (regenerates the fixture in memory from C# and asserts equality with the committed file).",
|
||||
"shaders/CultMath.glsl: the committed generated artifact.",
|
||||
"tests/CultMath.Tests/fixtures/glsl-parity.json: the golden fixture (seed, points, per-function expected bit patterns, tolerance class per function: exact for integer-hash paths, ulp-bounded for transcendental).",
|
||||
"tools/get-glslang.ps1 and tools/compile-glsl.ps1, mirroring get-dxc.ps1 / compile-hlsl-spirv.ps1 in shape.",
|
||||
"packages/cultmath/docs/design.md: a section 'GLSL Target: Source Transformations' after :134 with the complete list, the common-subset rule for HLSL, the regeneration command, and that the GLSL is a derived file never edited by hand.",
|
||||
"packages/cultmath/README.md: one paragraph that CultMath.glsl exists, what dialect it is, and how a WebGL consumer includes it (string concatenation before its own #version-led source; declare highp)."
|
||||
],
|
||||
"file_changes": [
|
||||
{
|
||||
"location": { "path": "packages/cultmath/shaders/CultMath.hlsl", "line": 114 },
|
||||
"change": "step(h, 0.0) -> step(h, float4(0.0, 0.0, 0.0, 0.0)); same at :200. Bit-identical in HLSL and C#."
|
||||
},
|
||||
{
|
||||
"location": { "path": "packages/cultmath/shaders/CultMath.hlsl", "line": 470 },
|
||||
"change": "jitter components and the id at :502: an explicit (float) cast around the shifted uint before the float multiply, so the lowered text has no uint*float."
|
||||
},
|
||||
{
|
||||
"location": { "path": "packages/cultmath/shaders/CultMath.Phacelle.hlsl", "line": 1 },
|
||||
"change": "whatever common-subset edits the glslang compile demands; each one bit-identical, each one listed in the cut report."
|
||||
},
|
||||
{
|
||||
"location": { "path": "packages/cultmath/docs/design.md", "line": 134 },
|
||||
"change": "new section: GLSL Target: Source Transformations."
|
||||
}
|
||||
],
|
||||
"authority_map": {
|
||||
"owner": "GlslLowering (tests/CultMath.Tests) owns the HLSL -> GLSL transformation; shaders/CultMath.glsl is its derived output, pinned by GlslMirrorTests. CultMath.hlsl stays the one hand-written shader source.",
|
||||
"inputs": [
|
||||
"shaders/CultMath.hlsl and shaders/CultMath.Phacelle.hlsl text",
|
||||
"C# math for the golden fixture values"
|
||||
],
|
||||
"outputs": [
|
||||
"shaders/CultMath.glsl (GLSL ES 3.00 library, every public cultmath_* function except texture samplers)",
|
||||
"tests/CultMath.Tests/fixtures/glsl-parity.json"
|
||||
],
|
||||
"derived_state": [
|
||||
"CultMath.glsl is derived; a hand edit fails CommittedGlslEqualsLowering",
|
||||
"the fixture is derived from C#; a stale fixture fails GoldenFixtureMatchesCSharp"
|
||||
],
|
||||
"forbidden_writers": [
|
||||
"no per-function special case in the lowering; a dialect gap is fixed in the HLSL in the common subset",
|
||||
"no second transformation implementation (no PowerShell or JS copy of the rules)",
|
||||
"no GLSL in the Unity package; no precision statement in the library file"
|
||||
],
|
||||
"shared_paths": [
|
||||
"both tracked CultMath.hlsl copies receive the same edits; the existing one-blob rule covers them",
|
||||
"the same dotnet test run pins the C# mirror, the GLSL pin and the fixture"
|
||||
],
|
||||
"deletion_line": "Nothing is deleted in CultLib. The liability bought is one generated file, one test file, one fixture and two tool scripts; what it retires is every future hand copy of CultMath noise in a GLSL consumer, starting with the site."
|
||||
},
|
||||
"verification": {
|
||||
"builds": [
|
||||
"Yggdrasil: dotnet test packages/cultmath/tests/CultMath.Tests green; paste counts",
|
||||
"Yggdrasil: dotnet stryker --since:c49c76ee from a plain clone; triage every survivor by name",
|
||||
"Starfire: tools/compile-glsl.ps1 exits 0 on the wrapper over shaders/CultMath.glsl; a negative-control wrapper with one lerp( call must fail (records that the compile check sees errors)",
|
||||
"Starfire: FXC cs_5_0 and dxc -T lib_6_3 -HV 2021 still compile both HLSL copies after the common-subset edits"
|
||||
],
|
||||
"tests": [
|
||||
{
|
||||
"name": "GlslMirrorTests.CommittedGlslEqualsLowering",
|
||||
"pins": "the committed GLSL is exactly the lowering of the committed HLSL; the regeneration path is the same code."
|
||||
},
|
||||
{
|
||||
"name": "GlslMirrorTests.NoHlslOnlyTokensSurvive",
|
||||
"pins": "the list is complete: no HLSL-only token survives in the output."
|
||||
},
|
||||
{
|
||||
"name": "GlslMirrorTests.EveryPublicFunctionIsLowered",
|
||||
"pins": "no function is silently dropped except the texture samplers."
|
||||
},
|
||||
{
|
||||
"name": "GlslMirrorTests.GoldenFixtureMatchesCSharp",
|
||||
"pins": "the fixture the site will read on WebGL2 is C#'s current truth."
|
||||
},
|
||||
{
|
||||
"name": "HlslSourceCompatibilityTests.EveryMirrorFunctionMatchesCSharpMath (existing)",
|
||||
"pins": "the common-subset edits changed no bit of any HLSL function."
|
||||
}
|
||||
],
|
||||
"negative": [
|
||||
{
|
||||
"pattern": "lerp\\(|frac\\(|saturate\\(|float[234]\\(|int[234]\\(|static const|\\((float|int|uint)\\)|Texture2D|SamplerState|#include",
|
||||
"scope": "packages/cultmath/shaders/CultMath.glsl: 0 matches."
|
||||
},
|
||||
{
|
||||
"pattern": "precision (high|medium|low)p",
|
||||
"scope": "packages/cultmath/shaders/CultMath.glsl: 0 matches (the host declares precision)."
|
||||
},
|
||||
{
|
||||
"pattern": "CultMath\\.glsl",
|
||||
"scope": "packages/cultmath/unity/**: 0 matches; the Unity package does not ship the GLSL."
|
||||
},
|
||||
{
|
||||
"pattern": "git diff c49c76ee..HEAD -- packages/cultmath/shaders/CultMath.hlsl | grep '^[-+]' | grep -v '^[-+][-+]'",
|
||||
"scope": "every changed HLSL line is listed in the cut report with the reason (dialect gap it closes); none changes a value, proven by the mirror test and the existing goldens."
|
||||
}
|
||||
],
|
||||
"operator": []
|
||||
},
|
||||
"estimate": {
|
||||
"lines_added": 1100,
|
||||
"lines_removed": 8,
|
||||
"dependencies_added": [],
|
||||
"dependencies_removed": [],
|
||||
"formats_added": ["GLSL ES 3.00 library file (generated)", "glsl-parity.json fixture"],
|
||||
"formats_removed": [],
|
||||
"targets_added": [],
|
||||
"targets_removed": []
|
||||
},
|
||||
"rulings": [],
|
||||
"questions": []
|
||||
}
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
{
|
||||
"campaign": "cultmath-tapes",
|
||||
"cut": "interval-ops",
|
||||
"revision": 2,
|
||||
"title": "Interval ops r2: envelope-then-noise fields, scenarios with authored empty space including the void, the tile march on cell ranges, iv_frustum_ball, wasted probes removed, dxc run",
|
||||
"repo": "GameCult/CultLib",
|
||||
"branch": "hands/cultmath-interval-ops",
|
||||
"base": "c9bd003b",
|
||||
"depends_on": [],
|
||||
"first": [
|
||||
"Follow-up on cut report cultmath-tapes:cut_report:cut-interval-ops.h1 (head c9bd003b, worktree F:\\Projects\\CultLib-iv). Rulings in force: operator-tile-amortized-march (the bound stays; one probe per screen tile), operator-compiler-downloads. The operator's challenge, verbatim in the map's Pass 3 section: r1 measured a uniform noise slab with no empty space to skip, the worst case for intervals, and the density that matters is an analytic envelope (a pre-distortion SDF) modulated by noise, where the envelope's interval is tight and proves whole ray lengths empty in one probe. Map: docs/cultmath-interval-ground-cut.md, 'Pass 3'. r1's ops and bounds stay; this revision touches math.Interval.cs, CultMath.Interval.hlsl (both tracked copies), NoiseBoundTests.cs, design.md and README only.",
|
||||
"The field, from Aetheria (Volumetric.cginc:165-221 and :91-102; Zone.cs:369-397 GetHeight, :426 PowerPulse; Settings.asset). SDF s(p) = p.y + h(p.xz), h >= 0 the well map: h = PowerPulse(|p.xz| / (2R), 2) * 64 (the zone bowl) + sum over wells of PowerPulse(|p.xz - c_b| / r_b, 16) * d_b, with PowerPulse(x, e) = (1 - 4x^2)^e for x in [0, 1/2] and 0 beyond, r_b = 500 M^0.25, d_b = 30 M^0.175 (Aetheria's GravityRadius/GravityDepth curves). Noise only where s < S: fade(s) = 1 - smoothstep(0.75 S, S, s); displaced height s' = s + A fade(s) n(p) with n the warped two-phase coverage noise plus half the detail octave (r1's field shape); density = max(0, (F - s') / B) (Aetheria's floor: FloorOffset F = -20, FloorBlend B = 10; the patch band and the 1e-9 fill are left out). Above the safety distance density is exactly 0, and so is the noise cost: Aetheria's dense march gates the noise pointwise (`if (dist < _SafetyDistance)`); the test's dense march does the same.",
|
||||
"The composed bound over a tile's frustum slice [z0, z1], Envelope over the slice's exact box, not the ball: with slopes m in [m_c - s, m_c + s] per axis and z in [z0, z1], y = iv_mul([z0, z1], m_y) and x = iv_mul([z0, z1], m_x) (plus the tile's translation o); h's interval over the xz box is the sum over bowls of [pulse(far), pulse(near)], near and far the least and greatest distance from the box to the bowl's centre (pulse is monotone decreasing in distance, so each term is exact; the only slack is the sum). iv_s = iv_add(y, iv_h); iv_fade = the ordered endpoints of fade over iv_s (monotone); iv_n = iv_snoise_ball(ball.xyz * F0, ball.w * F0) with ball = iv_frustum_ball(..., warp = D); iv_s' = iv_add(iv_s, iv_scale(iv_mul(iv_fade, iv_n), A)); the slice is provably empty iff iv_s'.lo >= F. When iv_fade.hi == 0 the noise term is identically zero and iv_snoise_ball is not evaluated: the envelope alone proves the slice empty at no snoise cost. That short-circuit is what tiles compound.",
|
||||
"Where the warp lives in the bound. Aetheria warps only the noise argument (triNoise3d(pos + fl * ...)); the surface height is read at the unwarped xz and the displacement is applied to y afterwards. So the envelope is evaluated on the pre-distortion SDF over the unwarped slice (no D), and only the noise ball is enlarged by D (iv_frustum_ball's warp argument). A warp applied to the envelope's argument would be unsound without enlarging the box by D; the field is defined so that it is not. The test EnvelopeBoundEnclosesDensity pins this: 2,000 seeded slices x 64 warped points, the composed bound encloses density(p) with no tolerance (every op is monotone or already widened in r1; if a seeded case fails at an endpoint, widen in the implementation, never the test).",
|
||||
"iv_frustum_ball(float2 centreSlope, float z0, float z1, float footprintPerDepth, float warp) -> float4(centre, radius), in C# and both HLSL copies, is the one new CultMath function: the noise ball of a tile over a depth segment. Camera: pinhole at the origin looking +z, focal length f pixels; a pixel's ray is p(z') = (m z', z'); a tile's slopes lie in m_c + [-N/(2f), N/(2f)]^2 (full footprint, so sub-pixel jitter is covered), s = N / (sqrt(2) f). Centre c = (m_c z_m, z_m), z_m = (z0 + z1) / 2; radius r = ((z1 - z0) / 2) |(m_c, 1)| + z1 s + warp. Proof: a point of slope m_c + e (|e| <= s) at depth z' is c + (z' - z_m)(m_c, 1) + z'(e, 0), the terms bounded by the two radius terms, and the warp moves it by at most D (WarpedPointsStayEnclosed). The slice is a convex polytope, so every point between rays is enclosed too. The two vectors are never parallel, so the inequality is strict and float32 cannot cross it.",
|
||||
"(Tile ball, continued.) Test TileBallEnclosesEveryRaySegment: 2,000 seeded tiles (N in {1, 4, 8, 16}, f in [500, 3000], |m_c| <= 1.1, segments across each scenario's z range), 64 points each with sub-pixel offsets, both depth ends and a flow of length <= warp; no tolerance. Hand mutations that must die, listed in the file header: z0 for z1 in the lateral term; |m_c| for |(m_c, 1)|; the warp dropped. A moving camera needs nothing more: every frame's pre-pass evaluates iv_frustum_ball from that frame's camera and never carries a ball, a mask or a probe result across frames; the only cross-frame state is the TAA history, which is colour, not a soundness input. The test says so by drawing each tile's camera independently.",
|
||||
"Implementation review of r1's march (NoiseBoundTests.cs:344-376), each item fixed here: (1) every probe was charged one snoise per ray; the tile shares it (ruling). (2) Coarse probes were attempted where the ball could never prove emptiness: iv_snoise_ball's hi is n(c) + L r clamped to 1, so a probe with L r F0 >= 1 + cutoff is a guaranteed failure; at r1's StepMax = 2 that is L r F0 = 3.7, so every coarse probe was a wasted evaluation (the 0.74x row). r2 attempts a noise probe only when L * radius * F0 < 1 - (the cutoff the probe needs), otherwise the level is 'maybe' at no cost. (3) Growth was capped at StepMax = 8 Step0, so a ray through clear space paid Depth / StepMax probes; r2 grows to the remaining range (the hierarchy is over cell-index ranges, halving on maybe, doubling on skip, capped only by the grid's end).",
|
||||
"(Review, continued.) (4) The dense sample evaluated the detail octave even where occupancy was 0; r2's dense sample evaluates coverage first and the detail only when the envelope and coverage leave density > 0, as Aetheria's `if (dist < _SafetyDistance)` does; the dense baseline gets cheaper and the comparison honest. (5) The finest-level probe before integrating a cell stays: amortized over the tile it costs 1/N^2 and can skip 3 snoise per ray. (6) The step never regrew after a dense cell until a skip; with (3) it regrows from the next skip, which is enough.",
|
||||
"The march, on cell ranges. The dense grid is a scenario-owned list of cell boundaries z_0 < ... < z_J (uniform for the slab scenarios; Aetheria's quadratic spacing z_j = near + (j/J)^2 (far - near), J = 256, for the Aetheria-like ones, from CloudShader.shader:116). MarchTile takes a range [j0, j1): the envelope probe over the slice (z_j0, z_j1); if empty, skip and double the next range; else if the noise probe is affordable (item 2) and proves empty, skip; else if the range is more than one cell, halve; else the cell is dense. It runs to the grid's end once per tile (a pre-pass does not know any ray's transmittance) and yields the dense cells; each of the N^2 rays integrates exactly those cells in order with the dense march's own Integrate and early-out at 0.02. Counted separately: snoise evaluations and envelope evaluations (pointwise in the dense march; interval probes in the tile march), per ray with probes divided by N^2.",
|
||||
"The cost model, printed and reasoned about in snoise units: a pointwise envelope evaluation or an envelope probe costs e = 0.2 (a handful of pulses or a height-map fetch against ~150 flops of snoise; stated as an estimate, both counts printed so any weight can be applied). Per scenario the test prints: dense snoise and envelope per ray, tile snoise and envelope probes per ray, the combined cost ratio, and the probe-free ceiling (dense cost over the cost of the dense cells alone). The 2x contract is on the combined cost at N = 8 for scenarios (a) and (c); (b) and (d) are printed as the worst cases and not asserted.",
|
||||
"Scenarios (IV-REPORT lines, each with 64 tiles at N in {1, 4, 8, 16}, warp in {0, D}): (a) height fog: h = 0, camera at y = S + 30, rays with slope m_y in [0, 0.5] (level and up), Aetheria's grid, expected near-total skip (0 snoise either way; the envelope count carries the ratio). (b) inside the fog: h = 0, camera at y = F - 10, rays level, expected near 1x: every ray saturates within a few cells and the pre-pass still probes the whole grid. (c) Aetheria-like: the zone bowl R = 2000 and four wells of mass {100, 1000, 10000, 1000} at seeded positions inside R, camera at a seeded xz in the bowl at y = max(0, S + 10 - h(camera)), rays gazing across with m_y in [-0.25, 0.1], Aetheria's units (noise scale 414 so F0 = 1/414, D = 15 * 8 / 2 = 60, A = 20 with snoise in [-1, 1]), Aetheria's grid. (d) the uniform slab: r1's field and parameters, kept as the reported worst case. The headline is (e) at N = 8, 200 tiles, printed with both its numbers; (a) and (c) are asserted.",
|
||||
"(e) the void, the shipped scene (rulings operator-ground-void-brush, operator-void-true-cavity; site-ground r3): a cloud volume everywhere, carved by one negative pseudo-Gaussian sphere, parametrised by the hollow radius Rh (density exactly 0 inside) and the ramp width S (density rises to full over [Rh, Rh + S]); the brush (1 - (d / Rc)^2)^e with CARVE gives Rh = Rc sqrt(1 - CARVE^(-1/e)) and S from the exponent, so (Rh, S) are the authored knobs and (Rc, e) derived. Noise displaces the wall only: fade(d) = smoothstep(Rh - S, Rh, d); d' = d + A fade(d) n(warp(p)), A = 20, F0 = 0.01 (wavelengths 100 and 25), D = 10; K = 1/30 (optical depth 1 per 30 units of full density), termination at T < 0.02. Shipped point: Rh = 198, S = 50, the sun on the hollow's bottom, the camera 120 behind it along the orbit tangent and 45 above it (186 from the centre, 'low'). Rays over the whole frustum (vertical FOV 60, f = 935, so theta_px = 1/f). Every ray crosses empty interior and ends in a wall.",
|
||||
"Footprint LOD (the operator's challenge, map 'Footprint LOD'): theta_px = 2 tan(fov/2) / H; the footprint at distance z is z theta_px. Step: Delta(z) = max(k z theta_px, min(S / 4, lambda_fine(z) / 2)), k = 4: never finer than four footprints, otherwise the finer of the two content limits (four samples across the ramp, two per wavelength of the finest live octave). Octaves: octave i of wavelength lambda_i carries weight w_i(z) = 1 - smoothstep(lambda_i / (2m), lambda_i / m, z theta_px), m = 4: one smooth blend per octave, no popping; TAA hides per-frame sparsity. The weights are part of the field, so the bound encloses the truncated field exactly: the noise interval over a slice is the sum over octaves of iv_mul([w_i(z1), w_i(z0)], iv_snoise_ball(c F_i, r F_i)), w_i monotone in z. At the shipped parameters (wavelengths 100 and 25, z <= 2 Rc) every weight is 1 in the frustum and the content limits set the step; the footprint bites when S < 4 k z theta_px.",
|
||||
"(e) runs three marches per tile and prints them: the fixed-step reference (Delta_fixed = min(10, S / 4) everywhere, the pointwise envelope gate, the baseline a march that does not know where the wall is must pay); the masked fixed-step march (the same cells, so transmittance agrees exactly: the soundness pin, as (a)-(d)); and the masked LOD march, the shipped one (steps from Delta(z) inside the unmasked range only, octave weights applied), whose transmittance must be within 0.02 of the reference (printed; an approximation by design, not a soundness claim). Printed per configuration: steps per pixel and snoise per pixel for all three, the combined cost ratio reference / LOD, probes per tile. The grid for (e): Rh in {100, 200, 400, 1000} x camera offset in {centred, mid (0.5 Rh), low (0.94 Rh, the shipped)} x S in {10, 50, 150}, 32 tiles each at N = 8; the shipped point with 200 tiles.",
|
||||
"Predictions to confirm or refute, N = 8, combined cost: (a) above 50x. (b) 0.8-1.0x. (c) 2-4x: clear cells collapse to a few probes per tile, fog cells cost the same on both sides; the noise ball proves nothing at Aetheria's scale (L r F0 >= 1.6), so item 2 keeps it unevaluated and the saving is the envelope's. (d) 1.3-1.5x: the ceiling at L = 10.099261. (e), from Imagination's model (scratchpad cavity_model.py): the LOD march needs about 6 steps and 12 snoise per pixel at every grid point with S <= 50 (the content limits S / 4 and the detail half-wavelength 12.5 set the step, not the footprint); S = 150 needs 14 steps and 36 snoise. Against the fixed-step reference at the shipped point: steps 35 to 6 (5-8x fewer), combined cost 1.7x, snoise alone 1.25x; cost 3.2x at S = 10, 1.4x at S = 150, 2.5x at Rh = 1000; camera offset barely matters. (e)'s contract is steps per pixel (Q8 retired). If (a) or (c) misses 2x, skip naming saving-2x-scenarios. Never tune the fields, the grids or e.",
|
||||
"Stryker on Yggdrasil from a plain clone: --since:c9bd003b -m math.Interval.cs; report timeouts separately from kills (a timeout is not a demonstrated kill); if the saving test is what times mutants out, exclude it from Stryker's test selection (a measurement, not a pin) rather than raising the timeout. Compile checks on Starfire, one job, detached with a log, polled in the foreground: FXC cs_5_0 over every cultmath_iv_* including the new function, both tracked copies; then tools/get-dxc.ps1 (pinned, ruling operator-compiler-downloads) and dxc -T lib_6_3 -HV 2021 on both copies. Carried from r1 and still out of scope, name them in the report: the Unity CultMath.dll is not rebuilt; the Unity HLSL copies stay one blob with packages/cultmath/shaders; public asfloat(uint) stays. Branch from c9bd003b; if main moved by docs only, say so as r1 did."
|
||||
],
|
||||
"deletes": [
|
||||
{
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"lines": 33,
|
||||
"note": ":344-376 MarchIntervals, the per-ray adaptive march with its StepMax cap and unconditional probes, replaced by MarchTile on cell ranges."
|
||||
},
|
||||
{
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"lines": 10,
|
||||
"note": ":401-408 the doc comment naming the r1 fork and :424-425 the Assert.Skip naming it; the fork is answered. A skip survives only on a shortfall in (a) or (c), naming saving-2x-scenarios."
|
||||
},
|
||||
{
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"lines": 12,
|
||||
"note": ":319-330 Density: the unconditional three-snoise sample is replaced by the envelope-gated sample (coverage first, detail only when density can be nonzero)."
|
||||
},
|
||||
{
|
||||
"path": "packages/cultmath/docs/design.md",
|
||||
"lines": 15,
|
||||
"note": ":423-437 the saving paragraph with r1's numbers (0.93x, 1.83x, 0.74x), replaced by the scenario paragraph with r2's printed numbers."
|
||||
}
|
||||
],
|
||||
"keeps_moves": [
|
||||
"Every iv_* op, both ball bounds, SNOISE_LIPSCHITZ and its provenance tests: untouched. The ruling keeps the bound; only the field model, the march and its test change.",
|
||||
"SavingField's noise shape (F0 0.35 in the slab, the warped two-phase coverage, the detail octave, period 4) and (d)'s parameters: kept, as the worst case; scenario (d) must reproduce r1's dense count where the detail gating does not bite (print it).",
|
||||
"MarchDense and Integrate: unchanged except that Density is envelope-gated; the dense grid becomes the scenario's boundary list.",
|
||||
"IntervalTests, WarpedPointsStayEnclosed, TightnessReport, MeasureLipschitz, LipschitzConstantPinsSampledGradients: unchanged.",
|
||||
"HlslSourceCompatibilityTests: no change expected; the new function must find its C# twin through the existing mirror."
|
||||
],
|
||||
"adds": [
|
||||
"packages/cultmath/src/CultMath/math.Interval.cs: iv_frustum_ball after iv_fbm_ball, doc comment carrying the derivation and that the caller scales by F0 and adds its translation.",
|
||||
"packages/cultmath/shaders/CultMath.Interval.hlsl: cultmath_iv_frustum_ball, same text in both tracked copies.",
|
||||
"tests/CultMath.Tests/NoiseBoundTests.cs: Scenario (grid, wells, camera, ray slopes, units), SavingField with the envelope (PowerPulse wells, fade, floor), EnvelopeBound (the composed interval, test-side; iv_* ops only), MarchTile, MarchLod (footprint step and octave weights, the shipped march), the per-tile Run; TileBallEnclosesEveryRaySegment; EnvelopeBoundEnclosesDensity; IntervalSkipHalvesEvaluations prints the scenario x N x warp grid and asserts (a) and (c) at N = 8.",
|
||||
"packages/cultmath/docs/design.md, 'Intervals': the frustum-ball paragraph after :401; the envelope-then-noise paragraph (how a consumer composes the bound, the short-circuit, where the warp lives); the rewritten saving paragraph with the four scenarios and the headline.",
|
||||
"packages/cultmath/README.md :38-43: iv_frustum_ball named; one sentence that the bounds compose with an analytic envelope."
|
||||
],
|
||||
"file_changes": [
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/src/CultMath/math.Interval.cs",
|
||||
"line": 152
|
||||
},
|
||||
"change": "after iv_fbm_ball: public static float4 iv_frustum_ball(float2 centreSlope, float z0, float z1, float footprintPerDepth, float warp)."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/shaders/CultMath.Interval.hlsl",
|
||||
"line": 91
|
||||
},
|
||||
"change": "after cultmath_iv_fbm_ball: float4 cultmath_iv_frustum_ball(...); both tracked copies."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 294
|
||||
},
|
||||
"change": "SavingField becomes envelope-then-noise over a Scenario; Density is envelope-gated; MarchTile replaces MarchIntervals; Run draws tiles and N^2 rays each, counting snoise and envelope evaluations separately."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 409
|
||||
},
|
||||
"change": "IntervalSkipHalvesEvaluations: scenarios (a)-(e), N in {1, 4, 8, 16}, warp in {0, D}; the (e) grid Rh x offset x S with three marches; assert >= 2x combined cost for (a) and (c) and >= 2x fewer steps per pixel for (e) at the shipped point, else Skip naming saving-2x-scenarios."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 89
|
||||
},
|
||||
"change": "TileBallEnclosesEveryRaySegment and EnvelopeBoundEnclosesDensity beside WarpedPointsStayEnclosed."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/docs/design.md",
|
||||
"line": 401
|
||||
},
|
||||
"change": "the frustum-ball and envelope-composition paragraphs; :423-437 the saving paragraph rewritten with r2's printed numbers only."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/README.md",
|
||||
"line": 38
|
||||
},
|
||||
"change": "iv_frustum_ball named; the envelope composition in one sentence."
|
||||
}
|
||||
],
|
||||
"authority_map": {
|
||||
"owner": "CultMath owns the interval ops, the noise bounds and now the frustum ball; a consumer (the test's SavingField, the site's pre-pass, Aetheria's march) owns its envelope and composes the bound from iv_* ops. The saving test owns the proving-ground numbers per scenario; the site's browser measurement owns the shipped gain. The bound (SNOISE_LIPSCHITZ, iv_snoise_ball) is unchanged.",
|
||||
"inputs": [
|
||||
"the r1 interval surface, unchanged",
|
||||
"a tile's geometry (centre slope, depth segment, footprint per depth, warp bound) and a scenario's grid, wells, camera and units; Aetheria's shapes and settings as read from its checkout, cited by file:line in the test"
|
||||
],
|
||||
"outputs": [
|
||||
"iv_frustum_ball / cultmath_iv_frustum_ball: a ball enclosing every point of every ray of the tile over the segment, warped",
|
||||
"printed numbers per scenario: dense and tile snoise and envelope counts per ray, combined cost ratio, probe-free ceiling, at N in {1, 4, 8, 16}"
|
||||
],
|
||||
"derived_state": [
|
||||
"the tile ball and the envelope box are derived from the tile's geometry; no radius is read from a texture, a uniform or a tuned constant",
|
||||
"whether a noise probe is attempted is derived from L, the radius and the cutoff (item 2), never from a mode flag",
|
||||
"the probe-free ceiling is derived from the same run; it states what no amortization can beat"
|
||||
],
|
||||
"forbidden_writers": [
|
||||
"no change to SNOISE_LIPSCHITZ, iv_snoise_ball, the noise shape or e; the scenarios may not be tuned toward 2x",
|
||||
"no per-ray probe in the tile march; no early-out of the pre-pass on any ray's transmittance",
|
||||
"no warp on the envelope's argument; the warp enlarges the noise ball only",
|
||||
"no new CultMath function for the envelope: PowerPulse, the box and the composition are consumer-side, built from iv_* ops",
|
||||
"no tape, prune, affine or choice token in the interval files; no number in design.md or a comment that the committed test did not print in this run"
|
||||
],
|
||||
"shared_paths": [
|
||||
"the dense march and the tile march integrate the same cells of the same grid with the same Integrate; the transmittance check is the proof that nothing provably empty was there",
|
||||
"the one composed bound (EnvelopeBound) serves the enclosure test and the march; no second composition",
|
||||
"the existing mirror test covers the new HLSL function; the Unity copy stays one blob"
|
||||
],
|
||||
"deletion_line": "r1's per-ray march, its unconditional probes, its growth cap and the unconditional three-snoise dense sample go first; the tile march on cell ranges and the gated sample are written in their place. Nothing in src is deleted. The liability added is one geometry function and two tests plus the scenario model; it buys the tile-march soundness invariant in three runtimes and a saving number measured where there is empty space to find."
|
||||
},
|
||||
"verification": {
|
||||
"builds": [
|
||||
"Yggdrasil: dotnet test packages/cultmath/tests/CultMath.Tests green (MeasureLipschitz/MeasureMonotonicity excluded by trait); paste counts and every IV-REPORT line",
|
||||
"Yggdrasil: dotnet stryker --since:c9bd003b -m math.Interval.cs from a plain clone; 0 survivors; timeouts listed separately from kills",
|
||||
"Starfire: FXC cs_5_0 kernel over every cultmath_iv_* including cultmath_iv_frustum_ball, both HLSL copies",
|
||||
"Starfire: tools/get-dxc.ps1 then dxc -T lib_6_3 -HV 2021 on both HLSL copies, exit 0"
|
||||
],
|
||||
"tests": [
|
||||
{
|
||||
"name": "NoiseBoundTests.TileBallEnclosesEveryRaySegment",
|
||||
"pins": "every sample point of every ray of an N x N tile over a depth segment, sub-pixel jitter and warp included, lies inside iv_frustum_ball."
|
||||
},
|
||||
{
|
||||
"name": "NoiseBoundTests.EnvelopeBoundEnclosesDensity",
|
||||
"pins": "the composed bound (envelope box, fade, noise ball enlarged by the warp) encloses density at every warped point of 2,000 slices, no tolerance; intervals-enclose holds for the composition the site and Aetheria will use."
|
||||
},
|
||||
{
|
||||
"name": "NoiseBoundTests.IntervalSkipHalvesEvaluations",
|
||||
"pins": "scenarios (a) and (c) at N = 8 cost at most half the dense combined cost with transmittance within 1e-3; (e) at the shipped point: the masked LOD march takes at most half the fixed-step reference's steps per pixel, the masked fixed-step march agrees exactly, the LOD march within 0.02; the (e) grid and every scenario, N and warp printed with both counts and the ceiling."
|
||||
},
|
||||
{
|
||||
"name": "IntervalTests.EveryOpEnclosesItsPoints (existing)",
|
||||
"pins": "r1's enclosure for every op still holds."
|
||||
},
|
||||
{
|
||||
"name": "HlslSourceCompatibilityTests.EveryMirrorFunctionMatchesCSharpMath (existing)",
|
||||
"pins": "cultmath_iv_frustum_ball matches iv_frustum_ball bit for bit."
|
||||
}
|
||||
],
|
||||
"negative": [
|
||||
{
|
||||
"pattern": "tape|prune|affine|choice",
|
||||
"scope": "math.Interval.cs and CultMath.Interval.hlsl: 0 matches (case-insensitive)."
|
||||
},
|
||||
{
|
||||
"pattern": "snoise_grad|fbm_grad",
|
||||
"scope": "math.Interval.cs and CultMath.Interval.hlsl: 0 matches."
|
||||
},
|
||||
{
|
||||
"pattern": "PowerPulse|pulse|envelope|Scenario",
|
||||
"scope": "math.Interval.cs and CultMath.Interval.hlsl: 0 matches; the envelope is consumer-side."
|
||||
},
|
||||
{
|
||||
"pattern": "Assert\\.Skip",
|
||||
"scope": "NoiseBoundTests.cs: at most one, in IntervalSkipHalvesEvaluations, naming saving-2x-scenarios; 0 if (a), (c) and (e) meet their contracts."
|
||||
},
|
||||
{
|
||||
"pattern": "10\\.099261|0\\.35f",
|
||||
"scope": "git diff c9bd003b..HEAD -- packages/cultmath/src: the constant is not touched; the slab's F0 stays in (d)."
|
||||
},
|
||||
{
|
||||
"pattern": "git diff --name-only c9bd003b..HEAD",
|
||||
"scope": "only packages/cultmath/**; both tracked CultMath.Interval.hlsl copies change together."
|
||||
}
|
||||
],
|
||||
"operator": []
|
||||
},
|
||||
"estimate": {
|
||||
"lines_added": 420,
|
||||
"lines_removed": 80,
|
||||
"dependencies_added": [],
|
||||
"dependencies_removed": [],
|
||||
"formats_added": [],
|
||||
"formats_removed": [],
|
||||
"targets_added": [],
|
||||
"targets_removed": []
|
||||
},
|
||||
"rulings": [
|
||||
"cultmath-tapes:ruling:operator-tile-amortized-march",
|
||||
"cultmath-tapes:ruling:operator-ground-void-brush",
|
||||
"cultmath-tapes:ruling:operator-void-true-cavity",
|
||||
"cultmath-tapes:ruling:operator-compiler-downloads",
|
||||
"cultmath-tapes:ruling:operator-campaign-home"
|
||||
],
|
||||
"questions": []
|
||||
}
|
||||
|
|
@ -1,268 +0,0 @@
|
|||
{
|
||||
"campaign": "cultmath-tapes",
|
||||
"cut": "interval-ops",
|
||||
"revision": 3,
|
||||
"title": "Interval ops r3: the frustum ball widened by its derived float32 bound, the analytic constant-density body, one shipped void, (c) held to its ceiling, LOD weights exercised, Stryker kills demonstrated",
|
||||
"repo": "GameCult/CultLib",
|
||||
"branch": "hands/cultmath-interval-ops",
|
||||
"base": "43183276",
|
||||
"depends_on": [],
|
||||
"first": [
|
||||
"Follow-up on cut report cultmath-tapes:cut_report:cut-interval-ops.h2 (head 43183276, worktree F:\\Projects\\CultLib-iv) and Soul's verdict cultmath-tapes:verdict:cut-interval-ops.s1 with its five findings, each resolved below in order. New rulings in force: operator-wells-contract-vs-ceiling ((c)'s contract is at least 90% of the probe-free ceiling, not 2x) and operator-analytic-wall-body (where the envelope interval proves the density constant, one closed-form Beer-Lambert step). Map: docs/cultmath-interval-ground-cut.md, 'Pass 4'. r2's ops, field models, tile march and scenarios stand; this revision touches math.Interval.cs, both tracked CultMath.Interval.hlsl copies, NoiseBoundTests.cs, SnoiseBall tests in the same file, design.md and README only. Nothing new in src beyond the radius widening.",
|
||||
"Finding 1, frustum-ball-float-crossing (Medium). The ball is widened, in C# and both HLSL copies: radius += (|c.x| + |c.y| + |c.z| + radius) * 2^-20 (the literal 9.5367431640625e-7, exact in float32), after the r2 radius. Derivation, u = 2^-24: the centre c = (m_c z_m, z_m) carries at most two roundings per component (z_m = fl(z0 + z1) * 0.5, then the product), so |c_float - c_exact| <= 2u |c|_1; the radius carries at most seven first-order roundings (z1 - z0; the axis sqrt at 3u; the product; z1 * footprint; the sum; + warp), so r_exact <= r_float (1 + 7u); the widening's own addition rounds once more. 2^-20 = 16u covers 8u (|c|_1 + r) twice over. fma contraction removes roundings and only tightens, so dxc's HLSL and any driver's GLSL stay covered. The sentence 'the two vectors are never parallel, so the inequality is strict and float32 cannot cross it' leaves math.Interval.cs:179-180, the HLSL and design.md:412; the rule and its derivation take its place.",
|
||||
"(Finding 1, continued.) TileBallEnclosesEveryRaySegment gains Soul's extreme families beside the r2 domain, 2,000 cases each, 8 footprint corners at both depth ends with the flow pointed outward along the corner's direction from the centre, the distance computed in double from the float ball and the float ray (point (m z, z) formed in double from the float m and z), no tolerance: z0 == z1; thin (z1 - z0 = 1e-6 z1); |m_c| up to 1000; far (z up to 1e7); z0 = 0 with z1 down to 1e-3; f down to 1; warp up to 1e6. The derivation pin becomes two-sided: r_exact <= ball.w <= r_exact + 2^-19 (r_exact + |c|_1), r_exact in double, so a ball that forgets the widening fails and a ball that grows past the rule fails. The hand mutations of r2 stay; a fourth, the widening dropped, must fail the degenerate family alone. WarpedPointsStayEnclosed is unchanged.",
|
||||
"Finding 2, aetheria-far-plane-miscited (Low). FogField's grid becomes Quadratic(0.3, 2048, 256), cited as the Main Camera (Aetheria d3075eb0 Assets/Scenes/ARPG.unity:31777 m_Name Main Camera, :31830 far clip plane 2048; VolumeCloudRenderer.cs:36 RequireComponent(Camera)); the comment at NoiseBoundTests.cs:594 no longer names the Tint camera's 1000. Soul re-measured (c) at far 2048: 1.39-1.51x at warp D, 2.46-2.74x at warp 0, ceiling within 1-2% of the ratio; the verdict on (c) does not move. Print every (a)-(c) row again at 2048.",
|
||||
"Finding 3, shipped-void-two-scenes (Medium). ONE shipped void, authored as (Rh, S) per the map's own rule, everything else derived; the map, the site-ground r3 spec and the test agree on it: Rh = 198, S = 50, so Rc = Rh + S = 248 and e = -ln CARVE / ln(1 - (Rh / Rc)^2) = 0.40 (CARVE 1.5); fade band [Rh - S, Rh], A = 20, F0 = 0.01, D = 10, K = 1/30. This is the scene r2 measured (2.21x) and the Footprint-LOD rule's; the map's site section (Rc 640, e 4, a 442-wide ramp) was the other scene and is superseded. The honest choice changes the look (a 50-wide noisy wall with a kinked outer edge, not a soft pseudo-Gaussian wall), so it is Q9 in the map, a fork for the operator with (a) recommended. Hands codes (a); another ruling moves only the shipped constants and the site section. VoidField's doc comment derives (Rc, e) from (Rh, S) and names it the one shipped void.",
|
||||
"Finding 4, lod-weights-never-exercised (Low). VoidField takes a focal length. One configuration reaches the weights: '(e) LOD far' Rh = 4000, S = 50, camera low, f = 467 (1080 rows at a 98 degree vertical FOV): theta = 1/467, so the fine weight (wavelength 25) is below 1 past z = 1460 and 0 past 2920, the coarse weight (100) below 1 past 5840, and the grid (|camera - c_v| + Rc + 6/K, about 7990) reaches both; 4 z theta exceeds 12.5 past z = 1460, so LodStep is footprint-set there too. It joins EnvelopeBoundEnclosesDensity's field list (the truncated check is no longer vacuous) and the saving test prints it, not asserted. Hand mutation that must fail the truncated check on it: w(z0) for w(z1) in WeightBound.",
|
||||
"(Finding 4, continued: the deviation.) Recorded in the test's doc and design.md: the bound takes each octave weight over [w(z1), 1], not the spec's [w(z1), w(z0)], because one Bound serves the full field (w = 1) and the truncated one (w in [w(z1), w(z0)], w decreasing in z). [w(z1), 1] contains both, iv_mul is monotone in its interval argument, so the composed interval is a superset of either field's and encloses both. The slack is only in far tiles whose step the footprint already sets.",
|
||||
"Finding 5, stryker-timeout-survivor (Low). Mutant 2525 (iv_sqr a.x >= 0 to a.x > 0) is equivalent: at a.x == 0 both branches return the same ends ((0, h) either way, (0, 0) when a.y == 0); the report says so by its diff, since numbers are per run. New enclosure cases kill the mirror-only survivors semantically: SnoiseBallEnclosesPoints and FbmBallEnclosesPoints draw radii LogUniform(1e-3, 1e3), so L / r in iv_snoise_ball (too tight once r > 1: at r = 100 the mutant's bound is 0.2 wide while snoise over the ball spans [-1, 1]) dies to an enclosure test, not only to the HLSL mirror. The mutants Soul listed as mirror-only (iv_scale s >= 0 to s > 0; the two iv_abs mutants) are hand-run and each either named equivalent with the one-line argument (signed zero or a boundary both branches map alike) or killed by one added point case in IntervalTests.",
|
||||
"(Finding 5, continued.) Stryker runs again as r2 did, --since:43183276; every timeout in math.Interval.cs is hand-run (patch from the mutation report, dotnet test minus IntervalSkipHalvesEvaluations) and the report lists killed, survived and equivalent by diff. The report claims only demonstrated kills; a timeout is not a kill.",
|
||||
"The analytic body (ruling operator-analytic-wall-body), consumer-side in the test and the site, no new CultMath function. When it applies: the Scenario's Bound over the remaining range [z, z_end] of the grid, gated, is a point interval with positive value: the envelope with the free noise interval [-1.5, 1.5] (coverage in [-1, 1] plus half the detail) proves fade = 1 and d_lo + A (fade n)_lo >= Rc, so every point of every ray of the tile over the rest of the grid has density exactly K for any noise value; or, generically, lo == hi > 0 from whichever path the bound took. Only then does the march integrate T *= exp(-K ext (z_end - z) |dir|) in one step and terminate (the grid end is past opacity by construction). For (a)-(d) no Bound is a positive point (the fog's density varies with s'), so the step never applies there, and the test asserts bodyStart = -1 for every (a)-(d) tile.",
|
||||
"(Analytic body, continued.) MarchTile returns (dense cells, bodyStart, bodyDensity). As r2 on cell ranges; when a single cell is found not provably empty and its own gated bound is a positive point (the cell is provably constant), one more probe over [grid[j], grid[^1]]: if that is a positive point too, bodyStart = j, bodyDensity = its value, and every remaining cell is appended dense without probing (adding cells is always sound; it also drops the body's probes). The mask's meaning is unchanged, so the masked fixed-step march's exact agreement with the reference stands as the soundness pin. MarchLod: inside unmasked runs as r2; on reaching a cell >= bodyStart it takes the one analytic step over [z, grid end], counts one step, and stops.",
|
||||
"(Analytic body, the grid end.) Every void configuration's grid ends at |camera - c_v| + Rc + 6/K (was 5/K): the body starts at d >= Rc + A 1.5 = Rc + 30 plus box slack, so 6/K leaves at least 4.9 of optical depth, T <= 0.0075 < 0.02, and 'unfinished rays 0' still prints. This is the grid end, not the field; the reference stops by opacity and its count does not move.",
|
||||
"(Analytic body, the pin.) New test AnalyticBodyMatchesFineMarch over the (e) grid, the shipped void and the LOD-far configuration, 2,000 seeded tiles at N in {1, 4, 8, 16}: (1) wherever MarchTile reports bodyStart b: the Bound over [grid[b], grid end] is a positive point; the full and the truncated Density at 64 random (pixel, sub-pixel, cell >= b, depth in cell) points equal bodyDensity exactly, no tolerance; (2) the analytic factor exp(-bodyDensity ext L) over the body equals the fixed-step product of the body cells' Integrate factors (no early-out, same cells) within 1e-4 relative; (3) for every (a)-(d) tile bodyStart = -1. Hand mutation that must fail (1): in VoidField.Compose, CarveRadius for HollowRadius in the lo test (the body claimed from the ramp). The site's mirror of this (site-ground r3): the pre-pass writes the body-start cell in the texel's fourth word, the main pass takes the same one step; its browser check is the dense-reference agreement already in that spec.",
|
||||
"Contracts after r3, asserted at N = 8 in IntervalSkipHalvesEvaluations, else one Assert.Skip naming saving-2x-scenarios with every shortfall: (a) at least 2x combined cost (the lower of warp 0 and D), measured 16384x. (c) at least 0.90 of its probe-free ceiling (Ratio / Ceiling, the lower of warp 0 and D), measured 0.99 at warp D and 0.96 at warp 0 in r2; the ruling's reading is that the ceiling is what intervals control and the fog band's cost is the field's. (e) at least 2x fewer LOD steps per pixel than the fixed-step reference at the ONE shipped void (200 tiles), the masked fixed-step march agreeing exactly and the LOD march within 0.02 of the reference. (b), (d) and the (e) grid incl. LOD-far: printed, not asserted. Nothing is tuned toward any contract: the fields, the grids (beyond the stated 6/K end), e = 0.2 and the seeds stay.",
|
||||
"Predictions to confirm or refute (Imagination's rule from r2's printed rows: the LOD steps that evaluated noise are about snoise / 2.8 plus two pre-band steps; the rest were constant-body steps, which the analytic step makes one). Shipped void: steps per pixel 36.34 (ref) to about 12.5 (was 16.45), 2.6-3.3x fewer; snoise per pixel about 26.6, unchanged; combined cost about 1.38x (was 1.34x); envelope probes per tile about 50 (was 59.1). Grid, low camera, ref/LOD steps: S = 10 about 5-6x at Rh 100-400 and 11x at Rh 1000 (was 2.1-3.4x and 6.8x); S = 50 about 2.5x, 2.8x, 3.5x, 5.2x for Rh 100, 200, 400, 1000 (was 1.8, 2.2, 2.9, 4.5); S = 150 unchanged (rays end inside the 150-wide ramp before the body is proven). Rh 1000 centred S 10: about 30x (was 12.4x). LOD-far: printed, none predicted. If the shipped point lands under 2.6x, say which assumption failed: the pre-band's 0-snoise steps, or a body start later than Rc + 30 by box slack.",
|
||||
"Compile and mutation checks as r2: Yggdrasil dotnet test (counts and every IV-REPORT line pasted); Stryker --since:43183276 -m math.Interval.cs from a plain clone with every timeout hand-run as in Finding 5; Starfire FXC cs_5_0 over every cultmath_iv_* on both HLSL copies and tools/get-dxc.ps1 then dxc -T lib_6_3 -HV 2021 on both, exit 0 (Soul could not re-run these for r2; they are re-run here because the HLSL changes). Carried and still out of scope, named in the report: the Unity CultMath.dll is not rebuilt; the Unity HLSL copies stay one blob with packages/cultmath/shaders; public asfloat(uint) stays. Main moved by docs only since 43183276's base (Imagination passes 3-3d and 4); say so. glsl-lowering r2 rebases onto this revision's head, not r2's (its fixture family for cultmath_iv_frustum_ball changes with the widening); its spec now says so."
|
||||
],
|
||||
"deletes": [
|
||||
{
|
||||
"path": "packages/cultmath/src/CultMath/math.Interval.cs",
|
||||
"lines": 2,
|
||||
"note": ":179-180 the sentence 'The two vectors are never parallel, so the triangle inequality is strict' and its float32 claim; replaced by the widening rule and its derivation."
|
||||
},
|
||||
{
|
||||
"path": "packages/cultmath/docs/design.md",
|
||||
"lines": 1,
|
||||
"note": ":412 'the two vectors are never parallel, so the bound is strict'; replaced by the rule."
|
||||
},
|
||||
{
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"lines": 1,
|
||||
"note": ":594 the Tint camera citation (ARPG.unity:635-636, far 1000); replaced by the Main Camera's :31830, far 2048."
|
||||
},
|
||||
{
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"lines": 3,
|
||||
"note": ":1181-1194 the 2x test on ratios[c] and the skip message's wording; replaced by the 0.90-of-ceiling test and a message naming each shortfall."
|
||||
},
|
||||
{
|
||||
"path": "packages/cultmath/docs/design.md",
|
||||
"lines": 12,
|
||||
"note": ":461-497 the r2 numbers (1.41x, 16.45, 2.21x, the (c) shortfall paragraph); replaced by r3's printed numbers and the three contracts."
|
||||
}
|
||||
],
|
||||
"keeps_moves": [
|
||||
"Every iv_* op, iv_snoise_ball, iv_fbm_ball, SNOISE_LIPSCHITZ and its provenance tests: untouched. iv_frustum_ball's centre and the r2 radius terms: untouched; only the widening term is appended.",
|
||||
"FogField, SlabField, WarpedNoise, Compose, the gated probe (BestBall), MarchTile's range hierarchy, the masked fixed-step march and Run: kept; MarchTile gains the body return and MarchLod the one analytic step.",
|
||||
"VoidField's field, Density, Bound, Weight, WeightBound and LodStep: kept; the constructor gains the focal length and the grid end moves to 6/K; the shipped point stays Shipped() = (198, 50, shipped camera).",
|
||||
"TileBallEnclosesEveryRaySegment's r2 domain, EnvelopeBoundEnclosesDensity, IntervalTests, TightnessReport, MeasureLipschitz, LipschitzConstantPinsSampledGradients: kept; the first two gain cases.",
|
||||
"HlslSourceCompatibilityTests: the mirror count stays 53; cultmath_iv_frustum_ball's new text must match the C# bit for bit."
|
||||
],
|
||||
"adds": [
|
||||
"math.Interval.cs and both CultMath.Interval.hlsl: the widening line in iv_frustum_ball / cultmath_iv_frustum_ball, and the rule in the doc comment.",
|
||||
"NoiseBoundTests.cs: the extreme families and the two-sided derivation pin in TileBallEnclosesEveryRaySegment; VoidField focal parameter and the LOD-far configuration; MarchTile's (bodyStart, bodyDensity); MarchLod's analytic step; AnalyticBodyMatchesFineMarch; the (c) ceiling contract; radii to 1e3 in the two ball tests; the WeightBound deviation in the doc comment; the shipped-void derivation in VoidField's doc comment.",
|
||||
"IntervalTests.cs: at most three point cases for the mirror-only mutants that are not equivalent (Finding 5).",
|
||||
"design.md 'Intervals': the widening rule and derivation; the analytic-body paragraph (condition, pre-pass word, one step, the pin); the WeightBound deviation; r3's printed numbers under the three contracts; the one shipped void with (Rc, e) derived.",
|
||||
"README.md :41: one clause that iv_frustum_ball's radius carries its float32 rounding bound."
|
||||
],
|
||||
"file_changes": [
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/src/CultMath/math.Interval.cs",
|
||||
"line": 183
|
||||
},
|
||||
"change": "iv_frustum_ball: after the r2 radius, radius += (abs(cx) + abs(cy) + abs(zm) + radius) * 9.5367431640625e-7f; doc comment :168-182 states the rule and derivation in place of the strictness sentence."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/shaders/CultMath.Interval.hlsl",
|
||||
"line": 110
|
||||
},
|
||||
"change": "cultmath_iv_frustum_ball: the same widening line; both tracked copies (packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.Interval.hlsl identical blob)."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 128
|
||||
},
|
||||
"change": "TileBallEnclosesEveryRaySegment: the seven extreme families, outward flow at corners, double distance, the two-sided pin; the file header lists the fourth hand mutation (widening dropped) and the WeightBound and Compose mutations."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 58
|
||||
},
|
||||
"change": "SnoiseBallEnclosesPoints and FbmBallEnclosesPoints: radius LogUniform(1e-3, 1e3)."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 594
|
||||
},
|
||||
"change": "FogField: Grid = Quadratic(0.3, 2048, 256), Main Camera citation."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 829
|
||||
},
|
||||
"change": "VoidField: focal parameter (default 935); LodFar() = (4000, 50, low, f 467); grid end reach + Rc + 6/K; doc comment with the (Rh, S) -> (Rc, e) derivation, the one shipped void, the [w(z1), 1] deviation."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 1009
|
||||
},
|
||||
"change": "MarchTile returns (cells, bodyStart, bodyDensity) with the one confirming probe; MarchLod takes the analytic step at bodyStart; Run and RunStats carry it; ReportVoid prints body steps."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 1160
|
||||
},
|
||||
"change": "IntervalSkipHalvesEvaluations: (c) asserted as Ratio >= 0.90 Ceiling; the LOD-far row printed; the (e) grid printed with the analytic body; AnalyticBodyMatchesFineMarch added beside it."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/docs/design.md",
|
||||
"line": 403
|
||||
},
|
||||
"change": ":403-430 the frustum-ball paragraph gains the widening rule; :412 strictness sentence deleted; :461-497 the saving section rewritten with r3's printed numbers, the three contracts, the analytic body and the shipped void."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/README.md",
|
||||
"line": 41
|
||||
},
|
||||
"change": "iv_frustum_ball: 'its radius carries its own float32 rounding bound'."
|
||||
}
|
||||
],
|
||||
"authority_map": {
|
||||
"owner": "CultMath owns the interval ops, the noise bounds and the frustum ball, whose enclosure promise now covers its own float32 rounding (the widening is part of the function, not of any consumer). The consumer (the test's Scenario, the site's pre-pass) owns the envelope, the composed bound, the body proof and the Beer-Lambert step. The saving test owns the proving-ground numbers; the operator owns the shipped void (Q9) and the contracts (the two rulings).",
|
||||
"inputs": [
|
||||
"the r2 surface unchanged; a tile's geometry and warp; a scenario's grid, wells, camera, focal length and units, Aetheria's Main Camera far plane 2048 cited by line",
|
||||
"the one shipped void as (Rh, S) = (198, 50); every other void constant derived from it"
|
||||
],
|
||||
"outputs": [
|
||||
"iv_frustum_ball / cultmath_iv_frustum_ball: a float32 ball that encloses every exact point of every ray of the tile over the segment, warped, under float32 evaluation of the ball itself",
|
||||
"per tile: the dense cells and, when the envelope proves the rest constant, the body-start cell and its density; printed numbers per scenario including the body steps"
|
||||
],
|
||||
"derived_state": [
|
||||
"the widening is derived from the computed centre and radius (2^-20 of |c|_1 + r), never a tuned epsilon; a consumer adds nothing for the ball's own rounding and remains responsible for its world transform",
|
||||
"(Rc, e) are derived from (Rh, S): Rc = Rh + S, e = -ln CARVE / ln(1 - (Rh/Rc)^2); no void constant is authored twice",
|
||||
"the body start is derived from the same Bound the mask is derived from, by one more probe; the main pass reads it, never decides it",
|
||||
"the (c) contract value is derived from the run (0.90 of its own ceiling); no absolute target is written for (c)"
|
||||
],
|
||||
"forbidden_writers": [
|
||||
"no change to SNOISE_LIPSCHITZ, iv_snoise_ball, iv_fbm_ball, the noise shape, e = 0.2, the seeds or any field toward a contract; the grid end moves to 6/K for termination only",
|
||||
"no analytic step from a pointwise density or from the noise ball alone: only a point-interval Bound over the whole remaining range proves the body",
|
||||
"no early-out of the pre-pass on transmittance; no probe in the main pass (the body-start cell is read, its constant is one pointwise envelope value)",
|
||||
"no second shipped void anywhere: the map's site section, the site-ground spec and VoidField.Shipped() name (198, 50) and derive the rest; no number in a doc or comment that the committed test did not print in this run",
|
||||
"no new CultMath function; PowerPulse, the box, the composition and the Beer-Lambert step stay consumer-side; no tape, prune, affine or choice token in the interval files"
|
||||
],
|
||||
"shared_paths": [
|
||||
"the masked fixed-step march still integrates exactly the dense march's cells (the soundness pin); only MarchLod takes the analytic step, and AnalyticBodyMatchesFineMarch ties that step to the fixed-step product over the same cells",
|
||||
"one Bound serves the enclosure tests, the mask, the body proof and the gated probe; no second composition and no second constancy test",
|
||||
"the widening lives once in C# and is mirrored bit for bit into both HLSL copies by the existing mirror test; GLSL follows by the lowering (glsl-lowering r2 rebases onto this head)"
|
||||
],
|
||||
"deletion_line": "The false strictness claim leaves the C# doc, the HLSL and design.md before the widening is written; the Tint camera citation leaves before the Main Camera's is written; the (c) 2x assertion and the second shipped void leave before the ceiling contract and the one void are written. Net in src: one line and a doc comment. In tests: one new pin test, seven case families, one configuration, the body return; what they buy is intervals-enclose holding for the ball's own float32 evaluation, the analytic body the ruling asked for with its soundness pinned, LOD weights actually exercised, and a mutation report that claims only what it demonstrated."
|
||||
},
|
||||
"verification": {
|
||||
"builds": [
|
||||
"Yggdrasil: dotnet test packages/cultmath/tests/CultMath.Tests green (MeasureLipschitz/MeasureMonotonicity excluded by trait); paste counts and every IV-REPORT line, (a)-(d) at far 2048, the (e) grid with body steps, LOD-far, the shipped headline",
|
||||
"Yggdrasil: dotnet stryker --since:43183276 -m math.Interval.cs from a plain clone; every timeout hand-run; killed, survived and equivalent listed by diff; 0 undemonstrated",
|
||||
"Starfire: FXC cs_5_0 kernel over every cultmath_iv_* including cultmath_iv_frustum_ball, both HLSL copies, exit 0",
|
||||
"Starfire: tools/get-dxc.ps1 (pinned) then dxc -T lib_6_3 -HV 2021 on both HLSL copies, exit 0, no diagnostics"
|
||||
],
|
||||
"tests": [
|
||||
{
|
||||
"name": "NoiseBoundTests.TileBallEnclosesEveryRaySegment",
|
||||
"pins": "every exact point of every ray of the tile, corners at both depth ends with an outward flow, lies inside the float32 ball, in the r2 domain and in the seven extreme families, distance in double, no tolerance; the radius is the derivation plus at most 2^-19 (r + |c|_1) and at least the derivation."
|
||||
},
|
||||
{
|
||||
"name": "NoiseBoundTests.EnvelopeBoundEnclosesDensity",
|
||||
"pins": "the composed bound encloses the full and the footprint-truncated density at every warped point of 2,000 slices, including the LOD-far configuration where octave weights are strictly between 0 and 1 and at 0."
|
||||
},
|
||||
{
|
||||
"name": "NoiseBoundTests.AnalyticBodyMatchesFineMarch",
|
||||
"pins": "a body start is reported only where the Bound over the whole remaining range is a positive point and every sampled density there equals it exactly; the analytic factor equals the fixed-step product within 1e-4 relative; no (a)-(d) tile reports a body."
|
||||
},
|
||||
{
|
||||
"name": "NoiseBoundTests.IntervalSkipHalvesEvaluations",
|
||||
"pins": "(a) at least 2x combined cost; (c) at least 0.90 of its probe-free ceiling; (e) at the one shipped void at least 2x fewer LOD steps per pixel, the masked fixed-step march exact, the LOD march within 0.02; every row printed including body steps; one Skip naming saving-2x-scenarios on any shortfall."
|
||||
},
|
||||
{
|
||||
"name": "NoiseBoundTests.SnoiseBallEnclosesPoints and FbmBallEnclosesPoints",
|
||||
"pins": "the ball bounds enclose their functions for radii up to 1e3, so a bound tightened for r > 1 (L / r) dies to an enclosure test."
|
||||
},
|
||||
{
|
||||
"name": "IntervalTests.EveryOpEnclosesItsPoints (existing, plus point cases)",
|
||||
"pins": "r1's enclosure for every op still holds; any non-equivalent mirror-only mutant from Finding 5 has a point case that kills it."
|
||||
},
|
||||
{
|
||||
"name": "HlslSourceCompatibilityTests.EveryMirrorFunctionMatchesCSharpMath (existing)",
|
||||
"pins": "the widened cultmath_iv_frustum_ball matches iv_frustum_ball bit for bit; 53 functions compared."
|
||||
}
|
||||
],
|
||||
"negative": [
|
||||
{
|
||||
"pattern": "never parallel|cannot cross|strict",
|
||||
"scope": "math.Interval.cs iv_frustum_ball doc comment, CultMath.Interval.hlsl, design.md 'Intervals' frustum-ball paragraph: 0 matches."
|
||||
},
|
||||
{
|
||||
"pattern": "635-636|far 1000|Tint",
|
||||
"scope": "NoiseBoundTests.cs: 0 matches; ':31830' and '2048' appear in FogField's citation."
|
||||
},
|
||||
{
|
||||
"pattern": "Rc = 640|e = 4\\b|Rc ~ 220|e ~ 0\\.6|about 220|about 0\\.6",
|
||||
"scope": "NoiseBoundTests.cs and design.md: 0 matches; the shipped void is derived from (198, 50) as Rc = 248, e = 0.40."
|
||||
},
|
||||
{
|
||||
"pattern": "tape|prune|affine|choice|PowerPulse|pulse|envelope|Scenario|body|Beer",
|
||||
"scope": "math.Interval.cs and CultMath.Interval.hlsl: 0 matches (case-insensitive); the analytic body is consumer-side."
|
||||
},
|
||||
{
|
||||
"pattern": "Assert\\.Skip",
|
||||
"scope": "NoiseBoundTests.cs: at most one, in IntervalSkipHalvesEvaluations, naming saving-2x-scenarios; 0 if (a), (c) and (e) meet their contracts."
|
||||
},
|
||||
{
|
||||
"pattern": "10\\.099261|0\\.35f",
|
||||
"scope": "git diff 43183276..HEAD -- packages/cultmath/src: the constant is not touched; the slab's F0 stays in (d)."
|
||||
},
|
||||
{
|
||||
"pattern": "git diff --name-only 43183276..HEAD",
|
||||
"scope": "only packages/cultmath/**; both tracked CultMath.Interval.hlsl copies change together and stay one blob."
|
||||
},
|
||||
{
|
||||
"pattern": "git diff 43183276..HEAD -- packages/cultmath/src | grep '^[-+]' | grep -v '^[-+][-+]' | grep -v '///' | wc -l",
|
||||
"scope": "at most 4 changed code lines in src: the widening line in iv_frustum_ball and nothing else."
|
||||
}
|
||||
],
|
||||
"operator": []
|
||||
},
|
||||
"estimate": {
|
||||
"lines_added": 260,
|
||||
"lines_removed": 40,
|
||||
"dependencies_added": [],
|
||||
"dependencies_removed": [],
|
||||
"formats_added": [],
|
||||
"formats_removed": [],
|
||||
"targets_added": [],
|
||||
"targets_removed": []
|
||||
},
|
||||
"rulings": [
|
||||
"cultmath-tapes:ruling:operator-wells-contract-vs-ceiling",
|
||||
"cultmath-tapes:ruling:operator-analytic-wall-body",
|
||||
"cultmath-tapes:ruling:operator-tile-amortized-march",
|
||||
"cultmath-tapes:ruling:operator-measure-with-empty-space",
|
||||
"cultmath-tapes:ruling:operator-footprint-lod-march",
|
||||
"cultmath-tapes:ruling:operator-ground-void-brush",
|
||||
"cultmath-tapes:ruling:operator-void-true-cavity",
|
||||
"cultmath-tapes:ruling:operator-compiler-downloads",
|
||||
"cultmath-tapes:ruling:operator-campaign-home"
|
||||
],
|
||||
"questions": [
|
||||
"Q9 in the map: the one shipped void. (a, recommended) Rh 198, S 50, derived Rc 248, e 0.40, the scene r2 measured; (b) the site section's Rc 640, e 4; (c) Rh 198, S 100. Hands codes (a)."
|
||||
]
|
||||
}
|
||||
|
|
@ -1,292 +0,0 @@
|
|||
{
|
||||
"campaign": "cultmath-tapes",
|
||||
"cut": "interval-ops",
|
||||
"revision": 4,
|
||||
"title": "Interval ops r4: LOD agreement against a converged march at the body start, BodyDepth pinned, (c) against an oracle ceiling, WeightBound pinned, design.md from r4's own run",
|
||||
"repo": "GameCult/CultLib",
|
||||
"branch": "hands/cultmath-interval-ops",
|
||||
"base": "3ac7250a",
|
||||
"depends_on": [],
|
||||
"first": [
|
||||
"Follow-up on verdict cultmath-tapes:verdict:cut-interval-ops.s2 (range 43183276..3ac7250a, worktree F:\\Projects\\CultLib-iv), four findings, one item each below. Touches packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs and packages/cultmath/docs/design.md only: no src, no HLSL, no README. Question cultmath-tapes:question:wells-ceiling-oracle decides only (c)'s assertion lines (item 9). Self dispatches with its ruling in the brief. Line numbers are at 3ac7250a. Every probe number quoted here is from Imagination's scratch probe at 3ac7250a, with the test's own seeds, tiles and N = 8. It is never committed; r4's run reprints its own numbers.",
|
||||
"Finding lod-agreement-blind-past-body (High), measured. Comparing with the 10-unit fixed-step march cannot pin LodStep. At the shipped void, the LOD march's T at the body start differs from it by up to 0.300 of max(T, 0.02). LodStep doubled differs by 0.309. Both are unconverged midpoint quadratures of a ramp with 25-unit detail. A converged march separates them: h = 0.5, which agrees with h = 1 to 0.0065. Relative optical-depth error per ray, max at the headline: fixed-step 0.061, LOD 0.086, LodStep x2 0.177, LodStep x0.5 0.036. With the pre-body integration dropped, T is wrong by 26.5x max(T, 0.02). So the LOD march is 1.4x less accurate than the dense march before the body. Report it; it is not tuned.",
|
||||
"The agreement rule. MarchLod gains `float cutoff`; Run's costed march passes 0.02, as now. It returns (Transmittance, AtBody). AtBody is T when z first reaches the body start, before the body step, or the final T when there is no body. New helper ConvergedDepth(field, tile, slope, stop, h, lod) returns the optical depth in double, by midpoint quadrature at step h. It runs over EVERY grid cell before `stop`, never the mask's, so it trusts no pre-pass, using the field's Density.",
|
||||
"(Agreement, continued.) In Run with lod, per ray, with stop = bodyStart, or the cell count (grid.Length - 1) when there is none:\n- tauConv = ConvergedDepth(..., stop, 0.5, lod: true).\n- tauLod = -ln(max(AtBody, 1e-30)) from a second MarchLod at cutoff 0.\n- tauRef = -ln of the product of Integrate over cells < stop, lod: false, with no early-out.\nRunStats keeps MaxLodDepthError and MaxRefDepthError, each max |tau - tauConv| / max(tauConv, 1e-3). The converged march and the cutoff-0 march count into a throwaway Counts and no steps, so cost rows do not move.",
|
||||
"The (e) agreement contract replaces 'LOD within 0.02 at the end': at the shipped headline, MaxLodDepthError <= 2 x MaxRefDepthError. The footprint march may be at most twice as wrong as the dense march it is measured against. This mirrors the 2x step contract and is Imagination's default; Self may put it to the operator. Measured 0.086 <= 0.122. LodStep x2 and the dropped pre-body integration fail it. The end-of-ray assertion :1455 is deleted. End-of-ray |dT| stays printed. Every (e) row and LOD-far prints both errors and their ratio, which is printed and not asserted. LOD-far's tauRef carries the deliberate octave truncation, so its ratio is not comparable.",
|
||||
"BodyFactor becomes BodyDepth (:1198-1201). It returns the optical depth Density x ext x (grid end - z) x |dir| as a float. MarchLod multiplies by exp(-BodyDepth(...)). AnalyticBodyMatchesFineMarch part (2) deletes its own tau and analytic (:1375-1377). It compares BodyDepth(field, tile, ray, grid[b], lod: true, counts) with the double sum over body cells of -ln(Integrate(cell, lod: true)), within 1e-4 relative. Probe: in the T domain the comparison underflows at LOD-far (float exp of about -256 is 0, relative error 1); in the depth domain it does not. With |dir| dropped, the depth error is |dir| - 1, at least 0.00145 at the shipped camera, so the mutant dies on every tile.",
|
||||
"Finding wells-ceiling-contract-vacuous (Medium), measured at N = 8. Warp 0: ratio 2.744x, mask ceiling 2.855x (0.961), oracle ceiling 7.263x (0.378), oracle-empty cells skipped 0.952, skip-nothing pre-pass 0.136. Warp D: 1.391x, 1.401x (0.993), 7.165x (0.194), 0.842, 0.139. At warp D about 80% of the masked cost lies in cells where every ray's sample is 0, so design.md's 'where the rays need them' is false. The ruling's reason needs a ceiling the pre-pass cannot move. Hence the question.",
|
||||
"Measurement, whatever the ruling. Run draws a tile's N^2 slopes first, then marches. The random stream reorders, so every IV-REPORT row reprints. The oracle mask is the cells whose Density at mid-depth is > 0 for at least one of those slopes, evaluated into a throwaway Counts. The oracle march runs each ray over the oracle cells only, with the same Integrate and early-out, costed into a new RunStats.Oracle. Assert its T equals the dense march's exactly (probe max |dT| 0). RunStats.Ceiling (:1259-1260) goes. In its place: OracleCeiling = Dense.Cost / Oracle.Cost; Efficiency = Oracle.Cost / (Masked.Cost + Probes.Cost); ProbeOverhead = Masked.Cost / (Masked.Cost + Probes.Cost); CullFraction = oracle-empty cells the mask skips / oracle-empty cells. Report prints all four.",
|
||||
"(c)'s assertion, at N = 8, the lower of warp 0 and D, per the ruling on wells-ceiling-oracle. :1437 records the chosen value. For the cull floor, Assert.True fails rather than skips.\n- oracle-target-cull-floor (recommended): hard CullFraction >= 0.5, plus Efficiency >= 0.90 in the shortfall list.\n- oracle-target-only: the shortfall alone.\n- cull-fraction: CullFraction >= 0.90 in the shortfall list.\n- overhead-plus-floor: ProbeOverhead >= 0.90 in the shortfall list, plus the hard floor.\nThe contracts line names the measured quantity. Note: under options 1 and 2 the test skips today, naming saving-2x-scenarios with (c)'s shortfall; (a) and (e) are still evaluated and listed.",
|
||||
"Finding weightbound-mutation-survives (Low). VoidField gains public (float2 Coarse, float2 Fine) WeightBounds(float z0, float z1) beside Weights(z) (:992). WeightBound (:1035) stays private and is what WeightBounds calls. New Fact WeightBoundEnclosesOneSignedNoise on LodFar, 2,000 cases: z0 ~ U(1460, 0.95 x grid end), z1 ~ U(z0, grid end), 16 depths in [z0, z1] including both ends. Each octave weight lies in its bound. For the one-signed noise interval n = [0.25, 1] and n_s in {0.25, U(0.25, 1), 1}, w(z) n_s lies in iv_mul(bound, n). Probe: the spec's mutant [w(z0), 1] misses w(z1) x 0.25 in 2000 of 2000 fine-octave cases on (1460, 2920). The header (:14-20) moves that mutant to this test.",
|
||||
"Finding design-saving-section-stale (Medium). Rewrite design.md :475-512 from r4's final run, where every figure appears in an IV-REPORT line the report pastes. Delete :422-423's '6e-4 at the shipped scales' sentence, which no test prints. Contents:\n- the tile march and its marches;\n- (a)-(d) at far 2048, with (c)'s oracle ceiling, efficiency, cull fraction and probe overhead;\n- (e) at the shipped void, Rh 198, S 50, Rc = Rh + S, e = -ln 1.5 / ln(1 - (Rh/Rc)^2): steps, snoise, cost, body tiles, the two depth errors;\n- the analytic body (condition, one probe, one step, the pin);\n- the WeightBound [w(z1), 1] deviation and why it encloses;\n- the contracts as asserted, (c) in the ruling's words.\n'The test skips' appears only if this run skips. No r2 or r3 number survives.",
|
||||
"Site-ground's dense-reference browser check has the same blind spot (its converged mean luminance is compared after the walls' bodies). It is follow-up cultmath-tapes:follow_up:site-ground-dense-check-blind-past-body, owned by the next site-ground spec. Not this cut.",
|
||||
"Mutation evidence, cheap by design. src is untouched, so Stryker has nothing in scope and is not run; the negative check proves the premise. Run ONE tools/mutation-table.sh job on Yggdrasil against the final head, test filter FullyQualifiedName~NoiseBoundTests, with these rows:\n- MarchLod `transmittance *= Integrate(field, tile, slope, z, next, lod: true, counts);` -> `transmittance *= 1.0f;` must fail IntervalSkipHalvesEvaluations;\n- LodStep's return wrapped in `2.0f * (...)`: same test;\n- BodyDepth without its sqrt factor: AnalyticBodyMatchesFineMarch;\n- WeightBound `).x, 1.0f)` -> `).y, 1.0f)`: WeightBoundEnclosesOneSignedNoise;\n- MarchTile `if (bound.y <= 0.0f)` -> `if (bound.y <= 0.0f && !field.Name.Contains(\"wells\"))`: IntervalSkipHalvesEvaluations, under the floor options; a survivor under oracle-target-only, by that option's design;\n- Compose `CarveRadius` -> `HollowRadius` in the lo test: AnalyticBodyMatchesFineMarch (carried).\nNo per-mutant hand loops, no Starfire jobs.",
|
||||
"Budget: under 200k Hands tokens. Read NoiseBoundTests.cs :480-1469 and design.md :344-515 once; no other file. Verification is one full CultMath.Tests run on Yggdrasil after the code commits (paste counts and every IV-REPORT line), the mutation-table job, and one fix round. If the headline agreement fails at r4's own numbers, do not move the factor or the step: report the measured errors as a fork."
|
||||
],
|
||||
"deletes": [
|
||||
{
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"lines": 1,
|
||||
"note": ":1455 the end-of-ray assertion 'the footprint-aware march differs from the reference by'; blind past the body (finding lod-agreement-blind-past-body)."
|
||||
},
|
||||
{
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"lines": 4,
|
||||
"note": ":1198-1201 BodyFactor and its comment; replaced by BodyDepth, the one owner of the body's optical depth."
|
||||
},
|
||||
{
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"lines": 3,
|
||||
"note": ":1375-1377 the test's own tau, its exp and the T-domain comparison; replaced by BodyDepth against the cells' summed depth."
|
||||
},
|
||||
{
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"lines": 2,
|
||||
"note": ":1259-1260 RunStats.Ceiling and 'What no amortization can beat'; the mask-derived ceiling stops being a contract owner."
|
||||
},
|
||||
{
|
||||
"path": "packages/cultmath/docs/design.md",
|
||||
"lines": 38,
|
||||
"note": ":475-512 the saving section with r2's rows (1.41x, 16.45, 2.21x, 1.34x) and '(c) does not, so the test skips'."
|
||||
},
|
||||
{
|
||||
"path": "packages/cultmath/docs/design.md",
|
||||
"lines": 2,
|
||||
"note": ":422-423 'At the shipped scales (|c|_1 near 600, r near 5) it adds 6e-4.'; printed by no test."
|
||||
}
|
||||
],
|
||||
"keeps_moves": [
|
||||
"packages/cultmath/src, both CultMath.Interval.hlsl copies, README.md and every other test file: untouched.",
|
||||
"Fields, grids (6/K end), seeds, e = 0.2, LodStep, Density, Bound, Compose, MarchTile, Integrate, the masked fixed-step march's exactness asserts, the (a) and (e) step contracts and the Skip mechanism: unchanged.",
|
||||
"TileBallEnclosesEveryRaySegment, EnvelopeBoundEnclosesDensity, the Lipschitz tests, TightnessReport, IntervalTests, HlslSourceCompatibilityTests: unchanged.",
|
||||
"AnalyticBodyMatchesFineMarch parts (1) and (3): unchanged; part (2) changes domain and owner only.",
|
||||
"design.md :344-474 (Intervals up to the saving section, with the r3 frustum-ball paragraph minus the 6e-4 sentence) and every other section: unchanged."
|
||||
],
|
||||
"adds": [
|
||||
"NoiseBoundTests.cs: BodyDepth; MarchLod's cutoff and (Transmittance, AtBody) return; ConvergedDepth; Run's slope-first draw, oracle mask, oracle march and the cutoff-0 agreement march; RunStats Oracle, OracleCeiling, Efficiency, ProbeOverhead, CullFraction, MaxOracleDifference, MaxLodDepthError, MaxRefDepthError; Report and ReportVoid fields; the (e) depth contract; (c)'s assertion per the ruling.",
|
||||
"NoiseBoundTests.cs: VoidField.WeightBounds(z0, z1); Fact WeightBoundEnclosesOneSignedNoise; the header's mutation list updated.",
|
||||
"design.md: the saving section rewritten from r4's run."
|
||||
],
|
||||
"file_changes": [
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 14,
|
||||
"end_line": 20
|
||||
},
|
||||
"change": "Header mutation list: w(z0) for w(z1) now fails WeightBoundEnclosesOneSignedNoise. Add: the dropped pre-body integration and LodStep x2 fail IntervalSkipHalvesEvaluations's depth agreement; BodyDepth without |dir| fails AnalyticBodyMatchesFineMarch."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 992
|
||||
},
|
||||
"change": "Beside Weights(z): public (float2 Coarse, float2 Fine) WeightBounds(float z0, float z1) => (WeightBound(CoarseWavelength, z0, z1), WeightBound(FineWavelength, z0, z1))."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 1198,
|
||||
"end_line": 1201
|
||||
},
|
||||
"change": "BodyFactor -> BodyDepth(Scenario field, Tile tile, float2 slope, float z, bool lod, Counts counts): the float optical depth Density x Extinction x (Grid[^1] - z) x sqrt(m.x^2 + m.y^2 + 1), with no exp."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 1205,
|
||||
"end_line": 1235
|
||||
},
|
||||
"change": "MarchLod(..., float cutoff, ref long steps, ref long bodySteps) returns (float Transmittance, float AtBody). Both loop guards use cutoff. At z >= body: AtBody = transmittance, then transmittance *= exp(-BodyDepth(...)), steps++, bodySteps++, return. Without a body, AtBody = final T."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 1236
|
||||
},
|
||||
"change": "New ConvergedDepth(Scenario field, Tile tile, float2 slope, int stop, float h, bool lod): sum over cells c < stop of ceil(len / h) midpoint samples, Density(field, tile, slope, z, lod, throwaway) x Extinction x dz x |dir| in double. Every cell, not the mask's."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 1237,
|
||||
"end_line": 1265
|
||||
},
|
||||
"change": "RunStats:\n- Counts Oracle; OracleEmpty and OracleEmptySkipped; MaxOracleDifference; MaxLodDepthError; MaxRefDepthError.\n- OracleCeiling, Efficiency, ProbeOverhead and CullFraction as the spec's 'first' defines them.\n- Ceiling deleted.\nRatio and LodRatio are unchanged."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 1270,
|
||||
"end_line": 1305
|
||||
},
|
||||
"change": "Run, per tile:\n- MarchTile.\n- Draw N^2 slopes.\n- Oracle mask over all cells (mid-depth Density > 0 for any slope; throwaway Counts); count oracle-empty cells and those the mask skips.\n- Per ray: dense, masked and oracle marches (oracle into stats.Oracle; MaxOracleDifference).\n- With lod: the costed MarchLod (cutoff 0.02), then a cutoff-0 MarchLod and ConvergedDepth at h 0.5 into throwaway Counts, and tauRef with no early-out; update both depth errors."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 1307,
|
||||
"end_line": 1318
|
||||
},
|
||||
"change": "Report prints oracle ceiling, efficiency, cull fraction (with counts), probe overhead and max oracle |dT| in place of 'ceiling'. ReportVoid adds the depth errors of LOD and fixed-step against the converged march and their ratio; the end-of-ray |dT| stays."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 1332
|
||||
},
|
||||
"change": "AnalyticBodyMatchesFineMarch part (2): depth = BodyDepth(field, tile, ray, grid[b], lod: true, counts); sum = the double sum over c in [b, last) of -Math.Log(Integrate(field, tile, ray, grid[c], grid[c + 1], lod: true, counts)); assert |depth - sum| <= 1e-4 x sum, naming field, b and both values. The doc comment says so."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 1430,
|
||||
"end_line": 1440
|
||||
},
|
||||
"change": "(a)-(d) loop: Assert MaxOracleDifference == 0 beside the existing <= 1e-3. :1437 records the ruling's (c) quantity (Efficiency, CullFraction or ProbeOverhead), the lower of warp 0 and D at N = 8, and the CullFraction floor value when the ruling has one."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 1453,
|
||||
"end_line": 1466
|
||||
},
|
||||
"change": "Headline:\n- MaxDifference == 0 stays.\n- :1455 is replaced by Assert MaxLodDepthError <= 2 x MaxRefDepthError, with both values in the message.\n- Under a floor option: Assert CullFraction >= 0.5 for (c) at both warps (hard).\n- Shortfalls: (c) per the ruling.\n- The contracts line names the quantity and prints the (e) depth errors."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 1402,
|
||||
"end_line": 1414
|
||||
},
|
||||
"change": "IntervalSkipHalvesEvaluations doc comment:\n- the (e) agreement is the converged depth, 2x the fixed-step's;\n- (c) per the ruling, citing it;\n- the oracle is defined;\n- 'within 0.02' leaves."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs",
|
||||
"line": 304
|
||||
},
|
||||
"change": "After EnvelopeBoundEnclosesDensity: Fact WeightBoundEnclosesOneSignedNoise, as item 10 of 'first'. Its message names z0, z1, z, the octave, the weight and the bound."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/docs/design.md",
|
||||
"line": 475,
|
||||
"end_line": 512
|
||||
},
|
||||
"change": "The saving section is rewritten from r4's IV-REPORT lines (contents in item 11 of 'first'). The next paragraph, 'Consumers: ...' at :514, is kept."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "packages/cultmath/docs/design.md",
|
||||
"line": 422,
|
||||
"end_line": 423
|
||||
},
|
||||
"change": "Delete the sentence 'At the shipped scales (|c|_1 near 600, r near 5) it adds 6e-4.' Leave the rest of the paragraph."
|
||||
}
|
||||
],
|
||||
"authority_map": {
|
||||
"owner": "The saving test owns the proving-ground numbers and their oracles. The converged march (h = 0.5) is the accuracy oracle before the body. The oracle mask (cells some ray of the tile samples nonzero) is (c)'s ceiling oracle. BodyDepth is the one owner of the body's optical depth, in the march and in its pin. CultMath src is untouched. The operator owns the contracts: the rulings and the answer to wells-ceiling-oracle.",
|
||||
"inputs": [
|
||||
"the r3 fields, grids, seeds, tiles and marches at 3ac7250a",
|
||||
"the ruling answering cultmath-tapes:question:wells-ceiling-oracle"
|
||||
],
|
||||
"outputs": [
|
||||
"IV-REPORT rows: the LOD and fixed-step depth errors against the converged march; (c)'s oracle ceiling, efficiency, cull fraction and probe overhead",
|
||||
"design.md's saving section, every figure from those rows"
|
||||
],
|
||||
"derived_state": [
|
||||
"(c)'s ceiling is no longer derived from the pre-pass's own mask; it is derived from the oracle mask, which the pre-pass cannot move.",
|
||||
"Dense/Masked is no longer a contract owner; Masked/(Masked+Probes) is printed as probe overhead (an owner only under overhead-plus-floor).",
|
||||
"The fixed-step march is no longer the LOD march's accuracy oracle; it is a peer measured against the converged march, and its error sets the LOD's allowance (2x).",
|
||||
"End-of-ray |dT| is display-only.",
|
||||
"The body's optical depth is derived once, by BodyDepth; the test no longer re-derives it."
|
||||
],
|
||||
"forbidden_writers": [
|
||||
"no assertion compares the LOD march with the fixed-step march directly, or after the body",
|
||||
"AnalyticBodyMatchesFineMarch does not compute the body's depth itself; it calls BodyDepth",
|
||||
"neither the oracle mask nor ConvergedDepth reads MarchTile's output",
|
||||
"no change to src, the HLSL, the fields, grids, LodStep, seeds, the 0.2 weight or the 2x factor toward a contract",
|
||||
"no figure in design.md that this run's IV-REPORT lines do not print"
|
||||
],
|
||||
"shared_paths": [
|
||||
"MarchLod is one function for the costed march (cutoff 0.02) and the agreement march (cutoff 0)",
|
||||
"one Run computes dense, masked, oracle, LOD and converged for every scenario; the headline and the grid print the same fields",
|
||||
"the site's mirror of the march inherits the same check through follow-up site-ground-dense-check-blind-past-body"
|
||||
],
|
||||
"deletion_line": "The end-of-ray LOD assertion, BodyFactor, the test's own tau, RunStats.Ceiling, the (c) Ratio/Ceiling special case, design.md's r2 rows and the unprinted 6e-4 sentence all leave before their replacements are written. Net: about +170 / -60 in tests and docs, nothing in src. What that buys: (e) can now fail on a wrong pre-body march, the body depth can fail on a wrong |dir|, (c) can fail on a pre-pass that proves nothing, and the WeightBound mutant dies."
|
||||
},
|
||||
"verification": {
|
||||
"builds": [
|
||||
"Yggdrasil: dotnet test packages/cultmath/tests/CultMath.Tests -c Release green or skipped only by saving-2x-scenarios as the ruling implies (MeasureLipschitz/MeasureMonotonicity excluded by trait); paste counts and every IV-REPORT line",
|
||||
"Yggdrasil: one tools/mutation-table.sh job at the final head with the six rows in 'first' item 13; the table pasted; every row CAUGHT by the named test except the wells row under oracle-target-only",
|
||||
"No Stryker run and no Starfire job: no src or HLSL change in 3ac7250a..HEAD (negative check)"
|
||||
],
|
||||
"tests": [
|
||||
{
|
||||
"name": "NoiseBoundTests.IntervalSkipHalvesEvaluations",
|
||||
"pins": "At the shipped void the footprint march's optical depth at the body start is within 2x the fixed-step march's error of a converged march; the oracle march equals the dense march exactly; (c) is asserted per the ruling against a ceiling the pre-pass cannot move; (a) and (e) step contracts as before."
|
||||
},
|
||||
{
|
||||
"name": "NoiseBoundTests.AnalyticBodyMatchesFineMarch",
|
||||
"pins": "BodyDepth, the depth MarchLod uses, equals the summed fixed-step depth of the body cells within 1e-4 relative, in the depth domain (no underflow), so a body depth without |dir| fails on every tile; parts (1) and (3) unchanged."
|
||||
},
|
||||
{
|
||||
"name": "NoiseBoundTests.WeightBoundEnclosesOneSignedNoise",
|
||||
"pins": "Each octave weight at every sampled depth in [z0, z1] lies in WeightBounds(z0, z1), and w(z) n lies in iv_mul(bound, n) for a one-signed n, so a lower end above w(z1) fails."
|
||||
},
|
||||
{
|
||||
"name": "NoiseBoundTests (rest) and CultMath.Tests (rest)",
|
||||
"pins": "unchanged and green."
|
||||
}
|
||||
],
|
||||
"negative": [
|
||||
{
|
||||
"pattern": "git diff --name-only 3ac7250a..HEAD",
|
||||
"scope": "exactly NoiseBoundTests.cs and design.md under packages/cultmath."
|
||||
},
|
||||
{
|
||||
"pattern": "git diff 3ac7250a..HEAD -- packages/cultmath/src packages/cultmath/shaders packages/cultmath/unity",
|
||||
"scope": "empty."
|
||||
},
|
||||
{
|
||||
"pattern": "BodyFactor|Ceiling =>|\\.Ceiling\\b|MaxLodDifference <= ",
|
||||
"scope": "NoiseBoundTests.cs: 0 matches."
|
||||
},
|
||||
{
|
||||
"pattern": "Math\\.Exp\\(-\\(double\\)tau\\)|var tau = density",
|
||||
"scope": "NoiseBoundTests.cs: 0 matches (the test does not re-derive the body)."
|
||||
},
|
||||
{
|
||||
"pattern": "1\\.41x|16\\.45|2\\.21x|1\\.34x|6e-4|does not, so the test skips|where the rays need them",
|
||||
"scope": "packages/cultmath/docs/design.md: 0 matches."
|
||||
},
|
||||
{
|
||||
"pattern": "Assert\\.Skip",
|
||||
"scope": "NoiseBoundTests.cs: at most one, in IntervalSkipHalvesEvaluations, naming saving-2x-scenarios."
|
||||
}
|
||||
],
|
||||
"operator": []
|
||||
},
|
||||
"estimate": {
|
||||
"lines_added": 170,
|
||||
"lines_removed": 60,
|
||||
"dependencies_added": [],
|
||||
"dependencies_removed": [],
|
||||
"formats_added": [],
|
||||
"formats_removed": [],
|
||||
"targets_added": [],
|
||||
"targets_removed": []
|
||||
},
|
||||
"rulings": [
|
||||
"cultmath-tapes:ruling:operator-wells-contract-vs-ceiling",
|
||||
"cultmath-tapes:ruling:operator-analytic-wall-body",
|
||||
"cultmath-tapes:ruling:operator-footprint-lod-march",
|
||||
"cultmath-tapes:ruling:operator-measure-with-empty-space",
|
||||
"cultmath-tapes:ruling:operator-tile-amortized-march",
|
||||
"cultmath-tapes:ruling:operator-shipped-void"
|
||||
],
|
||||
"questions": [
|
||||
"cultmath-tapes:question:wells-ceiling-oracle"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
{
|
||||
"campaign": "cultmath-tapes",
|
||||
"cut": "interval-ops",
|
||||
"revision": 1,
|
||||
"title": "Interval ops: cultmath_iv_* over float2(lo, hi) in C# and HLSL with enclosure tests, a committed Lipschitz constant for snoise, and ball bounds for snoise and fbm",
|
||||
"repo": "GameCult/CultLib",
|
||||
"branch": "hands/cultmath-interval-ops",
|
||||
"base": "c49c76ee",
|
||||
"depends_on": [],
|
||||
"first": [
|
||||
"Map: docs/cultmath-interval-ground-cut.md, sections 'Ownership', 'Interval culling along the ray' and 'The proving-ground contract'. Parked target: docs/cultmath-tape-target.md, sketch step 1 (:59-64) and 'Open before unparking' (:106-115). This cut is step 1 and nothing after it: no tape, no pruning, no min/max choice tracking, no affine arithmetic (map D1).",
|
||||
"Common rules from the Asura cut map (Asura docs/cut-map.md, 'Common rules for every addition', :453-489): C# in packages/cultmath/src/CultMath as a partial of math (the Phacelle pattern: math.Phacelle.cs beside math.cs), HLSL in a new shaders/CultMath.Interval.hlsl included from CultMath.hlsl the way CultMath.Phacelle.hlsl is (:506), names prefixed cultmath_, both tracked HLSL copies one blob, bodies inside design.md's transformation list (:92-134), THIRD-PARTY-NOTICES untouched (nothing is ported; Fidget's ideas are used, not its code).",
|
||||
"Representation: an interval is float2(lo, hi), lo <= hi, both finite. The empty interval does not exist; a caller that needs 'provably empty' tests hi < cutoff. Point intervals are iv_point(x) = float2(x, x).",
|
||||
"Op set, each with the C# twin the mirror test demands: iv_point, iv_add, iv_sub, iv_neg, iv_mul, iv_scale(iv, float), iv_abs, iv_min, iv_max, iv_sqr, iv_sqrt (lo clamped at 0), iv_exp, iv_clamp(iv, float, float), iv_saturate, iv_smoothstep(float e0, float e1, iv) (monotone: endpoints), iv_lerp(iv, iv, float t). No others until a consumer names one.",
|
||||
"Noise bounds: CULTMATH_SNOISE_LIPSCHITZ (static const float, provenance in the comment: measured max |snoise_grad| over 1e6 seeded points plus 1e4 gradient-ascent refinements, times 1.10; the test that measures it is committed as a slow, explicitly-run test and the constant is pinned by a fast test). iv_snoise_ball(float3 c, float r) = [snoise(c) - L r, snoise(c) + L r] intersected with [-1, 1]. iv_fbm_ball(c, r, octaves, lacunarity, gain) = the octave sum of iv_snoise_ball(c * f_i, r * f_i) scaled by a_i, octaves clamped to [0, 16] like fbm_grad.",
|
||||
"Write the enclosure property first, as the shared harness: for each op, 10,000 seeded random intervals (widths from 1e-4 to 10, centres in [-20, 20]) and 16 points inside each; assert f(x) in op(I) with no tolerance (the bound is computed in the same float32 arithmetic; where rounding could exclude an endpoint, widen the op by one ulp in the implementation, not the test, and say so in design.md). For the ball bounds: 2,000 balls, r in [1e-3, 2], 64 points inside each.",
|
||||
"The saving test: a representative coverage field (one snoise octave at F0 = 0.35, cutoff 0.25) and 1,000 seeded rays through a slab of depth 12; march once at the dense step and once with the adaptive interval march from the map (Δ0, ΔMAX = 8 Δ0, ΔDENSE = Δ0/2, grow on skip, halve on maybe); count snoise evaluations; assert the integrated transmittance agrees within 1e-3 and the evaluation count is at least 2x lower. Print both counts and the ratio; the cut report quotes them.",
|
||||
"The warp enclosure test (the site's march rests on it): for 2,000 seeded (centre c, radius r, warp bound D) triples and random flow vectors with |flow| <= D, every warped point c' = x + flow (x in the ball of radius r) lies in the ball of radius r + D, and snoise(c') lies in iv_snoise_ball(c, r + D). This is a property of the existing bound, not a new function; it is written as a named test so the domain-warp use is pinned and documented in design.md.",
|
||||
"The saving test's field is domain-warped: coverage = sum over two phases of snoise(p + flow * (phase_k - 0.5) * PERIOD) with a bounded flow, matching the map's field shape, so the interval radius includes D.",
|
||||
"Tightness: a committed test prints the mean width ratio (interval width / true range over the 64 points) for iv_snoise_ball at r = 0.05, 0.25, 1.0 and for iv_fbm_ball at 4 octaves. No assertion beyond finiteness; the number is for the report and for a later affine cut to beat.",
|
||||
"Verification on Yggdrasil: dotnet test from packages/cultmath/tests/CultMath.Tests; Stryker --since:c49c76ee from a plain clone. Starfire: FXC cs_5_0 kernel calling every iv_* function and both ball bounds; dxc -T lib_6_3 -HV 2021. Detached with a log, polled in the foreground; never a background shell task. Numbers in comments or docs come from committed tests only.",
|
||||
"If glsl-lowering has merged to main before this branch does, rebase and regenerate shaders/CultMath.glsl (CULTMATH_WRITE_GLSL=1) so the pin test is green on the merge; if not, the lowering cut regenerates after this one. Either order works because the GLSL is derived."
|
||||
],
|
||||
"deletes": [],
|
||||
"keeps_moves": [
|
||||
"snoise, snoise_grad, fbm_grad, ridged_grad, cellular, phacelle: untouched. The bounds call snoise (value) only; snoise_grad is used by the Lipschitz measurement test, not by any bound (map D2).",
|
||||
"design.md's invariant 8 section (136-) is unchanged; intervals are not value-and-gradient primitives and do not claim the float4(grad, value) layout.",
|
||||
"HlslMirrorTests' ban on erosion kernels stands; nothing named interval touches it."
|
||||
],
|
||||
"adds": [
|
||||
"packages/cultmath/src/CultMath/math.Interval.cs: public static partial class math with iv_point, iv_add, iv_sub, iv_neg, iv_mul, iv_scale, iv_abs, iv_min, iv_max, iv_sqr, iv_sqrt, iv_exp, iv_clamp, iv_saturate, iv_smoothstep, iv_lerp, iv_snoise_ball, iv_fbm_ball, and the constant SNOISE_LIPSCHITZ; each with a doc comment stating the enclosure it guarantees and the ulp widening where one is needed.",
|
||||
"packages/cultmath/shaders/CultMath.Interval.hlsl: the HLSL mirrors with the same names prefixed cultmath_, and CULTMATH_SNOISE_LIPSCHITZ as static const; included from CultMath.hlsl after the Phacelle include (:506). Both tracked HLSL copies.",
|
||||
"tests/CultMath.Tests/IntervalTests.cs: the enclosure harness over every op; iv_mul sign cases (all four quadrant combinations and a zero-straddling factor); iv_sqrt with lo < 0; iv_abs straddling zero gives [0, max(|lo|,|hi|)]; iv_smoothstep monotonicity; iv_min/iv_max against the pointwise min/max; a mutant that shrinks any bound by one ulp dies (hand-mutation list in the file header for the float arithmetic Stryker cannot reach, as in 2a-ii-a).",
|
||||
"tests/CultMath.Tests/NoiseBoundTests.cs: iv_snoise_ball and iv_fbm_ball enclosure; LipschitzConstantPinsSampledGradients (fast, 1e5 points); MeasureLipschitz (slow, explicit trait, prints the max and the refined max); TightnessReport; the saving test (InvalSkipHalvesEvaluations) with its printed counts.",
|
||||
"packages/cultmath/docs/design.md: a section 'Intervals' after the invariant 8 section: representation, the op list, the ulp-widening rule, the Lipschitz provenance and margin, what the ball bounds guarantee and what they do not (nothing about tapes), and that the site's ground shader and Aetheria's nebula raymarch are the consumers.",
|
||||
"packages/cultmath/README.md: one paragraph under the noise entry naming the interval family and its purpose (empty-space culling with a proof)."
|
||||
],
|
||||
"file_changes": [
|
||||
{
|
||||
"location": { "path": "packages/cultmath/shaders/CultMath.hlsl", "line": 506 },
|
||||
"change": "add #include \"CultMath.Interval.hlsl\" after the Phacelle include; both tracked copies."
|
||||
},
|
||||
{
|
||||
"location": { "path": "packages/cultmath/src/CultMath/math.cs", "line": 602 },
|
||||
"change": "no change to snoise; a comment pointer from snoise to SNOISE_LIPSCHITZ in math.Interval.cs so the next kernel change knows the constant must be re-measured."
|
||||
},
|
||||
{
|
||||
"location": { "path": "packages/cultmath/docs/design.md", "line": 136 },
|
||||
"change": "new 'Intervals' section after invariant 8."
|
||||
},
|
||||
{
|
||||
"location": { "path": "packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs", "line": 348 },
|
||||
"change": "none expected; confirm the mirror resolves the second #include and that every cultmath_iv_* finds its C# twin (a mirror without a twin fails Assert.Contains MirrorOnly)."
|
||||
}
|
||||
],
|
||||
"authority_map": {
|
||||
"owner": "CultMath owns interval arithmetic and the noise bounds in C# and HLSL (and GLSL by derivation). The Lipschitz constant is owned by the committed measurement test; the kernel of snoise owns its value.",
|
||||
"inputs": [
|
||||
"the existing snoise(float3) value function; snoise_grad for the measurement test only",
|
||||
"seeded random points and intervals in tests"
|
||||
],
|
||||
"outputs": [
|
||||
"cultmath_iv_* and the two ball bounds, each enclosing its function over its input region",
|
||||
"three printed numbers for the report: the Lipschitz max, the tightness ratios, the evaluation saving"
|
||||
],
|
||||
"derived_state": [
|
||||
"CULTMATH_SNOISE_LIPSCHITZ is derived from the snoise kernel; a kernel change must re-run MeasureLipschitz and re-pin",
|
||||
"shaders/CultMath.glsl (if the lowering has landed) is derived and regenerated, never edited"
|
||||
],
|
||||
"forbidden_writers": [
|
||||
"no interval op without the enclosure test and a C# twin",
|
||||
"no bound that reads snoise_grad; no value-only/gradient twin of any iv_* function",
|
||||
"no tape, bytecode, pruning, choice flag or affine form in this cut; names containing tape, prune or affine do not appear",
|
||||
"no number in a comment or design.md that a committed test did not print"
|
||||
],
|
||||
"shared_paths": [
|
||||
"the same IntervalTests enclosure harness runs every op; a new op is one entry in its table",
|
||||
"the existing HLSL mirror test covers every HLSL interval function with no new comparison path"
|
||||
],
|
||||
"deletion_line": "Nothing is deleted. The liability is one C# file, one HLSL file, two test files and a doc section; it buys the invariant intervals-enclose for every GPU consumer, and it is step 1 of the parked tapes campaign landed in advance."
|
||||
},
|
||||
"verification": {
|
||||
"builds": [
|
||||
"Yggdrasil: dotnet test packages/cultmath/tests/CultMath.Tests green (MeasureLipschitz excluded by trait); paste counts",
|
||||
"Yggdrasil: dotnet stryker --since:c49c76ee from a plain clone; 0 survivors in math.Interval.cs except listed equivalents; hand mutations for float3 arithmetic listed and killed",
|
||||
"Starfire: FXC cs_5_0 kernel over every new function; dxc -T lib_6_3 -HV 2021 on both HLSL copies"
|
||||
],
|
||||
"tests": [
|
||||
{
|
||||
"name": "IntervalTests.EveryOpEnclosesItsPoints",
|
||||
"pins": "intervals-enclose for every op, no tolerance."
|
||||
},
|
||||
{
|
||||
"name": "NoiseBoundTests.SnoiseBallEnclosesPoints / FbmBallEnclosesPoints",
|
||||
"pins": "the ball bounds enclose snoise and fbm over 2,000 balls x 64 points."
|
||||
},
|
||||
{
|
||||
"name": "NoiseBoundTests.WarpedPointsStayEnclosed",
|
||||
"pins": "a domain warp bounded by D is enclosed by enlarging the ball radius by D; the site's interval march is sound."
|
||||
},
|
||||
{
|
||||
"name": "NoiseBoundTests.LipschitzConstantPinsSampledGradients",
|
||||
"pins": "no sampled |snoise_grad| exceeds the constant; a constant lowered by 1% fails."
|
||||
},
|
||||
{
|
||||
"name": "NoiseBoundTests.IntervalSkipHalvesEvaluations",
|
||||
"pins": "the adaptive march needs <= half the evaluations of the dense march for the same transmittance within 1e-3."
|
||||
},
|
||||
{
|
||||
"name": "HlslSourceCompatibilityTests.EveryMirrorFunctionMatchesCSharpMath (existing)",
|
||||
"pins": "every cultmath_iv_* and both bounds match C# bit for bit."
|
||||
}
|
||||
],
|
||||
"negative": [
|
||||
{
|
||||
"pattern": "tape|prune|affine|choice",
|
||||
"scope": "packages/cultmath/src/CultMath/math.Interval.cs and shaders/CultMath.Interval.hlsl: 0 matches (case-insensitive)."
|
||||
},
|
||||
{
|
||||
"pattern": "snoise_grad|fbm_grad",
|
||||
"scope": "math.Interval.cs and CultMath.Interval.hlsl: 0 matches; the bounds use values only."
|
||||
},
|
||||
{
|
||||
"pattern": "spherical_erosion|AdvancedErosionFilter",
|
||||
"scope": "packages/cultmath: 0 matches (HlslMirrorTests' standing rule)."
|
||||
},
|
||||
{
|
||||
"pattern": "git diff --name-only c49c76ee..HEAD",
|
||||
"scope": "only packages/cultmath/** (and shaders/CultMath.glsl if regenerated); no other package or src tree."
|
||||
}
|
||||
],
|
||||
"operator": []
|
||||
},
|
||||
"estimate": {
|
||||
"lines_added": 900,
|
||||
"lines_removed": 0,
|
||||
"dependencies_added": [],
|
||||
"dependencies_removed": [],
|
||||
"formats_added": [],
|
||||
"formats_removed": [],
|
||||
"targets_added": [],
|
||||
"targets_removed": []
|
||||
},
|
||||
"rulings": [],
|
||||
"questions": []
|
||||
}
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
{
|
||||
"campaign": "cultmath-tapes",
|
||||
"cut": "site-ground",
|
||||
"revision": 3,
|
||||
"title": "Site ground r3: A cloud volume carved by one negative pseudo-Gaussian sphere, a sun at the hollow's bottom lights the walls and orbits, the camera follows low inside; pre-pass, camera-aware TAA",
|
||||
"repo": "GameCult/gamecult-site",
|
||||
"branch": "main",
|
||||
"base": "535d170",
|
||||
"depends_on": [
|
||||
"site-masthead:cut_spec:wash-home-only.r1",
|
||||
"cultmath-tapes:cut_spec:glsl-lowering.r2",
|
||||
"cultmath-tapes:cut_spec:interval-ops.r3"
|
||||
],
|
||||
"first": [
|
||||
"Map: F:/Projects/CultLib/docs/cultmath-interval-ground-cut.md, 'Pass 3' (the void scene, the composed bound, the pre-pass, the budget), then 'The shader design' for what pass 3 does not restate (sampled wash, palette input, pausing, contrast). Rulings in force: cultmath-tapes:ruling:operator-ground-void-brush (the scene), operator-tile-amortized-march (the march), site-masthead:ruling:operator-chromeless-column; wash-home-only's authority map. Re-pin base to main after wash-home-only lands. Stardust and DoF stay the next cut; leave the named composite slot. r2's token, readback, controller, vendoring, parity and fallback items stand unless this revision names them.",
|
||||
"The original, read once (Aetheria at e6181c11, 2021-03-13, Assets/Scenes/Main Menu.unity; Prefabs/RPG/Planets/Sun.prefab; Materials/Brushes/Gravity Well.mat, Main Menu Sun Fog Tint.mat, Main Menu Ambient Fog Tint.mat, Sector Boundary Fog.mat; Materials/Fog Marching Main Menu.mat; Shaders/Raymarching/VolumeRender.shader at that commit; Shaders/Brushes/PowerBrush.shader). What survives, in the original's units: the brush is PowerBrush (1 - x^2)^16 with depth 40 over radius 256 (half depth at r = 52, pseudo-Gaussian sigma about 45), the sun's own gravity-well child; a wide boundary bowl (depth 75, power 0.25, radius 1500) is flat across the scene and acts as a constant 75; floor offset -16.46, floor blend 39.6, so the fog top is at y = -91 outside the void and dips to -131 at the sun; the sun sphere (radius 12.5) sits at y = -130, the bottom of the void.",
|
||||
"What survives (continued): the sun is a child of 'Center', which rotates 5 degrees per second about y (72 s period), at orbit radius 175; the camera is a Cinemachine vcam following the sun (framing transposer, distance 150, damping 10, handheld noise amplitude 1 at frequency 0.25) and looking at 'Look Target' (0, -130, 0), the orbit centre at the sun's depth; the saved camera sits at y = -27, 64 above the fog top. The lighting was authored, not marched: the sun's 'Fog Tint' brush (1 - x^2)^3, depth 2, radius 128, colour (1, 0.4, 0.1) orange, painted into the tint map, times a scene-wide ambient tint (0.18, 0.59, 1) blue, depth 0.01. Stardust: 256^2 particles, spacing 2, floor -60, ceiling 0, height exponent 800 / (ceiling + wellDepth) so particles climb higher where the well is deeper (the void fills with stardust). Noise: strength 150, frequency 0.01, safety 50. Not surviving: the 2021 d() details, the tint map's runtime contents, the video.",
|
||||
"The field (ruling operator-void-true-cavity: a spherical hollow inside a cloud volume that surrounds it on every side, not the 2021 bowl). Units are the original's. Outer volume: an unbounded uniform fill, chosen over a shell because every ray then ends in a wall and the march terminates by opacity, never by a far plane; Z1 = |camera - c_v| + Rc + 6/K (614 at the shipped point) bounds the pre-pass range and is past opacity for every ray, so the analytic body step below always terminates. The carve is the pseudo-Gaussian brush g(d) = (1 - (d / Rc)^2)^e, d = |p - c_v|, density = K max(0, 1 - CARVE g(d')), authored by two knobs: the hollow radius Rh (density exactly 0 inside; Rh = Rc sqrt(1 - CARVE^(-1/e))) and the ramp width S (density full at Rh + S; set by the exponent: e = 4 gives S = 1.3 Rh, e = 0.25 gives S = 0.12 Rh).",
|
||||
"Shipped (Q9 in the map, option (a) pending the operator): Rh = 198, S = 50, so Rc = Rh + S = 248 and e = -ln CARVE / ln(1 - (Rh / Rc)^2) = 0.40; CARVE = 1.5, K = 1/30: a wall is opaque within about 150 units of full density. The ramp and the noise fade band are both S wide; nothing void-shaped is authored twice (interval-ops r3, finding shipped-void-two-scenes). Noise displaces the wall only: fade(d) = smoothstep(Rh - S, Rh, d), d' = d + A fade(d) n(warp(p)), A = 20, F0 = 0.01.",
|
||||
"Scene motion. The sun orbits the origin at radius 175 in xz with period 72 s (5 degrees per second, the original's Center pivot). The cavity centre is c_v = sun + (0, Rh - 12.5, 0): the sun sphere (radius 12.5) rests on the hollow's bottom and the hollow moves with it. The camera follows the sun 120 behind it along the orbit's tangent and 45 above it, so it sits 186 from c_v, 12 inside the hollow (the handheld drift is 2 units, two low-frequency snoise on time), low in the cavity, looking at the sun's position plus (0, 60, 0) so the lit lower wall and the dark upper wall share the frame. The 2021 follow distance 150 and height 25 do not fit a hollow a camera can stand in; 120 and 45 are the re-derivation, and the report says so. worldAt(t) in ground.js is the one owner of sun, cavity and camera, uploaded as uniforms; the shaders never integrate motion.",
|
||||
"The flow field, authored around the void: flow(xz) = SWIRL * tangent(xz - sun.xz) * (1 - (r / Rw)^2)^2 + GLOBAL * (snoise(xz Ff + o1), snoise(xz Ff + o2)) (the swirl is largest mid-slope and zero at the rim and the centre; the global term is r2's). The flow texture is a world-space window of side 4 Rw centred on the sun, quarter of the canvas resolution, re-rendered every frame; warp, hue, motion vectors and (next cut) the stardust read it, so D4 holds with a moving scene. D = FLOW_MAX * PERIOD / 2 as before, with FLOW_MAX the authored maximum of the swirl plus the global amplitude.",
|
||||
"The march (tile pre-pass, interval-ops r2's design). Pre-pass at W/8 x H/8: per tile, the loop over the slab's cell ranges (uniform cells, StepDense = min(10, S / 4) = 10, 62 cells to Z1 = 614 along +view depth; the mask has 96 bits): the envelope probe first (exact box: y and xz ranges by cultmath_iv_mul of the depth and slope intervals, d's interval is the box's least and greatest distance to c_v, exact; g, fade and the carve are monotone in it), then the noise ball (cultmath_iv_frustum_ball with D, cultmath_iv_snoise_ball) only when fade.hi > 0 and L r F0 leaves room to prove anything; writes the 96-bit mask of cells not proven empty to an RGBA32UI texel's first three words, and in the fourth word the body-start cell: the first cell whose own bound is a positive point and whose slice to Z1 is one too (the envelope with the free noise interval proves density exactly K for the rest of the range), 0xFFFFFFFF when none (interval-ops r3, ruling operator-analytic-wall-body).",
|
||||
"(March, continued.) Main pass: texelFetch the mask, then march the unmasked range with the footprint step Delta(z) and the octave weights (next item), the per-frame sample count capped by the budget S_budget and the start jittered so TAA completes what one frame does not; masked cells are skipped exactly; at the body-start cell take one analytic Beer-Lambert step, T *= exp(-K (Z1 - z) |dir|) with K the field's pointwise envelope value there (no probe in the main pass), and stop; integrate transmittance and the depth-weighted mean distance, apply the light. Every tile's interior cells retire in a few envelope probes and its wall cells are marked; there are no sky tiles. Behind a fully transmissive pixel the resolve shows --gamecult-nebula-base; by construction only an unfinished ray (budget exhausted before the wall, measured, must be under 1%) reaches it.",
|
||||
"Footprint LOD (the operator's challenge, map 'Footprint LOD'): theta_px = 2 tan(fov/2) / H; the footprint at distance z is z theta_px. Step: Delta(z) = max(k z theta_px, min(S / 4, lambda_fine(z) / 2)), k = 4: never finer than four footprints, otherwise the finer of the two content limits (four samples across the ramp, two per wavelength of the finest live octave). Octaves: octave i of wavelength lambda_i carries weight w_i(z) = 1 - smoothstep(lambda_i / (2m), lambda_i / m, z theta_px), m = 4: one smooth blend per octave, no popping; TAA hides per-frame sparsity. The weights are part of the field, so the bound encloses the truncated field exactly: the noise interval over a slice is the sum over octaves of iv_mul([w_i(z1), w_i(z0)], iv_snoise_ball(c F_i, r F_i)), w_i monotone in z. At the shipped parameters (wavelengths 100 and 25, z <= 2 Rc) every weight is 1 in the frustum and the content limits set the step; the footprint bites when S < 4 k z theta_px.",
|
||||
"Where the scene sits and what to turn (Imagination's model, scratchpad cavity_model.py; the test and the browser confirm): interval-ops r2 measured the shipped point at 16.45 LOD steps and 26.6 snoise per pixel against 36.3 fixed steps (2.21x); r3's analytic body is predicted to bring the steps to about 12.5 (2.6-3.3x), snoise unchanged; the wall's step is set by S / 4 and the detail half-wavelength, not by distance; the browser confirms or refutes. Cheaper without losing the mood, in order: (1) a lower per-frame S_budget, which LOD and the mask make nearly lossless (S_budget = 4 converges in about three frames at the walls); (2) a thinner ramp S (S = 10 is 3.2x on cost but reads as a surface; 25-50 keeps the fog); (3) a longer detail wavelength or no detail octave near the far wall (the 12.5-unit half-wavelength is the floor for S >= 50); (4) a larger hollow (Rh = 1000: 2.5x) costs nothing per pixel and changes the composition. Camera offset barely matters at these scales.",
|
||||
"Lighting, the cheapest version that keeps it moody (the original's own mechanism, made 3D): at each sample, light = SUN_COLOUR * (1 - (d / Rl)^2)^3 for d = |p - sun| < Rl, Rl = Rh = 198 (the original's 128 scaled with the hollow), plus AMBIENT * ambientTint; colour = density * (light + ambient) * hue(flow). No light march: zero extra snoise, one length and one pow per sample. The lower wall within Rl of the sun glows and the upper wall stays dark because the pulse is zero beyond Rl, which is the moody split; the sun inside the hollow is occluded by nothing, so the sun itself is a small additive disc drawn in the resolve at its projected position, occluded by the accumulated transmittance along its ray. Not in this cut, measured before it is ever added: one envelope-only transmittance sample toward the sun per primary sample (self-shadowing by the bowl's geometry), about +25% march cost; it is desktop-only if ever.",
|
||||
"TAA with a moving camera. ground.js uploads this frame's and the previous frame's view-projection. For each pixel the resolve reconstructs the world point at the march's depth-weighted mean distance (written by the main pass into a second history channel, R16F), moves it back by the field motion (p + flow(p) * dt: the pattern moves at -flow), projects it with the previous VP and fetches history at that uv. The same 3x3 min/max clamp, alpha = max(1/frames, 1/32), out-of-bounds alpha 1; history reset on resize, first frame and budget step. Transmittance, colour and the depth channel are all reprojected by the one motion vector. ?ground=debug-mv shows |mv| with the camera moving and the flow frozen, and with the flow moving and the camera frozen, so both terms are seen separately.",
|
||||
"Stardust distribution and the sun disc are the next cut (site-stardust), authored to the void: particles spawned in the bowl's volume with the original's height rule (more headroom where the well is deeper), lit by the same pulse, moving with the swirl. This cut leaves the composite slot and the depth history they need.",
|
||||
"Fit with the tokens. Inputs: --gamecult-nebula-0 (orange 255 138 42) is SUN_COLOUR; --gamecult-nebula-2 (sky 89 183 255) is the ambient tint; --gamecult-nebula-1 (violet 109 96 255) is the cloud hue's second pole; --gamecult-nebula-base the sky. Outputs from the 8x8 readback as r2, with one change: --gamecult-light-0-at is written from the sun's projected screen position (quantised to 1%, at most 2 Hz), so the CSS wash's main light follows the sun while the shader runs; --gamecult-light-0 and -a come from the texel there; lights 1 and 2 keep their seed positions and sample their colours. When the canvas is absent the seeds paint a still of the same composition (the seed light-0-at is the sun's mean screen position).",
|
||||
"Prove in the browser pane, in addition to r2's list: with ?ground=debug the pre-pass's mean probes per tile and the fraction of cells masked, and the mean snoise per pixel and the fraction of samples placed in the wall ramp against a dense-reference frame (?ground=dense: no pre-pass, S samples over the whole slab; the two converged frames' mean luminance agree within 1/255; the masked frame's per-frame variance at the walls is lower, printed); the camera reprojection test above; the orbit (after 36 s the sun's screen position has moved half an orbit and the TAA shows no smear at the walls); the contrast clamp with the lit cavity (max luminance <= 0.035 still holds; the sun disc is exempt only inside its own radius, which the clamp test masks and reports)."
|
||||
],
|
||||
"deletes": [
|
||||
{
|
||||
"path": "site/quartz/styles/custom.scss",
|
||||
"lines": 6,
|
||||
"note": "ritual body block 119-124: its own background goes; its seeds live under :root:has(body[data-slug]) (r2)."
|
||||
},
|
||||
{
|
||||
"path": "site/quartz/styles/custom.scss",
|
||||
"lines": 9,
|
||||
"note": "body 10-27: the literal colours and stops become token references (r2)."
|
||||
}
|
||||
],
|
||||
"keeps_moves": [
|
||||
"r2's tokens, sampled wash, controller, vendoring (now two files: the site vendors CultMath.glsl only, MIT; the parity run concatenates CultMath.Phacelle.glsl for the phacelle family), parity flags, fallbacks and the contrast clamp: unchanged except where this revision names them.",
|
||||
"--gamecult-wash and the index override from wash-home-only: untouched.",
|
||||
".page::before, the tour/Pitch VN ground, the Thing deck's .hero-bg, the masthead, the engine: untouched.",
|
||||
"The CSS wash stays the ground when the canvas is absent, not yet drawn, or removed by the controller."
|
||||
],
|
||||
"adds": [
|
||||
"site/quartz/components/GameCultGround.tsx: as r2.",
|
||||
"site/quartz/static/ground/ground.js (~500 lines): r2's passes plus worldAt(t) (sun, void, camera), the two view-projections, the pre-pass, the mask texture, the depth history, the sun-disc composite and the light-0-at writer.",
|
||||
"site/quartz/static/ground/flow.frag.glsl (world-space window, swirl plus global), prepass.frag.glsl (the tile loop, mask and body-start out), cloud.frag.glsl (masked sampling, light, depth out), taa.frag.glsl (camera plus flow reprojection), downsample.frag.glsl.",
|
||||
"site/quartz/static/ground/CultMath.glsl and glsl-parity.json, vendored from CultLib at interval-ops r3's head or later (cultmath_iv_frustum_ball with its float32 widening present).",
|
||||
"scripts/ground/check-cultmath-glsl.ps1: the vendoring drift check.",
|
||||
"custom.scss and docs/brand-design-language.md: as r2, with the void composition named in the brand doc's ground paragraph."
|
||||
],
|
||||
"file_changes": [
|
||||
{
|
||||
"location": {
|
||||
"path": "site/quartz/styles/custom.scss",
|
||||
"line": 4,
|
||||
"end_line": 8
|
||||
},
|
||||
"change": ":root block: the fifteen ground seeds and --gamecult-ground-mean; light-0-at seeded at the sun's mean screen position."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "site/quartz/styles/custom.scss",
|
||||
"line": 10,
|
||||
"end_line": 31
|
||||
},
|
||||
"change": "body: four nebula input tokens; radials and the linear gradient reference the ground tokens (r2)."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "site/quartz/styles/custom.scss",
|
||||
"line": 103,
|
||||
"end_line": 124
|
||||
},
|
||||
"change": "ritual block: background declaration deleted; :root:has(body[data-slug]) carries its seeds and nebula inputs (r2)."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "site/quartz.layout.ts",
|
||||
"line": 19
|
||||
},
|
||||
"change": "header: [GameCultThemeLock(), GameCultGround(), GameCultMasthead()]."
|
||||
},
|
||||
{
|
||||
"location": {
|
||||
"path": "docs/brand-design-language.md",
|
||||
"line": 69,
|
||||
"end_line": 84
|
||||
},
|
||||
"change": "wash section rewritten: the void scene, the sampled wash, the seeds, the nebula inputs and what each drives."
|
||||
}
|
||||
],
|
||||
"authority_map": {
|
||||
"owner": "The shader owns the ground. ground.js owns worldAt(t) (sun, void, camera), the flow texture, the pre-pass mask, the march, the TAA, the readback and the sampled tokens; custom.scss owns the four nebula inputs, --gamecult-wash and the seeds; CultLib owns every noise and interval function; the composed bound in prepass.frag.glsl is built from cultmath_iv_* only.",
|
||||
"inputs": [
|
||||
"the four nebula tokens and --gamecult-wash (computed on body)",
|
||||
"prefers-reduced-motion, visibilityState, canvas size, devicePixelRatio, elapsed time",
|
||||
"measured GPU frame time (timer query, fence, or rAF cadence)",
|
||||
"the shader's own 8x8 readback and the sun's projected position",
|
||||
"the vendored CultMath.glsl at its pinned CultLib commit"
|
||||
],
|
||||
"outputs": [
|
||||
"one opaque moving canvas under the page, luminance <= 0.035 outside the sun disc",
|
||||
"the fifteen ground tokens and --gamecult-ground-mean on :root inline, at most 2 Hz; light-0-at follows the sun",
|
||||
"debug, dense-reference and parity readbacks (console, flag only)"
|
||||
],
|
||||
"derived_state": [
|
||||
"sun, void and camera positions are derived from time by worldAt(t); nothing integrates motion frame to frame",
|
||||
"the tile mask is derived each frame from that frame's camera and the field; it is never carried across frames",
|
||||
"the flow texture is the one owner of motion: warp, hue, motion vectors and the stardust read it",
|
||||
"the TAA history (colour, transmittance, depth) is a cache reprojected by camera and flow; reset on resize, first frame and budget step",
|
||||
"the ground tokens are derived from the readback while the shader runs; the seeds are the fallback; the shader never reads them",
|
||||
"S and dpr are derived from measurement within the session; the sessionStorage token set is a cache"
|
||||
],
|
||||
"forbidden_writers": [
|
||||
"no colour, alpha, position or radius literal in ground.js or any GLSL file; the scene's dimensions (H0, Dw, Rw, F, S, Rl, orbit radius, period, camera offset) are named constants in one place in ground.js, uploaded as uniforms",
|
||||
"no sample placed in a masked cell; no probe in the main pass; no early-out of the pre-pass on transmittance",
|
||||
"no light march in this cut; the light is the analytic pulse",
|
||||
"no read of the ground output tokens by the shader or ground.js except the seed read before the first readback",
|
||||
"no userAgent, platform or maxTouchPoints; no class or attribute set on body or html; no second wash token; no second flow evaluation; no noise or interval function defined in the site's GLSL",
|
||||
"no synchronous readPixels to client memory; one requestAnimationFrame scheduler; localStorage never"
|
||||
],
|
||||
"shared_paths": [
|
||||
"every page paints one canvas and one token set; the ritual essays differ only in seeds and inputs",
|
||||
"slow device, hidden tab, reduced motion, missing WebGL2 and missing half-float all pass through the one controller",
|
||||
"the 8x8 readback and the sun projection are the only paths from the shader to CSS",
|
||||
"the pre-pass and the main pass read the same uniforms from the same worldAt(t); the dense-reference mode differs only by skipping the mask"
|
||||
],
|
||||
"deletion_line": "As r2: the ritual block's own background and the body literals go in the same commit the tokens are declared. Nothing else in the engine or content changes. Against pass 2's design this revision removes the static camera and the everywhere-clouds field; it adds one pre-pass, one mask texture, one depth history channel and the scene function."
|
||||
},
|
||||
"verification": {
|
||||
"builds": [
|
||||
"Deploy Quartz green on the landing commit: gh run list -R GameCult/gamecult-site -w \"Deploy Quartz\" -L 1",
|
||||
"scripts/ground/check-cultmath-glsl.ps1 exits 0 against a CultLib checkout at the pinned commit",
|
||||
"the built stylesheet contains rgb(var(--gamecult-light-0) / calc(var(--gamecult-light-0-a) * var(--gamecult-wash))) verbatim and the ritual :root:has rule"
|
||||
],
|
||||
"tests": [
|
||||
{
|
||||
"name": "browser pane, /?ground=debug: max relative luminance over the canvas outside the sun disc after 32 frames <= 0.035",
|
||||
"pins": "text contrast with the lit cavity."
|
||||
},
|
||||
{
|
||||
"name": "browser pane, /?ground=debug vs /?ground=dense: converged mean luminance within 1/255; steps and snoise per pixel printed for both, debug at most half of dense's steps; mask fraction printed",
|
||||
"pins": "the mask culls only provably empty cells; the shipped march takes at most half the fixed-step steps; the saving on the shipped scene is a number."
|
||||
},
|
||||
{
|
||||
"name": "browser pane, /?ground=debug-mv: |mv| non-zero with the camera moving and flow frozen, and with flow moving and camera frozen; zero under reduced motion",
|
||||
"pins": "both reprojection terms are live and separable."
|
||||
},
|
||||
{
|
||||
"name": "browser pane, / for 40 s: the sun's screen position moves about half an orbit; no history smear at the walls (3x3 clamp); light-0-at on :root follows within 2 s",
|
||||
"pins": "the moving scene converges and the CSS wash follows the sun."
|
||||
},
|
||||
{
|
||||
"name": "browser pane, /?ground=parity: every fixture family within its tolerance class on WebGL2, cultmath_iv_frustum_ball exact; numbers and GPU string in the report",
|
||||
"pins": "GLSL = C# on a device, including the tile ball."
|
||||
},
|
||||
{
|
||||
"name": "browser pane, /?ground=parity-wash and a ritual essay: canvas vs CSS seed wash within 2/255 at 16 points with density 0 and the sun disc off",
|
||||
"pins": "the seeds and the shader's base agree."
|
||||
},
|
||||
{
|
||||
"name": "reduced motion, hidden tab, WebGL2 disabled, sessionStorage cleared: as r2",
|
||||
"pins": "every fallback goes through the controller."
|
||||
},
|
||||
{
|
||||
"name": "git diff base..head --stat: custom.scss, quartz.layout.ts, docs/brand-design-language.md, the new component, static/ground/*, scripts/ground/* only",
|
||||
"pins": "no engine, content or masthead change."
|
||||
}
|
||||
],
|
||||
"negative": [
|
||||
{
|
||||
"pattern": "#[0-9a-fA-F]{6}\\b|rgba?\\(|\\b(255|138|109|183)\\b",
|
||||
"scope": "site/quartz/static/ground/*.js and *.glsl: 0 matches."
|
||||
},
|
||||
{
|
||||
"pattern": "gamecult-ground-|gamecult-light-",
|
||||
"scope": "site/quartz/static/ground/*.glsl: 0 matches; in ground.js only inside the token writer and the seed read."
|
||||
},
|
||||
{
|
||||
"pattern": "userAgent|navigator\\.platform|maxTouchPoints|localStorage|setInterval|classList|setAttribute\\(",
|
||||
"scope": "site/quartz/static/ground/ground.js: 0 matches."
|
||||
},
|
||||
{
|
||||
"pattern": "cultmath_iv_",
|
||||
"scope": "cloud.frag.glsl, taa.frag.glsl, flow.frag.glsl: 0 matches; the bound lives only in prepass.frag.glsl."
|
||||
},
|
||||
{
|
||||
"pattern": "float cultmath_|vec4 cultmath_|float snoise|hash\\(",
|
||||
"scope": "every site .frag.glsl: 0 matches; cultmath_* is defined only in the vendored CultMath.glsl."
|
||||
},
|
||||
{
|
||||
"pattern": "Mozilla|MPL|cultmath_phacelle",
|
||||
"scope": "site/quartz/static/ground/CultMath.glsl: 0 matches; the site vendors the MIT file."
|
||||
},
|
||||
{
|
||||
"pattern": "radial-gradient\\([^)]*rgba?\\([0-9]",
|
||||
"scope": "site/quartz/styles/custom.scss, body rule and ritual block: 0 matches."
|
||||
},
|
||||
{
|
||||
"pattern": "gamecult-titlebar|gamecult-nav-link|sidebar|composite|GameCult-Quartz",
|
||||
"scope": "git diff base..head changed lines: 0 matches."
|
||||
}
|
||||
],
|
||||
"operator": [
|
||||
"open / on the desktop and on the phone; say whether the hollow, the sun's orbit and the camera's height read as the old main menu's mood (uniforms: A, SWIRL, Rl, CARVE, the camera offset)",
|
||||
"on the phone with ?ground=debug, read the logged GPU ms per pass and the controller's final S/dpr; say whether it got warm after five minutes",
|
||||
"say whether the analytic light is moody enough or the self-shadowing sample (desktop only) should be tried",
|
||||
"navigate / -> /Blog -> back; say whether the ground is continuous (Q5)"
|
||||
]
|
||||
},
|
||||
"estimate": {
|
||||
"lines_added": 1250,
|
||||
"lines_removed": 15,
|
||||
"dependencies_added": [],
|
||||
"dependencies_removed": [],
|
||||
"formats_added": [
|
||||
"GLSL ES 3.00 shader files in static/ground",
|
||||
"vendored glsl-parity.json",
|
||||
"one sessionStorage key holding the last sampled token set",
|
||||
"an RGBA32UI tile-mask texture and an R16F depth history (GPU-side only)"
|
||||
],
|
||||
"formats_removed": [],
|
||||
"targets_added": [],
|
||||
"targets_removed": []
|
||||
},
|
||||
"rulings": [
|
||||
"cultmath-tapes:ruling:operator-ground-void-brush",
|
||||
"cultmath-tapes:ruling:operator-void-true-cavity",
|
||||
"cultmath-tapes:ruling:operator-tile-amortized-march",
|
||||
"cultmath-tapes:ruling:operator-analytic-wall-body",
|
||||
"cultmath-tapes:ruling:operator-split-phacelle",
|
||||
"site-masthead:ruling:operator-chromeless-column"
|
||||
],
|
||||
"questions": [
|
||||
"Q4 in the map: palette input tokens (a, recommended)",
|
||||
"Q5 in the map: carry the last sampled wash across page loads (a, recommended)",
|
||||
"Q9 in the map: the one shipped void (a, recommended: Rh 198, S 50, Rc 248, e 0.40); ruled in interval-ops r3's fork, this spec follows it"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
{
|
||||
"campaign": "cultmath-tapes",
|
||||
"cut": "site-ground",
|
||||
"revision": 2,
|
||||
"title": "Site ground: a per-pixel WebGL2 nebula under every page, flow-warped, interval-culled, TAA-reprojected, budget-controlled by measurement; the CSS wash is sampled from the result, seeds fall back",
|
||||
"repo": "GameCult/gamecult-site",
|
||||
"branch": "main",
|
||||
"base": "535d170",
|
||||
"depends_on": [
|
||||
"site-masthead:cut_spec:wash-home-only.r1",
|
||||
"cultmath-tapes:cut_spec:glsl-lowering.r1",
|
||||
"cultmath-tapes:cut_spec:interval-ops.r1"
|
||||
],
|
||||
"first": [
|
||||
"Map: F:/Projects/CultLib/docs/cultmath-interval-ground-cut.md, 'The shader design' (field, culling with the warp, TAA motion-aware, sampled wash, palette input), 'Budget', D4-D8, Q4 and Q5. Rulings in force: site-masthead:ruling:operator-chromeless-column; wash-home-only's authority map (no second wash token, no class toggled by script, no page rule setting a radial alpha). Re-pin base to main after wash-home-only lands; line numbers below are from 535d170; re-read by selector. Stardust and DoF are the next cut: leave the resolve pass with a named, empty composite slot for them (a uniform-gated branch is not needed; a comment and a function stub are).",
|
||||
"Aetheria reference, read once, copy nothing: Volumetric.cginc:165-221 (two-phase flow-map warp, tri2 cross-fade), :130-135 (globalFlow); Raymarching/CloudShader.shader:83-104 (integration) and :261-314 (what not to do: gamma 0.5 clip, fixed 0.05 blend, packed depth, the density2 bug at :311).",
|
||||
"Engine facts to confirm before writing: how GameCult-Quartz bundles a component's afterDOMLoaded and whether a dynamic import() of /static/ground/ground.js is allowed, else emit a module script tag; site/quartz/static/** served at /static/**; enableSPA false (quartz.config.ts:15). Local build: scripts/quartz/quartz.ps1 dev.",
|
||||
"Tokens. Inputs (Q4 a), on body in custom.scss beside --gamecult-wash: --gamecult-nebula-0/1/2 (\"255 138 42\", \"109 96 255\", \"89 183 255\") and --gamecult-nebula-base (#07111a). Outputs with seeds, on :root: --gamecult-ground-0/1/2 (#03070d #07111a #09141f), and for N in 0..2 --gamecult-light-N (rgb triple), -a (0.18/0.16/0.14), -at (\"78% 14%\"/\"18% 10%\"/\"52% 0%\"), -r (18%/24%/28%), plus --gamecult-ground-mean (#07111a). The body background becomes radial-gradient(circle at var(--gamecult-light-0-at), rgb(var(--gamecult-light-0) / calc(var(--gamecult-light-0-a) * var(--gamecult-wash))), transparent var(--gamecult-light-0-r)) x3 over linear-gradient(180deg, var(--gamecult-ground-0) 0%, var(--gamecult-ground-1) 44%, var(--gamecult-ground-2) 100%).",
|
||||
"Ritual essays: their seeds go under :root:has(body[data-slug=\"Blog/...\"]) for the three slugs (yellow 255 178 30 .15 at 78% 8% 24rem; cobalt 49 88 255 .22 at 18% 4% 30rem; violet 91 54 184 .19 at 44% 20% 34rem; stops #030414 0%, #070b24 42%, #040514 100%; nebula inputs likewise overridden to yellow/cobalt/violet and base #050614). The ritual block's own background declaration is deleted; it inherits body's.",
|
||||
"ground.js owns: WebGL2 context on #gamecult-ground (alpha:false, antialias:false, powerPreference 'low-power'); capability check (WebGL2, EXT_color_buffer_half_float or float); input token read via getComputedStyle(document.body) -> uniforms; the flow texture pass (quarter res, RG16F, two snoise per texel, once per frame); the march pass; the TAA resolve with reprojection; the 8x8 downsample and the async PBO readback; the token writer on document.documentElement.style; the scheduler (30 fps cadence, visibilityState pause, reduced-motion freeze and stop at 32 frames); the budget controller; the debug flag (?ground=debug) and parity flags.",
|
||||
"Sampled wash: after the resolve, draw the final frame into an 8x8 RGBA8 target; gl.readPixels into a PIXEL_PACK_BUFFER; gl.fenceSync; on later frames poll getSyncParameter(SYNC_STATUS) and only when SIGNALED call getBufferSubData. Never readPixels to client memory directly (that stalls). At most 2 Hz. Compute the token values as the map states (row means for the stops; light colour and excess at the seed light's centre texel; the mean for --gamecult-ground-mean) and write them with setProperty on :root only when a value moved by more than 1/255. Q5 (a): keep the last written set in sessionStorage under one key and apply it on load before the first frame; it is a cache, and a missing or malformed entry means the seeds.",
|
||||
"Budget controller, one function, one state machine: GPU time per frame by EXT_disjoint_timer_query_webgl2 when present, else a fenceSync after the resolve polled to completion, else rAF cadence. Over a 60-frame window: if the median exceeds the budget (4.0 ms), step down: S 16 -> 8 -> 4, then dpr -> 1 (Q2), then remove the canvas (the seeds paint). Never step up within a session. A median rising 50% over 5 minutes also steps down. No storage of the verdict, no userAgent, no maxTouchPoints.",
|
||||
"cloud.frag.glsl: #version 300 es; precision highp float; precision highp int; the vendored CultMath.glsl is concatenated by ground.js before this file's body. The field, warp, interval march (ball radius F0 * (Δ/2 + D) with D = FLOW_MAX * PERIOD / 2) and integration exactly as the map's pseudocode; hue from the flow texture's channels mixed over the three nebula inputs; uniforms: resolution, time, dt, frame, jitter, S, intensity, nebula colours, base, Z0/Z1, F0, CUTOFF, K, PERIOD, FLOW_MAX. flow.frag.glsl: the two low-frequency snoise. taa.frag.glsl: motion vector from the flow texture (mv = project(-flow * dt)), history fetched at uv - mv, 3x3 min/max clamp, alpha = max(1/frames, 1/32), out-of-bounds alpha 1; writes new history and the screen (luminance clamp 0.035) via drawBuffers or two draws.",
|
||||
"Vendoring (D3): site/quartz/static/ground/CultMath.glsl byte-equal to CultLib shaders/CultMath.glsl at the commit named in its first line. scripts/ground/check-cultmath-glsl.ps1 diffs it against git show <sha>:packages/cultmath/shaders/CultMath.glsl from a CultLib checkout and exits non-zero on drift.",
|
||||
"Parity on the device: with ?ground=parity, ground.js evaluates the vendored fixture (CultLib tests/CultMath.Tests/fixtures/glsl-parity.json) one pixel per point, reads back float bit patterns, and reports per function the max ulp distance and the count outside its tolerance class. With ?ground=parity-wash, density and stardust are forced to 0 and the flow frozen; the canvas must equal the CSS seed wash within 2/255 at 16 points on / and on a ritual essay (this pins that the seed values and the shader's base agree at zero density, which is what the fallback promises).",
|
||||
"Prove in the browser pane: max relative luminance after 32 frames on / at intensity 1 (<= 0.035); the parity-wash probe; the debug readback (mean evals per pixel, unfinished-ray fraction < 1%); the token writer (after 2 s on /, :root's inline style carries all fifteen tokens and --gamecult-ground-mean, each within the expected range, and getComputedStyle(body).backgroundImage resolves to the sampled values); the motion test (with ?ground=debug-mv the resolve writes |mv| into red: non-zero where flow is non-zero, zero under reduced motion); GPU ms per pass with the timer query at the pane's desktop size and at the mobile preset's pixel count. Phone GPU time is operator verification."
|
||||
],
|
||||
"deletes": [
|
||||
{
|
||||
"path": "site/quartz/styles/custom.scss",
|
||||
"lines": 6,
|
||||
"note": "ritual body block 119-124: its own background (three radials and the linear gradient) goes; its seeds live under :root:has(body[data-slug]) and it inherits body's ground."
|
||||
},
|
||||
{
|
||||
"path": "site/quartz/styles/custom.scss",
|
||||
"lines": 9,
|
||||
"note": "body 10-27: the six literal colours and three literal stops leave as literals (rewritten as token references in the same lines; counted 9 removed, 9 added)."
|
||||
}
|
||||
],
|
||||
"keeps_moves": [
|
||||
"--gamecult-wash and the index override from wash-home-only: untouched; the shader reads it as intensity and the CSS wash still multiplies by it.",
|
||||
".page::before (the grid overlay), the tour/Pitch VN ground, the Thing deck's .hero-bg, the masthead, the engine: untouched.",
|
||||
"The CSS wash stays the ground when the canvas is absent, not yet drawn, or removed by the controller; it renders the sampled tokens when there are any and the seeds otherwise.",
|
||||
"docs/brand-design-language.md 'The ground is a wash, not a flat fill' (69-84): rewritten: the ground is the shader; the CSS wash is sampled from it; the seeds are today's literal wash; the four nebula inputs are where the palette is tuned."
|
||||
],
|
||||
"adds": [
|
||||
"site/quartz/components/GameCultGround.tsx: renders <canvas id=\"gamecult-ground\" aria-hidden=\"true\"></canvas>, mounts /static/ground/ground.js after DOM load; in sharedPageComponents.header after GameCultThemeLock (quartz.layout.ts:19). Its beforeDOMLoaded script applies the sessionStorage token cache to :root if present (Q5 a), so the first paint matches the last sample.",
|
||||
"site/quartz/static/ground/ground.js (~400 lines, no dependencies, no build step): context, tokens in and out, flow/march/resolve/downsample passes, PBO readback, scheduler, controller, debug and parity modes, a named composite slot for the stardust cut.",
|
||||
"site/quartz/static/ground/flow.frag.glsl, cloud.frag.glsl, taa.frag.glsl, downsample.frag.glsl; a shared 3-vertex full-screen vertex shader inline in ground.js.",
|
||||
"site/quartz/static/ground/CultMath.glsl and glsl-parity.json, vendored from CultLib with the pin in the header.",
|
||||
"scripts/ground/check-cultmath-glsl.ps1: the vendoring drift check.",
|
||||
"custom.scss: the four nebula input tokens on body; the fifteen ground seeds plus --gamecult-ground-mean on :root with the ritual overrides under :root:has(body[data-slug]); #gamecult-ground { position: fixed; inset: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none; }.",
|
||||
"docs/brand-design-language.md: the wash section rewritten as above; one paragraph on the ground shader: what it reads (inputs), what it writes (outputs), what it may not hold, and the fallback."
|
||||
],
|
||||
"file_changes": [
|
||||
{
|
||||
"location": { "path": "site/quartz/styles/custom.scss", "line": 4, "end_line": 8 },
|
||||
"change": ":root block: the fifteen ground seeds and --gamecult-ground-mean declared beside color-scheme."
|
||||
},
|
||||
{
|
||||
"location": { "path": "site/quartz/styles/custom.scss", "line": 10, "end_line": 31 },
|
||||
"change": "body: four nebula input tokens declared; radials and the linear gradient reference the ground tokens; alphas keep the wash-home-only calc."
|
||||
},
|
||||
{
|
||||
"location": { "path": "site/quartz/styles/custom.scss", "line": 103, "end_line": 124 },
|
||||
"change": "ritual block: background declaration deleted; a :root:has(body[data-slug]) rule carries its seeds and nebula inputs."
|
||||
},
|
||||
{
|
||||
"location": { "path": "site/quartz.layout.ts", "line": 19 },
|
||||
"change": "header: [GameCultThemeLock(), GameCultGround(), GameCultMasthead()]."
|
||||
},
|
||||
{
|
||||
"location": { "path": "docs/brand-design-language.md", "line": 69, "end_line": 84 },
|
||||
"change": "wash section rewritten: shader-owned ground, sampled wash, seeds, nebula inputs."
|
||||
}
|
||||
],
|
||||
"authority_map": {
|
||||
"owner": "The shader owns the ground. custom.scss owns the four nebula inputs, --gamecult-wash and the seed values; ground.js owns the flow texture, the march, the TAA, the readback and the sampled ground tokens on :root; CultLib owns every noise and interval function the field calls.",
|
||||
"inputs": [
|
||||
"the four nebula tokens and --gamecult-wash (computed on body)",
|
||||
"prefers-reduced-motion, visibilityState, canvas size, devicePixelRatio, elapsed time",
|
||||
"measured GPU frame time (timer query, fence, or rAF cadence)",
|
||||
"the shader's own 8x8 readback",
|
||||
"the vendored CultMath.glsl at its pinned CultLib commit"
|
||||
],
|
||||
"outputs": [
|
||||
"one opaque moving canvas under the page, luminance <= 0.035",
|
||||
"the fifteen ground tokens and --gamecult-ground-mean on :root inline, at most 2 Hz",
|
||||
"debug and parity readbacks (console, flag only)"
|
||||
],
|
||||
"derived_state": [
|
||||
"the ground tokens are derived from the shader's result while it runs; the seeds are the fallback; the CSS wash renders whichever is in force; the shader never reads them",
|
||||
"the flow texture is the one owner of motion: warp, hue, motion vectors (and the stardust, next cut) read it",
|
||||
"the TAA history is a cache, reprojected by the flow; reset on resize, first frame and budget step",
|
||||
"S and dpr are derived from measurement within the session; never stored",
|
||||
"the sessionStorage token set is a cache of the last sample; absent or malformed means seeds",
|
||||
"CultMath.glsl in the site is derived from CultLib at the pinned commit"
|
||||
],
|
||||
"forbidden_writers": [
|
||||
"no colour, alpha, position or radius literal in ground.js or any GLSL file",
|
||||
"no read of --gamecult-ground-* or --gamecult-light-* by the shader or by ground.js, except the seed read before the first readback",
|
||||
"no navigator.userAgent, platform, maxTouchPoints, or any device-name branch",
|
||||
"no class or attribute set on body or html by ground.js; token writes are setProperty on :root only",
|
||||
"no second wash token; no page rule setting a radial alpha; no second flow evaluation outside flow.frag.glsl",
|
||||
"no noise or interval function defined in the site's GLSL; only cultmath_* calls",
|
||||
"no synchronous readPixels to client memory; no setInterval; one requestAnimationFrame scheduler; localStorage never"
|
||||
],
|
||||
"shared_paths": [
|
||||
"home, every content page and the ritual essays paint one canvas and one token set, in CSS and in the shader",
|
||||
"slow device, hidden tab, reduced motion, missing WebGL2 and missing half-float all pass through the one controller",
|
||||
"the 8x8 readback is the only path from the shader to CSS; the beforeDOMLoaded cache apply and the live writer set the same tokens the same way",
|
||||
"the same Deploy Quartz workflow; static files ship with the overlay"
|
||||
],
|
||||
"deletion_line": "The ritual block's own background and the nine body literals go in the same commit the tokens are declared. The canvas, four shaders, ground.js and the vendored library are the whole addition; nothing else in the engine or the content changes."
|
||||
},
|
||||
"verification": {
|
||||
"builds": [
|
||||
"Deploy Quartz green on the landing commit: gh run list -R GameCult/gamecult-site -w \"Deploy Quartz\" -L 1",
|
||||
"scripts/ground/check-cultmath-glsl.ps1 exits 0 against a CultLib checkout at the pinned commit",
|
||||
"the built stylesheet contains rgb(var(--gamecult-light-0) / calc(var(--gamecult-light-0-a) * var(--gamecult-wash))) verbatim and a :root:has(body[data-slug=\"Blog/small-ritual-of-reach\"]) rule"
|
||||
],
|
||||
"tests": [
|
||||
{
|
||||
"name": "browser pane, /?ground=debug: max relative luminance over the canvas after 32 frames <= 0.035",
|
||||
"pins": "text contrast: body text keeps >= 7:1 on every ground pixel."
|
||||
},
|
||||
{
|
||||
"name": "browser pane, /?ground=parity-wash and a ritual essay: canvas vs CSS seed wash within 2/255 at 16 points with density 0 and flow frozen",
|
||||
"pins": "the seeds and the shader's base agree; the fallback promise holds."
|
||||
},
|
||||
{
|
||||
"name": "browser pane, / after 2 s: :root inline style carries the sixteen tokens; body's computed backgroundImage resolves to the sampled values; a read 1 s later differs only where the frame changed",
|
||||
"pins": "the CSS wash is sampled from the shader, at most 2 Hz, without a stall (no readPixels-to-client in the profiler)."
|
||||
},
|
||||
{
|
||||
"name": "browser pane, /?ground=parity: every fixture function within its tolerance class on WebGL2; numbers and GPU string in the report",
|
||||
"pins": "GLSL = C# on a device; the proving ground's third runtime."
|
||||
},
|
||||
{
|
||||
"name": "browser pane, /?ground=debug at desktop and the mobile preset: mean evals per pixel, unfinished rays < 1%, GPU ms per pass; ?ground=debug-mv shows motion where flow is non-zero",
|
||||
"pins": "the step budget, the interval saving with the warp, and the reprojection on the shipped field."
|
||||
},
|
||||
{
|
||||
"name": "reduced motion emulated: frames stop at 32, mv is 0, time frozen; hidden tab: no frames; WebGL2 disabled: no canvas in the DOM and the seed wash paints; sessionStorage cleared: seeds paint first",
|
||||
"pins": "every fallback goes through the controller and lands on the same CSS ground."
|
||||
},
|
||||
{
|
||||
"name": "git diff base..head --stat: custom.scss, quartz.layout.ts, docs/brand-design-language.md, the new component, static/ground/*, scripts/ground/* only",
|
||||
"pins": "no engine, content or masthead change."
|
||||
}
|
||||
],
|
||||
"negative": [
|
||||
{
|
||||
"pattern": "#[0-9a-fA-F]{6}\\b|rgba?\\(|\\b(255|138|109|183)\\b",
|
||||
"scope": "site/quartz/static/ground/*.js and *.glsl: 0 matches."
|
||||
},
|
||||
{
|
||||
"pattern": "gamecult-ground-|gamecult-light-",
|
||||
"scope": "site/quartz/static/ground/*.glsl: 0 matches; in ground.js only inside the token writer and the seed read, never in a uniform upload."
|
||||
},
|
||||
{
|
||||
"pattern": "userAgent|navigator\\.platform|maxTouchPoints|localStorage|setInterval|classList|setAttribute\\(",
|
||||
"scope": "site/quartz/static/ground/ground.js: 0 matches."
|
||||
},
|
||||
{
|
||||
"pattern": "readPixels\\([^)]*(Uint8Array|Float32Array|Uint16Array)",
|
||||
"scope": "ground.js: 0 matches outside the parity and debug readbacks, which are flag-gated and named."
|
||||
},
|
||||
{
|
||||
"pattern": "float cultmath_|vec4 cultmath_|float snoise|hash\\(",
|
||||
"scope": "flow/cloud/taa/downsample.frag.glsl: 0 matches; cultmath_* is defined only in the vendored CultMath.glsl."
|
||||
},
|
||||
{
|
||||
"pattern": "radial-gradient\\([^)]*rgba?\\([0-9]",
|
||||
"scope": "site/quartz/styles/custom.scss, body rule and ritual block: 0 matches."
|
||||
},
|
||||
{
|
||||
"pattern": "gamecult-titlebar|gamecult-nav-link|sidebar|composite|GameCult-Quartz",
|
||||
"scope": "git diff base..head changed lines: 0 matches."
|
||||
}
|
||||
],
|
||||
"operator": [
|
||||
"open / on the desktop and on the phone; say whether the nebula's motion and intensity are right (uniforms: PERIOD, FLOW_MAX, intensity)",
|
||||
"on the phone with ?ground=debug, read the logged GPU ms and the controller's final S/dpr; say whether it got warm after five minutes",
|
||||
"navigate / -> /Blog -> back; say whether the ground is continuous or shows a seed flash (Q5)",
|
||||
"open a ritual essay and confirm its ground still reads as its own paper"
|
||||
]
|
||||
},
|
||||
"estimate": {
|
||||
"lines_added": 980,
|
||||
"lines_removed": 15,
|
||||
"dependencies_added": [],
|
||||
"dependencies_removed": [],
|
||||
"formats_added": ["GLSL ES 3.00 shader files in static/ground", "vendored glsl-parity.json", "one sessionStorage key holding the last sampled token set"],
|
||||
"formats_removed": [],
|
||||
"targets_added": [],
|
||||
"targets_removed": []
|
||||
},
|
||||
"rulings": [
|
||||
"site-masthead:ruling:operator-chromeless-column"
|
||||
],
|
||||
"questions": [
|
||||
"Q4 in the map: palette input tokens (a, recommended)",
|
||||
"Q5 in the map: carry the last sampled wash across page loads (a, recommended)"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
{
|
||||
"campaign": "cultmath-tapes",
|
||||
"cut": "site-stardust",
|
||||
"revision": 1,
|
||||
"title": "Site stardust: stateless particles advected by the ground's flow texture, rendered to a half-res layer, blurred by the clouds' accumulated opacity (raymarch-driven DoF), composited behind the nebula",
|
||||
"repo": "GameCult/gamecult-site",
|
||||
"branch": "main",
|
||||
"base": "535d170",
|
||||
"depends_on": [
|
||||
"cultmath-tapes:cut_spec:site-ground.r2"
|
||||
],
|
||||
"first": [
|
||||
"Map: F:/Projects/CultLib/docs/cultmath-interval-ground-cut.md, 'Stardust', 'Depth of field driven by the raymarch', 'Budget', D4, D9, D10. Re-pin base to main after site-ground lands. The composite slot site-ground left in the resolve is where this cut plugs in; do not restructure the resolve.",
|
||||
"Aetheria reference, read once, copy nothing: Compute/Stardust/Stardust.compute:118-147 (grid cell -> hashed spawn, lifetime = frac(time/period + hash), position -= flow * lifetime * period, size = parabola(lifetime, 2), colour from a hue ramp x nebula tint); Stardust.shader:49-86 (billboard from quadPoints by SV_VertexID/SV_InstanceID, powerPulse falloff, distance exponents); CustomDoF/DepthOfField.hlsl:29-32 (CoC read from a raymarch output), :150-201 (disk gather weighted by CoC), :217-242 (combine). The web version is far smaller: no distance exponents (the slab is thin), no shadow pass, no near/far split.",
|
||||
"Particles are stateless: no buffers are updated. One static 6-vertex quad VBO; drawArraysInstanced with count N; the vertex shader derives everything from gl_InstanceID, time and the flow texture (vertex texture fetch is standard in WebGL2): cell = (id mod G, id / G) over the slab mid-plane; hashes via cultmath_pcg3d (the vendored CultMath.glsl is concatenated before the vertex source too) for spawn jitter, lifetime offset, size and brightness; lifetime = fract(t / PERIOD_P + offset); position = spawn - flow(spawn uv) * lifetime * PERIOD_P; size = base * parabola(lifetime, 2); colour = hue at the screen position (from the flow texture's channels, the same mix as the clouds) * brightness. Reduced motion freezes t as site-ground does.",
|
||||
"Stardust layer: half-resolution RGBA16F target, cleared each frame, additive blend, powerPulse falloff in the fragment shader; N = 16384 desktop, 4096 phone; the controller scales N with S (N halves when S halves) and the layer follows dpr.",
|
||||
"DoF: in the resolve's composite slot, read opacity = 1 - T from the reprojected, clamped history (not the current frame) and gather the stardust layer with an 8-tap Poisson disk of radius RMAX * opacity in layer texels (RMAX ~ 12 px at dpr 1, scaled by dpr); weight taps by a soft disk; composite out = ground + clouds + stardust_blurred * T, then the luminance clamp. All stardust is behind the whole cloud layer (D9).",
|
||||
"Debug flags: ?ground=debug-stardust writes the stardust layer alone; ?ground=debug-coc writes opacity into red. Prove in the browser pane: the stardust and the clouds move together (with ?ground=debug-mv from site-ground and the particle velocity written in green under ?ground=debug-stardust-v, the two directions agree within 5 degrees at 64 sampled pixels where |flow| is above a threshold); luminance clamp still holds after the composite; GPU ms per pass (stardust layer, gather) with the timer query at desktop and at the mobile preset's pixel count; the full-stack total against the budget table; the unfinished-ray fraction unchanged by this cut.",
|
||||
"Budget contract (map, 'Budget'): the stardust layer plus the gather cost no more than a quarter of the march at the same S; if the measurement says otherwise, the gather drops to 4 taps before N drops, and the report says which. Stardust is never cut before the clouds' S on the ladder."
|
||||
],
|
||||
"deletes": [],
|
||||
"keeps_moves": [
|
||||
"site-ground's flow texture, march, TAA, readback and token writer: untouched; the stardust reads the flow texture and the hue mix the same way the clouds do.",
|
||||
"The sampled wash now includes the stardust in its 8x8 readback by construction (it samples the final frame); nothing changes in the writer.",
|
||||
"The budget controller gains one scaled quantity (N) and no new state; the ladder order is unchanged."
|
||||
],
|
||||
"adds": [
|
||||
"site/quartz/static/ground/stardust.vert.glsl and stardust.frag.glsl: the instanced billboard and the powerPulse sprite.",
|
||||
"ground.js: the stardust pass (one VBO, one program, drawArraysInstanced), the half-res layer, N in the controller, the gather in the resolve's composite slot, the two debug flags.",
|
||||
"taa.frag.glsl: the composite slot filled: opacity from history, the 8-tap gather, out = ground + clouds + stardust_blurred * T.",
|
||||
"docs/brand-design-language.md: two sentences under the ground shader paragraph: the stardust and the DoF, and that they read the same flow and hue as the nebula."
|
||||
],
|
||||
"file_changes": [
|
||||
{
|
||||
"location": { "path": "site/quartz/static/ground/ground.js", "line": 1 },
|
||||
"change": "stardust pass, layer, N scaling, gather hookup, debug flags; line numbers are site-ground's and are re-read by function name."
|
||||
},
|
||||
{
|
||||
"location": { "path": "site/quartz/static/ground/taa.frag.glsl", "line": 1 },
|
||||
"change": "the named composite slot becomes the opacity-weighted gather and the final composite."
|
||||
},
|
||||
{
|
||||
"location": { "path": "docs/brand-design-language.md", "line": 69 },
|
||||
"change": "two sentences on stardust and DoF under the ground shader paragraph (post site-ground numbering)."
|
||||
}
|
||||
],
|
||||
"authority_map": {
|
||||
"owner": "ground.js owns the stardust pass and the DoF gather; the flow texture (site-ground) owns the particles' motion; the TAA history owns the opacity the DoF reads; the nebula input tokens own the particles' hue through the same mix as the clouds.",
|
||||
"inputs": [
|
||||
"the flow texture and the hue mix (site-ground)",
|
||||
"the reprojected, clamped history's transmittance",
|
||||
"time, N and dpr from the controller; gl_InstanceID"
|
||||
],
|
||||
"outputs": [
|
||||
"a half-res stardust layer per frame",
|
||||
"the composite with stardust blurred by cloud opacity behind the nebula, still under the luminance clamp"
|
||||
],
|
||||
"derived_state": [
|
||||
"every particle's position, lifetime, size and colour are derived from gl_InstanceID, time and the flow texture each frame; nothing is stored or updated",
|
||||
"N is derived from S by the controller; the layer size from dpr",
|
||||
"the blur radius is derived from the accumulated opacity; there is no second CoC source"
|
||||
],
|
||||
"forbidden_writers": [
|
||||
"no particle buffer update, transform feedback or CPU-side particle state",
|
||||
"no second flow function or hue mix; the vertex shader samples the flow texture",
|
||||
"no colour literal; no per-depth transmittance; no second march",
|
||||
"no new controller state beyond N; the ladder order is not changed",
|
||||
"no change to the readback or the token writer"
|
||||
],
|
||||
"shared_paths": [
|
||||
"clouds and stardust read one flow texture and one hue mix, so they move and colour as one",
|
||||
"the one resolve pass composites ground, clouds and stardust and applies the one luminance clamp",
|
||||
"the one controller scales S, N and dpr together"
|
||||
],
|
||||
"deletion_line": "Nothing is deleted. The addition is two small shaders, one pass and one gather; it buys the stardust and the DoF the operator named, and it must prove it fits the same budget."
|
||||
},
|
||||
"verification": {
|
||||
"builds": [
|
||||
"Deploy Quartz green on the landing commit",
|
||||
"scripts/ground/check-cultmath-glsl.ps1 still exits 0"
|
||||
],
|
||||
"tests": [
|
||||
{
|
||||
"name": "browser pane, /?ground=debug-stardust-v with ?ground=debug-mv: particle velocity and cloud motion vectors agree within 5 degrees at 64 sampled pixels where |flow| exceeds the threshold",
|
||||
"pins": "one flow: the stardust and the nebula move as one."
|
||||
},
|
||||
{
|
||||
"name": "browser pane, /?ground=debug-coc: opacity is 0 where the clouds are empty and rises inside them; the stardust gather radius follows it (a visible blur under a dense cloud, sharp in a gap)",
|
||||
"pins": "the raymarch drives the DoF, as in Aetheria's hack."
|
||||
},
|
||||
{
|
||||
"name": "browser pane, /?ground=debug: max relative luminance after 32 frames <= 0.035 with stardust on",
|
||||
"pins": "the clamp holds after the composite."
|
||||
},
|
||||
{
|
||||
"name": "browser pane, timer query at desktop and at the mobile preset: stardust layer + gather <= 25% of the march at the same S; full-stack totals quoted against the budget table",
|
||||
"pins": "the stardust fits the same budget or the report says what gave."
|
||||
},
|
||||
{
|
||||
"name": "reduced motion: particles frozen, frames stop at 32; hidden tab: no frames",
|
||||
"pins": "the stardust obeys the one controller."
|
||||
}
|
||||
],
|
||||
"negative": [
|
||||
{
|
||||
"pattern": "bufferSubData|transformFeedback|TRANSFORM_FEEDBACK|new Float32Array\\((N|count|particles)",
|
||||
"scope": "ground.js stardust section: 0 matches; particles are stateless."
|
||||
},
|
||||
{
|
||||
"pattern": "#[0-9a-fA-F]{6}\\b|rgba?\\(",
|
||||
"scope": "stardust.vert.glsl, stardust.frag.glsl, ground.js: 0 matches."
|
||||
},
|
||||
{
|
||||
"pattern": "snoise\\(|float cultmath_",
|
||||
"scope": "stardust.vert.glsl and stardust.frag.glsl: 0 matches; the flow and hue come from the texture, not from a second evaluation."
|
||||
},
|
||||
{
|
||||
"pattern": "readPixels|setProperty",
|
||||
"scope": "git diff base..head changed lines in ground.js: 0 matches; the readback and the token writer are not touched."
|
||||
}
|
||||
],
|
||||
"operator": [
|
||||
"open / on the desktop and on the phone; say whether the stardust sells the motion and whether the blur under the clouds reads like the video (RMAX, N, PERIOD_P are the dials)",
|
||||
"on the phone with ?ground=debug, read the full-stack GPU ms and the final S/N/dpr; say whether it got warm"
|
||||
]
|
||||
},
|
||||
"estimate": {
|
||||
"lines_added": 320,
|
||||
"lines_removed": 4,
|
||||
"dependencies_added": [],
|
||||
"dependencies_removed": [],
|
||||
"formats_added": [],
|
||||
"formats_removed": [],
|
||||
"targets_added": [],
|
||||
"targets_removed": []
|
||||
},
|
||||
"rulings": [
|
||||
"site-masthead:ruling:operator-chromeless-column"
|
||||
],
|
||||
"questions": []
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,117 +0,0 @@
|
|||
# CultMath tapes: interval-pruned field evaluation on CPU and GPU
|
||||
|
||||
Status: **parked**, 2026-10-02. The operator wants Asura working first. This
|
||||
note records the direction so it can be picked up without re-deriving it. It is
|
||||
not a cut map: no Imagination pass has run, and nothing here is ruled except
|
||||
that the campaign exists and waits.
|
||||
|
||||
## Intent
|
||||
|
||||
Evaluate large implicit fields, meaning deeply nested CSG trees of brushes with
|
||||
thousands of nodes, cheaply enough to mesh at many levels of detail and to
|
||||
raymarch. The operator's words (2026-10-02): "I would love to design a space
|
||||
station out of a grammar of nested brushes, and have the ability to
|
||||
automatically mesh it at multiple levels of detail so I can get arbitrarily
|
||||
close to its tiny details without melting my GPU."
|
||||
|
||||
GameCult has no deep CSG trees today because nothing could evaluate them
|
||||
cheaply. The capability comes first, and the content follows.
|
||||
|
||||
## Prior art
|
||||
|
||||
[Fidget](https://www.mattkeeter.com/projects/fidget/) by Matt Keeter
|
||||
(MPL-2.0, Rust, v0.5.1 on 2026-09-27; repo `mkeeter/fidget`).
|
||||
- It compiles an expression to a register-allocated bytecode tape.
|
||||
- It evaluates that tape as points, SIMD batches, intervals, and forward-mode
|
||||
gradients.
|
||||
- The central trick is interval evaluation over a region. When it proves that
|
||||
one operand of a `min` or `max` always wins, the tape for that region is
|
||||
rewritten without the other branch. In the project's example, the tape
|
||||
shrinks from 254 clauses to about 20 at pixel level.
|
||||
- `fidget-jit` targets x86-64 and AArch64.
|
||||
- `fidget-wgpu` has a WGSL tape interpreter, interval ops and tape
|
||||
simplification on the GPU (`interval_ops.wgsl`, `tape_interpreter.wgsl`,
|
||||
`tape_simplify.wgsl`).
|
||||
- `fidget-mesh` implements Manifold Dual Contouring. It is known to be weak on
|
||||
thin features.
|
||||
- Halfspace is the IDE codesigned with `fidget-wgpu` (Rust, egui and wgpu,
|
||||
MPL-2.0).
|
||||
|
||||
## Chosen shape (option c)
|
||||
|
||||
The ideas are ported into CultLib and owned here. Fidget is not a dependency.
|
||||
Unity renders HLSL on its own graphics backend, so `fidget-wgpu` cannot run
|
||||
inside it.
|
||||
|
||||
Three options were weighed on 2026-10-02:
|
||||
|
||||
| Option | What it is | Trade |
|
||||
|---|---|---|
|
||||
| a | Fidget as a Rust native plugin in Unity | Fastest demo; costs per-platform native builds, an FFI boundary, and an upstream that warns of breaking changes |
|
||||
| b | Offline bake with a Rust tool; ship meshes | Simplest; the baked depth caps how close a viewer can get |
|
||||
| c | CultLib owns tape, interval and pruning in C# and HLSL | Most coherent end state; the largest code liability we would own |
|
||||
|
||||
The operator parked (c). An (a) spike to validate authoring and LOD behaviour
|
||||
before owning code was suggested but not ruled.
|
||||
|
||||
Sketch, to be replaced by the target pass:
|
||||
|
||||
1. **CultMath `interval`** in C# and HLSL with parity tests:
|
||||
- arithmetic, `min`/`max`, `smin`, `sqrt`, `clamp` and `smoothstep`;
|
||||
- interval or Lipschitz bounds for every CultMath noise primitive.
|
||||
|
||||
This step needs no tape. It stands alone, and is the first cut to take even
|
||||
if the rest stays parked.
|
||||
2. **Tape**: an expression graph lowered to bytecode, with point, interval and
|
||||
gradient evaluators in C#. Gradients reuse CultMath's analytic-gradient
|
||||
primitives, which Asura cut 2a built.
|
||||
3. **Pruning**: per-region tape simplification driven by interval results.
|
||||
4. **HLSL interpreter** for the same bytecode, with interval ops and pruning.
|
||||
Fidget's MPL-2.0 WGSL is the reference. MPL code keeps its own MPL-headed
|
||||
files, as Phacelle does.
|
||||
5. **Meshing**: an octree of chunks with seams stitched between LOD levels.
|
||||
- The default is surface nets (GameCult.Geometry, Asura Cut 2) plus
|
||||
Asura's tile pass: Newton refinement onto `f = 0`, then crease snapping
|
||||
from field gradients (Asura Cut 6c).
|
||||
- The operator's lean (2026-10-02) is that gradient work beats dual
|
||||
contouring here. Thin authored bevels can be shaded by their analytic
|
||||
normals, and the refined positions can be iterated further.
|
||||
- Dual contouring is not planned. The tile pass on station brushes is its
|
||||
test.
|
||||
|
||||
## Truth and lowerings
|
||||
|
||||
The field is data: a typed CultCache document holding the expanded tree. A
|
||||
generator, such as a brush grammar, expands into that document, and everything
|
||||
downstream caches it. The CPU evaluator, the HLSL interpreter, the mesher and
|
||||
collision are all lowerings of that one document. This is how "one
|
||||
implementation of the field" survives having both CPU and GPU evaluators.
|
||||
Asura's invariant 2 had to forbid CPU evaluation because its field is HLSL
|
||||
source.
|
||||
|
||||
## Known consumers
|
||||
|
||||
- **Aetheria volumetric raymarching** (clouds, nebulae). Step 1 alone gives it
|
||||
empty-space skipping. Coverage and shape terms carry the culling, because
|
||||
detail octaves only bound to ±amplitude.
|
||||
- Prerequisite: Aetheria's shaders must use CultMath noise, not their own
|
||||
copies. That is already owed at Aetheria's CultMath pin bump (see the Asura
|
||||
cut map, snoise release follow-ups).
|
||||
- **Space stations from a brush grammar**, meshed in LOD chunks around the
|
||||
camera. This is a separate project with its own target. This campaign
|
||||
supplies its evaluator.
|
||||
- Asura does not need this campaign. Its field is noise-dominated, and its
|
||||
`A_max` shell and band-limiting already give the cheap culling.
|
||||
|
||||
## Open before unparking
|
||||
|
||||
- Run the (a) spike first, or go straight to (c)?
|
||||
- Pick the interval representation (plain intervals or affine arithmetic) and
|
||||
the noise bound strategy.
|
||||
Affine forms for noise and warp were unparked on 2026-10-03 (ruling
|
||||
`cultmath-tapes:ruling:affine-forms-unparked`): one shared symbol, mapped in
|
||||
`docs/cultmath-interval-ground-cut.md`, "Pass 5".
|
||||
- Choose the bytecode register model for the HLSL interpreter, given GPU
|
||||
register pressure.
|
||||
- Decide whether the brush-grammar station project lives in Aetheria or in
|
||||
its own repo.
|
||||
|
|
@ -1,663 +0,0 @@
|
|||
# CultNet error contract: cut map
|
||||
|
||||
Status: cut map, not started. Imagination pass 1, 2026-09-30, against CultLib `main` at `8fc74c70`.
|
||||
This document owns the means. There is no separate target doc: section 1 holds the ends until the
|
||||
operator rules on section 3. After that, Self moves the rulings and invariants into
|
||||
`contracts/cultnet/cultnet-distributed-database.md` ("Error Codes") and the schema's own description.
|
||||
|
||||
Open: operator questions Q1-Q8 (section 3). No cut is dispatched until Q1, Q2, Q3 and Q8 are ruled. Q4-Q7
|
||||
change only single emit sites and can be ruled during Cut 1.
|
||||
|
||||
Depends on: `hands/cultmesh-put-serve-bound` merging first. It introduces `document_unservable` in Python
|
||||
(`fd394d54`), a codeless unservable refusal in cultmesh-ts (`0eb7434a`) and `CultMeshRudpRejectionReason` in
|
||||
cultmesh-rs (`c9cdad12`, `85288a5a`). The cuts below convert those sites. Their anchors are by commit, not by
|
||||
line, because the branch is not on main.
|
||||
|
||||
Found by the put-serve Hands (batch 3, `38000efa`, 2026-09-30); they belong to this map's cuts:
|
||||
- **Python codes outside the closed code list.** Python refuses with `document_unservable`,
|
||||
`malformed_document_put` and `snapshot_*_exceeded`. None of these are in the closed `cultnet.error.v0` code
|
||||
list, and Rust and TS peers refuse to decode an unknown code.
|
||||
- **Two error-map shapes.** TS sends a two-key error map that its own peers cannot parse. Rust sends the five-key
|
||||
contract shape. The two agree only at the message level.
|
||||
- **The session after a refusal.** TS and Python keep the session open after a refusal; Rust sends a goodbye and
|
||||
ends it.
|
||||
- **New Rust refusal frame.** The put-serve branch adds a Rust refusal frame, `cultnet.error.v0` carrying the
|
||||
reason text with no code and no details, sent before the goodbye and acknowledging nothing. Cut 1 gives it a
|
||||
code.
|
||||
- Found by the put-serve Soul (merge verdict, 2026-09-30):
|
||||
- A store `OSError(ENOSPC)` in Python is refused as `malformed_document_put`, with the text "Raw put document could
|
||||
not be stored." That is a server fault reported as the peer's fault.
|
||||
- Over RUDP, Python's `document_unservable` details add `fragmentCount` and `maxFragmentCount`, and send
|
||||
`maxSnapshotBytes: null` when it is unbounded. The map specifies `{responseBytes, maxSnapshotBytes}`.
|
||||
- `response_unqueueable` (Python, P3) is a new code outside the closed list.
|
||||
- A TS peer cannot tell "stored, receipt lost" from "refused" (put-serve Soul F4, pass 3).
|
||||
|
||||
Map branch: `main`.
|
||||
|
||||
---
|
||||
|
||||
## 0. What the Body does today (probed)
|
||||
|
||||
Every claim in this table was established by running code. The C#, Rust and TypeScript probes ran on
|
||||
Yggdrasil through `ygg-verify.sh` at `8fc74c70`. The Python bytes came from `msgpack.packb` on the exact
|
||||
dicts `cultmesh_py/server.py:573-589` and `cultmesh-ts/src/index.ts:5921-5926` build. The six probe inputs:
|
||||
|
||||
| Input | Bytes from |
|
||||
|---|---|
|
||||
| `cs_cursor_stale` | Rust `tests/error_message.rs` captured C# hex (`cursor_stale`, details `{asOf:1,current:2}`) |
|
||||
| `cs_routing` | C# `new CultNetErrorMessage { Error, RoutingHint = { Reason="not_primary", Shard=... } }` serialized with `CultNetSchemaMessageSerialization` |
|
||||
| `cs_codeless` | C# `new CultNetErrorMessage { Error = "boom" }` |
|
||||
| `cs_unknown_code` | C# with `Code = "document_unservable"`, empty `CultNetErrorDetails` |
|
||||
| `py_snapshot_byte_limit` | the Python `_error_response` shape: `messageId`, `code: "snapshot_byte_limit_exceeded"`, `details {responseBytes, maxSnapshotBytes}` |
|
||||
| `ts_two_key` | the cultmesh-ts shape `{schemaVersion, error}` |
|
||||
|
||||
Decoded by:
|
||||
|
||||
| Input | C# `CultNetSchemaMessageSerialization.Deserialize` | Rust `decode_cultnet_message_from_slice` | TS `parseCultNetMessage` |
|
||||
|---|---|---|---|
|
||||
| `cs_cursor_stale` | ok | ok, typed | ok |
|
||||
| `cs_routing` | ok | ok, **routingHint dropped** (`code: None, details: None`) | ok (untyped passthrough) |
|
||||
| `cs_codeless` | ok | ok | ok |
|
||||
| `cs_unknown_code` | ok (free string) | **Err: "not a recognized cultnet.error.v0 code"** | **throws: /code must be one of the allowed values** |
|
||||
| `py_snapshot_byte_limit` | ok, but **details silently become an all-null object** (`responseBytes`, `maxSnapshotBytes` lost), `messageId` dropped | **Err: not a recognized code** | **throws** (missing routingHint, extra messageId, code enum, details keys) |
|
||||
| `ts_two_key` | ok | ok | **throws: missing routingHint, code, details** — TypeScript cannot parse the errors TypeScript sends |
|
||||
|
||||
Captured C# bytes for the two shapes Rust's vector file does not hold (for Hands' reuse):
|
||||
|
||||
- `cs_codeless`: `85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72A4626F6F6DAB726F7574696E6748696E74C0A4636F6465C0A764657461696C73C0`
|
||||
- `cs_routing`: see the probe; the shard descriptor writes ten keys, nil included.
|
||||
|
||||
Other findings, from reading source at `8fc74c70`:
|
||||
|
||||
1. **The interop CI lane has been red for 40 consecutive `main` runs.** It fails at "Verify CultNet schema
|
||||
contracts stayed consolidated" (`.github/workflows/cultnet-interop.yml:67-90`). That step forbids every
|
||||
`*.schema.json` outside `contracts/cultnet/`, and R-AB deliberately vendors 22 copies into
|
||||
`packages/cultnet-rs/contracts/cultnet/`. Those copies are held byte-equal by
|
||||
`packages/cultnet-rs/tests/schema_discovery_contracts.rs:74-99`. Nothing after that step (every
|
||||
cross-runtime interop test) has run on main in that time. Run `36714768445` is the latest.
|
||||
2. **The schema contradicts itself.** `contracts/cultnet/cultnet.error.schema.json:29` says the code is
|
||||
"Additive: a v0 peer that does not know this field ignores it" and, in the same sentence, "a closed
|
||||
enum: Rust and TypeScript refuse". The closed-enum text came from Soul pass 4d (`3f942a57`). It
|
||||
described what the code did; no operator ruled it. `docs/` holds no ruling on unknown codes.
|
||||
3. **Emitters that break the schema:**
|
||||
- cultmesh-ts sends `{schemaVersion, error}` only, at `packages/cultmesh-ts/src/index.ts:5875-5878`,
|
||||
`5885-5888`, `5913-5916` and `5922-5925`, and on the branch at `0eb7434a`.
|
||||
- cultmesh-py `_error_response` (`packages/cultmesh-py/src/cultmesh_py/server.py:573-589`) adds a
|
||||
top-level `messageId`, omits `routingHint`, omits `code` and `details` rather than sending nil, and
|
||||
sends eight out-of-enum codes: `unsupported_schema_version` (`:161`), `malformed_document_put`
|
||||
(`:358`), `unregistered_document_put` (`:365`), `malformed_document_delete` (`:386`),
|
||||
`unregistered_document_delete` (`:393`), `snapshot_document_limit_exceeded` (`:558`),
|
||||
`snapshot_byte_limit_exceeded` (`:567`) and `simulation_observations_disabled` (`:596`). The branch
|
||||
adds a ninth, `document_unservable` (`fd394d54`).
|
||||
- `packages/cultnet-py/src/cultnet_py/interop_peer.py:409-417` also sends `unsupported_schema_version`.
|
||||
R-2 (`docs/cultnet-selection-cut.md:2046-2053`) required that code, but it never entered the schema
|
||||
enum.
|
||||
4. **A second schema authority.** `packages/cultnet-py/src/cultnet_py/schema_catalog.py:17`, `:272` and
|
||||
`:357-362` publish a hand-built `cultnet.error.v0` descriptor under the canonical `$id`. It has a
|
||||
different shape: `messageId`, `code` as any string, `details` open, and only `schemaVersion` and `error`
|
||||
required. The same file does this for every wire message (about 350 lines). This is recorded as
|
||||
follow-up F-1, not cut here.
|
||||
5. **Silent refusals.** cultmesh-rs's RUDP document server returns every rejection to the local caller only
|
||||
(`packages/cultmesh-rs/src/rudp_document_server.rs:470-590`, `CultMeshRudpApplicationRejection`). The
|
||||
peer gets nothing and times out. On the branch this includes the new `DocumentUnservable`.
|
||||
6. **Receivers drop the code.**
|
||||
- Every C# receiver turns an error into `new IOException(error.Error)` or
|
||||
`new InvalidOperationException(error.Error)`, so a caller cannot branch on the code. The sites:
|
||||
`src/GameCult.Mesh/CultMeshBodySessions.cs:310`, `CultMeshContentSessions.cs:178/256-260`,
|
||||
`CultMeshPeerExchange.cs:430/466`, `CultMeshSnapshots.cs:91/107/222-226/743` and
|
||||
`CultMeshVerseDiscovery.cs:273`, plus `src/GameCult.Networking/CultNetShardReplication.cs:440/574`.
|
||||
- Rust reads `Error { error, .. }` at `packages/cultmesh-rs/src/lib.rs:823`.
|
||||
- TS types `CultNetErrorMessage` as `{schemaVersion, error}` only (`packages/cultnet-ts/src/contracts.ts:167-170`).
|
||||
- Kotlin has no error type. A request that gets an error back fails a
|
||||
`require(response.schemaVersion == ...)` (`packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt:1278`,
|
||||
`:1334`), and the peer's text is lost.
|
||||
7. **No correlation.** Only Python echoes `messageId`.
|
||||
- `cultmesh-browser` rejects an error only when exactly one operation is pending
|
||||
(`packages/cultmesh-browser/src/index.ts:724-731`); otherwise the error is dropped and the operation
|
||||
times out.
|
||||
- C# `CultMeshContentSessions.OnError` (`:256-260`) fails every pending request on any error.
|
||||
8. **The reference is inconsistent with itself.** `src/GameCult.Networking/CultNetDatabaseServer.cs:560`
|
||||
refuses a variant change with a codeless error. The subscription server refuses the same condition with
|
||||
`ForVariantUnsupported` (`CultNetDatabaseSubscriptionServer.cs:211/333/366`).
|
||||
9. **routingHint is never consumed.** No receiver in any runtime reads it. C# builds it at
|
||||
`CultNetDatabaseServer.cs:638-650`, and Rust's decode discards it.
|
||||
10. **Consumer audit** (read-only, all of `F:\Projects` except CultLib; no git in Aetheria):
|
||||
- Outside CultLib, nothing sets `code`, `details` or `routingHint`, and nothing branches on a code.
|
||||
- Emitters:
|
||||
- Huginn: `crates/huginn-daemon/src/serve.rs:288/307/312`, Rust struct literals
|
||||
`Error { error, code: None, details: None }`, pinned at cultnet-rs `8fc74c70`.
|
||||
- AetheriaEve: `Aetheria.State.Daemon/Program.cs:1737/1778`, C# object initializers.
|
||||
- Decoders read `.error` only: Gjallar `Program.cs:574`, Mimir
|
||||
`MimirOdinMoveProofEvidenceRingProvider.cs:80`, Hermodr `hermodr-daemon.cjs:1380` (hand-rolled),
|
||||
AetheriaEve's smoke test (text match against its own emit text), and Huginn's tests (text match).
|
||||
- Stale vendored TS:
|
||||
- `StreamPixels/vendor/CultLib` carries the current closed schema, so any change here breaks its
|
||||
TS error decode until it is re-vendored.
|
||||
- `Heimdall/vendor/CultLib` carries a pre-R-X schema with `additionalProperties:false` and no
|
||||
`routingHint`, so it already refuses every error C# sends today.
|
||||
- Odin, Idunn, Muninn, Ghostlight and voidbot: zero hits.
|
||||
|
||||
## 1. Ends
|
||||
|
||||
- **One schema.** `contracts/cultnet/cultnet.error.schema.json` is the only authority on the shape and on the
|
||||
code registry. C# embeds it (`GameCult.Networking.csproj:19`), TS mirrors it at build time, and Rust
|
||||
vendors a copy held byte-equal by test. Nothing else publishes a `cultnet.error.v0` shape (F-1 covers
|
||||
Python).
|
||||
- **Every runtime emits only schema-valid errors.** This is proven per emit path against the schema, and
|
||||
byte-equal to the C# reference for every registry code.
|
||||
- **Every runtime decodes every error it can receive into a typed value.** A known code becomes its typed
|
||||
variant with its typed details. An unknown code, if Q1 says so, becomes a typed "other" that keeps the
|
||||
code string and the raw details. No well-formed error is ever refused, and no field is silently dropped
|
||||
(routingHint, messageId).
|
||||
- **Code and details have one owner.** The details a message carries are decided by its code. The types in
|
||||
every runtime make "cursor_stale with a field" unrepresentable. No free-standing code string plus a
|
||||
grab-bag details object.
|
||||
- **A refusal is an answer, not a silence** (R-2 precedent, and Q4). A server that refuses a peer's request,
|
||||
and can still send, sends a coded error that correlates to the request.
|
||||
- **The public surface is complete in every runtime**, per the consumer-expectation doctrine:
|
||||
- a typed error value;
|
||||
- a constructor per registry code;
|
||||
- a typed exception or error that request APIs raise when the peer refuses, carrying the typed value.
|
||||
|
||||
## 2. Identity, lifecycle and authority
|
||||
|
||||
| Kind | What names it | Lifecycle | Who decides |
|
||||
|---|---|---|---|
|
||||
| Error message | `schemaVersion: cultnet.error.v0`; correlated to its request by `messageId` (Q3) | Sent once, in reply to one request or subscription. Never persisted. | The server that refused. |
|
||||
| Error code | A snake_case string in the schema's registry (`$defs/knownCode`) | Added by one coordinated change: schema, C# constructor, a C#-written vector, and a typed variant in every runtime. Never renamed and never reused. A retired code stays in the registry marked retired, so an old reader still decodes it. | The operator rules meaning; Self keeps the registry. |
|
||||
| Details for a code | The code | Frozen with the code. Adding a key to an existing code's details is a new code (or a v1), never an in-place edit (see Q2). | Same as the code. |
|
||||
| Unknown code, at a reader | The raw string | Preserved as typed `Other`. The reader acts on the `error` text and the absence of a known code. | The reader (Q1). |
|
||||
| Vector file | `contracts/cultnet/interop/error-vectors.cs-written.json` | Regenerated only by the C# writer test under `CULTNET_WRITE_VECTORS=1` and committed. Every other runtime judges it read-only. | C#, the reference. |
|
||||
| routingHint | Shard authority refusal | Sent with a null code (unchanged). Typed in every decoder. | CultNetDatabaseServer's authority path. |
|
||||
|
||||
## 3. Operator questions
|
||||
|
||||
**Rulings (operator, 2026-09-30):** "Q1: B / Q2: B / Q3: A / Q8: A".
|
||||
- **Q1:** closed for emitters, open for readers. An unknown code decodes to a typed `Other`.
|
||||
- **Q2:** per-code details.
|
||||
- **Q3:** a nullable top-level `messageId`.
|
||||
- **Q8:** v0 is corrected in place, with the semver bumps the policy requires.
|
||||
- Q4-Q7 are still open.
|
||||
- Cut 1 waits for `hands/cultmesh-put-serve-bound` to merge.
|
||||
|
||||
### Q1. What does a reader do with an error code it does not know?
|
||||
|
||||
Today Rust and TypeScript refuse the whole message. The probes above show the result: the peer's error text
|
||||
is lost and the caller sees "not a recognized code". In cultmesh-rs's snapshot client the `?` at `lib.rs:812-815`
|
||||
even turns a peer refusal into a local decode failure. So under a closed enum every new code breaks every
|
||||
reader that has not upgraded. By `docs/semver-policy.md:86-91` that makes each new code a breaking wire
|
||||
change, and nine codes are already in flight.
|
||||
|
||||
- **A. Closed enum (today).** Readers refuse unknown codes. Every code addition is a coordinated,
|
||||
semver-major wire change across all runtimes and consumers.
|
||||
- **B. Closed for emitters, open for readers.** The schema keeps the registry, and every runtime's emit
|
||||
constructors can produce only registry codes (tests prove each emit path valid against the schema).
|
||||
Readers decode an unrecognized code (matching `^[a-z][a-z0-9_]*$`) into a typed `Other { code, details }`.
|
||||
A new code is an additive, minor change: old readers still surface the text and know "an error I cannot
|
||||
classify". This is how gRPC status codes and protobuf open enums behave.
|
||||
- **C. Fully open.** No registry; any string, emit and decode.
|
||||
|
||||
**Recommended: B.** A refusal must never fail to arrive because it is more specific than the reader
|
||||
expected. B keeps the registry as the single meaning authority. C loses the meaning authority, and A makes
|
||||
the error channel the most brittle message in CultNet.
|
||||
|
||||
Depends on this: schema `code` shape, every decoder, the doc text at `cultnet-distributed-database.md:107-115`.
|
||||
|
||||
### Q2. How are details shaped on the wire?
|
||||
|
||||
Today C# writes all four detail keys (`field`, `value`, `asOf`, `current`), nil included, because
|
||||
`CultNetErrorDetails` (`CultNetSchemaMessages.cs:592-603`) is a MessagePack map-mode object. The codes in
|
||||
flight need new keys: `responseBytes`, `maxSnapshotBytes`, `documentCount`, `maxSnapshotDocuments` and
|
||||
`schemaVersion`.
|
||||
|
||||
- **A. One shared details record; grow it.** Every detail key of every code sits on one record, written as
|
||||
nil when unused. Each new key changes the bytes of every existing error that has details, so all of the
|
||||
C# vectors must be recaptured on every addition. It also breaks every strict reader: TS Ajv, and
|
||||
StreamPixels' vendored schema, have `additionalProperties:false`. And the type cannot tie a key to its
|
||||
code.
|
||||
- **B. Per-code details.** `details` carries exactly the keys its code defines. A defined-but-optional key
|
||||
is present as nil; another code's keys are absent. Keys are named for their meaning, which retires the
|
||||
`field: "schemaId"` / `field: "subscriptionId"` indirection that exists only because the record is
|
||||
shared. Adding a code never changes an existing code's bytes. In every runtime, code and details are
|
||||
fused into one tagged type:
|
||||
- Rust: an enum with data.
|
||||
- C#: sealed subclasses, with a custom formatter for `CultNetErrorMessage`. There is precedent in
|
||||
`src/GameCult.Mesh/CultMeshRouteRecordFormatter.cs`.
|
||||
- TS: a discriminated union.
|
||||
- Kotlin: a sealed class.
|
||||
- Python: constructors per code over a frozen dataclass.
|
||||
|
||||
The churn is one time. The five existing C# cases with details change bytes, and all eight Rust hex
|
||||
cases move into the shared vector file anyway (Cut 1).
|
||||
- **C. Opaque string map.** Untyped; no runtime can offer a typed accessor.
|
||||
|
||||
**Recommended: B.** A is the churn the brief warned of, and it recurs on every future code. B pays it once,
|
||||
at the moment every vector is being recaptured regardless.
|
||||
|
||||
Depends on this: the schema's `details`, the C# model and formatter, `encode_error_details`
|
||||
(`packages/cultnet-rs/src/contracts.rs:1339-1366`), and every decoder.
|
||||
|
||||
### Q3. Does an error carry the `messageId` of the request it refuses?
|
||||
|
||||
Python already sends `messageId` (`server.py:583`); the schema forbids it. Without it:
|
||||
|
||||
- `cultmesh-browser` drops an error whenever two operations are pending (`index.ts:724-731`);
|
||||
- C# content sessions fail every pending request on any error (`CultMeshContentSessions.cs:256-260`).
|
||||
|
||||
Adding a top-level key changes the bytes of every C# error. That happens once, in the same recapture as Q2.
|
||||
|
||||
- **A.** Add a nullable top-level `messageId`. A reply to a request echoes the request's id, and an error
|
||||
with no request (a subscription push, an unsolicited refusal) sends nil. Receivers correlate by it and
|
||||
fall back to their current behavior when it is nil.
|
||||
- **B.** No correlation. Python deletes the field.
|
||||
|
||||
**Recommended: A.** Correlation is a capability any multiplexing client needs, and one runtime already
|
||||
depends on it.
|
||||
|
||||
### Q4. Do servers send `document_unservable` (and every other refusal they can) to the peer?
|
||||
|
||||
Self ruled that "unservable" must be distinguishable as a typed code in every runtime. On the branch:
|
||||
Python sends it coded, TypeScript sends it codeless, and Rust tells only its local caller, so the Rust
|
||||
peer times out. The same Rust silence covers every other document-server rejection: a sink refusal, a
|
||||
failed snapshot source, too many snapshot documents, and a snapshot response that is too large.
|
||||
|
||||
- **A.** Every refusal of a peer's request that the server can still send is answered with a coded error
|
||||
carrying the request's `messageId`, and is also reported locally (Rust's `ApplicationRejected` stays as
|
||||
the local report). The refusals that cannot be answered are the ones where the answer itself cannot be
|
||||
queued: Rust `PayloadBudgetFull` and `ResponseQueueFailed`. Those stay local-only, and the doc says so.
|
||||
- **B.** Local-only in Rust; peer replies only where a runtime already sends them.
|
||||
|
||||
**Recommended: A.** R-2 already ruled that a silence is worse than a missing feature
|
||||
(`docs/cultnet-selection-cut.md:2049-2053`), and the operator's ruling that CultMesh "refuses" puts only
|
||||
means something to the party that sent the put.
|
||||
|
||||
### Q5. Does Python's `unregistered_document_put` / `unregistered_document_delete` become `unowned_schema`?
|
||||
|
||||
Python sends these when the node has no registered document type for the record's schemaId
|
||||
(`cultmesh_py/node.py:653-666`). C# `unowned_schema` means "no shard owns this schema and key"
|
||||
(`CultNetSchemaMessages.cs:537-544`). To a peer both mean "this server will not take that schema here; route
|
||||
elsewhere". The put and delete variants duplicate what the peer already knows (it sent the message).
|
||||
|
||||
- **A.** Both collapse into `unowned_schema { schemaId }`. The registry text broadens to "this server
|
||||
stores no document for that schema (and key)".
|
||||
- **B.** Join as a separate `schema_unregistered { schemaId }`.
|
||||
- **C.** Join both codes as they are.
|
||||
|
||||
**Recommended: A.** It is one meaning at the peer, and there is one action to take.
|
||||
|
||||
### Q6. Does Python's `malformed_document_put` / `malformed_document_delete` become one `message_invalid { field }`?
|
||||
|
||||
These are sent when the request lacks a required field (`server.py:355-359`, `383-387`). C# has the same
|
||||
condition, codeless, at `CultNetDatabaseServer.cs:373/415` and `CultNetDatabaseSubscriptionServer.cs:148`
|
||||
("requires a subscriptionId or messageId").
|
||||
|
||||
- **A.** One `message_invalid { field }`, naming the missing or ill-typed field. It is used by Python's four
|
||||
sites and C#'s three.
|
||||
- **B.** Join both per-message codes and leave C# codeless.
|
||||
|
||||
**Recommended: A.** The per-message suffix repeats the request's own schemaVersion, and C# gains a code for
|
||||
free.
|
||||
|
||||
### Q7. Does `simulation_observations_disabled` become `unsupported_schema_version`?
|
||||
|
||||
Python sends it when the observation hub is off (`server.py:593-598`), with
|
||||
`details { schemaVersion: "cultnet.simulation_observation.v0" }`, which is exactly the
|
||||
`unsupported_schema_version` shape. cultmesh-ts's "no cache for snapshot requests" and "no operation
|
||||
handler" refusals (`index.ts:5875`, `5913`) mean the same thing: this server does not serve that message.
|
||||
|
||||
- **A.** Collapse. `unsupported_schema_version` means "this server does not serve messages of this
|
||||
schemaVersion: unknown, a newer version, or not enabled here". The three TS sites and Huginn's "answers
|
||||
only X, Y, Z" adopt it.
|
||||
- **B.** Keep a separate `feature_disabled`-style code.
|
||||
|
||||
**Recommended: A.** A peer cannot act differently on "not enabled" and "not known". Python's
|
||||
`verify.py:335` check follows the rename.
|
||||
|
||||
### Q8. Is this a correction of `cultnet.error.v0` in place, or a new `cultnet.error.v1`?
|
||||
|
||||
Under Q2-B and Q3-A, strict v0 readers (TS Ajv and vendored copies) refuse the new bytes. Tolerant readers
|
||||
(C#, Rust, Python) keep working, because the probes show they ignore unknown keys and treat a missing key
|
||||
as null.
|
||||
|
||||
- **A. In place.** The v0 contract has never been decodable across all five runtimes (section 0). No
|
||||
consumer outside CultLib reads code or details (finding 10). Every publishing package takes the semver
|
||||
bump its policy requires:
|
||||
- `org.gamecult.cultlib` major, because the public `CultNetErrorDetails` properties change;
|
||||
- `cultnet-py` and `cultmesh-py` major, because emitted codes change;
|
||||
- Rust pins move deliberately.
|
||||
|
||||
StreamPixels and Heimdall re-vendor.
|
||||
- **B. v1.** Add `cultnet.error.v1`, keep v0 emit for peers that did not advertise v1 in hello, and run
|
||||
two error encoders in every runtime until v0 retires.
|
||||
|
||||
**Recommended: A.** B builds a version-negotiation path for a message nobody outside CultLib parses beyond
|
||||
`.error`. The one thing every old reader does need, the `error` text, survives A in every tolerant runtime.
|
||||
|
||||
## 4. The registry (as recommended; rewrite on each ruling)
|
||||
|
||||
| Code | Meaning | Details (per Q2-B; `?` = present-as-nil when absent) | Emitted by after this map |
|
||||
|---|---|---|---|
|
||||
| `selection_invalid` | A selection field failed the door. | `{ field, value? }` | C#, Rust (selection) |
|
||||
| `cursor_stale` | The cursor predates the server's retained history. | `{ asOf, current }` | C#, Rust |
|
||||
| `cursor_invalid` | The cursor could not be parsed. | none (details nil) | C#, Rust |
|
||||
| `reference_outside_target` | A hop reference left the declared target. | none | C#, Rust |
|
||||
| `variant_unsupported` | The selection selects a document variant CultNet does not carry. | `{ subscriptionId? }` | C# (incl. `DatabaseServer.cs:560`) |
|
||||
| `unowned_schema` | This server stores no document for that schema (and key). | `{ schemaId }` | C#, Python (Q5) |
|
||||
| `unsupported_schema_version` | This server does not serve messages of this schemaVersion. | `{ schemaVersion }` | Python, TS, Huginn (Q7) |
|
||||
| `message_invalid` | The request lacks a required field or has an ill-typed one. | `{ field }` | C#, Python (Q6) |
|
||||
| `snapshot_document_limit_exceeded` | The snapshot answer would exceed the document bound. | `{ documentCount, maxSnapshotDocuments }` | Python, Rust (Q4) |
|
||||
| `snapshot_byte_limit_exceeded` | The snapshot answer would exceed the byte bound. | `{ responseBytes, maxSnapshotBytes }` | Python, Rust (Q4) |
|
||||
| `document_unservable` | A put was refused because no snapshot could ever return the stored document under the byte bound. | `{ responseBytes, maxSnapshotBytes }` | Python, TS, Rust (Q4) |
|
||||
|
||||
A null code is legal and means "unclassified". Examples: an exception message from a failed snapshot
|
||||
source, an authority refusal carrying `routingHint`, and "not authorized"
|
||||
(`CultNetDatabaseSubscriptionServer.cs:165`). This map does not invent codes for those.
|
||||
|
||||
`snapshot_byte_limit_exceeded` and `document_unservable` have identical details. That is deliberate, and it
|
||||
is the fixture trap named in the verification rules.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cuts
|
||||
|
||||
All cuts are made on one campaign branch, `hands/error-contract`, from `main` after
|
||||
`hands/cultmesh-put-serve-bound` merges. The branch merges to `main` once, after Cut 5's Soul pass. The
|
||||
reason: Cut 1 changes C# bytes, and Rust's static hex cases would stay green against the stale bytes
|
||||
(finding: `tests/error_message.rs` pins constants, not the reference). A partial merge would ship a
|
||||
wire split while every suite passed. Each cut gets its own worktree, created by Self. Heavy verification
|
||||
runs through `ygg-verify.sh`, never on Starfire.
|
||||
|
||||
### Cut 0. Unblock the interop lane
|
||||
|
||||
- **Repo/branch:** CultLib, `hands/interop-guard` from `main`, merged alone ahead of the campaign. It
|
||||
does not depend on the rulings.
|
||||
- **Deletes first:** nothing.
|
||||
- **Per-file changes:** `.github/workflows/cultnet-interop.yml:72`. The `$stray` filter also accepts
|
||||
`^packages/cultnet-rs/contracts/cultnet/`, with a comment naming R-AB and
|
||||
`packages/cultnet-rs/tests/schema_discovery_contracts.rs:74-99` as the equality guard. The namespace
|
||||
check is unchanged.
|
||||
- **Authority map:** no ownership change. The canonical owner stays `contracts/cultnet/`, and the Rust
|
||||
mirror is derived from it, with drift judged by the Rust test.
|
||||
- **Verification:**
|
||||
- Push the branch and run the workflow. Report every step that goes red after the guard. None of those
|
||||
steps has run on main for 40 runs, so expect more red, and treat each failure as its own finding;
|
||||
do not fix it in this cut.
|
||||
- Negative: edit one byte of a vendored copy locally and check that the Rust equality test fails.
|
||||
Reach it on Yggdrasil with `cd packages/cultnet-rs && cargo test --test schema_discovery_contracts`.
|
||||
- **Ledger:** +3 lines.
|
||||
|
||||
### Cut 1. The contract and the C# reference
|
||||
|
||||
- **Repo/branch:** CultLib `hands/error-contract`, worktree named by Self.
|
||||
- **First:** capture the current C# bytes for all eight factory cases, plus `cs_codeless` and `cs_routing`,
|
||||
into the Hands report. This is the before-image a Soul pass compares against.
|
||||
- **Deletes first:**
|
||||
- `CultNetErrorDetails` (`src/GameCult.Networking/CultNetSchemaMessages.cs:588-603`, 16 lines) as a
|
||||
shared record.
|
||||
- The `field`/`value`/`asOf`/`current` property block and the `[MessagePackObject]`/`[Key]` attributes
|
||||
on `CultNetErrorMessage` (`:497-521`). Its serialization moves to one formatter.
|
||||
- `SoulP4dProbeTests.Wire_UnownedSchemaBytes` (`tests/GameCult.Networking.Tests/SoulP4dProbeTests.cs:191-199`).
|
||||
It is an `[Explicit]` hex printer, superseded by the vector writer.
|
||||
- The closed-enum paragraph at `contracts/cultnet/cultnet-distributed-database.md:107-115` and
|
||||
the `code`/`details` descriptions in the schema (`:28-42`).
|
||||
- **Adds:**
|
||||
- **Schema** (`contracts/cultnet/cultnet.error.schema.json`, rewritten; the Rust mirror is re-copied
|
||||
byte-equal):
|
||||
- Top level: `schemaVersion`, `error` and `routingHint` as today, plus `messageId: string|null` (Q3)
|
||||
and `code`. All five are required, present-as-nil (R-X is unchanged at the top level).
|
||||
- `$defs/knownCode` is the registry enum from section 4.
|
||||
- `code` is `null`, or a string matching `^[a-z][a-z0-9_]*$` (Q1-B).
|
||||
- `details` is decided by a top-level `oneOf`, one branch per known code, each fixing `code` by
|
||||
`const` and `details` by exact `properties`/`required`/`additionalProperties:false`. Two more
|
||||
branches: a null code with null details, and an unknown code (`not: {$ref knownCode}`) with details
|
||||
`object|null`, open.
|
||||
- Use `oneOf`/`not`/`const` only. `tests/GameCult.Networking.Tests/MiniJsonSchemaValidator.cs:30-35`
|
||||
implements those and **not** `if`/`then`/`allOf`, and it refuses unimplemented keywords. TS
|
||||
compiles with Ajv2020 (`packages/cultnet-ts/src/contracts.ts:1,674`), which handles both.
|
||||
- **C# model** (`GameCult.Networking`):
|
||||
- `CultNetErrorMessage { SchemaVersion, Error, MessageId?, RoutingHint?, Refusal? }`, where
|
||||
`Code => Refusal?.Code` is derived and not settable.
|
||||
- `abstract CultNetRefusal { string Code }`, with one sealed subclass per registry code carrying its
|
||||
typed details (`CultNetCursorStaleRefusal(ulong AsOf, ulong Current)` and so on), plus
|
||||
`CultNetUnknownRefusal(string Code, IReadOnlyDictionary<string, object?>? Details)` for Q1-B.
|
||||
- `CultNetErrorCodes`: string constants, one per registry code.
|
||||
- `CultNetErrorMessageFormatter : IMessagePackFormatter<CultNetErrorMessage>`. It writes the five
|
||||
top-level keys in order (schemaVersion, error, messageId, routingHint, code, details), and details
|
||||
as exactly the refusal's keys. It reads by key name, tolerates unknown keys at both levels, and
|
||||
maps an unknown code to `CultNetUnknownRefusal`. Register it through `[MessagePackFormatter]` on
|
||||
the type, the way `CultMeshRouteRecordFormatter` is wired.
|
||||
- Factories: keep `ForSelectionInvalid`, `ForCursor`, `ForReferenceOutsideTarget`,
|
||||
`ForVariantUnsupported` and `ForUnownedSchema` as thin constructors over the refusal types, and add
|
||||
one per new code. Each takes an optional `messageId`.
|
||||
- `CultNetPeerErrorException : IOException`, carrying the `CultNetErrorMessage`.
|
||||
- **Vector writer:** `ErrorVectorTests.WriteErrorVectors`, gated on `CULTNET_WRITE_VECTORS=1` like
|
||||
`SelectionParityVectorTests.cs:460-552`. It writes `contracts/cultnet/interop/error-vectors.cs-written.json`:
|
||||
one entry per registry code (both optional-key states where a key is optional), `cs_codeless`,
|
||||
`cs_routing`, one unknown code with open details, and one error with `messageId` set. Each entry
|
||||
holds `{ name, messagePackBase64, expected: { error, messageId, code, details, routingHint } }`,
|
||||
where `expected` is a JSON projection every runtime compares its typed decode against.
|
||||
- **Per-file changes** (`8fc74c70`):
|
||||
- `CultNetDatabaseServer.cs:560`: `ForVariantUnsupported(refusal.Message, subscriptionId)`.
|
||||
- `CultNetDatabaseServer.cs:373`, `:415` and `CultNetDatabaseSubscriptionServer.cs:148` become
|
||||
`message_invalid { field: "subscriptionId" }` (Q6-A).
|
||||
- Every `SendCultNet(CultNetErrorMessage...)` that answers a request passes the request's `MessageId`.
|
||||
- Each receiver listed in finding 6 throws `new CultNetPeerErrorException(error)` instead of
|
||||
wrapping `error.Error`. `CultMeshContentSessions.OnError` (`:256-260`) fails only the pending entry
|
||||
whose id matches `error.MessageId`, and falls back to all entries only when the id is nil.
|
||||
- `CultNetSchemaContractTests.cs:165-214`: the per-factory tests are replaced by one test that validates
|
||||
every vector's decoded JSON against the schema with `MiniJsonSchemaValidator`, and one test that
|
||||
serializes each registry factory and asserts byte equality with its committed vector.
|
||||
- Rewrite `contracts/cultnet/cultnet-distributed-database.md` "Error Codes" to the registry, and state
|
||||
the Q1 and Q2 rules.
|
||||
- **Authority map:**
|
||||
- Owner: the schema's registry owns code meaning and detail shape. `CultNetErrorMessageFormatter` owns
|
||||
C# bytes. The refusal subclass owns the code-to-details binding.
|
||||
- Inputs: the refusal instance, messageId, routingHint.
|
||||
- Outputs: bytes, and `cs-written.json` for the other runtimes.
|
||||
- Derived state: `CultNetErrorMessage.Code` becomes derived; the refusal decides it.
|
||||
- Forbidden writers: any `Code = "..."` assignment (it cannot compile); `[Key]` attribute serialization
|
||||
of the message; hand-typed hex anywhere.
|
||||
- Shared paths: every server send uses the factories; every receiver uses `CultNetPeerErrorException`.
|
||||
- Deletion line: `CultNetErrorDetails` and the settable `Code`.
|
||||
- **Verification** (`dotnet` image):
|
||||
- Builds: `tests/GameCult.Networking.Tests`, `tests/GameCult.Mesh.Tests`.
|
||||
- `ErrorVectorTests` with `CULTNET_WRITE_VECTORS=1`, then without, and check that the committed file
|
||||
reproduces byte-for-byte.
|
||||
- Tests and the rules they pin:
|
||||
- vector round trip: bytes, decode, bytes are equal for every entry;
|
||||
- a registry factory is byte-equal to its vector;
|
||||
- every vector validates against the schema;
|
||||
- an unknown code decodes to `CultNetUnknownRefusal` with its raw details intact;
|
||||
- a Python-shaped error (`py_snapshot_byte_limit` above, with the Q2/Q3 shape) decodes typed with
|
||||
`responseBytes` and `maxSnapshotBytes` intact. Today's C# silently empties those.
|
||||
- content-session correlation: two pending requests, an error for the second, and the first still
|
||||
completes.
|
||||
- Mutation: Stryker.NET on the formatter and factories. The survivors that must die:
|
||||
- swapping two known codes whose details are identical (`snapshot_byte_limit_exceeded` and
|
||||
`document_unservable`);
|
||||
- padding a nil key into details;
|
||||
- dropping `messageId` on encode;
|
||||
- mapping an unknown code to null.
|
||||
- Negative greps (checked at HEAD, where they hit the sites being cut):
|
||||
- `git grep -nE 'new (IOException|InvalidOperationException)\(error\.Error\)' -- src` must return 0
|
||||
(9 hits at HEAD).
|
||||
- `git grep -nE 'Field = "(schemaId|subscriptionId)"' -- src` must return 0 (2 at HEAD).
|
||||
- Operator: none.
|
||||
- **Ledger:** about -60 C# (the details record, the settable code, the probe printer, and the per-factory
|
||||
schema tests), +250 (the formatter, the refusal types, the vector writer). The schema grows by about 90
|
||||
JSON lines. The net is positive, bought by Q1/Q2 typing in the reference.
|
||||
|
||||
### Cut 2. Rust: cultnet-rs and cultmesh-rs
|
||||
|
||||
- **Deletes first:**
|
||||
- `CultNetErrorCode` and `CultNetErrorDetails` as separate types (`packages/cultnet-rs/src/contracts.rs:252-310`,
|
||||
59 lines);
|
||||
- `require_error_details` (`:1121-1146`);
|
||||
- `encode_error_details` (`:1339-1366`);
|
||||
- the eight hex `Case`s and the header comment in `packages/cultnet-rs/tests/error_message.rs:1-160`.
|
||||
That file is rewritten to read the vector file.
|
||||
- Keep `require_legacy_optional_u64` (`:1149`). Check with `git grep` for other callers first; at HEAD,
|
||||
only the error details use it.
|
||||
- **Adds:**
|
||||
- `CultNetMessage::Error { error: String, message_id: Option<String>, routing_hint: Option<CultNetShardRoutingHint>, refusal: Option<CultNetRefusal> }`.
|
||||
- `enum CultNetRefusal`, with one variant per registry code and its typed fields, plus
|
||||
`Other { code: String, details: Option<rmpv::Value> }`.
|
||||
- `CultNetRefusal::code(&self) -> &str`.
|
||||
- A constructor `CultNetMessage::error(text)` and builder-style `with_refusal` / `with_message_id`, so
|
||||
consumers stop writing struct literals. Huginn has three.
|
||||
- Decode reads every top-level key by name and routes unknown codes to `Other` (Q1-B).
|
||||
- Encode writes the Cut 1 key order.
|
||||
- `CultNetShardRoutingHint` is typed, reusing the crate's existing shard descriptor type.
|
||||
- **Per-file changes:**
|
||||
- `packages/cultnet-rs/src/contracts.rs:361-368` (variant), `:1097-1115` (decode), `:1300-1336` (encode).
|
||||
- `packages/cultnet-rs/src/selection.rs:1210-1255`: the four refusals build `CultNetRefusal` variants.
|
||||
- `packages/cultnet-rs/src/content.rs:316`: uses `CultNetMessage::error`.
|
||||
- `packages/cultmesh-rs/src/lib.rs:812-827`: a peer error becomes a typed error carrying the refusal,
|
||||
instead of `anyhow!` text. Name it `CultMeshPeerError`, next to the crate's existing error types.
|
||||
- `packages/cultmesh-rs/src/rudp_document_server.rs` (Q4-A): each `CultMeshRudpRejectionReason` that
|
||||
can be answered sends the matching coded error, correlated by the request's message id, before
|
||||
returning `ApplicationRejected`. `PayloadBudgetFull` and `ResponseQueueFailed` stay local.
|
||||
- **Authority map:**
|
||||
- Owner: `CultNetRefusal` owns code and details. The schema owns the registry.
|
||||
- Forbidden writers: string codes (`from_wire_str` survives only inside decode); struct literals of
|
||||
details.
|
||||
- Shared paths: selection refusals, document-server refusals and content errors all go through the one
|
||||
encoder.
|
||||
- **Verification** (`rust` image, `cd packages/cultnet-rs` then `cd ../cultmesh-rs`):
|
||||
- `cargo test --workspace`.
|
||||
- `tests/error_message.rs` pins: every vector decodes to `expected`; encoding the decoded value
|
||||
reproduces the vector's bytes; the unknown-code vector decodes to `Other`; the routing vector keeps
|
||||
`routing_hint`.
|
||||
- cultmesh-rs: a put over the bound makes the peer receive `document_unservable` with the request's
|
||||
message id, and details equal to the computed size and the bound. Run it at two bounds so that a
|
||||
constant or an offset in `maxSnapshotBytes` dies.
|
||||
- A peer that sends a Python-shaped `snapshot_byte_limit_exceeded` produces a `CultMeshPeerError`, not
|
||||
a decode failure.
|
||||
- Mutation: `cargo mutants --in-diff` on the cut's diff. Triage every survivor by name.
|
||||
- Negative: `git grep -n 'CultNetErrorDetails' -- packages` must return 0.
|
||||
- **Consumer note:** Huginn's `serve.rs:288/307/312` and its tests stop compiling at the next pin bump.
|
||||
The migration is mechanical (`CultNetMessage::error(...)`), and `:312` adopts `unsupported_schema_version`
|
||||
(Q7). Self routes it as a Huginn change; it is not part of this branch.
|
||||
|
||||
### Cut 3. TypeScript: cultnet-ts, cultmesh-ts and cultmesh-browser
|
||||
|
||||
- **Deletes first:**
|
||||
- the two-field `CultNetErrorMessage` interface (`packages/cultnet-ts/src/contracts.ts:167-170`);
|
||||
- the four literal error constructions in `packages/cultmesh-ts/src/index.ts:5875-5878`, `5885-5888`,
|
||||
`5913-5916` and `5922-5925`, and the branch's codeless unservable site (`0eb7434a`);
|
||||
- `rejectCorrelated`'s single-pending heuristic (`packages/cultmesh-browser/src/index.ts:724-731`).
|
||||
- **Adds:**
|
||||
- `CultNetErrorMessage` with every field.
|
||||
- `type CultNetRefusal` as a discriminated union on `code`, with an `{ code: string; details: Record<string, unknown> | null; known: false }` arm.
|
||||
- `createCultNetError(text, { messageId, refusal })` as the only constructor.
|
||||
- `class CultNetPeerError extends Error { message: CultNetErrorMessage }`.
|
||||
- `parseCultNetMessage` keeps Ajv validation against the canonical schema; it now accepts every schema
|
||||
branch, including unknown codes.
|
||||
- **Per-file changes:**
|
||||
- cultmesh-ts: the three "does not serve this" sites (`5875`, `5913`, `5922`) send
|
||||
`unsupported_schema_version` (Q7-A); the snapshot catch (`5885`) sends a null code; the unservable site
|
||||
sends `document_unservable` with details.
|
||||
- cultmesh-ts `6527-6529` throws `CultNetPeerError`.
|
||||
- cultmesh-browser `163`, `584` and `690`: correlate by `messageId`, falling back to today's behavior only
|
||||
for a nil id.
|
||||
- `packages/cultnet-ts/src/idunn-odin-presence-publisher.ts:118-120` throws `CultNetPeerError`.
|
||||
- The legacy `gamecult.networking.v0` path (`contracts.ts:840-844`, `:899-900`) is unchanged. That is a
|
||||
separate legacy contract.
|
||||
- **Verification** (`kotlin` image, which carries Node 24; `npm install`, then build cultcache-ts before
|
||||
cultnet-ts):
|
||||
- A new `test/error-vectors.test.ts` pins that every vector parses and deep-equals `expected`, and that
|
||||
`encode(createCultNetError(...))` is byte-equal to each registry vector.
|
||||
- The cultmesh-ts document-server tests assert that each refusal parses with TypeScript's own
|
||||
`parseCultNetMessage`. That is the probe's `ts_two_key` failure, pinned.
|
||||
- Browser: two pending operations, an error for the second, and the first still resolves.
|
||||
- Mutation: StrykerJS on the diff.
|
||||
- Negative: `git grep -nE 'schemaVersion: "cultnet\.error\.v0",' -- packages/cultmesh-ts/src packages/cultmesh-browser/src`
|
||||
must return 0. Exclude `cultnet-ts/src/schema-discovery.ts:302` (a descriptor) and
|
||||
`contracts.ts:842` (legacy decode); both are legitimate hits for the unscoped pattern.
|
||||
|
||||
### Cut 4. Python: cultnet-py and cultmesh-py
|
||||
|
||||
- **Deletes first:**
|
||||
- `_error_response` (`packages/cultmesh-py/src/cultmesh_py/server.py:573-589`);
|
||||
- `unsupported_schema_version_error` (`packages/cultnet-py/src/cultnet_py/interop_peer.py:409-417`);
|
||||
- every string literal code in `server.py` (lines in finding 3).
|
||||
- **Adds:**
|
||||
- `cultnet_py/errors.py`, holding:
|
||||
- `CultNetErrorCode(StrEnum)`, the registry;
|
||||
- a frozen `CultNetError` dataclass (`error`, `message_id`, `routing_hint`, `code: CultNetErrorCode | str | None`,
|
||||
`details: Mapping | None`);
|
||||
- one constructor function per code, taking exactly that code's keys;
|
||||
- `to_wire()`, which writes the five top-level keys in the Cut 1 order, nil included;
|
||||
- `parse_cultnet_error(dict)`.
|
||||
- `CultNetPeerError` (`client.py:46-50`) keeps `.response` and gains `.error: CultNetError`.
|
||||
- **Per-file changes:**
|
||||
- Sites `server.py:158-163`, `355-394`, `555-569` and `593-598` and the branch's `document_unservable`
|
||||
site use the constructors, with the Q5-A/Q6-A/Q7-A codes.
|
||||
- `interop_peer.py` uses the constructor.
|
||||
- `verify.py:335` reads `error.error.code`.
|
||||
- Tests `packages/cultmesh-py/tests/test_cultmesh.py:2088`, `3791`, `4323` and `4363-4371`, and
|
||||
`packages/cultnet-py/tests/test_cultnet.py:2342`, follow the renames.
|
||||
- **Verification** (the image the campaign already uses for Python; state it in the report):
|
||||
- A vector test pins that every vector parses to `expected` and that each constructor's `msgpack.packb`
|
||||
is byte-equal to its vector.
|
||||
- Mutation: mutmut on `errors.py` and the changed server sites.
|
||||
- Negative: `git grep -nE '"schemaVersion": "cultnet\.error\.v0"' -- packages/cultnet-py/src packages/cultmesh-py/src`
|
||||
returns only `errors.py`.
|
||||
- **Not here:** F-1 (`schema_catalog.py` publishes its own shapes).
|
||||
|
||||
### Cut 5. Kotlin: cultmesh-kotlin
|
||||
|
||||
- **Adds:**
|
||||
- A `CultNetErrorMessage` data class and a `sealed class CultNetRefusal` (known codes plus `Other`),
|
||||
with a `CultNetMessage.asError(): CultNetErrorMessage?` view.
|
||||
- `class CultNetPeerException(val error: CultNetErrorMessage) : IOException`.
|
||||
- At `CultMesh.kt:1278` and `:1334`, and at every `receiveSchemaMessage` consumer that expects a
|
||||
response, an error response throws `CultNetPeerException` before the `require`.
|
||||
- **Verification** (`kotlin` image, `pwsh -File build.ps1`):
|
||||
- A vector test pins that every vector decodes to `expected`.
|
||||
- Kotlin emits no errors today, so encode parity applies only if a Kotlin server path is added. Say
|
||||
so in the report; do not invent one.
|
||||
- There is no mutation tool. Behavioural tests cover it: an error reply to a schema-catalog request
|
||||
raises `CultNetPeerException` carrying the code.
|
||||
|
||||
### Cut 6. Merge, release, consumers
|
||||
|
||||
- Soul runs over the whole branch before the merge:
|
||||
- rerun every vector suite;
|
||||
- decode each runtime's live emissions with every other runtime. The interop lane from Cut 0 is the
|
||||
harness; add an error round to `tests/GameCult.Networking.InteropPeer` and
|
||||
`packages/cultnet-ts/test/interop` if Soul finds a runtime pair unreached.
|
||||
- Release per `docs/semver-policy.md`, with a Soul pass before tags:
|
||||
- `org.gamecult.cultlib` major, and the tracked Unity DLLs rebuilt, since they embed the schema;
|
||||
- `cultnet-py` and `cultmesh-py` major.
|
||||
- Consumers, routed by Self as separate repo changes:
|
||||
- Huginn: pin bump plus Cut 2's note.
|
||||
- StreamPixels and Heimdall: re-vendor. Heimdall is already broken by R-X.
|
||||
- AetheriaEve, Gjallar and Mimir compile against the live CultLib path and read only `.Error`, so they
|
||||
need no change. That is inferred from the audit; the next build of each confirms it.
|
||||
- Hermodr: none.
|
||||
|
||||
## 6. Subtraction ledger
|
||||
|
||||
| Cut | Removed (estimate) | Added (estimate) | Formats / targets |
|
||||
|---|---|---|---|
|
||||
| 0 | 0 | 3 | none |
|
||||
| 1 | ~60 C#, schema text | ~250 C#, ~90 schema | + `error-vectors.cs-written.json`; - `[Explicit]` hex printer |
|
||||
| 2 | ~150 (types, two helpers, 160-line hex test) | ~200 | hex constants gone; vectors read from file |
|
||||
| 3 | ~40 | ~120 | none |
|
||||
| 4 | ~40 | ~120 (`errors.py`) | 8 ad-hoc codes → registry |
|
||||
| 5 | 0 | ~80 | none |
|
||||
|
||||
The net is positive, by roughly 500 lines, across five runtimes. It buys typed decode where there was
|
||||
none (Kotlin, TS and Python), a correlation capability, and one vector file in place of hand-copied hex.
|
||||
The pressure point: if Cut 1's formatter passes 150 lines, report it rather than trimming typing.
|
||||
|
||||
## 7. Follow-ups outside this map
|
||||
|
||||
- **F-1.** `packages/cultnet-py/src/cultnet_py/schema_catalog.py` publishes about 350 lines of hand-built
|
||||
schemas under canonical `$id`s. Python should package and serve `contracts/cultnet/*.schema.json`, the
|
||||
way Rust vendors them. It is a separate cut, because it covers every message, not errors. It can wait
|
||||
because the catalog is advisory: no runtime validates a peer's message against a published descriptor.
|
||||
- **F-2.** Codes for the remaining codeless refusals (authorization, failed snapshot sources, routing).
|
||||
Wait for a consumer that branches on them.
|
||||
- **F-3.** Whatever the Cut 0 lane reveals once its later steps run again.
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -271,15 +271,6 @@ Current progress:
|
|||
flush where the runtime supports one, so callers that require confirmed
|
||||
transport delivery do not substitute a fixed sleep. TCP-framed profiles do
|
||||
not publish the field because they do not own the RUDP pending-reliable queue.
|
||||
- The receive window is a contract, not a discovery. A receiver must hold 1,023
|
||||
sequences and 4 MiB of payload beyond its contiguous received watermark, and
|
||||
a conforming sender never exceeds either. The sender's flow window enforces
|
||||
it without a wire field: a reliable packet is admitted only while its
|
||||
sequence is at most 1,023 above the lowest unacked sequence and the payload
|
||||
above that sequence stays within 4 MiB. The first packet is always
|
||||
admissible, so a payload larger than 4 MiB goes out alone, and packets wait
|
||||
in the FIFO in order, so a small packet never overtakes a large one. C#,
|
||||
Rust, TypeScript, and Python enforce it; Kotlin does not yet.
|
||||
- TypeScript, C#, Rust, Python, and Kotlin now share a portable
|
||||
`cultnet.reconnect_policy.v0` document and deterministic exponential delay
|
||||
helper, and RUDP transport profiles advertise that policy under
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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.
|
||||
|
|
@ -11,12 +8,9 @@ CultNet, and CultCache ownership boundaries.
|
|||
powershell -ExecutionPolicy Bypass -File .\scripts\pack-nuget.ps1
|
||||
```
|
||||
|
||||
The command packs `GameCult.Mesh`, `GameCult.Networking.WebSockets`, and every
|
||||
project they reach through `ProjectReference` (including `GameCult.Math`, which
|
||||
carries its own version) into a local feed under `artifacts/nuget`. It verifies
|
||||
that each of those packages exists at the identity MSBuild reports for it, then
|
||||
restores and runs a clean .NET consumer using only
|
||||
`PackageReference Include="GameCult.Mesh"`.
|
||||
The command creates a local feed under `artifacts/nuget`, verifies that every
|
||||
internal dependency package exists at the same version, then restores and runs
|
||||
a clean .NET consumer using only `PackageReference Include="GameCult.Mesh"`.
|
||||
|
||||
The Unity distribution remains `org.gamecult.cultlib`, built by
|
||||
`scripts/build-unity-package.ps1`, because Unity consumes the managed assembly
|
||||
|
|
@ -25,5 +19,5 @@ closure through UPM rather than NuGet restore.
|
|||
Install the Unity package from the immutable release tag:
|
||||
|
||||
```text
|
||||
https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.57
|
||||
https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.46
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,286 +0,0 @@
|
|||
# Analytic noise integration along warped rays: feasibility probe
|
||||
|
||||
Campaign `cultmath-tapes`, Imagination probe, 2026-10-03. This is a measurement,
|
||||
not a spec. The tape target stays parked under ruling
|
||||
`cultmath-tapes:ruling:tape-target-unparks-when-asura-stable`.
|
||||
|
||||
**Verdict.** Exact integration of the noise does not beat sampling for optical
|
||||
depth on either shipped field.
|
||||
- The exact line integral of CultMath `snoise` is cheap and correct. Affine warp
|
||||
spans are long, at 100 to 300 world units.
|
||||
- The exact noise integral is not the optical depth, because both densities are
|
||||
nonlinear in the noise.
|
||||
- The void's wall profile is `pow(·, 0.40)` with two clamps.
|
||||
- Aetheria's fog is clamped at zero and goes opaque within a few world units.
|
||||
- Given the *exact* span mean of the noise for free, the resulting transmittance
|
||||
is still 3.6x worse than the shipped march on the void and 22x worse on the
|
||||
wells.
|
||||
- Exact integration wins only on the linear functional `∫ n dt` of octaves the
|
||||
march undersamples. That gain does not survive the density's nonlinearity.
|
||||
- Aetheria's triangle noise is integrable exactly as piecewise linear, but costs
|
||||
3x to 22x the march, and its shipped form does not meet the preconditions.
|
||||
|
||||
## Scenarios and method
|
||||
|
||||
- **Void:** the shipped void (`NoiseBoundTests.VoidField.Shipped()` at
|
||||
`hands/cultmath-interval-ops` 2c8585bd).
|
||||
- Rh 198, S 50, Rc 248, K 1/30, A 20, F0 0.01 (cells of 100 and 25 world), warp
|
||||
D 10.
|
||||
- The shipped orbiting camera, N = 8 tiles at f = 935, one jittered pixel ray per
|
||||
tile.
|
||||
- **Wells:** the test's Aetheria wells at warp D = 60 (`FogField.Wells(60)`).
|
||||
- F0 1/414.2167, A 20, B 10, extinction 0.5, flow frequency 1/512, period 8.
|
||||
- Like the test, this uses the snoise field shape (r1's), not Aetheria's own
|
||||
triangle noise. Triangle noise is measured separately below, on the same rays.
|
||||
- **Rays:** 200 per scenario, and 120 for the triangle basis.
|
||||
- The wells give 147 rays with an active interval; the rest look up and miss
|
||||
the fog.
|
||||
- The active interval is where the envelope leaves the density to the noise.
|
||||
It is cut where the true optical depth reaches 6.
|
||||
- Active length per ray: void median 140 world (p10 114, p90 193); wells median
|
||||
268 (p10 137, p90 623).
|
||||
- **March steps:** the void's march step (LodStep, 12.5 in z) is a median 14.9
|
||||
world along the ray. The wells' quadratic grid gives a median of 9.1 in the
|
||||
active region.
|
||||
- **Warp:** `w(p) = flow(p) · shift`, so the affine error scales exactly with
|
||||
`|shift|`.
|
||||
- Spans are grown at the worst phase shift, Period / 2. A span is therefore
|
||||
valid at every frame's phase, for any camera that crosses the same world
|
||||
segment.
|
||||
- Each span is anchored at p0 with J·d from a central difference. It grows in
|
||||
1-unit (void) or 2-unit (wells) steps until `|w − (w0 + J·d·Δ)| > τ`.
|
||||
- **Exact simplex integral:** CultMath `snoise(float3)` has kernel radius² 0.5,
|
||||
so its support never leaves the incident simplices.
|
||||
- Along a line it is therefore a sum over the lattice points within √0.5 of the
|
||||
line. Each point contributes `∫ (0.5 − |x|²)⁴ (g·x) dt` on its chord, a
|
||||
closed-form degree-9 polynomial.
|
||||
- Validation:
|
||||
- The lattice sum matches `snoise` within 2e-4 (float32 rounding at
|
||||
coordinates near 300).
|
||||
- The segment integral matches a 5e-4-step quadrature within 2.5e-5 per
|
||||
unit length.
|
||||
- **Run:** probe code was in a scratch clone (probe-clone@91c8cbf1, never merged)
|
||||
and ran on Yggdrasil through the verify stopgap, in a dotnet 10 container with
|
||||
4 CPUs. Timings are single-threaded C# on that host and are relative.
|
||||
|
||||
## 1. Affine-valid span length (ray length, world units)
|
||||
|
||||
| τ (× coverage cell) | Void p10 / median / p90 | Wells p10 / median / p90 |
|
||||
|---|---|---|
|
||||
| 0.05 | 94 / 129 / 211 | 71 / 103 / 169 |
|
||||
| 0.10 | 141 / 197 / 324 | 108 / 151 / 253 |
|
||||
| 0.25 | 263 / 420 / 1244 | 184 / 275 / 715 |
|
||||
| 0.05 × detail cell | 44 / 63 / 97 | 34 / 47 / 75 |
|
||||
|
||||
- One affine span at τ = 0.1 covers about 13 void march steps and about 16 wells
|
||||
march steps.
|
||||
- The void shell is shorter than one span. The tiled spans are clipped by the
|
||||
active interval, at a median of 27 world and 2 spans per ray.
|
||||
- On the wells, the tiled spans run a median of 128 world, with 2 per ray (p90 4).
|
||||
- The tangent anchor is the worst affine choice. A secant or Chebyshev fit over
|
||||
the span cuts the error bound by 2x to 8x, so the spans could be about 1.4x to
|
||||
2.8x longer at the same τ. That was not measured.
|
||||
|
||||
## 2. Simplex cells per span, per octave
|
||||
|
||||
Octave `xm` is the coverage octave's frequency times m, warped like the coverage
|
||||
octave. The shipped detail octave is x4 and is *unwarped* in both fields, so it
|
||||
needs no affine span at all.
|
||||
|
||||
Counts are for τ = 0.1 tiled spans with both warp phases, given as the median and
|
||||
then the p90. The rows are simplex cells crossed, then lattice points touched,
|
||||
which is the exact work.
|
||||
|
||||
| Octave | Void cells | Void lattice points | Wells cells | Wells lattice points |
|
||||
|---|---|---|---|---|
|
||||
| x1 | 6 / 16 | 10 / 16 | 4 / 8 | 8 / 10 |
|
||||
| x2 | 8 / 27 | 10 / 26 | 8 / 12 | 10 / 14 |
|
||||
| x4 | 13 / 52 | 14 / 44 | 12 / 20 | 14 / 20 |
|
||||
| x8 | 20 / 100 | 20 / 80 | 22 / 38 | 22 / 34 |
|
||||
| x16 | 40 / 201 | 35 / 154 | 44 / 72 | 38 / 60 |
|
||||
| x32 | 75 / 396 | 62 / 302 | 86 / 144 | 70 / 112 |
|
||||
| x64 | 151 / 793 | 118 / 592 | 172 / 287 | 132 / 218 |
|
||||
|
||||
- On the same spans, the march takes a median of 2 samples per span on the void
|
||||
(p90 10) and 12 on the wells (p90 25).
|
||||
- A straight line touches 3.44 lattice points per noise unit.
|
||||
|
||||
## 3. Cost: exact against the march
|
||||
|
||||
- **Measured cost.**
|
||||
- One snoise is 530 to 610 ns in C# on this host.
|
||||
- One lattice-point integral (hash, gradient, closed-form polynomial) is
|
||||
85 ns, or 0.16 snoise.
|
||||
- With this probe's naive box-scan enumeration it is 1.24 snoise. That is an
|
||||
upper bound; a simplex walk visits only crossed cells.
|
||||
- **How the table counts.** Exact is lattice points × 0.16 + 1 snoise per span
|
||||
for the Jacobian. March is 1 snoise per sample. Both sides carry the two warp
|
||||
phases.
|
||||
- **Tolerance.** τ is 0.1 × *that octave's own* cell, so the affine error is
|
||||
comparable across octaves.
|
||||
- **Columns.** The two error columns are RMS errors of the active-length mean
|
||||
of one warped octave. The last error column is what replacing the octave by its
|
||||
expectation of 0 costs.
|
||||
|
||||
| Octave | Void exact/march cost | Void err exact / march / expectation | Wells exact/march cost | Wells err exact / march / expectation |
|
||||
|---|---|---|---|---|
|
||||
| x1 | 0.36 | 1.1e-2 / 1.6e-3 / 0.21 | 0.11 | 1.9e-2 / 9e-5 / 0.29 |
|
||||
| x2 | 0.46 | 1.0e-2 / 4.7e-3 / 0.13 | 0.15 | 1.5e-2 / 2e-4 / 0.21 |
|
||||
| x4 | 0.68 | 9.0e-3 / 4.0e-2 / 0.10 | 0.23 | 1.1e-2 / 5e-4 / 0.18 |
|
||||
| x8 | **1.03** | 5.8e-3 / 8.0e-2 / 0.067 | 0.36 | 7.2e-3 / 1.1e-3 / 0.10 |
|
||||
| x16 | 1.64 | 4.0e-3 / 9.0e-2 / 0.050 | 0.56 | 6.4e-3 / 9.4e-3 / 0.088 |
|
||||
| x32 | 2.89 | 2.8e-3 / 0.105 / 0.036 | 0.87 | 4.7e-3 / 3.3e-2 / 0.057 |
|
||||
| x64 | 5.26 | 2.0e-3 / 0.095 / 0.025 | **1.44** | 3.2e-3 / 5.3e-2 / 0.040 |
|
||||
|
||||
- **Where exact stops paying on cost:**
|
||||
- At the measured kernel cost: x8 on the void and between x32 and x64 on the
|
||||
wells.
|
||||
- At the brief's model of one snoise per cell crossed: about x1 to x2 on the
|
||||
void and x8 on the wells, from the per-span table in the probe log.
|
||||
- **Exact against the march on accuracy:**
|
||||
- At the shipped step the march is *more* accurate for octaves it oversamples:
|
||||
void x1 to x2, wells x1 to x8. The exact path's error floor is the affine warp
|
||||
error, about 1e-2 at τ = 0.1 per cell, and it scales with τ.
|
||||
- Exact is more accurate at about 0.7x the cost where the march undersamples:
|
||||
void x4 to x8, wells x16 to x32.
|
||||
- Above about x16 on the void, the undersampled march is worse than simply
|
||||
using the expectation (0.095 against 0.025 at x64). That is a band-limiting
|
||||
result, which the footprint octave weights already exploit. It is not an
|
||||
exact-integration result.
|
||||
|
||||
## 4. High-octave variance over a span
|
||||
|
||||
- **Span-mean statistics.** Over straight lines of noise length ℓ, the std of
|
||||
the snoise span mean, against a point std of 0.358, is:
|
||||
- 0.234 at ℓ = 1;
|
||||
- 0.170 at ℓ = 2;
|
||||
- 0.121 at ℓ = 4;
|
||||
- 0.087 at ℓ = 8;
|
||||
- 0.061 at ℓ = 16;
|
||||
- 0.031 at ℓ = 64.
|
||||
|
||||
This is about ℓ^-0.5: the high octaves average out like a random field, not
|
||||
like a periodic one.
|
||||
- **Detail octave replaced by its expectation of 0,** on τ = 0.1 spans, measured
|
||||
as |ΔOD| per span:
|
||||
- Void: median 0.000 and p90 0.091. Relative to the span's OD (OD > 0.01) the
|
||||
median is 2.5% and the p90 is 7.6%. The mean detail displacement is a median
|
||||
1.0 world (p90 3.6) against the ramp width S = 50.
|
||||
- Wells: median 0.11 and p90 5.7 optical depths. The relative median is 72%.
|
||||
- **Why the wells are so bad.** Normalised to the clamp, the density moves
|
||||
A·fade/B = 2 per unit of noise, and with extinction 0.5 that is 1 optical depth
|
||||
per world unit per unit of noise. The mean detail displacement of 1.5 world
|
||||
(p90 3.8) is 15% to 38% of the floor blend B = 10. The wells' optical depth is
|
||||
set by where the clamp boundary lies, and that is exactly what the expectation
|
||||
erases.
|
||||
- So the expectation is adequate for the void's detail octave and is not
|
||||
adequate for the wells.
|
||||
|
||||
## 5. The clamp and other nonlinearity
|
||||
|
||||
- **Void.** 50.0% of τ = 0.1 spans cross a regime boundary: 0 (d' ≤ Rh), the
|
||||
wall ramp, or K (d' ≥ Rc). 45.6% of the active length lies in the ramp, where
|
||||
the density is `K max(0, 1 − 1.5 (1 − (d'/Rc)²)^0.40)`. That is not a
|
||||
polynomial in n, so exact ∫n does not give ∫ρ there. Outside the ramp the
|
||||
density is constant and needs no noise.
|
||||
- **Wells.** 42.3% of spans cross the clamp. Only 9.8% of the active length is
|
||||
unclamped, so most of the length where the noise "matters" is length where the
|
||||
noise only decides whether the density is zero.
|
||||
- **The decisive check: transmittance error.** This is the sup over the ray of
|
||||
|T_est − T_true|, with the truth taken on a 0.5- or 1-unit grid.
|
||||
|
||||
| Estimator | Void median / p90 | Wells median / p90 |
|
||||
|---|---|---|
|
||||
| Shipped march | 0.020 / 0.038 | 0.033 / 0.155 |
|
||||
| March at 4x density | 0.006 / 0.009 | 0.030 / 0.049 |
|
||||
| Density of the *exact* span-mean n (τ = 0.1 spans) | 0.072 / 0.125 | 0.719 / 0.997 |
|
||||
|
||||
The last row is an upper bound on what any exact-mean method can deliver
|
||||
without also modelling the distribution inside the span. It is the
|
||||
Heitz-style correction the survey describes. It loses to the march on both
|
||||
fields.
|
||||
|
||||
## Triangle basis (operator's addition)
|
||||
|
||||
The triangle noise is `triNoise3d` in Aetheria `Assets/Shaders/Volumetric.cginc`
|
||||
(origin/master f1dee184).
|
||||
- `tri(x) = |frac(x) − 0.5|`.
|
||||
- It runs two Nimitz-style iterations. Each iterates `p += tri3(bp·2)`, scales
|
||||
`bp` by 1.8 and `p` by 1.2, and adds `tri(p.z + tri(p.x + tri(p.y)))/z`.
|
||||
- It makes 18 tri calls per evaluation.
|
||||
|
||||
As shipped, `density()` uses it as follows:
|
||||
- `pow(triNoise3d(·), exponent)`, with Noise.Exponent −0.25 multiplied by a
|
||||
slope `flatness` read from a texture (NOISE_SLOPE is on).
|
||||
- Two phases, warped by `globalFlow`, which is
|
||||
`Tri3D = cross(normalize(tri3(P)), normalize(tri3(1.618P))) × 15`.
|
||||
- The fine octave at ×8 is evaluated at a `pos` whose y the coarse octave has
|
||||
already displaced.
|
||||
- Amplitude −36.17.
|
||||
|
||||
Self's reading, tested:
|
||||
|
||||
- **"The warped ray is an exact polyline."** This holds for the warp *inside*
|
||||
`triNoise3d`. It does not hold for Aetheria's flow: `normalize` and `cross`
|
||||
make the flow piecewise rational. With the normalize dropped, the cross product
|
||||
of two piecewise-linear vectors is piecewise quadratic. tri composed with a
|
||||
piecewise polynomial stays piecewise polynomial of the same degree, with
|
||||
breakpoints at quadratic roots, so that variant is still closed-form.
|
||||
Counted along the real flow at the worst shift, the kinks run 15% to 30% above
|
||||
the straight-ray counts, but the pieces between them are curved.
|
||||
- **"The clamp roots are linear."** This holds only if the exponent is 1. With
|
||||
−0.25·flatness, each piece is `(a + bt)^e`. One such piece has a closed-form
|
||||
integral, but the clamp's root on a sum of two powered phases plus the
|
||||
smoothstep fade has none.
|
||||
- **"Whole periods give the exact mean."** This holds for one triangle wave at
|
||||
whole periods, where the mean is exactly 0.25. It fails for the composed form,
|
||||
which has no period along a general line; the scales 2, 1.8 and 1.2 and the
|
||||
nested warp are incommensurate.
|
||||
- Measured on lines, the span-mean std of `triNoise3d` is 0.029 at ℓ = 1,
|
||||
0.010 at ℓ = 8 and 0.0042 at ℓ = 64, against a point std of 0.083. That is
|
||||
about ℓ^-0.47, the same random-field decay as simplex.
|
||||
- Its bias against the global mean of 0.1995 is within ±0.0015, so the
|
||||
expectation is unbiased. It is not exact.
|
||||
- **The cost decides it.** The exact piecewise-linear walk is verified, matching
|
||||
a 1e-4 quadrature within 3.4e-8. But the nest has 77.7 linear pieces per noise
|
||||
unit of straight line, against 3.44 lattice points for simplex.
|
||||
- One piece costs 1.38 point evaluations with a derivative carried.
|
||||
- At Aetheria's scale, on the wells rays, the pieces per world unit and the
|
||||
exact cost relative to the march are:
|
||||
|
||||
| Octave | Pieces per world unit | Exact cost / march |
|
||||
|---|---|---|
|
||||
| x1 | 0.20 | 2.8x |
|
||||
| x2 | 0.38 | 5.5x |
|
||||
| x4 | 0.76 | 11x |
|
||||
| x8 (shipped fine) | 1.5 | 22x |
|
||||
| x64 | 12 | 176x |
|
||||
|
||||
- On the void rays it is 4.1x at x1 and 29x at x8.
|
||||
- At the same time the march's RMS error on the span mean is already small:
|
||||
2.4e-3 at x1 and 1.7e-2 at x8.
|
||||
|
||||
## Per-basis verdict
|
||||
|
||||
| Basis | Exact line integral | Under the shipped warp | Optical depth | Verdict |
|
||||
|---|---|---|---|---|
|
||||
| Simplex (CultMath `snoise`) | Closed form, 0.16 snoise per lattice point, 3.44 points per noise unit; validated | Affine spans of 100 to 300 world at τ = 0.1 cell; error floor about 1e-2 | Not delivered: the void's pow profile and the wells' clamp make ρ nonlinear in n; the exact-mean transmittance loses 3.6x and 22x | **Viable kernel, wrong target.** Pays only if the density were affine in n over the span, or as a control variate for a march (survey §4, Kettunen 2021) |
|
||||
| Triangle (`triNoise3d`) | Closed form per linear piece; validated | Not piecewise linear under Aetheria's normalized-cross flow or the −0.25 power; made linear by exponent 1 and a non-normalized flow | Same nonlinearity, plus 78 pieces per noise unit | **Loses.** 3x to 22x the march even where its preconditions hold |
|
||||
| Gabor kernels (survey §1.2, not probed) | Closed form for any affine-transformed kernel (complex erf, at most 16 terms) | The affine warp spans above transfer directly, with each span a whitening transform | Same density-nonlinearity limit | **Candidate smooth basis for large structure**, to be measured only if the density is first made linear in the noise over a span |
|
||||
|
||||
## What would change the verdict
|
||||
|
||||
- **A density that is affine in the noise inside a span.** For example, the
|
||||
void's ramp replaced by a linear ramp in d', with clamp crossings bracketed by
|
||||
the interval bound. Exact simplex integration would then deliver optical depth
|
||||
directly in the 54% of void length that is outside the ramp's clamps. As
|
||||
measured, that 54% is constant density and needs no noise at all.
|
||||
- **Exact integration as a control variate,** not a replacement. The exact mean
|
||||
is cheap and unbiased for ∫n, and the march corrects the nonlinear residual.
|
||||
That is the Kettunen construction. It was not measured here.
|
||||
- **Wider skipping.** The operator's first idea, skipping space outside the warp
|
||||
range, is already what the interval envelope does, and the probe's active
|
||||
intervals are its output.
|
||||
- On the void the noise matters over only about 140 world per ray.
|
||||
- On the wells, opacity ends the ray within a few world units of entering the
|
||||
unclamped region.
|
||||
|
|
@ -1,425 +0,0 @@
|
|||
# Analytic Noise Integration Along Warped Rays: Prior Art Survey
|
||||
|
||||
This is the facts file for an idea the operator raised on 2026-10-03:
|
||||
|
||||
> "I would be happy if we could just skip the space we know has no meaningful structure
|
||||
> because it's outside of the noise warp range. But what you said about affine arithmetic
|
||||
> makes me wonder if we can somehow, even within the distorted domain, figure out a series
|
||||
> of optimal (in terms of extracting structure over multiple frames) spans for which we can
|
||||
> analytically integrate the noise gradients over those ray segments."
|
||||
|
||||
Self's sketch, as given to this pass:
|
||||
|
||||
- Simplex noise along a straight ray is piecewise polynomial in t, so its line integral
|
||||
is closed-form per crossed cell.
|
||||
- A domain warp p + w(p) is nearly affine over a short span, with an affine-arithmetic
|
||||
error bound, so within that span the warped ray is a straight line in noise space.
|
||||
- Low octaves are integrated exactly. High octaves are replaced by their expectation,
|
||||
with a variance bound.
|
||||
- Clamped density max(0, n - c) is integrated by root-finding per piece.
|
||||
- Spans are cached and refined across frames.
|
||||
|
||||
Eyes pass, 2026-10-03. This file gives facts and evidence pointers only. It makes no
|
||||
recommendation.
|
||||
|
||||
Verification marks:
|
||||
|
||||
- **[fetched]** The claim was read in the cited source's full text during this pass.
|
||||
PDFs were converted with `pdftotext -layout`.
|
||||
- **[search]** The claim came from a search-result summary or abstract, not a full read.
|
||||
- **[derivation]** The claim is elementary mathematics worked out in this pass from a
|
||||
fetched definition. It is not a claim made by any cited source.
|
||||
- **[not found]** The pass searched for this kind of work and found nothing.
|
||||
|
||||
## 0. Premises of the sketch, checked against definitions
|
||||
|
||||
- **[fetched] + [derivation]** Simplex noise kernel degree. Gustavson, *Simplex noise
|
||||
demystified* (2005), gives the 3D reference code. Each of the four simplex corners
|
||||
contributes `t^4 * dot(g, d)` with `t = 0.6 - |d|^2`, and the contribution is zero
|
||||
when `t < 0`. On a straight ray `d` is affine in the ray parameter, so `|d|^2` is
|
||||
quadratic and each corner term is a polynomial of degree 8 + 1 = 9. The pieces have
|
||||
two kinds of breakpoint: the simplex faces the ray crosses (where the corner set
|
||||
changes), and the entry and exit of each corner's support sphere (`|d|^2 = 0.6`).
|
||||
Both kinds are roots of polynomials of degree 2 or lower in the ray parameter, so the
|
||||
breakpoints are closed-form. With the 0.6 radius, the support extends past the
|
||||
simplex, and the reference code truncates it at the simplex boundary. The resulting
|
||||
small discontinuity across faces is a known property of that constant. It does not
|
||||
change the piecewise-polynomial structure.
|
||||
- **[derivation]** Classic Perlin noise (Perlin 2002 quintic fade `6t^5 - 15t^4 + 10t^3`,
|
||||
read in Gustavson's text **[fetched]**) is, within one lattice cube along a straight
|
||||
ray, a polynomial of degree 3 x 5 + 1 = 16. Its breakpoints are the cube faces.
|
||||
- **[fetched]** Piece count grows with frequency. Balint (CESCG 2019, section 2)
|
||||
states that many noise functions are piecewise integrable, but the number of pieces
|
||||
is linear in the noise frequency. Their cost is therefore similar to numerical
|
||||
integration. Balint designed cosine noise to escape this limit (section 1.1 below).
|
||||
- **[derivation]** For the clamp `max(0, n - c)` on a degree-9 or degree-16 piece,
|
||||
the roots of `n - c` are needed. Polynomials of degree 5 or more have no general
|
||||
closed-form roots (Abel-Ruffini), so the clamp needs numerical root isolation per
|
||||
piece, for example Sturm sequences, Descartes' rule or Bernstein-basis subdivision.
|
||||
The integral between the isolated roots is closed-form.
|
||||
|
||||
## 1. Closed-form line integrals of noise, and analytic optical depth
|
||||
|
||||
### 1.1 Cosine noise: Balint, *Closed Form Transmittance in Heterogeneous Media Using Cosine Noise*, CESCG 2019 (non-peer-reviewed)
|
||||
|
||||
- **[fetched]** It computes noise defined as an offset plus a sum of cosines of
|
||||
`x . S_i`. The paper uses 24 quasirandom vectors S_i, with Sobol directions on the
|
||||
sphere and lengths in [0.8, 1.2], to look roughly like Perlin noise. Fractal octaves
|
||||
are further cosine sets. Its line integral is a sum of sines, so the optical depth
|
||||
over any segment is exact. The paper says this takes two noise samples, one at each
|
||||
segment end. It also gives closed-form distance sampling, by inverting the integral
|
||||
with binary search.
|
||||
- **[fetched]** Cost: two noise-sized evaluations per segment, independent of octave
|
||||
count. Table 1 reports transmittance evaluation at 179 ms closed-form against
|
||||
1250 ms for delta tracking, about 7x faster. A real-time demo with 16 samples ran
|
||||
at 80 fps at 675 x 1200 (RTX 2080 Ti).
|
||||
- **[fetched]** Error: exact, with no error term, for the bare sum. The limits are
|
||||
stated in section 6. Multiplying by a shape function, or composing with a shaping
|
||||
function, generally destroys the elementary antiderivative. The workarounds
|
||||
recover exactness only piecewise: constant multipliers between bounding isosurfaces,
|
||||
or a cubic polynomial interpolant of the multiplier along the ray, since the product
|
||||
of a polynomial and cosines has an elementary antiderivative. Negative densities from
|
||||
a low offset are handled by clamping samples and integrals, which is not an exact
|
||||
`max(0, .)` integral. Domain warp is not discussed.
|
||||
|
||||
### 1.2 Gabor kernels: Condor, Hermann, Yurtsever, Didyk, *Gabor Fields: Orientation-Selective Level-of-Detail for Volume Rendering*, ACM TOG 45(4) art. 62, 2026 (arXiv 2602.05081)
|
||||
|
||||
- **[fetched]** It computes closed-form definite line integrals of anisotropic 3D Gabor
|
||||
kernels along an arbitrary ray segment (eq. 9). Each kernel is whitened into its
|
||||
canonical space with a ZCA matrix, so any covariance, that is any affine-transformed
|
||||
kernel, uses the same formula. The segment integral is the real part of a
|
||||
complex-erf (Faddeeva) difference.
|
||||
- **[fetched]** Cost: the complex erf is evaluated by a truncated Taylor series with
|
||||
early exit, capped at 16 terms. Segments shorter than 1e-4 in whitened units fall back
|
||||
to a single midpoint evaluation. Kernels are spatially clamped with an adaptive extent
|
||||
that shrinks with frequency (eq. 15). The paper also renders procedural clouds as
|
||||
Gaussians modulated by Gabor noise.
|
||||
- **[fetched]** Error: each segment integral is exact apart from the erf truncation.
|
||||
Orientation-selective masking of kernels whose line integral is near zero trades a
|
||||
small error, or variance under stochastic selection, for speed. The paper reports
|
||||
about 1.78x to 1.96x speedups. Per segment the density-integral estimator is
|
||||
unbiased, but transmittance is biased by Jensen's inequality. Eq. 19 gives the
|
||||
second-order bias as `0.5 * Var(tau) * exp(-E[tau])`.
|
||||
|
||||
### 1.3 Analytically integrable media, older
|
||||
|
||||
- **[search]** Iser, *Real-time Light Transport in Analytically Integrable
|
||||
Quasi-heterogeneous Media*, CESCG 2018. Balint cites it **[fetched]** as using only
|
||||
exponential and spherical density functions.
|
||||
- **[fetched via Gabor Fields related work]** The closed-form Gaussian-primitive
|
||||
transmittance line (Knoll et al. 2021; Condor et al. 2025, *Don't Splat your
|
||||
Gaussians*) is the base that Gabor Fields extends.
|
||||
|
||||
### 1.4 Piecewise-polynomial segment integration in scientific volume rendering
|
||||
|
||||
- **[fetched]** Engel, Kraus, Ertl, *High-Quality Pre-Integrated Volume Rendering Using
|
||||
Hardware-Accelerated Pixel Shading*, Graphics Hardware 2001. The scalar field along
|
||||
the ray is assumed piecewise linear between samples. The volume rendering integral of
|
||||
each linear segment, through an arbitrary nonlinear transfer function, is then one
|
||||
lookup in a table indexed by front scalar, back scalar and segment length. Cost: one
|
||||
texture lookup per segment, plus recomputing the table when the transfer function
|
||||
changes. Error: exact for the piecewise-linear model. The model error is the
|
||||
scalar field's deviation from linear between samples.
|
||||
- **[search]** Williams, Max, Stein, *A High Accuracy Volume Renderer for Unstructured
|
||||
Data*, IEEE TVCG 4(1), 1998. Exact integration through tetrahedra with linearly
|
||||
varying scalar and piecewise-linear transfer functions.
|
||||
|
||||
### 1.5 Polynomial noise along a ray
|
||||
|
||||
- **[not found]** No published closed-form segment integral of Perlin or simplex noise
|
||||
was found, as a method or a code library. Balint's remark in section 0 is the only
|
||||
published statement found on the piecewise approach, and it dismisses it on cost
|
||||
grounds.
|
||||
|
||||
## 2. Noise designed for analytic filtering or integration
|
||||
|
||||
- **[fetched]** Lagae, Lefebvre, Drettakis, Dutré, *Procedural Noise using Sparse Gabor
|
||||
Convolution*, ACM TOG 28(3), 2009. It computes a sparse-convolution noise of Poisson
|
||||
impulses with Gabor kernels. Each kernel is a Gaussian in frequency, so filtering with
|
||||
a Gaussian pixel filter multiplies Gaussians, which gives an analytically filtered
|
||||
kernel. Cost: the paper typically uses 100 impulses per kernel, or 25 to 50 at lower
|
||||
densities. Table 2 (GTX 280) reports, at 20 impulses per cell, 290 fps for 2D noise,
|
||||
111 fps for unfiltered surface noise and 50 fps for filtered surface noise. Error:
|
||||
the filtering is exact for a Gaussian filter. The noise statistics converge with
|
||||
impulse density.
|
||||
- **[fetched]** Lagae, Drettakis, *Filtering Solid Gabor Noise*, ACM TOG 30(4), 2011. It
|
||||
slices solid Gabor noise, filters each slice of each Gabor kernel, and gets a
|
||||
generalisation of the projection-slice theorem. It also states that wavelet noise
|
||||
integrates solid noise perpendicular to the surface. That is a line integral of noise
|
||||
along the normal, done in the basis.
|
||||
- **[search]** Cook, DeRose, *Wavelet Noise*, ACM TOG 24(3), 2005. A quadratic B-spline
|
||||
noise band, which is almost band-limited, built from a precomputed coefficient tile.
|
||||
Its 3D-to-surface projection integrates the noise along the normal. The original PDF
|
||||
was not retrievable in this pass. Pixar and ACM both returned HTML.
|
||||
- **[fetched, survey]** Lagae et al., *A Survey of Procedural Noise Functions*, CGF 2010.
|
||||
- Lewis's sparse convolution noise has a power spectrum equal to the kernel's,
|
||||
scaled.
|
||||
- Frequency clamping (Norton, Rockwood, Skolmoski 1982) cancels bands above the filter
|
||||
rate. It works only for narrowly band-pass noise, and Perlin noise is only weakly
|
||||
band-pass.
|
||||
- Nonlinear functions of noise, such as thresholds and colour maps, are not filtered
|
||||
by filtering the noise. That is unsolved in general.
|
||||
- Heidrich, Slusallek, Seidel 1998 (below) computes an area average of a procedural
|
||||
shader with an error bound, using affine arithmetic.
|
||||
- Perlin noise's amplitude distribution is visibly non-Gaussian, because it is zero at
|
||||
every lattice point and has few gradients. The other noises surveyed are
|
||||
approximately Gaussian.
|
||||
- **[fetched, via the survey above]** "Filtering procedural textures" lineage:
|
||||
- Rhoades et al. 1992 filters the colour table over `[N - e, N + e]`.
|
||||
- Hart et al. 1999 sets `e` from the local noise gradient.
|
||||
- Lagae 2009 sets the range from the analytically known loss of variance.
|
||||
|
||||
## 3. Affine arithmetic, reduced AA, Taylor models, and bounds on warps
|
||||
|
||||
### 3.1 Gamito and Maddock
|
||||
|
||||
- **[fetched]** Gamito, Maddock, *Ray Casting Implicit Fractal Surfaces with Reduced
|
||||
Affine Arithmetic*, The Visual Computer 23:155-165, 2007. The full method is read in
|
||||
Gamito's PhD thesis (Sheffield), sections 5.3 to 5.4.
|
||||
- **Computes:** a root of `g(t) = 0` along a ray, by subdividing intervals of t. Each
|
||||
interval is tested with an inclusion `G(T)` computed in reduced AA (Messine's AF1).
|
||||
The form is `t0 + t1 e1 + t2 e2`: e1 is the shared ray-parameter symbol, and e2
|
||||
absorbs every new nonlinearity by condensation after each operation.
|
||||
- **Noise in RAA:** gradient-noise kernels lose no correlation under RAA, because the
|
||||
three multiplications are local to one kernel and the cubic Hermite fade is done by
|
||||
a single Chebyshev affine approximation. The same holds for sparse convolution
|
||||
noise. Cellular noise loses accuracy, because `min` needs cancellation between
|
||||
condensed terms, and the F2 and F3 kernels were not implementable.
|
||||
- **Interval optimisation:** the parallelogram `g1/t1` slope plus or minus `g2`
|
||||
shrinks `[ta, tb]` before subdividing (eq. 5.27).
|
||||
- **Cost:** Table 5.2, gradient-noise hypertexture, average evaluations per ray and
|
||||
time:
|
||||
|
||||
| Method | Evaluations per ray | Time |
|
||||
|---|---|---|
|
||||
| IA | 116.57 | 7m35s |
|
||||
| Standard AA | 61.69 | 40m41s |
|
||||
| RAA | 61.69 | 6m43s |
|
||||
| RAA + interval optimisation | 46.39 | 5m04s |
|
||||
|
||||
RAA costs three numbers per quantity against two for IA. Standard AA is tight
|
||||
but slow because its error-symbol lists grow.
|
||||
- **Error bound:** conservative inclusion, as proven for AA by Comba and Stolfi 1993.
|
||||
Rounding error is ignored.
|
||||
- **[fetched]** Gamito thesis, chapter 9, *Surface Deformation with Flow Mapping*.
|
||||
- Domain deformation `f(d(x))` with `d` defined by streamlines, that is by an ODE.
|
||||
This is the case closest to a domain warp in this survey.
|
||||
- Gamito states that a robust and tight AA estimate of the deformed ray `s(t)` was not
|
||||
obtainable through the numerical ODE solution, so robustness was given up.
|
||||
- The replacement test uses Lipschitz unbounding spheres at `s(ta)` and `s(tb)`. A
|
||||
span is skipped if `|s(tb) - s(ta)| < min(ra, rb)` (eq. 9.23). A sharp turn of the
|
||||
deformed ray between the endpoints can make this test wrong.
|
||||
- He names truncated Taylor-series range estimation (Nedialkov et al. 2004) as the
|
||||
possible fix, at higher cost and complexity.
|
||||
|
||||
### 3.2 Other affine and interval arithmetic sources
|
||||
|
||||
- **[fetched, bibliographic]** Comba, Stolfi, *Affine Arithmetic and its Applications to
|
||||
Computer Graphics*, SIBGRAPI 1993. de Figueiredo, Stolfi, *Affine Arithmetic: Concepts
|
||||
and Applications*, Numerical Algorithms 37, 2004. Gamito cites both. AA keeps
|
||||
first-order correlations through shared noise symbols. Each nonaffine operation adds
|
||||
one symbol, whose coefficient bounds the linearisation error. Chebyshev or min-range
|
||||
affine approximations give that coefficient for unary functions.
|
||||
- **[search]** Knoll, Hijazi, Kensler, Schott, Hansen, Hagen, *Fast Ray Tracing of
|
||||
Arbitrary Implicit Surfaces with Interval and Affine Arithmetic*, CGF 28(1), 2009.
|
||||
Inclusion-preserving reduced AA, SIMD CPU at interactive rates, and a stackless GPU
|
||||
version.
|
||||
- **[fetched, abstract]** Fryazinov, Pasko, Comninos, *Fast Reliable Ray-tracing of
|
||||
Procedurally Defined Implicit Surfaces Using Revised Affine Arithmetic*, Bournemouth
|
||||
TR-NCCA-2009-04. Revised AA, which also keeps one accumulated error symbol, is
|
||||
reported fastest among the IA and AA variants compared, including on pseudo-random
|
||||
procedural objects.
|
||||
- **[fetched]** Sharp, Jacobson, *Spelunking the Deep: Guaranteed Queries on General
|
||||
Neural Implicit Surfaces via Range Analysis*, ACM TOG 41(4), 2022. This is AA through
|
||||
long compositions of nonlinear layers, which is structurally like noise composed with
|
||||
a warp. Table 1 compares the variants. Time is relative to one scalar evaluation,
|
||||
length is the 1D region that can be certified, and raycast time is relative to the
|
||||
best method:
|
||||
|
||||
| Variant | Time | Length | Raycast time |
|
||||
|---|---|---|---|
|
||||
| Interval | 2.4x | 0.011 | 34.3x |
|
||||
| Affine-full | 95.3x | 0.821 | 8.4x |
|
||||
| Affine-fixed (no new symbols kept, extra error condensed into one) | 4.7x | 0.306 | 1.0x |
|
||||
|
||||
The paper uses affine-fixed for ray casting and affine-full for 3D queries.
|
||||
- **[fetched, survey quote of abstract]** Heidrich, Slusallek, Seidel, *Sampling of
|
||||
Procedural Shaders using Affine Arithmetic*, ACM TOG 17(3):158-176, 1998. It computes
|
||||
the average of a procedural shader over a finite area, with an AA error bound.
|
||||
- **[fetched, project page]** Keeter, *Massively Parallel Rendering of Complex
|
||||
Closed-Form Implicit Surfaces*, ACM TOG 39(4), 2020. Interval arithmetic over a
|
||||
shallow, high-branching tile hierarchy does two things: it skips empty tiles, and it
|
||||
prunes the expression tape per tile. One benchmark drops the expression size by about
|
||||
two orders of magnitude. It requires only C0 continuity, so warps and blends that break
|
||||
Lipschitz continuity are allowed.
|
||||
|
||||
### 3.3 Lipschitz bounds through warps
|
||||
|
||||
- **[fetched]** Galin, Guérin, Paris, Peytavie, *Segment Tracing Using Local Lipschitz
|
||||
Bounds*, CGF 39(2), 2020. It computes a local Lipschitz bound over a ray segment
|
||||
instead of a global one. Section 4.4 covers warps `f o w^-1`, and bounds the gradient
|
||||
by the product of the field gradient bound over the transformed segment and the
|
||||
Jacobian norm bound over the segment. Cost: fewer field queries than sphere tracing,
|
||||
with no acceleration structure. Error: conservative, if the local bounds are
|
||||
conservative.
|
||||
- **[fetched]** Seyb, Jacobson, Nowrouzezahrai, Jarosz, *Non-linear Sphere Tracing for
|
||||
Rendering Deformed Signed Distance Fields*, ACM TOG 38(6), 2019.
|
||||
- **Computes:** the ray in deformed space as a curve in undeformed space, by
|
||||
integrating an ODE whose derivative is the inverse-deformation Jacobian times the ray
|
||||
direction. An initial value comes from rasterising a deformed hull mesh.
|
||||
- **Error control:** step requirements for the curve are separated from those for the
|
||||
SDF. Adaptive Runge-Kutta (RKF45 and DP54 evaluated) is used far from the surface,
|
||||
and forward Euler near it, against an error tolerance.
|
||||
- **Scope:** surfaces only. There is no density integration.
|
||||
- **[fetched]** Moinet, Neyret, *Fast Sphere Tracing of Procedural Volumetric Noise for
|
||||
Very Large and Detailed Scenes*, CGF 2025 (Eurographics), HAL hal-05046040.
|
||||
- **Computes:** sphere tracing of FBM density fields, using first- and second-order
|
||||
(2-Lipschitz) forward inclusion functions. The FBM partial sums act as a nested
|
||||
bounding volume hierarchy: the remaining octaves are bounded by their amplitude sum
|
||||
`s(i+1, n)`, so evaluation stops early once a point is provably outside.
|
||||
- **Cost:** up to 12x faster than classical sphere tracing on opaque or constant-density
|
||||
scenes. Only up to 1.8x on non-constant density, and that gain comes from empty-space
|
||||
skipping alone. One example frame: 16 ms against 110 ms naive at 1024 x 1024 on a
|
||||
4080 Ti.
|
||||
- **Stated limits:** FBM used as a domain warp is not supported, nor are nonlinear
|
||||
transforms of FBM. The paper names analytic estimation of the contribution of
|
||||
sub-pixel detail as an open problem.
|
||||
|
||||
### 3.4 Taylor models
|
||||
|
||||
- **[search]** Berz, Makino, *Verified Integration of ODEs and Flows Using Differential
|
||||
Algebraic Methods on High-Order Taylor Models*, Reliable Computing 4, 1998. Makino,
|
||||
Berz, *Taylor Models and Other Validated Functional Inclusion Methods*, IJPAM 2003.
|
||||
- **Computes:** a Taylor model is a polynomial of order n plus an interval remainder
|
||||
bound, which encloses any function given as code over a box. The sharpness of the
|
||||
remainder scales with the box width to the power n + 1. The method also gives
|
||||
verified ODE flows.
|
||||
- **Verified integration:** Berz and Makino also published *New Methods for
|
||||
High-Dimensional Verified Quadrature* (Reliable Computing 1999). There the integral
|
||||
of the polynomial part is exact, and the remainder bound times the domain measure
|
||||
bounds the error.
|
||||
- First-order AA is the n = 1 case in spirit: an affine part plus a bounded remainder.
|
||||
|
||||
### 3.5 Affine bound on a warp over a span
|
||||
|
||||
- **[derivation]** Over a segment of half-length r, the best affine approximation
|
||||
(Chebyshev) of a C2 coordinate function has sup error at most `r^2 / 4` times the
|
||||
maximum of its second derivative along the segment. Linear interpolation of the
|
||||
endpoints has `r^2 / 2` times that maximum. The noise error caused by a displacement
|
||||
error of size `delta` is at most `L_noise * delta`. This is the same chain-rule
|
||||
structure Galin uses in section 4.4.
|
||||
|
||||
## 4. Statistical or expected-value treatment of high-frequency detail
|
||||
|
||||
- **[fetched]** Heitz, Nowrouzezahrai, Poulin, Neyret, *Filtering Color Mapped Textures
|
||||
and Surfaces*, I3D 2013. It computes the filtered value of a colour map applied to a
|
||||
procedural function as `<C, N(mu, sigma^2)>`. The noise's footprint distribution is
|
||||
replaced by a Gaussian with the footprint's mean and variance. This equals the colour
|
||||
map convolved with `N(0, sigma^2)` and evaluated at the mean, which is precomputed into
|
||||
a 2D table indexed by (mu, sigma). Cost: one lookup after mu and sigma are computed.
|
||||
Error: exact when the driving function's footprint distribution is Gaussian. For a
|
||||
specific noise instance it converges as the footprint grows, and the paper states this
|
||||
limit.
|
||||
- **[derivation]** For `X ~ N(mu, sigma^2)`, `E[max(0, X - c)] = (mu - c) Phi(z) +
|
||||
sigma phi(z)` with `z = (mu - c) / sigma`. This is the rectified-Gaussian mean, and the
|
||||
scalar instance of the Heitz 2013 construction for the map `max(0, . - c)`. The
|
||||
survey's fetched finding that Perlin noise is non-Gaussian bears on how accurate it is.
|
||||
- **[search]** Olano, Baker, *LEAN Mapping*, I3D 2010. Dupuy et al., *LEADR Mapping*, ACM
|
||||
TOG 32(6), 2013. They store the first and second moments of slopes in mipmaps. Linear
|
||||
filtering of the moments gives a Gaussian or Beckmann slope distribution per
|
||||
footprint. Cost: two extra texture channels and linear filtering. Error: the Gaussian
|
||||
assumption.
|
||||
- **[fetched]** Gabor Fields (1.2): band and orientation masking of high-frequency
|
||||
kernels, with the Jensen bias of transmittance `~ 0.5 Var(tau) exp(-E[tau])`.
|
||||
- **[search]** Vicini, Jakob, Kaplanyan, *A Non-exponential Transmittance Model for
|
||||
Volumetric Scene Representations*, ACM TOG 40(4), 2021. Bitterli et al., *A Radiative
|
||||
Transfer Framework for Non-exponential Media*, ACM TOG 37(6), 2018. Jarabo, Aliaga,
|
||||
Gutierrez 2018, arXiv 1805.02651. Unresolved, correlated sub-voxel density makes
|
||||
transmittance non-exponential. Positive correlation gives slower decay than exponential.
|
||||
- **[search]** Kettunen, d'Eon, Pantaleoni, Novák, *An Unbiased Ray-Marching
|
||||
Transmittance Estimator*, ACM TOG 40(4), 2021. A power-series estimator built around a
|
||||
control variate. The control variate is a ray-marched mean-density estimate, and rare
|
||||
higher-order terms remove the bias. It is relevant as the known way to make a
|
||||
deterministic or expected-value optical depth unbiased.
|
||||
- **[fetched]** Schneider, *The Real-time Volumetric Cloudscapes of Horizon: Zero Dawn*,
|
||||
SIGGRAPH 2015 Advances course. The high-detail noise is applied only as erosion where
|
||||
the low-detail sample is non-zero. Rays take cheap large steps until the low-detail
|
||||
isosurface is hit, step back once, and then take full samples. After several zero
|
||||
samples they return to cheap stepping. This is an octave-level skip heuristic with no
|
||||
bound. Moinet (3.3) is the bounded version for FBM.
|
||||
|
||||
## 5. Temporal reuse of per-pixel or per-tile raymarch structure
|
||||
|
||||
- **[fetched]** Schneider 2015 (above). The full raymarch cost about 20 ms. Each frame
|
||||
renders 1 of 16 pixels per 4 x 4 block into a quarter-resolution buffer, and
|
||||
reprojects the previous frame. Where reprojection fails, for example at screen edges,
|
||||
a low-resolution buffer is substituted. The paper reports 10x or more faster, with a
|
||||
target of about 2 ms on PS4.
|
||||
- **[search]** Klein, Strengert, Stegmaier, Ertl, *Exploiting Frame-to-Frame Coherence
|
||||
for Accelerating High-Quality Volume Raycasting on Graphics Hardware*, IEEE
|
||||
Visualization 2005. First-hit depths of the previous frame are reprojected by point
|
||||
splatting to estimate per-pixel ray start points. About 2x on typical datasets.
|
||||
- **[search]** Yagel, Shi, *Accelerating Volume Animation by Space-Leaping*, IEEE
|
||||
Visualization 1993. A C-buffer of first non-transparent voxel coordinates, updated
|
||||
across frames. Gudmundsson, Randén 1990 introduced inter-frame empty-space skipping.
|
||||
- **[search]** Scherzer et al., *Temporal Coherence Methods in Real-Time Rendering*,
|
||||
CGF 2012 STAR (Eurographics 2011). Reprojection caches, with depth-tolerance validity
|
||||
tests for disocclusion.
|
||||
- **[search]** Hillaire, *Physically Based Sky, Atmosphere and Cloud Rendering in
|
||||
Frostbite*, SIGGRAPH 2016 course. Temporal reprojection for volumetric clouds. Details
|
||||
were not read in this pass.
|
||||
- **[fetched]** Gamito thesis, chapter 7: progressive refinement rendering of
|
||||
Lipschitz-bounded and AA-bounded surfaces. It refines across passes within one image,
|
||||
not across animated frames.
|
||||
- **[not found]** No source was found that caches per-ray span partitions, affine
|
||||
forms or certified intervals across frames and refines them.
|
||||
|
||||
## 6. Warp linearisation combined with exact segment integration
|
||||
|
||||
- **[not found]** No source was found that combines piecewise affine linearisation of a
|
||||
domain warp, with an error bound, and exact closed-form integration of noise over the
|
||||
resulting segments.
|
||||
- Nearest pieces, each covering part of the combination:
|
||||
|
||||
| Source | What it covers | What it leaves out |
|
||||
|---|---|---|
|
||||
| Gamito ch. 9 | Bounding a deformed ray | No integral. AA gave up on the ODE warp |
|
||||
| Seyb 2019 | ODE curved ray with adaptive error control | Surfaces only |
|
||||
| Galin 2020 | Jacobian-norm local bounds through warps | Surfaces, not integration |
|
||||
| Keeter 2020 | Interval pruning that tolerates warps | Surfaces |
|
||||
| Gabor Fields 2026 | Closed form for any affine-transformed Gabor kernel along a segment | No warp |
|
||||
| Balint 2019 | Closed form for cosine noise | Composition and warps break it |
|
||||
| Pre-integration (Engel 2001) | Exact per-segment integral | Under a piecewise-linear model of the scalar |
|
||||
| Moinet 2025 | Bounded FBM octave skipping | Excludes FBM used as a domain warp |
|
||||
|
||||
- **[search]** Rezk-Salama et al., *Fast Volumetric Deformation on General Purpose
|
||||
Hardware*, Graphics Hardware 2001. Fetched text: piecewise-trilinear deformation
|
||||
patches with adaptive subdivision, rendered by object-aligned slicing. The
|
||||
approximate inverse is `x + O(delta^2)`. A search summary describes the deformed rays
|
||||
as polylines, but the fetched text uses slicing, not per-ray exact integration.
|
||||
|
||||
## Sources
|
||||
|
||||
- Balint 2019: https://www.cl.cam.ac.uk/~rkm38/pdfs/balint2019cosine_noise.pdf
|
||||
- Condor et al. 2026, Gabor Fields: https://arxiv.org/pdf/2602.05081
|
||||
- Gamito PhD thesis: https://staffwww.dcs.shef.ac.uk/people/S.Maddock/phd_theses/gamito/thesis_small.pdf
|
||||
- Gamito, Maddock 2007: https://link.springer.com/article/10.1007/s00371-006-0090-7
|
||||
- Lagae et al. 2009, Gabor noise: https://www-sop.inria.fr/reves/Basilic/2009/LLDD09/LLDD09PNSGC_paper.pdf
|
||||
- Lagae, Drettakis 2011: https://inria.hal.science/inria-00606907/file/paper_0049_preprint.pdf
|
||||
- Lagae et al. 2010 survey: https://www-sop.inria.fr/reves/Basilic/2010/LLCDDELPZ10/LLCDDELPZ10STARPNF.pdf
|
||||
- Heitz et al. 2013: https://inria.hal.science/hal-00765799
|
||||
- Sharp, Jacobson 2022: https://arxiv.org/pdf/2202.02444
|
||||
- Seyb et al. 2019: https://cs.dartmouth.edu/~wjarosz/publications/seyb19nonlinear.html
|
||||
- Galin et al. 2020: https://hal.science/hal-02507361
|
||||
- Moinet, Neyret 2025: https://inria.hal.science/hal-05046040
|
||||
- Keeter 2020: https://www.mattkeeter.com/research/mpr/
|
||||
- Fryazinov et al. 2009: https://eprints.bournemouth.ac.uk/11708/1/TR-NCCA-2009-04.pdf
|
||||
- Knoll et al. 2009: https://onlinelibrary.wiley.com/doi/10.1111/j.1467-8659.2008.01189.x
|
||||
- Berz, Makino 1998: https://link.springer.com/article/10.1023/A:1024467732637
|
||||
- Engel et al. 2001: https://www3.cs.stonybrook.edu/~mueller/teaching/cse616/engel.pdf
|
||||
- Gustavson 2005: https://cgvr.cs.uni-bremen.de/teaching/cg_literatur/simplexnoise.pdf
|
||||
- Schneider 2015: https://advances.realtimerendering.com/s2015/
|
||||
- Kettunen et al. 2021: https://jannovak.info/publications/URM/URM.pdf
|
||||
- Vicini et al. 2021: https://dvicini.github.io/non-exponential-representation/
|
||||
- Rezk-Salama et al. 2001: https://www3.cs.stonybrook.edu/~mueller/teaching/cse616/p17-rezk-salama.pdf
|
||||
- Klein et al. 2005: https://www.researchgate.net/publication/4188150
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
# Cross-process file locking and atomic replace: prior art
|
||||
|
||||
Facts only. Gathered 2026-10-02 for fork `variants:question:ts-python-write-lock`. No conclusions, no recommendation.
|
||||
|
||||
Provenance legend: **[src]** read from CultLib source this pass; **[primary]** vendor/spec/upstream doc or source fetched this pass (the fetch tool summarises pages through a small model, so quoted phrasing is paraphrase-grade unless shown in quotes); **[secondary]** search-result snippet or third-party issue, weak; **[UNVERIFIED]** not confirmed by any source read, or derived by inference only. Nothing here was tested by running code on Windows or Linux.
|
||||
|
||||
Dates caveat: the doc-site fetches reported release dates for `fs2 0.4.3` ("September 9, 2026") and `fs4 1.1.0` ("September 23, 2026"). The fs2 date is implausible against my memory of that crate (long stable, last release years earlier); treat crate dates as **[UNVERIFIED]**.
|
||||
|
||||
---
|
||||
|
||||
## 0. What CultLib does today (source, this checkout)
|
||||
|
||||
| Runtime | Lock | Temp name | Replace | Pointer |
|
||||
|---|---|---|---|---|
|
||||
| C# | Sidecar `<file>.lock`, `new FileStream(lockPath, OpenOrCreate, ReadWrite, FileShare.None, bufferSize 1)`. On `IOException`: if `wait` is false return null, else sleep 10 ms and retry for up to 30 s, after which the exception propagates. | `.{name}.{Guid:N}.tmp`, `FileMode.CreateNew`, `FileShare.None`, `WriteThrough`, `Flush(true)` | `File.Replace(temp, path, null)` if target exists, else `File.Move` | `F:\Projects\CultLib\src\GameCult.Caching\CultCache.cs` lines ~3475-3530 |
|
||||
| Rust | Sidecar `<file>.lock` (`.lock` appended to full file name), opened `create/read/write/truncate(false)` (std default share mode), then `fs2::FileExt::lock_exclusive` / `lock_shared` / `try_lock_exclusive` (WouldBlock => `None`). Unlock explicit, then drop. | `{name}.{uuid v4}.tmp`; staged file `sync_all` then closed | Unix: `fs::rename`. Windows: `MoveFileExW(MOVEFILE_REPLACE_EXISTING \| MOVEFILE_WRITE_THROUGH)` retried 25 times, 20 ms apart, on ERROR_ACCESS_DENIED(5)/SHARING_VIOLATION(32)/LOCK_VIOLATION(33). Comment cites a Muninn crash on 2026-09-11. | `F:\Projects\CultLib\packages\cultcache-rs\src\lib.rs` ~925-945, 1040-1084, 2870-3030; `Cargo.toml`: `fs2 = "0.4"`, edition 2024 |
|
||||
| TypeScript | None. In-process promise queue only (`next`/`result` chain). | `${file}.tmp-${pid}-${Date.now()}-${random}` (unique per writer) | `fs/promises.rename`, retried 10 times with 10 ms doubling on EPERM/EBUSY/EACCES; temp removed on error. No fsync of temp. | `F:\Projects\CultLib\packages\cultcache-ts\src\single-file-messagepack-backing-store.ts` ~143-205 |
|
||||
| Python | None. `threading.RLock` per store object (in-process only). | Fixed `path.with_suffix(suffix + ".tmp")`, i.e. `store.cc.tmp`, shared by all writers | `Path.replace` (`os.replace`); no fsync | `F:\Projects\CultLib\packages\cultcache-py\src\cultcache_py\stores.py` lines 70-82, 121-126 |
|
||||
|
||||
Both C# and Rust name the lock `<store path>.lock`. The same-name sidecar is the only thing the two currently share. Both also hold the lock around read-modify-write of the snapshot **[src]**. A lock file is never deleted by either (no stale-file problem; the lock is the kernel object, not the file's existence) **[src, inferred from absence of delete]**.
|
||||
|
||||
Note re the fork text: C# does use a lock (stated in the C# code comment: "excludes other handles in this process and in others alike").
|
||||
|
||||
---
|
||||
|
||||
## 1. Language and OS primitives
|
||||
|
||||
### 1.1 .NET `FileStream` / `FileShare`
|
||||
|
||||
- **Windows**: `FileShare` maps to the `dwShareMode` of `CreateFile`. `FileShare.None` = share mode 0: "Any request to open the file (by this process or another process) will fail until the file is closed." **[primary]** https://learn.microsoft.com/en-us/dotnet/api/system.io.fileshare . Win32 side: dwShareMode 0 "cannot be opened again, either by the application that opened it or by another application, until its handle has been closed"; failure is ERROR_SHARING_VIOLATION. **[primary]** https://learn.microsoft.com/en-us/windows/win32/fileio/creating-and-opening-files . Share modes are checked at **open** time and are not byte-range locks; they do not call `LockFileEx` **[inferred]**.
|
||||
- **Unix**: .NET emulates `FileShare` with advisory `flock(2)`. Source (`SafeFileHandle.Unix.cs`, runtime main): `lockOperation = (share == FileShare.None) ? LOCK_EX : LOCK_SH`, called as `FLock(handle, lockOperation | LOCK_NB)`; on `EWOULDBLOCK` an `IOException` is thrown; handle close issues `LOCK_UN`. **[primary]** https://raw.githubusercontent.com/dotnet/runtime/main/src/libraries/System.Private.CoreLib/src/Microsoft/Win32/SafeHandles/SafeFileHandle.Unix.cs
|
||||
- So non-`None` opens take a **shared** flock; `None` takes an **exclusive** flock; non-blocking in all cases. A plain read with `FileShare.ReadWrite | Delete` still takes `LOCK_SH`, which would conflict with a writer holding `LOCK_EX` on the same file (only relevant if the same file is opened both ways). **[inferred from source above]**
|
||||
- Disable switch: env `DOTNET_SYSTEM_IO_DISABLEFILELOCKING`; locking is off by default on Browser/WASI and iOS/tvOS (not Mac Catalyst). **[primary]**
|
||||
- Because it is advisory flock, it excludes only other openers that also take flock (other .NET processes, or any program calling flock on that file). It does not stop a process that merely `open()`s and writes. **[inferred]**
|
||||
- Since which .NET version: **[UNVERIFIED]**. I believe the flock-based FileShare emulation has existed since .NET Core 1.x/2.x, and .NET 6 rewrote FileStream into strategies while keeping it, but no source read this pass dates it.
|
||||
- Windows `FileShare.Delete` is meaningful; the Unix code does not differentiate it. **[primary]**
|
||||
- `File.Replace(src, dst, backup)` -> Win32 `ReplaceFile`; throws if volumes differ. **[primary]** https://learn.microsoft.com/en-us/dotnet/api/system.io.file.replace . Unix mapping to `rename` **[UNVERIFIED]**.
|
||||
|
||||
### 1.2 Rust
|
||||
|
||||
- `std::fs::File::lock`, `lock_shared`, `try_lock`, `try_lock_shared`, `unlock`: stable since **Rust 1.89.0**. **[primary]** https://doc.rust-lang.org/std/fs/struct.File.html
|
||||
- Unix/WASI: `flock` (`LOCK_EX`, `LOCK_SH`, `+LOCK_NB`, `LOCK_UN`). Windows: `LockFileEx` (flags `LOCKFILE_EXCLUSIVE_LOCK`, `LOCKFILE_FAIL_IMMEDIATELY`) and `UnlockFile`.
|
||||
- Docs: lock "may be advisory or mandatory", platform-dependent; on Windows the file must be opened for write (or read+append) for an exclusive lock; re-locking the same handle or a clone is unspecified, possibly deadlock; locks released when the file and all duplicated handles close; "this may change in the future".
|
||||
- `fs2` 0.4.x (what CultCache Rust uses): Unix `flock(2)`; Windows `LockFileEx` (source `src/windows.rs`: `lock_shared` passes 0, `lock_exclusive` passes `LOCKFILE_EXCLUSIVE_LOCK`, try variants add `LOCKFILE_FAIL_IMMEDIATELY`; `unlock` uses `UnlockFile`). Docs: "advisory"; do not lock one `File` concurrently; duplicated files need great care. **[primary]** https://docs.rs/fs2/latest/fs2/trait.FileExt.html , https://raw.githubusercontent.com/danburkert/fs2-rs/master/src/windows.rs . An earlier summary of the fs2 docs page said "LockFile" for Windows; the source file says `LockFileEx`. Maintenance status of fs2: **[UNVERIFIED]**.
|
||||
- `fs4`: fork of fs2 adding async, rustix instead of libc; docs say `flock`/`fcntl` on Unix, `LockFileEx` on Windows. Whether std's `File::lock` is mentioned or deprecates it: not stated on the page read. **[primary]** https://docs.rs/fs4/latest/fs4/ . Which Unix call it picks by default (flock vs fcntl): **[UNVERIFIED]**.
|
||||
- Windows default share mode of Rust `OpenOptions`: `FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE`; `share_mode(0)` makes exclusive. **[primary]** https://doc.rust-lang.org/std/os/windows/fs/trait.OpenOptionsExt.html
|
||||
- `std::fs::rename`: Unix `rename(2)`; Windows 10 1607+ `MoveFileExW(MOVEFILE_REPLACE_EXISTING)` or `SetFileInformationByHandle(FileRenameInfoEx)` (POSIX semantics); earlier, MoveFileExW only. "may change in future". **[primary]** https://doc.rust-lang.org/std/fs/fn.rename.html . (CultCache Rust bypasses std rename on Windows and calls MoveFileExW itself, per 0.)
|
||||
|
||||
### 1.3 Node
|
||||
|
||||
- No built-in advisory file lock in `node:fs`; **[UNVERIFIED]** by a direct docs read this pass, but nothing in the packages read contradicts it. Options seen:
|
||||
- `proper-lockfile`: "mkdir strategy" - creates `<target>.lock` as a **directory**; works across local and network FS; stale detection by lock dir **mtime** (default `stale` 10 s, min 5 s; mtime refreshed every `update` = stale/2, min 1 s); `onCompromised` callback when refresh fails or is late; avoids `O_EXCL` because "O_EXCL is broken on NFS" (their README); contrasts with `lockfile` package which uses ctime for staleness, unsuitable for long-running holders. **[primary]** https://raw.githubusercontent.com/moxystudio/node-proper-lockfile/master/README.md
|
||||
- `lockfile` (npm), `fs-ext` (native `flock`/`fcntl` bindings): not fetched. **[UNVERIFIED]**
|
||||
- Libuv rename on Windows uses `MoveFileExW(MOVEFILE_REPLACE_EXISTING)`; reports say it fails with EPERM while any handle holds the destination open, and refuses a READONLY destination; some runtimes (Bun PR) fall back to POSIX-semantic replace. **[secondary]** search snippets: https://github.com/oven-sh/bun/pull/43997 , https://github.com/joyent/libuv/issues/283 . Not read from libuv source: **[UNVERIFIED]**.
|
||||
- `npm/write-file-atomic`: temp name = `filename + "." + murmurhex(__filename, process.pid, ++invocations)` (plus worker thread id); same-process writes to one file queued and serialised; fsync on by default; no cross-process lock mentioned. **[primary]** https://raw.githubusercontent.com/npm/write-file-atomic/main/README.md
|
||||
|
||||
### 1.4 Python
|
||||
|
||||
- `fcntl.flock()` wraps `flock(2)`; docs: "On some systems, this function is emulated using fcntl()". `fcntl.lockf()` is "essentially a wrapper around the fcntl() locking calls". Unix only. "On at least some systems, LOCK_EX can only be used if the file descriptor refers to a file opened for writing." The page does not state whether flock and lockf interact. **[primary]** https://docs.python.org/3/library/fcntl.html
|
||||
- `msvcrt.locking(fd, mode, nbytes)`: locks `nbytes` from the current file position (may extend past EOF); `LK_LOCK`/`LK_RLCK` retry once per second, 10 attempts, then `OSError`; `LK_NBLCK` fails immediately; regions cannot overlap; adjacent regions not merged. Underlying Win32 call not stated in docs. **[primary]** https://docs.python.org/3/library/msvcrt.html . (The CRT `_locking` is documented elsewhere as `LockFile`-based; **[UNVERIFIED]** here.)
|
||||
- `filelock` (tox-dev): `FileLock` alias = `UnixFileLock` (`fcntl.flock`, described as kernel-enforced and released on crash) on Unix; `WindowsFileLock` described as `LockFileEx` byte-range locking on Windows (older versions used `msvcrt.locking`; version not checked, **[UNVERIFIED]**); `SoftFileLock` is a "cooperative file-existence marker", with same-host PID inspection and reclaim from dead processes; docs recommend verifying shared-filesystem semantics before deploying soft locks; no statement on cross-type exclusion. **[primary]** https://py-filelock.readthedocs.io/en/latest/index.html
|
||||
- `portalocker`: README fetch 404; its primitives are **[UNVERIFIED]** (not read).
|
||||
- `os.replace` on Windows semantics (MoveFileEx REPLACE_EXISTING): **[UNVERIFIED]**, not fetched.
|
||||
|
||||
### 1.5 Interoperability matrix (flock / fcntl / LockFileEx / share modes)
|
||||
|
||||
| Question | Finding | Source |
|
||||
|---|---|---|
|
||||
| Linux, local FS: does `flock` exclude `fcntl` (POSIX byte-range) locks? | No. man flock(2): on Linux "there is no interaction between the types of lock placed by flock() and fcntl(2)" (local filesystems). | **[primary]** https://man7.org/linux/man-pages/man2/flock.2.html |
|
||||
| Linux over NFS (since 2.6.12) | flock is emulated as whole-file fcntl byte-range locks, so flock and fcntl **do** interact there. | same |
|
||||
| Linux over CIFS (since 5.5) | flock emulated with SMB byte-range locks; flock and fcntl interact; and the lock becomes **mandatory**: I/O from other descriptors on a locked file fails with EACCES. | same |
|
||||
| Duplicate fds / fork | A lock belongs to the open file description; shared by dup/fork; persists until `LOCK_UN` or **all** duplicates close. Independent `open()`s in the same process conflict with each other under flock. | same |
|
||||
| Does a Rust `flock` (std or fs2) exclude a .NET-on-Unix `FileShare.None` open? | Both are `flock(LOCK_EX)` on the same inode; by the sources above they should conflict (.NET's non-blocking attempt gets EWOULDBLOCK -> IOException -> CultCache C# retries). **Not tested.** | **[inferred]**; **[UNVERIFIED]** empirically |
|
||||
| Same, but .NET `FileShare.ReadWrite` opener (shared flock) vs Rust `lock_exclusive` | Conflicts (LOCK_SH vs LOCK_EX), so a reader opening the lock file with any FileShare would block/fail against a Rust holder. CultCache does not open the lock file for reading. | **[inferred]** |
|
||||
| Windows: does a share-mode-0 open (.NET `FileShare.None`) exclude a Rust `LockFileEx` locker? | Order A: .NET holds the file with share mode 0. Rust's `OpenOptions::open` (share mode default RWD) then fails at `CreateFile` with ERROR_SHARING_VIOLATION, before it can lock. In `open_lock_file` that is a `with_context` error, **not a wait/retry** (looked at the source). Order B: Rust holds a handle (RWD) and `LockFileEx`; .NET `CreateFile` with share mode 0 conflicts with the existing open handle's access -> ERROR_SHARING_VIOLATION -> `IOException` -> C# retries up to 30 s. So mutual exclusion holds in both orders, but in order A Rust errors instead of waiting. | **[inferred]** from MS CreateFile share-mode table + Rust OpenOptionsExt default; **[UNVERIFIED]** empirically |
|
||||
| Windows: is `LockFileEx` mandatory? | Exclusive region lock "denies all other processes both read and write access to the specified region"; shared lock denies writes; locks are not honoured through mapped views. If a process dies or closes the handle the OS releases the locks, but "the time it takes ... depends upon available system resources" (MS recommends explicit unlock). Handle needs GENERIC_READ or GENERIC_WRITE. | **[primary]** https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-lockfileex |
|
||||
| Windows: second handle in same process | "If the locking process opens the file a second time, it cannot access the specified region through this second handle until it unlocks the region." | same |
|
||||
| Rust-on-Windows `LockFileEx` vs Python `msvcrt.locking` (same region?) | `LockFileEx` regions conflict if they overlap; fs2 locks which byte range (offset 0, len u32::MAX..?) : **[UNVERIFIED]**; msvcrt.locking locks `nbytes` from the current position (so depends on `lseek`); whether CRT `_locking` uses LockFile/LockFileEx and which range would need to match: **[UNVERIFIED]**. |
|
||||
| Python Unix `flock` vs Rust/.NET flock | Same kernel primitive on Linux/macOS (Python's flock "may be emulated using fcntl on some systems", so platform-dependent). Python `lockf` is fcntl: independent from flock on local Linux FS. | **[primary]** python docs + man page |
|
||||
| macOS flock vs fcntl | **[UNVERIFIED]** (not fetched). |
|
||||
| POSIX fcntl lock semantics | Per-process: `close()` of **any** fd for the file by the process drops all of that process's fcntl locks (SQLite doc 2.2 below). flock locks are per open-file-description and do not have that hazard. | **[primary]** SQLite howtocorrupt; flock(2) |
|
||||
| Python `fcntl.flock` on Windows | Module is Unix only (docs). | **[primary]** |
|
||||
|
||||
---
|
||||
|
||||
## 2. Lockfile protocols in established software
|
||||
|
||||
### git
|
||||
- Lockfile API: lock for `foo` is `foo.lock`, created with `O_CREAT|O_EXCL`; writer writes new content into it; `commit_lock_file()` renames it over `foo`; `rollback_lock_file()` removes it; writers exclude each other; readers never block and see either old or new content (relies on atomic rename); cleanup of outstanding lockfiles is registered with `atexit` and signal handlers. **[primary]** https://git-scm.com/docs/api-lockfile
|
||||
- `lockfile.c`: retry with quadratic backoff for at least `timeout_ms`; PID file registered as a tempfile for signal/atexit cleanup; failure message says another git process may be running "or the lock file may be stale" and tells the user to remove it manually. **[primary]** https://raw.githubusercontent.com/git/git/master/lockfile.c
|
||||
- Consequences recorded in the docs: a hard kill (SIGKILL, power loss) leaves a `.lock` file and later writers fail until it is removed; git does not time-based-reclaim index.lock **[inferred from message text, not stated; UNVERIFIED]**. Note the git design makes the lock file **the temp file** (lock name = staging name); CultCache's current design has a separate sidecar lock plus uniquely named temp.
|
||||
|
||||
### SQLite (see also section 4)
|
||||
- Unix default VFS: POSIX advisory locks via `fcntl()`; Windows VFS: `LockFile`/`LockFileEx`/`UnlockFile`. Five states UNLOCKED/SHARED/RESERVED/PENDING/EXCLUSIVE. **[primary]** https://www.sqlite.org/lockingv3.html (byte-range layout not stated on that page).
|
||||
- Built-in unix VFS variants: `unix` (POSIX advisory), `unix-dotfile` (dot-file locking), `unix-excl` (holds exclusive lock, WAL index in heap), `unix-none` (no locking), `unix-namedsem` (VxWorks). "Except for 'unix' and 'unix-excl', the various Unix VFSes all use incompatible locking implementations"; processes using different VFSes on one DB "may not see each others locks ... resulting in database corruption". **[primary]** https://www.sqlite.org/vfs.html . (`unix-flock` / `unix-afp` / `unix-nfs` appear in SQLite's source as separate io-method variants; not listed on that page: **[UNVERIFIED]**.)
|
||||
|
||||
### LMDB
|
||||
- Uses a separate `lock.mdb` file for the reader table and writer mutex. Header warnings: stale reader slots from aborted programs make the DB grow quickly (`mdb_reader_check`); stale writer lock clears automatically on Windows, BSD with SysV semaphores, and Linux with robust POSIX mutexes, otherwise all DB users must be closed; "Do not have open an LMDB database twice in the same process at the same time. Not even from a plain open() call - close()ing it breaks flock() advisory locking."; "Do not use LMDB databases on remote filesystems, even between processes on the same host. This breaks flock() on some OSes"; `MDB_NOLOCK` disables locking entirely. **[primary]** https://raw.githubusercontent.com/LMDB/lmdb/mdb.master/libraries/liblmdb/lmdb.h
|
||||
- Note: that warning is about `flock` and `close()`, which contradicts the usual statement that only fcntl locks have the close() hazard; the source of the discrepancy (older Linux/BSD semantics, or the emulated flock on some OS) is **[UNVERIFIED]**.
|
||||
|
||||
### Cargo / npm / pip / Gradle
|
||||
- Cargo: package-cache and build-dir lock using an OS file lock, prints "Blocking waiting for file lock"; source fetch (`src/cargo/util/flock.rs`) 404; details (flock/fs4/std, handling of filesystems without locking) **[UNVERIFIED]**.
|
||||
- Gradle: `DefaultFileLockManager` holds cross-process locks on cache/daemon-registry files; reports of builds hanging on locks left by crashed or killed daemons. **[secondary]** web search snippets only (https://discuss.gradle.org/t/gradle-daemon-keeps-locking-intermediate-files-preventing-builds/38004 , https://www.javathinking.com/blog/gradle-build-is-hanging-without-failure-defaultfilelockmanager-acquiring-and-releasing-lock-on-daemon-addresses-registry/ ); Gradle's own docs on lock protocol not read. **[UNVERIFIED]**
|
||||
- npm: `write-file-atomic` has no cross-process lock (above); npm's cache (cacache) locking: **[UNVERIFIED]**, not fetched.
|
||||
- pip: **[UNVERIFIED]**, not fetched.
|
||||
|
||||
### Windows rename retry (Postgres)
|
||||
- `pgrename()` on Windows calls `MoveFileEx(from, to, MOVEFILE_REPLACE_EXISTING)` and retries on ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION, ERROR_LOCK_VIOLATION: up to 100 retries, 0.1 s apart (~10 s). Comment: other applications may have the file open without sharing flags; LOCK_VIOLATION seen from some anti-virus software; bounded because the caller may hold locks affecting other backends. **[primary]** https://raw.githubusercontent.com/postgres/postgres/master/src/port/dirmod.c . CultCache Rust uses the same error set with 25 x 20 ms (0.5 s); TS retries EPERM/EBUSY/EACCES 10 times with doubling from 10 ms (about 10 s worst case) **[src]**.
|
||||
|
||||
---
|
||||
|
||||
## 3. Temp-file naming and rename atomicity
|
||||
|
||||
- Unique temp names per writer: C# GUID, Rust uuid v4, TS pid+time+random **[src]**; npm write-file-atomic pid + counter hash **[primary]**; Python CultCache fixed `.tmp` **[src]**. Rust additionally has a startup cleanup that deletes only temps matching `<store>.<uuid>.tmp` (tests at `lib.rs` ~3420, 4667-4689 distinguish `not-a-uuid.tmp`, bare `.tmp`, neighbour stores) **[src]**. Note the naming differs between runtimes: C# `.{name}.{guid:N}.tmp` (leading dot, no dashes) vs Rust `{name}.{uuid-with-dashes}.tmp` vs TS `{name}.tmp-...`; whether the Rust stale-temp sweeper recognises C#/TS temps: it matches the Rust format only (per the test names) **[src, partial reading]**.
|
||||
- Fixed temp name consequence (inferred, not tested): two processes writing `store.cc.tmp` concurrently share one file: interleaved `write_bytes` (truncate-and-write) can yield a mixed or truncated file that one process then renames into place, and the other process's `replace` can fail with source-not-found. **[inferred]**
|
||||
- POSIX `rename()`: if `new` exists it "shall be removed and old renamed to new"; the rationale requires the action to be atomic; a directory entry named `new` stays visible to other threads throughout and refers to either old or new file; on failure other than I/O errors `new` is unaffected; spec does not define crash durability. **[primary]** https://pubs.opengroup.org/onlinepubs/9799919799/functions/rename.html
|
||||
- Directory fsync: the usual pattern is write temp, fsync temp, rename, then fsync the parent directory so the rename survives power loss; many projects have filed bugs for omitting the directory fsync. **[secondary]** search snippets (e.g. https://github.com/npm/write-file-atomic/issues/64 titled "Rename atomicity is not enough"; https://0xkiire.com/crash-consistency-fsync-rename/). Linux ext4 `auto_da_alloc` replace-via-rename heuristic: **[UNVERIFIED]**, not fetched. Whether CultCache Rust/C# fsync the parent directory on Unix: **[UNVERIFIED]** (not seen in the excerpts read; Rust does `sync_all` on the temp, C# `Flush(true)` + `WriteThrough`; TS and Python do neither).
|
||||
- Windows `MoveFileExW`:
|
||||
- `MOVEFILE_REPLACE_EXISTING` replaces the destination if ACLs allow; errors if destination is an existing directory; `MOVEFILE_WRITE_THROUGH` waits for the move to be flushed (documented as guaranteeing flush of a copy+delete move). Same-volume rename is a metadata operation. The doc page does not use the word "atomic" and does not describe behavior when the destination is open by another handle. **[primary]** https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw
|
||||
- Open-handle failure: reported from libuv/Bun and Postgres as ERROR_ACCESS_DENIED / SHARING_VIOLATION / LOCK_VIOLATION when another process has the destination open without FILE_SHARE_DELETE, or an anti-virus scan holds it. **[primary]** Postgres comment; **[secondary]** libuv/Bun.
|
||||
- Rust's std rename (1.0+ Windows 10 1607) may use `FileRenameInfoEx` with POSIX semantics, which can replace an open destination; CultCache Rust calls `MoveFileExW` directly instead **[src; primary for std]**.
|
||||
- Windows `ReplaceFileW` (used by .NET `File.Replace`): replaced file opened with GENERIC_READ|DELETE|SYNCHRONIZE and share mode `READ|WRITE|DELETE`; replacement file opened with **no sharing mode specified**; preserves creation time, short name, object id, DACLs, encryption, compression, named streams; resulting file has the replacement's file ID; all files must be on the same volume; documented failure states leave the system in partially-moved states: ERROR_UNABLE_TO_REMOVE_REPLACED (1175; names unchanged), ERROR_UNABLE_TO_MOVE_REPLACEMENT (1176; with no backup, the replaced file "no longer exists" and the replacement exists under its original name), ERROR_UNABLE_TO_MOVE_REPLACEMENT_2 (1177). `REPLACEFILE_WRITE_THROUGH` "not supported". The page makes no statement that ReplaceFile is atomic; it describes it as combining steps. **[primary]** https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-replacefilew
|
||||
- So on Windows, C# (`File.Replace`) and Rust/TS/Python (MoveFileEx or equivalent) use different primitives with different failure windows. **[inferred from the two pages]**
|
||||
- .NET `File.Move` with overwrite on Windows/Unix: **[UNVERIFIED]** (CultCache C# uses `File.Move(temp, path)` only when the target does not exist; there is a window in which another writer creates the target between the `File.Exists` check and the move; the lock covers it when taken **[src, inferred]**).
|
||||
|
||||
---
|
||||
|
||||
## 4. Known failures and warnings
|
||||
|
||||
SQLite "How To Corrupt An SQLite Database File" (https://www.sqlite.org/howtocorrupt.html) **[primary]**:
|
||||
- 2.1 Filesystems with broken or missing lock implementations (network filesystems, NFS) can corrupt when multiple processes access.
|
||||
- 2.2 POSIX advisory locks are cancelled by `close()`: `close()` on any descriptor for the file by any thread in the process releases the process's locks on that file; example: thread B opens, reads and closes the DB file for a backup and thread A's locks vanish silently. SQLite works around it with a mutex-protected global list of open files, which fails if two copies of SQLite are linked into one app (2.3; a commercial product shipped this bug with infrequent corruption on Linux/Mac). WAL multi-process defenses added in 3.51.0 (2025-11-04).
|
||||
- 2.4 Two processes using different locking protocols (e.g. POSIX advisory vs dot-file) cannot see each other's locks; all connections must use the same protocol.
|
||||
- 2.5 Unlinking or renaming the DB file while in use: processes end up with different physical files sharing one journal; SQLite warns (since 3.7.17) when the file is unlinked while open.
|
||||
- 2.6 Multiple links (hard/symbolic links): same hazard; resolved by canonicalising symlinks since 3.10.0.
|
||||
- 2.7 Carrying a connection across `fork()`: all locking mechanisms fail with unpredictable results.
|
||||
- 1.3/1.4 Hot journal deleted, moved or mispaired with database.
|
||||
- The SQLite VFS page: incompatible unix VFS locking => corruption (section 2 above).
|
||||
|
||||
Other recorded failure modes:
|
||||
- Windows `LockFileEx` locks are released by the OS on process death but with a lag (MS doc). **[primary]**
|
||||
- CIFS flock becomes mandatory (man flock(2)). **[primary]**
|
||||
- Lock file left behind: git (cleanup via atexit/signal; a hard kill leaves `.lock`, message tells operator to remove) **[primary]**; Gradle hangs after killed daemons **[secondary]**; `proper-lockfile` and `filelock.SoftFileLock` provide mtime / PID-based stale reclaim as a mitigation **[primary]**.
|
||||
- Lock protocols that are compatible only when everyone uses the same one: SQLite (above); `filelock` docs do not state cross-type exclusion between `UnixFileLock`/`SoftFileLock`. **[primary]**
|
||||
- Lost-update by missing cross-process lock around read-modify-write: `write-file-atomic` documents only same-process serialisation; no source read this pass reports a concrete lost-update bug in it. A search-based report of corrupted/missing "settings.json" on Windows from a failed atomic write exists as an unvetted issue (https://github.com/BarganConstantin/ccdeck/issues/772, **[secondary]**, read-only destination). Reports specific to cross-language writers of one file, other than SQLite's different-VFS warning: not found this pass. **[UNVERIFIED]**
|
||||
- Lock file deleted by one participant while others hold or wait on it (different inode afterward): general hazard of delete-on-unlock lock files, not sourced this pass beyond SQLite 2.5 (unlink of a live file); **[UNVERIFIED]** as a named issue for flock sidecars.
|
||||
|
||||
---
|
||||
|
||||
## 5. Open items not resolved by reading (candidates for an empirical check; nothing done)
|
||||
|
||||
1. Do the C# `FileShare.None` lock and Rust `fs2::lock_exclusive` actually exclude each other on Windows (both open orders) and on Linux? Only inferred above.
|
||||
2. Does fs2 on Windows lock the same byte range as std 1.89 `File::lock` and as Python `msvcrt.locking`/`filelock`? Unread.
|
||||
3. macOS behavior of flock vs fcntl and of .NET FileShare there. Unread.
|
||||
4. .NET release that introduced Unix FileShare-via-flock. Unread.
|
||||
5. Whether any runtime fsyncs the parent directory after rename on Unix. Unread.
|
||||
6. Cargo, npm, pip, Gradle lock protocol primary sources. Unread.
|
||||
7. `portalocker`, `fs-ext`, `lockfile`, `proper-lockfile` behavior under Windows share modes. Unread.
|
||||
|
|
@ -1,305 +0,0 @@
|
|||
# Culling Efficiency Against an Oracle: Prior Art Survey
|
||||
|
||||
This is the facts file for Eureka question `cultmath-tapes:question:wells-ceiling-oracle`,
|
||||
which interprets ruling `cultmath-tapes:ruling:operator-wells-contract-vs-ceiling`. That
|
||||
ruling holds scenario (c) to "at least 90% of the probe-free ceiling (the cost of the
|
||||
dense cells alone)". The question asks what "ceiling" means:
|
||||
|
||||
- (a) the pre-pass's own mask, under which a pre-pass that skips nothing scores 0.98;
|
||||
- (b) an oracle mask, the cells where at least one ray samples nonzero density.
|
||||
|
||||
Under (b), Imagination measured 0.38 at warp 0 and 0.19 at warp D.
|
||||
|
||||
This survey covers how published work measures culling or skipping efficiency against
|
||||
ground truth, and what efficiency figures conservative bounds reach. Eyes pass,
|
||||
2026-10-03. It gives facts and evidence pointers only, with no recommendation.
|
||||
|
||||
Verification marks:
|
||||
|
||||
- **[fetched]** The claim was read in the cited source's full text during this pass.
|
||||
PDFs were converted with `pdftotext -layout`.
|
||||
- **[search]** The claim came from a search-result summary, not a full read.
|
||||
- **[not found]** The source was read, and the named kind of measurement is absent from it.
|
||||
|
||||
Reference kinds used below:
|
||||
|
||||
- **oracle**: an exact or ground-truth answer computed independently of the method,
|
||||
such as the exact visible set or the set of truly non-empty cells.
|
||||
- **self**: the method's own result, or a baseline that does no culling.
|
||||
- **peer**: another approximate method.
|
||||
|
||||
## 1. Visibility culling: conservative versus exact visible set
|
||||
|
||||
### 1.1 Survey vocabulary
|
||||
|
||||
- **[fetched]** Cohen-Or, Chrysanthou, Silva, Durand, *A Survey of Visibility for
|
||||
Walkthrough Applications* (IEEE TVCG 2003). Section 2 lists "Tightness of
|
||||
approximation" as a review criterion. It notes that few papers report the ratio
|
||||
between the size of the potentially visible set and the size of the visible set,
|
||||
and asks later work to report it. Most surveyed techniques are conservative, which
|
||||
means they overestimate the visible set.
|
||||
https://www.cs.tau.ac.il/~dcor/online_papers/papers/visibility-survey-ieee.pdf
|
||||
- **[fetched]** Bittner, *Hierarchical Techniques for Visibility Computations* (PhD
|
||||
thesis, CTU Prague, 2002), chapter 2, equations 2.1 to 2.4. It defines a quality
|
||||
measure against the exact PVS `S_E`:
|
||||
- relative overestimation `Q_o = |S_A \ S_E| / |S_E|`;
|
||||
- relative underestimation `Q_u = |S_E \ S_A| / |S_E|`;
|
||||
- the expected quality, which is the expectation of these over inputs.
|
||||
|
||||
The same text appears in Bittner and Wonka, *Visibility in Computer Graphics*
|
||||
(section 4.3).
|
||||
https://dcgi.fel.cvut.cz/home/bittner/publications/diss_noimages.pdf
|
||||
http://www.casa.ucl.ac.uk/mike-michigan-april1/mike's%20stuff/attach/bittner_wonka.pdf
|
||||
- **[fetched]** Nirenstein, Blake, Gain, *Exact From-Region Visibility Culling* (EGWR
|
||||
2002), introduction and Table 1. The paper classifies algorithms as exact,
|
||||
conservative, aggressive or approximate. Conservative methods "incur a false
|
||||
visibility error". The paper states:
|
||||
- exact visibility "provides a benchmark for comparing other approaches";
|
||||
- the percentage of polygons culled is "a very crude and imperfect metric", because
|
||||
it ignores the type, distribution and magnitude of visibility error.
|
||||
|
||||
Reported results (Table 2) are culling fractions of the whole scene: 99.45% for the
|
||||
town and 99.12% for the forest. No conservative method is measured against the
|
||||
exact set in this paper **[not found]**. A search summary attributed a 95.5% figure
|
||||
for extended projection to this paper, but that figure is not in the full text.
|
||||
https://people.cs.uct.ac.za/~jgain/wp-content/papercite-data/pdf/nirenstein2002.pdf
|
||||
|
||||
### 1.2 Measured conservative-versus-exact ratios
|
||||
|
||||
- **[fetched]** Bittner, Wonka, Wimmer, *Fast Exact From-Region Visibility in Urban
|
||||
Scenes* (EGSR 2005), section 6. The exact 2.5D algorithm is used as the oracle, and
|
||||
the paper simulates conservative methods against it:
|
||||
- **Section 6.2, one penumbra wedge per occluder.** The conservative PVS was more
|
||||
than 47% larger than the exact PVS for 2 m view cells and more than 76% larger
|
||||
for 4 m view cells. As exact/conservative ratios these are ≤ 0.68 and ≤ 0.57.
|
||||
- **Section 6.3, object-space discretization** (Wonka et al.'s method, a 1400×1400
|
||||
grid, Vienna). The PVS overestimate averaged 17%, a ratio of about 0.85.
|
||||
- **Section 6.3, Table 4, line-space discretization** (Leyvand et al.). PVS sizes by
|
||||
grid resolution, with the exact PVS last:
|
||||
|
||||
| Scene | 512 | 1024 | 2048 | 16384 | exact |
|
||||
|---------|--------|--------|--------|-------|-------|
|
||||
| Vienna | 633 | 535 | 489 | 455 | 235 |
|
||||
| Atlanta | 50,744 | 25,751 | 10,082 | 2,656 | 1,152 |
|
||||
|
||||
The ratios exact/conservative below are computed from that table, not quoted from
|
||||
the paper:
|
||||
- Vienna: 0.37, 0.44, 0.48, 0.52;
|
||||
- Atlanta: 0.023, 0.045, 0.11, 0.43.
|
||||
|
||||
The paper says the overestimation comes from conservative shrinking and from gaps
|
||||
"between formerly connected occluders".
|
||||
|
||||
https://peterwonka.net/Publications/pdfs/2005.EGSR.Bittner.FastExactFromRegionVisibility.pdf
|
||||
|
||||
### 1.3 Run-time occlusion culling against an ideal renderer
|
||||
|
||||
- **[fetched]** Mattausch, Bittner, Wimmer, *CHC++: Coherent Hierarchical Culling
|
||||
Revisited* (Eurographics 2008), section 7 (Figure 7) and the conclusion. The
|
||||
reference algorithm "OPT" renders only the visible nodes of the hierarchy and
|
||||
issues no queries. It was implemented by recording visibility with an exact
|
||||
stop-and-wait pass, so it is an oracle at node granularity that is independent of
|
||||
the method under test. CHC++ is reported to be "for most cases within a few percent
|
||||
of the 'ideal' method".
|
||||
|
||||
Caveat: occlusion queries are exact per-node tests, not conservative range bounds.
|
||||
The gap to OPT measures query overhead and latency, not bound looseness.
|
||||
|
||||
The paper also separates out the "optimal" algorithm defined by Guthe et al.
|
||||
(NOHC-OPT), and shows CHC++ "clearly below" that optimum. That earlier optimum was a
|
||||
cost-model optimum, not an oracle.
|
||||
https://dcgi.fel.cvut.cz/home/bittner/publications/chc++.pdf
|
||||
- **[fetched]** Zhang et al., *Visibility Culling using Hierarchical Occlusion Maps*
|
||||
(SIGGRAPH 1997). It reports "% culling" of the total model, and frame time
|
||||
normalized to rendering without HOM. Both are **self** references. No comparison
|
||||
with the exact visible set **[not found]**.
|
||||
https://www.cs.princeton.edu/courses/archive/spr01/cs598b/papers/zhang97.pdf
|
||||
- **[search]** Wonka et al., *Guided Visibility Sampling* (SIGGRAPH 2006). This is
|
||||
an aggressive, sampling-based PVS method. Its Table 2 reports error as false pixels
|
||||
on a 1000×1000 screen, together with PVS size. Section 4.5 compares it to Bittner's
|
||||
exact algorithm on one scene, because exact solvers do not run on the larger test
|
||||
scenes. The text was extracted in this pass, but not read in full.
|
||||
https://peterwonka.net/Publications/pdfs/2006.SG.Wonka.GuidedVisibilitySampling.final.pdf
|
||||
|
||||
## 2. Empty-space skipping in volume rendering
|
||||
|
||||
- **[fetched]** Li, Mueller, Kaufman, *Empty Space Skipping and Occlusion Clipping for
|
||||
Texture-based Volume Rendering* (IEEE Vis 2003):
|
||||
- **Section 5, Table 1.** Empty-space skipping is measured as a frame-rate speedup
|
||||
over no skipping, 2.8× on average. This is a **self** reference.
|
||||
- **Section 5, Table 2.** It reports the percentage of non-empty voxels skipped
|
||||
through occlusion, 48% to 64% with a 56.2% average. The denominator is all
|
||||
non-empty voxels, not the truly occluded voxels. The authors write that "there
|
||||
are still many occluded voxels that have not been skipped" and call the number
|
||||
"smaller than we have expected". The truly occluded set is not computed
|
||||
**[not found]**.
|
||||
- **Section 5.** Overhead is reported separately. The opacity map and clipping cost
|
||||
about 10% of the empty-space-skipping render time, measured with a mode that pays
|
||||
the overhead but skips nothing ("ESS+M").
|
||||
|
||||
https://www3.cs.stonybrook.edu/~mueller/papers/OcclusionClipping.pdf
|
||||
- **[fetched]** Zellmann, *Comparing Hierarchical Data Structures for Sparse Volume
|
||||
Rendering with Empty Space Skipping* (arXiv 1912.09596, 2019). It reports frame
|
||||
rates and build times, and compares only against naive ray marching and simple
|
||||
macrocell grids, as the paper itself states. No step count or sample count against
|
||||
an ideal traversal **[not found]**.
|
||||
https://arxiv.org/abs/1912.09596
|
||||
- **[search]** Hadwiger et al., *SparseLeap* (IEEE TVCG 24(1), 2018). It builds
|
||||
per-pixel lists of ray segments by rasterizing occupancy geometry. The full text
|
||||
was not retrieved (the repository returned HTML), so it is unknown whether it
|
||||
reports skip efficiency against the true empty space.
|
||||
https://vccvisualization.org/research/sparseleap/
|
||||
- **[search]** Deakin and Knackstedt, *Efficient ray casting of volumetric images using
|
||||
distance maps for empty space skipping* (Computational Visual Media 6(1), 2020). The
|
||||
full text was not retrieved.
|
||||
|
||||
## 3. Interval and affine arithmetic for implicits, noise and SDF marching
|
||||
|
||||
- **[fetched]** Keeter, *Massively Parallel Rendering of Complex Closed-Form Implicit
|
||||
Surfaces* (SIGGRAPH 2020):
|
||||
- **Section 3, Interval arithmetic.** Ambiguous regions "either contain the shape's
|
||||
boundary or are a false positive (due to interval arithmetic's conservative
|
||||
behavior)". The false-positive share is not measured **[not found]**.
|
||||
- **Section 2.1, Figure 5.** Reported efficiency is against **self** and peer
|
||||
baselines: a brute-force per-pixel interpreter (about 58 ms/MP) and a compiled
|
||||
kernel (about 3 ms/MP).
|
||||
- **Section 3.2, Figure 6.** Tape length is reduced 17× in 64² tiles and 216× in 8²
|
||||
subtiles, from 6056 clauses to means of 356 and 28.
|
||||
- **Section 5, Figures 9 and 10.** Heatmaps of work per pixel show extra work at
|
||||
model edges, where interval evaluation "can't skip entire regions". The text says
|
||||
many smooth blends lead "to less useful interval evaluation results".
|
||||
- **Section 6.** It names reduced affine arithmetic as a way "to more tightly bound
|
||||
intervals".
|
||||
|
||||
https://www.mattkeeter.com/research/mpr/keeter_mpr20.pdf
|
||||
- **[fetched]** Keeter, *Gradients are the new intervals* (blog, 2025-05-14). It
|
||||
compares interval evaluation with gradient-based pseudo-intervals by tape length
|
||||
(120.2 against 121.5 clauses) and by render time. These are peer references. There
|
||||
are no tile-classification counts and no ground truth **[not found]**.
|
||||
https://www.mattkeeter.com/blog/2025-05-14-gradients/
|
||||
- **[fetched]** Heidrich, Slusallek, Seidel, *Sampling Procedural Shaders Using Affine
|
||||
Arithmetic* (ACM TOG 17(3), 1998):
|
||||
- **What "error" measures.** Error is the width of the computed range per pixel. It
|
||||
is not compared with the true range from dense sampling **[not found]**. The
|
||||
tightness against the true variation is argued qualitatively, from shader cross
|
||||
sections.
|
||||
- **Section 3.2.** Noise over an affine form that spans several lattice cells falls
|
||||
back to interval arithmetic, and a span of more than two cells returns the full
|
||||
noise range [0, 1]. For turbulence (blue marble), 81% of pixels lie within 5%
|
||||
error and 59% within 1%. The eroded shader, with high-frequency noise, gets 77%
|
||||
and 53%.
|
||||
- **Section 4.** Against AA, interval arithmetic gave errors "up to 50 percent where
|
||||
affine arithmetic produces errors below 1/256" (wood shader, Figures 6 and 7).
|
||||
|
||||
https://vccimaging.org/Publications/Heidrich1998SPS/Heidrich1998SPS.pdf
|
||||
- **[fetched]** Gamito and Maddock, *Ray casting implicit fractal surfaces with reduced
|
||||
affine arithmetic* (The Visual Computer 23(3), 2007). Section 5, Tables 1 to 3, use
|
||||
a sphere hypertextured with three octaves of noise. The accuracy proxy is the
|
||||
average number of interval evaluations per ray, a **peer** comparison with no oracle
|
||||
count. The rows below are reconstructed from a mangled text layout, and the
|
||||
reconstruction agrees with the paper's prose.
|
||||
|
||||
| Noise | IA evals/ray | IA time | Standard AA evals/ray | Reduced AA evals/ray | Reduced AA + interval optimisation, evals/ray | Its time |
|
||||
|------------------------|--------------|-----------|-----------------------|----------------------|-----------------------------------------------|-----------|
|
||||
| Perlin gradient | 78.40 | 8m27.5s | 34.60 | 34.60 | 20.81 | 3m43.2s |
|
||||
| Lewis sparse convolution | 48.44 | 26m31.0s | 23.25 | 23.25 | 13.46 | 6m29.3s |
|
||||
| Worley cellular | 45.70 | 25m54.7s | 32.56 | 43.78 | 21.29 | 14m25.9s |
|
||||
|
||||
The text cites "the excessive conservativeness of IA estimates", and says IA
|
||||
compensates by being fast.
|
||||
https://eprints.whiterose.ac.uk/id/eprint/3762/1/gamito_newraycastvisual.pdf
|
||||
- **[fetched]** Knoll, Hijazi, Kensler, Schott, Hansen, Hagen, *Fast Ray Tracing of
|
||||
Arbitrary Implicit Surfaces with Interval and Affine Arithmetic* (CGF 28(1), 2009).
|
||||
Section 6.1.1 and Tables 1 and 2 report frames per second for IA against reduced
|
||||
AA:
|
||||
- RAA is "generally 1.5 to 2× faster", and "3 to 4 times faster" for functions
|
||||
"with high bound overestimation";
|
||||
- IA is faster for superquadrics.
|
||||
|
||||
These are **peer** references, with no oracle count of wasted subdivisions
|
||||
**[not found]**.
|
||||
https://sci.utah.edu/~knolla/cgrtia.pdf
|
||||
- **[fetched]** Sharp and Jacobson, *Spelunking the Deep: Guaranteed Queries on General
|
||||
Neural Implicit Surfaces via Range Analysis* (SIGGRAPH 2022), section 3.4 and Table 1.
|
||||
Tightness is measured as "the typical size of a region which can be bounded away
|
||||
from the zero":
|
||||
|
||||
| Method | 1D length | 3D volume | Ray-cast time (relative to the fastest) |
|
||||
|---------------|-----------|-----------------|-----------------------------------------|
|
||||
| interval | 0.011 | < 0.001 × 10⁻³ | 34.3× |
|
||||
| affine (full) | 0.821 | 3.499 × 10⁻³ | 8.4× |
|
||||
| affine (fixed)| 0.306 | 0.267 × 10⁻³ | 1.0× |
|
||||
|
||||
The prose says interval arithmetic "yields extremely pessimistic bounds". These are
|
||||
**peer** references, with no oracle region size **[not found]**.
|
||||
https://arxiv.org/abs/2202.02444
|
||||
- **[fetched]** Mitchell, *Robust Ray Intersection with Interval Arithmetic* (Graphics
|
||||
Interface 1990). It reports root-finding timings for its method and for peer
|
||||
root finders. No pruning-against-oracle measure **[not found]**.
|
||||
http://graphicsinterface.org/wp-content/uploads/gi1990-8.pdf
|
||||
- Duff, *Interval Arithmetic and Recursive Subdivision for Implicit Functions and
|
||||
Constructive Solid Geometry* (SIGGRAPH 1992), and Snyder, *Interval Analysis for
|
||||
Computer Graphics* (SIGGRAPH 1992), were not retrieved in this pass.
|
||||
https://doi.org/10.1145/142920.134027
|
||||
|
||||
## 4. Conservative rasterization and tile classification
|
||||
|
||||
- **[fetched]** Hasselgren, Akenine-Möller, Ohlsson, *Conservative Rasterization*
|
||||
(GPU Gems 2, chapter 42):
|
||||
- **Section 42.1.** It defines overestimated rasterization as every pixel whose cell
|
||||
intersects the polygon, and underestimated rasterization as every pixel whose
|
||||
cell lies wholly inside it.
|
||||
- **Section 42.5.** Overdraw is discussed qualitatively: it depends on triangle
|
||||
acuity and resolution. Figure 42-7 compares the two proposed algorithms only. No
|
||||
count of excess fragments against the exact overlap set **[not found]**.
|
||||
|
||||
https://developer.nvidia.com/gpugems/gpugems2/part-v-image-oriented-computing/chapter-42-conservative-rasterization
|
||||
- **[search]** Antochi, Juurlink, Vassiliadis, Liuha, *Scene management models and
|
||||
overlap tests for tile-based rendering* (DSD 2004), and the companion *Efficient
|
||||
tile-aware bounding-box overlap test*. In a search summary, the bounding-box
|
||||
triangle-to-tile test "can generate up to 30% false positives" on common workloads,
|
||||
measured against exact overlap. The full text could not be retrieved (the TU Delft
|
||||
host refused the connection, and CiteSeerX returned HTML).
|
||||
https://citeseerx.ist.psu.edu/document?doi=5c78f05f185b9513bf930588dd2a4ae334090620
|
||||
|
||||
## 5. Summary of reference kinds and reported figures
|
||||
|
||||
| Source | Reference | Figure reported |
|
||||
|--------|-----------|-----------------|
|
||||
| Bittner et al. 2005, §6.2 | oracle (exact PVS) | conservative PVS +47% / +76% (ratio ≤ 0.68 / ≤ 0.57) |
|
||||
| Bittner et al. 2005, §6.3 | oracle | object discretization +17% (≈ 0.85); line-space 0.023–0.52 by scene and resolution |
|
||||
| Bittner thesis, ch. 2 | oracle | defines `Q_o`, `Q_u` relative to `|S_E|` |
|
||||
| Cohen-Or et al. 2003 | — | notes the PVS/visible-set ratio is rarely reported |
|
||||
| Nirenstein et al. 2002 | oracle (its own exact) | percent culled; calls that metric crude |
|
||||
| CHC++ 2008 | oracle (OPT, node level) | within a few percent of OPT (exact queries, not bounds) |
|
||||
| HOM 1997 | self | % of model culled, normalized frame time |
|
||||
| Li, Mueller, Kaufman 2003 | self / all non-empty | 2.8× speedup; 56% of non-empty voxels skipped; overhead ≈ 10% reported separately |
|
||||
| Zellmann 2019 | self | fps against no skipping |
|
||||
| Keeter 2020 | self (brute force, compiled) | 17× / 216× tape reduction; false-positive share not measured |
|
||||
| Heidrich et al. 1998 | none (range width) | IA error up to 50% where AA < 1/256 |
|
||||
| Gamito, Maddock 2007 | peer | IA 2.1–3.8× more evaluations per ray than reduced AA + optimisation on noise |
|
||||
| Knoll et al. 2009 | peer | RAA 1.5–4× faster than IA |
|
||||
| Sharp, Jacobson 2022 | peer | IA provable region about 75× smaller in 1D than full AA |
|
||||
| GPU Gems 2, ch. 42 | peer | qualitative overdraw |
|
||||
| Antochi et al. 2004 [search] | oracle (exact overlap) | bounding box up to 30% false positives |
|
||||
|
||||
On the interval side (section 3), none of the sources read in this pass reports a
|
||||
pruning or culling fraction against an oracle mask, such as the truly empty cells or
|
||||
the truly non-ambiguous tiles. They report only against self or peer baselines. The
|
||||
oracle-referenced ratios found are all on the visibility side (sections 1.2 and 1.3),
|
||||
and the one from rasterization (section 4) is search-only. No published figure was
|
||||
found for interval-arithmetic culling of noise or warped-noise fields as a fraction of
|
||||
an oracle.
|
||||
|
||||
## 6. Local context (from the eureka mind, not re-measured)
|
||||
|
||||
- **Question** `cultmath-tapes:question:wells-ceiling-oracle` (admitted 2026-10-03,
|
||||
Imagination). At 3ac7250a it gives:
|
||||
- warp 0: oracle efficiency 0.38, mask ceiling 0.96, cull fraction 0.95;
|
||||
- warp D = 60: oracle efficiency 0.19, mask ceiling 0.99, cull fraction 0.84;
|
||||
- a pre-pass that skips nothing: 0.14 against the oracle.
|
||||
- **Finding** `cultmath-tapes:finding:cut-interval-ops.s2.wells-ceiling-contract-vacuous`
|
||||
(Soul, confirmed by mutation). The mask-referenced (c) check reports 0.98 with the
|
||||
wells skip disabled. Locations are in
|
||||
`packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs`: lines 1257–1260, 1437
|
||||
and 1461.
|
||||
|
|
@ -1,462 +0,0 @@
|
|||
# Foreign Records: Prior Art Survey
|
||||
|
||||
Facts file for the single-file "foreign records" fork (lay-back versus refusing
|
||||
whole-view writes while holding records the writer does not own). Eyes pass,
|
||||
2026-10-02. Facts and evidence pointers only; no recommendation.
|
||||
|
||||
Verification marks:
|
||||
|
||||
- **[fetched]** the claim was read on the cited page during this pass.
|
||||
- **[search]** the claim came from a search-result summary of the cited page,
|
||||
not a full read.
|
||||
- **[memory]** recalled from the cited source, not re-read in this pass.
|
||||
Treat it as unverified.
|
||||
|
||||
## 0. Our design, for comparison
|
||||
|
||||
Source: `src/GameCult.Caching/Contracts/cultcache-persistence-format.md` (main, 614fd445).
|
||||
|
||||
- Store is `[header, catalog, records]`; the catalog "must embed a catalog entry
|
||||
for every schema referenced by the records it contains" (Embedded Schema Catalog).
|
||||
- The stated purpose of the catalog is to let another implementation "decide
|
||||
whether it can: read directly, soft-migrate with warnings, or refuse the data
|
||||
honestly".
|
||||
- Soft migration lists "persisted schema has extra slots the local reader can
|
||||
safely ignore or preserve" and the warning "field preserved but not surfaced
|
||||
natively". That is slot-level preservation inside a record the reader owns. The
|
||||
contract does not mention records under schema ids the reader does not own;
|
||||
the lay-back ruling lives elsewhere (grep of `src/**/*.md` for "foreign" or
|
||||
"lay-back" found nothing).
|
||||
- Concurrency: "A plain flush and an unconditional commit are last-writer-wins
|
||||
... a single file is replaced by the writer's whole view." "Conditional commit
|
||||
is the only safe multi-process write." C# and Rust implement conditional
|
||||
commit; TypeScript and Python do not yet (Transaction Visibility).
|
||||
|
||||
Granularity note used throughout: most systems below handle unknown *fields
|
||||
inside a record whose type the writer knows*. Our fork is about unknown *whole
|
||||
records* (schema ids) inside a container the writer rewrites. Each entry says
|
||||
which granularity it covers.
|
||||
|
||||
## 1. Serialization formats
|
||||
|
||||
### 1.1 Protocol Buffers: unknown-field preservation
|
||||
|
||||
- **What it does.** Unknown fields are "well-formed protocol buffer serialized
|
||||
data representing fields that the parser does not recognize". Proto3 messages
|
||||
"preserve unknown fields and include them during parsing and in the serialized
|
||||
output, which matches proto2 behavior." [fetched] https://protobuf.dev/programming-guides/proto3/ (Unknown Fields)
|
||||
- **Where preservation fails, per the docs.** Serializing to JSON; iterating
|
||||
fields to populate a new message. Mitigation given: use binary, use
|
||||
`CopyFrom()`/`MergeFrom()`. TextFormat prints unknown fields by number but
|
||||
cannot parse them back. [fetched] same page.
|
||||
- **History.** Proto3 shipped dropping unknown fields. v3.5.0 release notes:
|
||||
"Unknown fields are now preserved in proto3 for most of the language
|
||||
implementations for proto3 by default." C++ and Python offer
|
||||
`DiscardUnknownFields()`, Java a `DiscardUnknownFieldsParser`, for callers who
|
||||
relied on dropping. The release notes give no rationale. [fetched]
|
||||
https://github.com/protocolbuffers/protobuf/releases/tag/v3.5.0
|
||||
- **Why it was restored.** No official rationale document was found. The
|
||||
user-facing request is issue #272 (2015-04-07): "I know that unknown fields
|
||||
have been removed from proto3, but I am trying to get an explanation about why
|
||||
this change was made". The thread body was not readable in this pass. [fetched,
|
||||
partial] https://github.com/protocolbuffers/protobuf/issues/272. Secondary
|
||||
accounts attribute the reversal to pass-through and read-modify-write
|
||||
intermediaries in uncoordinated upgrades. [search]
|
||||
https://github.com/scalapb/ScalaPB/issues/406, https://kmcd.dev/posts/protobuf-unknown-fields/.
|
||||
**The primary reasoning is unverified.**
|
||||
- **Cost and regret.** Go: preservation through `XXX_unrecognized []byte`
|
||||
broke users who compared proto3 messages with `==` or used them as map keys.
|
||||
[search] https://groups.google.com/d/msg/golang-nuts/F5xFHTfwRnY/sPv5nTVXBQAJ.
|
||||
JSON round trips still drop unknown fields; there is an open request to keep
|
||||
them. [search] https://github.com/golang/protobuf/issues/1390. A secondary
|
||||
source flags preserved unknown fields as a way to smuggle unvalidated payloads
|
||||
past validation at trust boundaries. [fetched] https://kmcd.dev/posts/protobuf-unknown-fields/
|
||||
- **Granularity.** Field level, inside a message the reader's code knows.
|
||||
Protobuf does not preserve unknown *message types*; the closest is
|
||||
`google.protobuf.Any` (type URL plus bytes), which carries an opaque typed
|
||||
payload as a declared field. [memory]
|
||||
- **Differs from us.** There is no in-band schema. Unknown bytes are
|
||||
uninterpretable without the newer `.proto`, and only tag and wire type are
|
||||
self-describing. There is no container rewrite or multi-process store
|
||||
concern; protobuf is a message codec.
|
||||
|
||||
### 1.2 Protobuf field-number reuse
|
||||
|
||||
- **Rule.** "Field numbers should never be reused. Never take a field number out
|
||||
of the reserved list for reuse with a new field definition." Listed
|
||||
consequences: "Developer time lost to debugging, A parse/merge error (best case
|
||||
scenario), Leaked PII/SPII, Data corruption." Syntax: `reserved 2, 15, 9 to 11;`
|
||||
and `reserved "foo", "bar";`. [fetched] https://protobuf.dev/programming-guides/proto3/
|
||||
- Best practices: "Never re-use a tag number. It messes up deserialization."
|
||||
Reserve deleted numbers. "Almost never change the type of a field." Renaming
|
||||
breaks JSON and text format because they key on names. [fetched]
|
||||
https://protobuf.dev/best-practices/dos-donts/
|
||||
- Kleppmann (2012): "you must never reuse the tag number for another field in
|
||||
future, because you may still have data stored that uses that tag for the field
|
||||
you deleted". [fetched] https://martin.kleppmann.com/2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html
|
||||
- **Named public incidents:** none found in this pass. The docs' consequence
|
||||
list is the strongest primary evidence; blog posts repeat it. **Unverified.**
|
||||
- **Differs from us.** Our `schemaId` is a content hash of the canonical slot
|
||||
schema, so changing slot meaning changes the id. The reuse hazard moves from
|
||||
"number collides" to "a record under an old id is carried, or reinterpreted,
|
||||
after an undeclared rename".
|
||||
|
||||
### 1.3 Avro and schema registries
|
||||
|
||||
- **Resolution.** The reader needs the writer's schema. Fields in the writer
|
||||
schema but not the reader's are ignored. Reader fields missing from the writer
|
||||
take the reader default, or raise an error when there is no default. Aliases
|
||||
map renames. [fetched] https://avro.apache.org/docs/1.11.1/specification/ (Schema Resolution)
|
||||
- **Object Container Files** carry the schema once in the header (`avro.schema`,
|
||||
required). One file has one writer schema. [fetched] same page.
|
||||
- **Kleppmann (2012):** Avro needs "the exact same version of the schema as the
|
||||
writer of the data", so you either "annotate each record with its schema
|
||||
version" or use "a schema registry". [fetched] blog above.
|
||||
- **Confluent wire format:** byte 0 is the magic byte (0), bytes 1 to 4 are the
|
||||
big-endian schema id; the deserializer fetches the writer schema by id.
|
||||
[fetched] https://docs.confluent.io/platform/current/schema-registry/fundamentals/serdes-develop/index.html
|
||||
- **Compatibility modes:** BACKWARD (the default), FORWARD, FULL, their
|
||||
`_TRANSITIVE` forms, and NONE. BACKWARD is the default "so that you can rewind
|
||||
consumers to the beginning of the topic". Upgrade order follows the mode:
|
||||
consumers first for BACKWARD, producers first for FORWARD. [fetched]
|
||||
https://docs.confluent.io/platform/current/schema-registry/fundamentals/schema-evolution.html
|
||||
- **Failure mode.** Resolution projects writer data onto the reader schema, so
|
||||
writer-only fields are gone from the decoded object. A read-modify-write by an
|
||||
older reader loses them unless the application keeps the writer record.
|
||||
[inference from the spec; no Avro doc states it as a warning]
|
||||
- **Differs from us.** Kafka records are immutable log entries, so consumers
|
||||
never rewrite the producer's records. A container file is written once by one
|
||||
writer. Neither case has whole-file rewrite by several processes.
|
||||
|
||||
### 1.4 Thrift
|
||||
|
||||
- Generated readers switch on field id and call
|
||||
`TProtocolUtil.skip(iprot, schemeField.type)` in the default case: unknown
|
||||
fields are skipped. [search; generated-code pattern] Kleppmann (2012) says the
|
||||
same of protobuf and Thrift: the parser "can figure out how many bytes it needs
|
||||
to skip". [fetched]
|
||||
- **Conflict.** "Thrift: The Missing Guide" says unknown fields "are not
|
||||
discarded, and if the message is later serialized, the unknown fields are
|
||||
serialized along with it". That text reads as copied from protobuf docs.
|
||||
[fetched] https://diwakergupta.github.io/thrift-missing-guide/. Apache Thrift's
|
||||
generated Java skips without retaining. **Retention is unverified and likely
|
||||
false for stock Apache Thrift.**
|
||||
- Rules: never change numeric tags; new fields optional; prefix removed fields
|
||||
`OBSOLETE_`. [fetched] Missing Guide.
|
||||
|
||||
### 1.5 Cap'n Proto
|
||||
|
||||
- Field ordinals are append-only: "as long as each new member's number is larger
|
||||
than all previous members"; "You cannot change a field, method, or enumerant's
|
||||
number." [fetched] https://capnproto.org/language.html
|
||||
- Unknown fields survive a copy: "If you copy the struct from one message to
|
||||
another (e.g. by calling a set() method on a parent object), the extra fields
|
||||
will be preserved." Caveat: "copying a struct value from another message into
|
||||
an element of a list will truncate the value". [fetched] https://capnproto.org/cxx.html
|
||||
- The wire format carries just enough structure (struct or list, sizes) to copy
|
||||
an object recursively without its schema. [search]
|
||||
https://capnproto.org/encoding.html. Preservation is a property of the encoding,
|
||||
not of a per-type rule.
|
||||
- **Differs from us.** The copy is opaque; nothing is reinterpreted. There is no
|
||||
catalog and no store.
|
||||
|
||||
### 1.6 FlatBuffers
|
||||
|
||||
- Add new fields at the end; never remove one, mark it `deprecated` instead (no
|
||||
accessors are generated); explicit `id` attributes relax ordering. Old readers
|
||||
ignore unknown fields. [search] https://flatbuffers.dev/evolution/
|
||||
- The object API (unpack, mutate, pack) builds a fresh buffer from native
|
||||
fields. Whether unknown fields survive that round trip: **not verified in
|
||||
this pass** [memory: they do not].
|
||||
- Known defect: C++ object API with deprecated fields generated non-compiling
|
||||
code. [search] https://github.com/google/flatbuffers/issues/5186
|
||||
|
||||
### 1.7 Microsoft Bond
|
||||
|
||||
- `bonded<T>` defers deserialization and holds a reference to the serialized
|
||||
form. "Using bonded<Response> allows the intermediary to aggregate responses,
|
||||
preserving their full content, even though schema of Response is not known
|
||||
when the aggregator is built." Transcoding through a bonded value preserves
|
||||
fields not in `T`. [fetched] https://microsoft.github.io/bond/manual/bond_cpp.html
|
||||
- Untagged protocols (Simple Binary) "serialize only data and thus require that
|
||||
consumers know the payload schema via some out-of-band mechanism"; Bond also
|
||||
has runtime schemas (`SchemaDef`) for that. [fetched; SchemaDef from memory]
|
||||
- Changing ordinals is a breaking change. [fetched]
|
||||
- **Note.** Bond's preservation is an explicit opt-in wrapper type at the
|
||||
declared pass-through point, not default retention of everything.
|
||||
|
||||
### 1.8 MessagePack-based schema systems
|
||||
|
||||
- MessagePack itself has no schema. MessagePack-CSharp (`[Key(n)]` int keys, the
|
||||
basis of our slot layout) has no documented unknown-key or extension-data
|
||||
retention for typed objects. A search found no such feature and no issue
|
||||
requesting it. **Unverified absence.**
|
||||
https://github.com/MessagePack-CSharp/MessagePack-CSharp
|
||||
- Comparable opt-in field-level retention in other ecosystems:
|
||||
Newtonsoft.Json and System.Text.Json `[JsonExtensionData]` capture unmapped
|
||||
properties into a dictionary and write them back; serde `#[serde(flatten)]` into
|
||||
a map does the same, and is incompatible with `deny_unknown_fields`. [search]
|
||||
https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/migrate-from-newtonsoft,
|
||||
https://serde.rs/attr-flatten.html. All are field level and opt in per type.
|
||||
- **FIDL (Fuchsia), RFC-0033:** messages with unknown fields "MUST be validated
|
||||
and parsed successfully"; the default is `flexible` (unknown values allowed and
|
||||
exposed), and the `strict` keyword makes unknown fields invalid. Unknown
|
||||
handles must be closed by default. The RFC frames strictness as a security tool
|
||||
for protocols where misinterpreting new data is dangerous. [fetched]
|
||||
https://fuchsia.dev/fuchsia-src/contribute/governance/rfcs/0033_handling_unknown_fields_strictness
|
||||
|
||||
## 2. Embedded stores and document databases
|
||||
|
||||
### 2.1 SQLite
|
||||
|
||||
- **Unknown tables.** The writer modifies pages through the B-tree and rollback
|
||||
or WAL journal. Tables a program never touches are never rewritten, so there is
|
||||
no rule about them. [memory; consistent with the file format doc]
|
||||
- **Unknown schema syntax.** A library older than 3.31.0 opening a schema with
|
||||
generated columns "will perceive the generated column syntax as an error and
|
||||
will report that the database schema is corrupt". Older versions can read and
|
||||
write newer files "as long as the database schema does not contain features
|
||||
... not understood by the earlier version". [search] https://sqlite.org/gencol.html.
|
||||
The whole database is refused, not just the table.
|
||||
- **File format versions.** Write version > 2: "the database file must be treated
|
||||
as read-only". Read version > 2: "that database cannot be read or written".
|
||||
[fetched] https://www.sqlite.org/fileformat2.html (offsets 18 and 19). This is
|
||||
a format-level read-only fallback for content the library cannot safely write.
|
||||
- **Schema change across connections.** The schema cookie (offset 40) is bumped
|
||||
on every schema change. A prepared statement checks it and reprepares or fails
|
||||
with `SQLITE_SCHEMA`. [fetched] same page.
|
||||
- `user_version` (offset 60) and `application_id` (offset 68) are application
|
||||
owned; SQLite does not use them. [fetched]
|
||||
- **Whole-file rewrite.** SQLite's own comparison: custom and wrapped
|
||||
pile-of-files formats "usually require a rewrite of the entire document in
|
||||
order to change a single byte", and application concurrency logic for them is
|
||||
"a notorious bug-magnet". [fetched] https://www.sqlite.org/appfileformat.html
|
||||
- **Differs from us.** The schema is SQL text in `sqlite_schema`, not a typed
|
||||
slot catalog. Writes are partial and in place, and there is one implementation
|
||||
rather than five.
|
||||
|
||||
### 2.2 LMDB
|
||||
|
||||
- Opaque byte keys and values, no schema; named sub-databases. Copy-on-write
|
||||
B+tree with MVCC. One writer at a time, serialized by a mutex in the lock file;
|
||||
readers do not block the writer and the writer does not block readers;
|
||||
several processes may open one environment. [search; lmdb.tech/doc was not
|
||||
reachable over TLS in this pass] https://en.wikipedia.org/wiki/Lightning_Memory-Mapped_Database,
|
||||
https://lmdb.readthedocs.io/
|
||||
- `MDB_INCOMPATIBLE`: an operation does not match a database's flags (for
|
||||
example dupsort), or "the database type changed". [search]
|
||||
- **Differs from us.** There are no types, so "foreign" is not a concept;
|
||||
untouched keys are never rewritten.
|
||||
|
||||
### 2.3 Core Data (Apple)
|
||||
|
||||
- **Model mismatch is refused.** "The model used to open the store is
|
||||
incompatible with the one used to create the store" (Cocoa error 134100)
|
||||
unless a migration runs: lightweight (inferred mapping, using
|
||||
`NSMigratePersistentStoresAutomaticallyOption` and
|
||||
`NSInferMappingModelAutomaticallyOption`) or custom/staged. [search]
|
||||
https://developer.apple.com/forums/thread/100639
|
||||
- Store metadata holds per-entity version hashes (`NSStoreModelVersionHashes`).
|
||||
`isConfiguration(withName:compatibleWithStoreMetadata:)` compares them with
|
||||
the model. [search]
|
||||
https://developer.apple.com/documentation/coredata/nsmanagedobjectmodel/isconfiguration(withname:compatiblewithstoremetadata:)
|
||||
- **Atomic stores (XML, binary) are the closest analogue to our single file.**
|
||||
They keep the whole object graph in memory and save the whole file: "the old
|
||||
file is not deleted until the new one has been successfully written."
|
||||
[fetched] https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/CoreData/PersistentStoreFeatures.html.
|
||||
Combined with the refusal above, an atomic store is never rewritten by a model
|
||||
that does not cover every entity in it. The page says nothing on multi-process
|
||||
access to atomic stores. [fetched: absence]
|
||||
- **Differs from us.** Single runtime, entity-hash catalog in metadata, a
|
||||
migration-or-refuse gate at open. No foreign entity is ever carried.
|
||||
|
||||
### 2.4 Realm
|
||||
|
||||
- Opening with a changed schema requires a higher `schemaVersion`, plus a
|
||||
`migrationBlock` where needed; otherwise "Migration is required". Added and
|
||||
removed properties are applied to the on-disk schema automatically on
|
||||
migration. [search] https://github.com/realm/realm-js/issues/4752,
|
||||
https://realm.netlify.app/docs/swift/latest/
|
||||
- A changelog entry allows opening old frozen versions "with a schema that
|
||||
contains additional classes, but not additional properties". [search]
|
||||
https://github.com/realm/realm-core/releases?page=5
|
||||
- Whether a Realm opened with a class *subset* keeps the other tables untouched:
|
||||
**not verified** [memory: the tables are kept, since Realm writes in place by
|
||||
table and does not rewrite the whole file].
|
||||
- Realm file-format upgrades are one-way. [memory]
|
||||
|
||||
### 2.5 CouchDB and PouchDB
|
||||
|
||||
- `PUT /{db}/{docid}` writes a new revision of the whole document; there is no
|
||||
field merge, and omitted fields are gone in the new revision. A missing or
|
||||
stale `_rev` returns `409 Conflict`: "specified revision is not latest for
|
||||
target document". The revision may be given in the body, as `rev`, or in
|
||||
`If-Match`. [fetched] https://docs.couchdb.org/en/stable/api/document/common.html
|
||||
- Under replication, conflicting revisions are all kept; one deterministic
|
||||
winner is chosen and the others are marked `_conflicts`; CouchDB does not merge.
|
||||
[search] https://docs.couchdb.org/en/stable/replication/conflicts.html
|
||||
- There is no schema; unknown fields survive only if the client writes them
|
||||
back. Document-mapper layers drop them: Mongoose's `strict` option "ensures
|
||||
that values passed to our model constructor that were not specified in our
|
||||
schema do not get saved to the db", and is "enabled by default". [fetched]
|
||||
https://mongoosejs.com/docs/guide.html (MongoDB, cited as the same pattern).
|
||||
- **RxDB** (on PouchDB or other storage): any schema `version` > 0 requires
|
||||
`migrationStrategies` for each step; a strategy returning `null` deletes the
|
||||
document; writes during a running migration fail with `COL25`; in multi-tab
|
||||
setups "exactly one tab is running a migration of a collection". [fetched]
|
||||
https://rxdb.info/migration-schema.html. Opening storage written by a newer
|
||||
RxDB major is refused ("Cannot open database state with newer RxDB version").
|
||||
[search] https://github.com/pubkey/rxdb/issues/5836
|
||||
- **Differs from us.** The per-document `_rev` gives optimistic concurrency at
|
||||
record granularity, and documents are never rewritten as a side effect of
|
||||
writing another.
|
||||
|
||||
### 2.6 IndexedDB
|
||||
|
||||
- No field schema: values are structured clones, stored by value. Schema is
|
||||
object stores and indexes, versioned by an integer. [fetched] https://www.w3.org/TR/IndexedDB/
|
||||
- Opening with a higher version fires `upgradeneeded` inside a versionchange
|
||||
transaction. Opening with a *lower* version than the stored one fails with
|
||||
`VersionError`: "An attempt was made to open a database using a lower version
|
||||
than the existing version." Older code is refused outright. [fetched] same spec.
|
||||
- Other open connections receive `versionchange`; if they stay open the upgrader
|
||||
gets `blocked`. [fetched] same spec and
|
||||
https://developer.mozilla.org/en-US/docs/Web/API/IDBOpenDBRequest/blocked_event
|
||||
- **Differs from us.** The browser engine is the single writer implementation
|
||||
and the schema has one integer owner per database.
|
||||
|
||||
### 2.7 Whole-file rewrite while preserving unknown content
|
||||
|
||||
Systems found that rewrite a container while keeping parts they do not
|
||||
understand:
|
||||
|
||||
- **Automerge binary format.** Programs that edit documents must "maintain all
|
||||
columns, even those that they don't understand the meaning of" and write null
|
||||
into unknown columns for new rows. Implementations "must preserve operations
|
||||
containing actions they don't support". Unknown value type codes: "MUST read and
|
||||
store the type code and `length` bytes". Changes are SHA-256 hashed over their
|
||||
binary form, so byte preservation is required for hash integrity. [fetched]
|
||||
https://automerge.org/automerge-binary-format-spec/. The unit carried is an
|
||||
immutable, content-addressed change; nothing preserved is ever re-decided by
|
||||
the carrier.
|
||||
- **Core Data atomic stores** rewrite the whole file but refuse to open under an
|
||||
incompatible model (2.3), so they never carry unknown entities.
|
||||
- No system was found that rewrites a whole mutable file while carrying
|
||||
mutable records of unknown types that other writers may change concurrently.
|
||||
**Absence of evidence, not a verified negative.**
|
||||
|
||||
## 3. Literature
|
||||
|
||||
- **Kleppmann, *Designing Data-Intensive Applications* (1st ed., O'Reilly 2017),
|
||||
ch. 4 "Encoding and Evolution", section "Dataflow Through Databases".** [memory,
|
||||
not re-read] The section says an older version of code may read, update and
|
||||
write back a record written by newer code, and newer fields can be lost unless
|
||||
the old code keeps unknown fields. Figure 4-7 shows this. The section contains
|
||||
the line "data outlives code" [search; confirmed in secondary notes
|
||||
https://gist.github.com/bcherny/b870a60d1650973df7e400c8603ac76d]. It also says
|
||||
rewriting data into a new schema is possible but expensive, so most databases
|
||||
avoid it. **The exact wording is unverified.**
|
||||
- **Scherzinger, Klettke, Störl, "Managing Schema Evolution in NoSQL Data
|
||||
Stores", DBPL 2013.** Contrasts eager migration with *lazy* migration (migrate
|
||||
on read or touch) for schema-less stores whose data grows heterogeneous.
|
||||
[search] https://arxiv.org/abs/1308.0514
|
||||
- **Ringlstetter, Scherzinger, Bissyandé, "Data model evolution using
|
||||
object-NoSQL mappers: folklore or state-of-the-art?", BIGDSE@ICSE 2016,
|
||||
pp. 33-36.** Mapper evolution annotations (rename, remove, transform on load)
|
||||
are used on GitHub, but mostly for tasks other than evolving the data model.
|
||||
[search] https://dblp.org/rec/conf/icse/RinglstetterSB16.html
|
||||
- **Scherzinger and Sidortschuck, "An Empirical Study on the Design and Evolution
|
||||
of NoSQL Database Schemas", ER 2020.** [search, not read]
|
||||
https://arxiv.org/pdf/2003.00054
|
||||
- **Bancilhon and Spyratos, "Update semantics of relational views", ACM TODS
|
||||
6(4), 1981, pp. 557-575.** A view update is translated by holding a chosen
|
||||
*complement* constant; a complete set of view updates has a translator iff it
|
||||
is translatable under constant complement. [search]
|
||||
https://dl.acm.org/doi/10.1145/319628.319634. Mapping (this survey's framing,
|
||||
not the paper's): a writer's owned records are a view of the store, the foreign
|
||||
records are the complement, and lay-back is constant-complement translation.
|
||||
- **Foster, Greenwald, Moore, Pierce, Schmitt, "Combinators for bidirectional tree
|
||||
transformations", POPL 2005 / TOPLAS 2007.** A lens is `get: S -> V` and
|
||||
`put: V x S -> S`. Laws: GetPut, `put(get(s), s) = s`; PutGet,
|
||||
`get(put(v, s)) = v`; PutPut is the stronger optional law. `put` takes the
|
||||
source `s` as an argument, so the complement is restored from whatever `s` is
|
||||
supplied. [search for the paper; the law statements are from memory, and one
|
||||
search summary had GetPut and PutGet swapped]
|
||||
https://www.cis.upenn.edu/~bcpierce/papers/lenses-toplas-final.pdf
|
||||
- **Litt, van Hardenberg, Henry, "Project Cambria" (Ink & Switch, Oct 2020).**
|
||||
Bidirectional lenses between schema versions, arranged as a graph. In
|
||||
Cambria-Automerge every write is tagged with its writer's schema and translated
|
||||
at read time, so "old changes can be evolved using lenses that didn't even
|
||||
exist at the time of the original change". Lenses travel inside the document.
|
||||
Stated limits: lossy conversions (scalar to array), one-way changes
|
||||
(split/merge), no external lookups, untested performance. "There are limits to
|
||||
interoperability." [fetched] https://www.inkandswitch.com/cambria/
|
||||
- **Local-first and CRDT, Automerge:** see 2.7. Unknown ops, columns and value
|
||||
types from newer peers are preserved byte for byte and are immutable once
|
||||
written. [fetched]
|
||||
|
||||
## 4. Concurrency facts for whole-file rewrite
|
||||
|
||||
- **Last-writer-wins on whole replace** loses a concurrent writer's changes
|
||||
unless the write is conditional. Our contract already says this (section 0).
|
||||
- **HTTP optimistic concurrency.** RFC 9110 §13.1.1 If-Match exists to prevent
|
||||
the "lost update" problem; a failed precondition returns 412 Precondition
|
||||
Failed (§15.5.13). [memory; the page fetch truncated before those sections]
|
||||
https://www.rfc-editor.org/rfc/rfc9110.html#section-13.1.1
|
||||
- **Per-record CAS:** CouchDB `_rev`, 409 (2.5).
|
||||
- **Git ref update:** `git update-ref <ref> <new> <old>` stores the new value
|
||||
"after verifying that the current value of the <ref> matches <old-oid>".
|
||||
Transactions lock every ref (`.lock` files) at `prepare`; "If all <ref>s can be
|
||||
locked with matching <old-oid>s simultaneously, all modifications are
|
||||
performed. Otherwise, no modifications are performed." [fetched]
|
||||
https://git-scm.com/docs/git-update-ref. The pattern: take an exclusive lock
|
||||
file, compare the old value under the lock, write, rename into place.
|
||||
- **SQLite** serializes writers by file lock (rollback) or WAL write lock;
|
||||
schema-cookie checks catch schema drift between prepare and run (2.1).
|
||||
- **LMDB:** single writer mutex across processes, MVCC readers (2.2).
|
||||
- **IndexedDB:** schema change is an exclusive versionchange transaction that
|
||||
waits on other connections (2.6).
|
||||
- **RxDB:** one migrating tab, writes refused during migration (2.5).
|
||||
- **Crash consistency of temp-plus-rename.** Pillai et al., "All File Systems Are
|
||||
Not Created Equal: On the Complexity of Crafting Crash-Consistent
|
||||
Applications", OSDI 2014. The ALICE tool found 60 crash vulnerabilities across
|
||||
11 widely used applications; persistence properties, including how rename
|
||||
orders against data writes, vary across six Linux file systems. [search]
|
||||
https://research.cs.wisc.edu/adsl/Publications/alice-osdi14.pdf. Whether
|
||||
`fsync` of the temp file and its directory is needed for durability of the
|
||||
rename: [memory: yes on Linux ext4 and others]. Windows `MoveFileEx` with
|
||||
`MOVEFILE_REPLACE_EXISTING` and `ReplaceFile` atomicity guarantees: **not
|
||||
verified in this pass.**
|
||||
|
||||
## 5. Cross-cutting facts, as observed (not conclusions)
|
||||
|
||||
- Every system found that preserves unknown data does so at *field* level
|
||||
inside a record whose type the code owns (protobuf, Cap'n Proto, Bond opt-in,
|
||||
JSON extension data, serde flatten), or as *immutable content-addressed*
|
||||
units (Automerge changes).
|
||||
- Systems that hold several record types in one store either rewrite only what
|
||||
they touch (SQLite, LMDB, Realm [unverified], CouchDB, IndexedDB), or rewrite
|
||||
the whole file and refuse a model that does not cover the file (Core Data
|
||||
atomic stores).
|
||||
- Systems where older code meets a newer schema at open time do one of:
|
||||
refuse to open (IndexedDB `VersionError`, Core Data 134100, RxDB newer-major,
|
||||
SQLite unknown schema syntax), go read-only (SQLite write version > 2), or
|
||||
require a migration before any write (Realm, RxDB, Core Data).
|
||||
- Protobuf moved from dropping to preserving (3.5). Its documented residual
|
||||
losses are in conversion paths (JSON, field-by-field copy), and Go's
|
||||
preservation broke message comparability.
|
||||
- FIDL made preserve-and-expose the default and added `strict` as an opt-in
|
||||
refusal, justified on security grounds.
|
||||
|
||||
## Not verified in this pass
|
||||
|
||||
- Primary rationale for restoring proto3 unknown fields in 3.5.
|
||||
- Any named incident caused by protobuf field-number reuse.
|
||||
- Stock Apache Thrift retention of unknown fields (the Missing Guide says yes,
|
||||
the generated-code pattern says skip).
|
||||
- FlatBuffers object-API round trip of unknown fields.
|
||||
- MessagePack-CSharp unknown-key retention (absence).
|
||||
- Realm behaviour opening with a class subset.
|
||||
- Exact DDIA wording and figure number.
|
||||
- LMDB primary docs (TLS failure); RFC 9110 section text.
|
||||
- Windows rename and replace atomicity semantics.
|
||||
|
|
@ -1,291 +0,0 @@
|
|||
# An integration-native volumetric noise domain: probe
|
||||
|
||||
Campaign `cultmath-tapes`, Imagination probe, 2026-10-03, under ruling
|
||||
`cultmath-tapes:ruling:integration-native-noise-domain`. This is research under the
|
||||
parked tape target. It has no spec, no shipped-field change and no committed code.
|
||||
|
||||
It follows `analytic-integration-feasibility.md`, which found that exact noise
|
||||
integrals are cheap but that the shipped densities are nonlinear in the noise. The
|
||||
question here is the reverse one. Suppose a domain is designed so that span
|
||||
integration holds by construction. Does it buy more visual detail per unit cost
|
||||
than the shipped fields?
|
||||
|
||||
On the operator's instruction (relayed by Self), the reference is physical, not
|
||||
Aetheria's tuned compositing:
|
||||
- transmittance is Beer–Lambert, `T = exp(-τ)`;
|
||||
- the images use single scattering with Henyey–Greenstein `g = 0.6` and albedo 1;
|
||||
- both fields are integrated the same way.
|
||||
|
||||
The shipped look is a comparison, not a target.
|
||||
|
||||
## Verdict
|
||||
|
||||
**On the void, yes, by a wide margin. On the wells, modestly.** At or below the
|
||||
shipped march's cost, the integration-native field carries much more resolvable
|
||||
detail, and it is exact. The catches:
|
||||
- The look changes character. The default cascade reads as fibres or brush strokes,
|
||||
not as soft cloud.
|
||||
- The cost is traversal, not integration.
|
||||
- The field cannot be ray-marched at all. Every consumer, shadow rays included,
|
||||
must use the exact path.
|
||||
|
||||
Headline numbers, 512 x 256, single-threaded C# on Yggdrasil:
|
||||
|
||||
| Void, transmittance image | Cost per pixel | vs shipped march | msf (grad²/var) | Power above 1/8 cycle/px | Pixels with \|∇T\| > 0.05 |
|
||||
|---|---|---|---|---|---|
|
||||
| Shipped field, converged reference (595 samples) | n/a | n/a | 0.0111 | 0.20% | 0.00% |
|
||||
| Shipped march (11.4 samples) | 28.9 µs (44 snoise3) | 1.00x | 0.0178 | 0.71% | 0.00% |
|
||||
| Integration-native, 2 levels, exact | 13.9 µs | **0.48x** | **0.0755** | **2.95%** | 41.9% |
|
||||
| Integration-native, 3 levels, exact | 35.7 µs | 1.24x | 0.125 | 9.15% | 54.7% |
|
||||
| Integration-native, 6 levels (LOD stops at 4 to 5), exact | 74.2 µs | 2.57x | 0.159 | 12.5% | 60.9% |
|
||||
|
||||
- **Mean density is matched.**
|
||||
- The integration-native field places mass from the shipped field's noise-averaged
|
||||
density.
|
||||
- Mean window optical depth is 1.62 to 1.74 against the shipped 1.81.
|
||||
- Mean T is 0.25 to 0.40 against 0.17. The difference comes from intermittency:
|
||||
the image std is 0.29 against 0.05, at a near-equal mean.
|
||||
- **msf is normalised by variance.** It measures spatial frequency, not contrast.
|
||||
- **Single-scattering images, void.** The images are 256 x 128 with a point light at
|
||||
the sun, and both are tonemapped with the same exposure. The shipped image's msf
|
||||
is 0.0023, with 0.22% of power above 1/8. The integration-native image's msf is
|
||||
0.101, with 16.9% of power above 1/8.
|
||||
- **Wells.**
|
||||
- Transmittance is a near-opaque silhouette for both fields: equal msf (0.011),
|
||||
so it carries no signal.
|
||||
- Single scattering at 6 levels (1.20x the march cost):
|
||||
- msf is 0.073 against 0.069;
|
||||
- power above 1/8 is 10.1% against 7.0%;
|
||||
- the edge fraction is 16.7% against 9.5%.
|
||||
- At 4 levels the cost is 0.77x the march.
|
||||
|
||||
**Exactness.** Against a converged march of the same field at 0.02 world:
|
||||
- The relative τ error is a median 3e-5 and a maximum of 1.0e-4 on the void, and a
|
||||
median 1e-5 and a maximum of 1.0e-4 on the wells.
|
||||
- The binned gather against brute force over all roots differs by at most 1.3e-15.
|
||||
- The shipped march against its own converged reference: |ΔT| is a median 0.0055
|
||||
(p90 0.013) on the void and a median 0.0006 (max 0.83 on grazing rays) on the
|
||||
wells.
|
||||
|
||||
**The detail is only reachable by integration.**
|
||||
- Marching the same integration-native field at the shipped step gives a void |ΔT|
|
||||
median of 0.14, a p90 of 0.48 and a maximum of 0.83.
|
||||
- A field this detailed is beyond the march at its step. That is the operator's
|
||||
premise, measured: the shipped fields' softness fits the regime that renders them.
|
||||
|
||||
## The primitive set
|
||||
|
||||
The primitive set has one design rule: nonlinearity lives in kernel placement, and
|
||||
linearity lives in the integration. The density is a sum of nonnegative,
|
||||
compact-support kernels. Everything nonlinear is evaluated once per kernel, at its
|
||||
centre, and never per sample. That covers envelopes, clamps, `pow` profiles, the
|
||||
flow warp and placement.
|
||||
|
||||
### Bases (closed-form line integral over a segment)
|
||||
|
||||
| Primitive | Why it integrates | Cost per span (measured unless marked) |
|
||||
|---|---|---|
|
||||
| Compact polynomial ellipsoid kernel, `(1 − u)^k` with `u = \|A⁻¹(x − μ)\|²` | On a line, u is quadratic in t, so the kernel is a polynomial of degree 2k between the two roots of `u = 1`, which are closed form. Same algebra as the simplex lattice integral in the first probe | k = 0: 5.5 to 7.4 ns; k = 1: 8 to 9.5 ns; k = 2: 11.7 ns. About 0.01 to 0.02 snoise3 (snoise3 is 610 to 650 ns in C#) |
|
||||
| Truncated Gaussian ellipsoid, `exp(−κu)` on `u ≤ 1` | `exp` of a quadratic is a Gaussian in t, giving an erf difference. Truncation only clips the limits | 32 to 36 ns with A&S 7.1.26 erf (error ≤ 1.5e-7), about 0.05 snoise3 |
|
||||
| k = 0 ellipsoid (constant density) | Chord length; this is EVER's primitive (Mai et al. 2024 [memory]) | 5.5 ns |
|
||||
| Gabor kernel (Gaussian × cosine) | Complex erf, any affine transform (Gabor Fields 2026, eq. 9 [fetched in the prior-art file]) | Not probed. Up to 16 Taylor terms [paper] |
|
||||
| Cosine sum (Balint 2019) | Integral is a sum of sines | Not probed. Two noise-sized evaluations per segment [paper]. Signed, so it needs an offset; low contrast |
|
||||
| Simplex lattice (CultMath `snoise`) | First probe: degree 9 per lattice point | 0.16 snoise3 per point, 3.44 points per noise unit. Signed: only usable where density is affine in it |
|
||||
|
||||
### Warps
|
||||
|
||||
| Warp | Why it stays exact | Cost |
|
||||
|---|---|---|
|
||||
| Per-kernel affine warp: centre moved by `f(μ)·s`, shape by `(I + J·s)` | An affine image of an ellipsoid is an ellipsoid, so the ray stays straight and the kernel stays closed form. The warp *defines* the field, so there is no approximation error. It is evaluated once per root per frame (7 flow evaluations with the Jacobian) | Frame build 39 ms (void, 2540 roots) and 57 ms (wells, 16947 roots), or 19 and 27 ns per 1080p pixel amortised |
|
||||
| Children inherit the parent's affine frame | The cascade has descendants that are exact affine images, so the warp is evaluated only at the roots | One 3x3 product per child, from a table |
|
||||
| Not allowed | A per-point warp `x + w(x)` inside a span: the warped ray curves, which was the first probe's problem | n/a |
|
||||
|
||||
### Transfer and placement
|
||||
|
||||
| Rule | Status |
|
||||
|---|---|
|
||||
| Any function of the kernel centre: the shipped clamp, `pow`, the smoothstep fade, the noise-averaged density `E_n[ρ(x, n)]` from 16 empirical noise quantiles, through a 1D LUT over d (void) or s (wells) | Exact by construction, at one lookup per kernel |
|
||||
| Affine transfer of the sum (`a + b Σ K_i`) | Exact |
|
||||
| Any 1D function of an *affine* coordinate, `∫ f(a + bt) dt = ΔF / b` (pre-integration, Engel 2001) | Exact [derivation, not probed]: height fog `exp(−y/h)` and ramps in y |
|
||||
| Polynomial or exp of a *quadric* coordinate; an indicator or clamp of a quadric (chord through roots) | Exact [derivation, not probed]: spherical shells and paraboloid bowls as envelopes |
|
||||
| Product of the sum with a polynomial envelope in t | Exact, polynomial times polynomial [derivation, not probed] |
|
||||
| Forbidden inside a span: `f(Σ K_i)` for nonlinear f, `max`/`min` of fields, a clamp of a signed sum, or noise displacing an envelope's argument | These are exactly what broke the first probe |
|
||||
|
||||
### Composition: the mass-conserving multiplicative cascade
|
||||
|
||||
- **Roots** sit on a jittered grid: spacing H0 = 30 world on the void and 40 on the
|
||||
wells, one root per cell.
|
||||
- Mass is `P(μ)·H0³`, so the mean density equals the shipped noise-averaged
|
||||
density.
|
||||
- The ellipsoid is elongated along the flow direction projected on the shell or
|
||||
fog layer (aspect 1.3 to 2.4), with ±0.6 rad of jitter, and flattened along the
|
||||
normal (×0.5 void, ×0.4 wells).
|
||||
- There are two copies per root, at the shipped two-phase shifts with weights w0
|
||||
and 1 − w0. Time animation is therefore the shipped crossfade, exact under
|
||||
summation.
|
||||
- **Children.** Each node keeps 25% of its mass and gives 75% to Nc = 5 children.
|
||||
- Child weights are `u²·P(child)/P(parent)`, normalised. The `u²` makes the cascade
|
||||
intermittent; the P ratio sharpens it toward the envelope.
|
||||
- A child is scale 0.4, with aspect up to 1.6 and ±0.6 rad of rotation.
|
||||
- Children sit along the parent's major axis: offset 0.6 along it, 0.21 across.
|
||||
- All of this comes from a 256-entry table indexed by hash. A descendant bound of
|
||||
1.77 times the ellipsoid holds the subtree, so the parent is a BVH node.
|
||||
- **LOD.** A node is a leaf when its children would be smaller than 0.5 × the pixel
|
||||
footprint at that depth. It then keeps its full mass. Because the cascade conserves
|
||||
mass, truncation is a mean-preserving prefilter: the expectation step of the first
|
||||
probe, exact here instead of approximate.
|
||||
- **Gather.**
|
||||
- Roots are binned by their subtree AABB into a hash grid of H0 cells.
|
||||
- The ray walks the grid with Amanatides–Woo DDA and mailboxing, clipped to the
|
||||
shipped active windows, so both fields integrate the same span.
|
||||
- It exits early once Στ ≥ 7, which is valid because every term is nonnegative.
|
||||
|
||||
## "Juice" under these constraints
|
||||
|
||||
| Effect | Mechanism here | Measured or prior art |
|
||||
|---|---|---|
|
||||
| Filaments, wisps | Anisotropic children chained along the parent's major axis, which follows the flow | Visible in `void-T-IN-L6.png` as streaks along the flow. Gabor Fields uses anisotropic Gabor kernels for orientation-selective detail [fetched] |
|
||||
| Sharp edges | Low-order kernels: k = 1 is the default, k = 0 gives hard ellipsoids | k = 0 (`L6-hard`) changes msf by −9% on the void. Edges come from kernel boundaries, not clamps |
|
||||
| Voids | Regions with no kernel mass are exactly zero and are skipped by the gather at no cost | Structural |
|
||||
| Turbulent intermittency | Random multiplicative cascade, the p-model of turbulent dissipation (Meneveau & Sreenivasan 1987 [memory]); clustered kernels, as in a Neyman–Scott process [memory] | Image std 0.29 against 0.05 at near-equal mean OD |
|
||||
| Flow and animation | Per-kernel warp at the shipped flow and phases. Alternatives are Lagrangian advection of kernel centres with lifetimes (advected textures, Neyret 2003 [memory]) and Gabor phase for oscillatory detail (flow noise, Perlin & Neyret 2001 [memory]) | Only the two-phase crossfade was measured |
|
||||
| Fine detail riding on structure | Children exist only inside parents, so detail appears only where matter is. This is Schneider's erosion idea (2015 [fetched]) without the per-sample clamp | Structural |
|
||||
|
||||
Wavelet turbulence (Kim et al. 2008 [memory]) and curl noise (Bridson 2007 [memory])
|
||||
were not used. A per-point warp is ruled out above. Curl noise could orient kernels,
|
||||
evaluated per kernel, at no cost to exactness.
|
||||
|
||||
## Measurements
|
||||
|
||||
**Setup.**
|
||||
- Probe source: scratch clone `probe-clone@5ec9f529`,
|
||||
`packages/cultmath/probe/DomainProbe/Program.cs`, never merged.
|
||||
- Run on Yggdrasil through the verify stopgap: dotnet 10 container, 4 CPUs, tiered
|
||||
JIT off.
|
||||
- Full log: Self's scratchpad, `dom-full1.log`.
|
||||
- The scenes are those of the first probe, with fixed cameras:
|
||||
- Void: the shipped orbiting camera at time 20, at the 1920-wide f = 935 field of
|
||||
view, rendered at 512 x 256.
|
||||
- Wells: warp D = 60, camera at xz (1200, 0) looking at the central well, slopes
|
||||
x ±1 and y from −0.85 to 0.15.
|
||||
- The window is the shipped field's active intervals. Every method integrates the
|
||||
same window.
|
||||
|
||||
**Cost.**
|
||||
- Single-threaded C#, best of three passes over the same 2048 random pixels.
|
||||
- The host is shared, and absolute timings moved by up to 2x between the smoke run
|
||||
and the full run; Flow went from 125 to 660 ns. Ratios within one run are the
|
||||
reliable numbers.
|
||||
- Flow is 47% of a shipped sample in this run, so a flow-cached march would cost
|
||||
about 15 µs on the void and 31 µs on the wells.
|
||||
|
||||
| Config (void) | Cost vs march | Roots visited / nodes tested / kernels integrated per pixel | Kernels per level |
|
||||
|---|---|---|---|
|
||||
| L1 | 0.12x | 43 / 8 / 8 | 8 |
|
||||
| L2 | 0.48x | 59 / 25 / 20 | 8, 12 |
|
||||
| L3 | 1.24x | 74 / 42 / 28 | 8, 12, 8 |
|
||||
| L4 | 1.81x | 82 / 60 / 35 | 8, 12, 8, 7 |
|
||||
| L6 (default) | 2.57x | 93 / 86 / 36 | 8, 12, 8, 7, 1, 0 |
|
||||
| L6, no child placement | 1.79x | 93 / 91 / 39 | same metrics as L6 |
|
||||
| L6, no LOD | 2.95x | 93 / 103 / 49 | 8, 12, 8, 7, 7, 7 |
|
||||
|
||||
| Config (wells) | Cost vs march | Kernels integrated per pixel |
|
||||
|---|---|---|
|
||||
| L1 | 0.04x | 5 |
|
||||
| L2 | 0.23x | 11 |
|
||||
| L3 | 0.49x | 16 |
|
||||
| L4 | 0.77x | 20 |
|
||||
| L6 | 1.20x | 25 |
|
||||
| L6, no child placement | 0.62x | 27 |
|
||||
|
||||
**Where the time goes.**
|
||||
- The integrals are about 36 kernels × 10 ns, or 0.4 µs of a 74 µs pixel.
|
||||
- The rest is traversal:
|
||||
- roots visited but missed: 93 visited for 8 hits, because AABBs of elongated
|
||||
rotated ellipsoids are loose;
|
||||
- child generation;
|
||||
- placement lookups. The wells' placement costs a 5-bowl `Sdf`, and dropping
|
||||
child placement halves the wells cost with identical metrics.
|
||||
|
||||
**Aliasing.** One sample per pixel against four on the default void field: |ΔT| has
|
||||
an RMS of 0.015 (p90 0.022). At 4 spp the msf is 0.146 against 0.159. So about 92%
|
||||
of the measured detail is resolved structure, not sparkle. Exact along the ray is
|
||||
not the same thing as antialiased across the pixel; the LOD rule is the prefilter.
|
||||
|
||||
**Variants, void L6 (msf / power above 1/8).**
|
||||
- k = 1 default: 0.159 / 12.5%.
|
||||
- Hard k = 0: 0.145 / 11.0%.
|
||||
- Gaussian (erf): 0.174 / 14.2%, at 0.89x the polynomial cost here. The erf kernel is
|
||||
3x the polynomial kernel in isolation, but the kernel is not where the time goes.
|
||||
|
||||
## Images
|
||||
|
||||
All images are in Self's session scratchpad,
|
||||
`C:\Users\Meta\AppData\Local\Temp\claude\F--Projects-CultLib\b7e842a1-2045-474e-a51b-240d832b770b\scratchpad\integration-domain-images\`:
|
||||
|
||||
- **Transmittance, 512 x 256.** Shown as `sqrt(1 − T)`, white for opaque.
|
||||
- `void-T-shipped-ref.png`, `void-T-shipped-march.png`.
|
||||
- `void-T-IN-L1.png`, `void-T-IN-L3.png`, `void-T-IN-L6.png`,
|
||||
`void-T-IN-L6-hard.png`, `void-T-IN-L6-gauss.png`.
|
||||
- The wells equivalents, which are silhouettes and uninformative.
|
||||
- **Single scattering, 256 x 128.**
|
||||
- Method: HG g = 0.6 and albedo 1. The point light is at the void's sun with 1/r²
|
||||
falloff; the wells use a directional sun with a sky background.
|
||||
- Each view sample takes a shadow ray: a 2-world march for the shipped field, an
|
||||
exact traversal for the integration-native field.
|
||||
- Tonemap: `1 − exp(−1.5 L / p99(shipped))`, then gamma 2.2, the same exposure for
|
||||
both fields.
|
||||
- `void-SS-shipped.png`, `void-SS-IN-L6.png`, `wells-SS-shipped.png`,
|
||||
`wells-SS-IN-L6.png`.
|
||||
|
||||
What they show:
|
||||
- The shipped void is a soft, low-contrast haze.
|
||||
- The integration-native void is dense flow-aligned streaks with deep gaps, like
|
||||
hair or brush strokes.
|
||||
- The shipped wells are smooth rolling dunes. The integration-native wells are
|
||||
layered pancakes with streaked detail.
|
||||
|
||||
## The catches
|
||||
|
||||
1. **The character is a design parameter, and this probe picked one.**
|
||||
- High anisotropy, axis-chained children and an intermittent `u²` split give
|
||||
fibres.
|
||||
- A cloudier look would need rounder children, wider offsets and less
|
||||
intermittency. That trades away some measured detail and was not swept.
|
||||
- The detail metrics measure spatial frequency, not taste. The operator has to
|
||||
look at the images.
|
||||
2. **The cost is a BVH, not arithmetic.**
|
||||
- Integration is nearly free. Traversal, child generation and placement dominate.
|
||||
- Tighter root bounds (OBB tests before AABB bins), cheaper placement and fewer
|
||||
children are obvious levers, but none was measured.
|
||||
- On a GPU, variable-length kernel lists per ray diverge. 3DGRT (Moënne-Loccoz et
|
||||
al. 2024 [memory]) puts the same problem on hardware ray tracing.
|
||||
- CPU C# ratios do not transfer to the GPU, where snoise is relatively cheaper.
|
||||
3. **It is all or nothing.**
|
||||
- This field cannot be marched. At the shipped step, |ΔT| is a median 0.14.
|
||||
- Shadow rays, light probes and any sampler consumer need the exact traversal too.
|
||||
The single-scattering pass here cost an exact shadow traversal per view sample:
|
||||
257 s for both void SS images together, against 54 s for the whole 512 x 256
|
||||
shipped transmittance set.
|
||||
- Lighting has no closed form. The in-scatter integral is still quadrature. What
|
||||
the domain makes exact is every optical depth inside it. A per-kernel lighting
|
||||
cache would amortise shadows, but that is a different shading model (splat-like),
|
||||
and so a product fork.
|
||||
4. **Exact along the ray, not across the pixel.** The LOD truncation is the
|
||||
prefilter. 8% of the measured detail at 1 spp is aliasing.
|
||||
5. **The wells gain little** because the shipped wells fog is near-opaque. Its look
|
||||
is the surface silhouette, and that is set at root scale. The domain pays most
|
||||
where the medium is semi-transparent, as in the void.
|
||||
6. **Timing noise.** Absolute costs vary up to 2x between runs on the shared host.
|
||||
The cost ratios above are within one run.
|
||||
|
||||
## What would change the verdict
|
||||
|
||||
- A sweep toward a cloud-like character: round children, Nc = 8, wider offsets,
|
||||
Gaussian kernels. It should be judged on the single-scattering images against the
|
||||
same cost.
|
||||
- Tight root culling: an exact ellipsoid-versus-bin test, or roots stored by
|
||||
oriented bound. If roots visited fall from 93 to near the 8 hits, the void L3 cost
|
||||
would fall well under the march.
|
||||
- A GPU port of one level-limited configuration, to replace the C# ratios with
|
||||
shader timings.
|
||||
|
|
@ -1,310 +0,0 @@
|
|||
# Read-Only Record Signal: Prior Art Survey
|
||||
|
||||
Facts file for Eureka question `variants:question:read-only-record-signal`: how a
|
||||
developer learns that a held record is read-only before a write is refused. Its
|
||||
options are (a) no new API, (b) a live query such as
|
||||
`CultCache.IsWritable(key)`, and (c) a list beside `ForeignRecords`. Eyes pass,
|
||||
2026-10-03. Facts and evidence pointers only; no recommendation.
|
||||
|
||||
Verification marks:
|
||||
|
||||
- **[fetched]** the claim was read on the cited page or source during this pass.
|
||||
- **[search]** the claim came from a search-result summary of the cited page,
|
||||
not a full read.
|
||||
- **[memory]** recalled from the cited source, not re-read in this pass.
|
||||
Treat it as unverified.
|
||||
|
||||
Signal shapes used below:
|
||||
|
||||
- **property**: a flag or state on the loaded object itself.
|
||||
- **query**: a call on the store, session or registry that answers at call time.
|
||||
- **list**: an enumeration of affected items, captured or computed.
|
||||
- **type**: the read-only status is a distinct static type.
|
||||
- **exception only**: nothing to ask; the write is the first signal.
|
||||
|
||||
## 0. Our design, for comparison
|
||||
|
||||
Source: branch `hands/variants-c2a` (head e9a66876), `src/GameCult.Caching/CultCache.cs`.
|
||||
|
||||
- `CultDocumentRegistry.Declares(string schemaId)` (line 738) is `internal`. Its
|
||||
comment: "The one answer to whether a cache may write, relabel or remove a
|
||||
record stored under this id ... Read live, so a type registered later that
|
||||
declares the id claims its records at their next write." [fetched]
|
||||
- `GetRequired<T>()` (line 631) registers lazily, so the set of declared ids can
|
||||
grow after load. [fetched]
|
||||
- The backing store already exposes `ForeignRecords` (line 3421), an
|
||||
`IReadOnlyList<CultForeignRecord>` "the last load found and carries untouched",
|
||||
set at load (`SetForeignRecords`, line 3599). `CultForeignRecord` (line 162)
|
||||
carries `Key`, `SchemaId`, `SchemaName`, `StoredAt`. [fetched]
|
||||
- The store also has a store-level `public bool IsReadOnly { get; }` (line 3419),
|
||||
fixed at construction. [fetched]
|
||||
- Main (162bd1fc) exposes `LastSchemaMigrationReports` (CultCache.cs:3136), whose
|
||||
entries carry no record key (per the question text). [fetched for the
|
||||
property; the no-key claim is the question's]
|
||||
|
||||
## 1. ORMs
|
||||
|
||||
### EF Core: no-tracking queries and keyless entity types
|
||||
|
||||
- Signal shape: **query** on the context, plus a **model** query.
|
||||
`context.Entry(e).State` returns an `EntityState`; `Detached` means "not being
|
||||
tracked by the DbContext", and its "Action on SaveChanges" is none.
|
||||
([Change Tracking](https://learn.microsoft.com/en-us/ef/core/change-tracking/), "Entity states" table) [fetched]
|
||||
- No-tracking results (`AsNoTracking`, or `QueryTrackingBehavior.NoTracking` on
|
||||
the context) are plain instances; changes to them are not persisted by
|
||||
`SaveChanges`. ([Tracking vs. No-Tracking](https://learn.microsoft.com/en-us/ef/core/querying/tracking), "No-tracking queries") [fetched]
|
||||
- Keyless entity types "are never tracked for changes in the DbContext and
|
||||
therefore are never inserted, updated or deleted"; their mapped object is
|
||||
"treated as a read-only query source".
|
||||
([Keyless Entity Types](https://learn.microsoft.com/en-us/ef/core/modeling/keyless-entity-types), "characteristics", "Mapping to database objects") [fetched]
|
||||
- Attempting to `Attach`/`Update` a keyless instance throws
|
||||
`InvalidOperationException` with `CoreStrings.KeylessTypeTracked`: "Unable to
|
||||
track an instance of type '{type}' because it does not have a primary key."
|
||||
([CoreStrings.KeylessTypeTracked](https://learn.microsoft.com/en-gb/dotnet/api/microsoft.entityframeworkcore.diagnostics.corestrings.keylesstypetracked?view=efcore-8.0)) [search]
|
||||
- The model answers keylessness: `IReadOnlyEntityType.IsKeyless` /
|
||||
`FindPrimaryKey() == null`. [memory]
|
||||
- Pitfall: modifying a no-tracking instance and calling `SaveChanges` is silent;
|
||||
nothing is written and nothing is thrown. `ChangeTracker.HasChanges()` returns
|
||||
false (Change Tracking page, closing tip). [fetched]
|
||||
|
||||
### Hibernate: @Immutable and read-only entities
|
||||
|
||||
- Signal shape: **query** on the session. `Session.isReadOnly(Object
|
||||
entityOrProxy)`: "Is the specified entity or proxy read-only?";
|
||||
`isDefaultReadOnly()` for the session default; `setReadOnly(Object, boolean)`
|
||||
flips one entity. ([Session javadoc 6.4](https://docs.hibernate.org/orm/6.4/javadocs/org/hibernate/Session.html)) [fetched]
|
||||
- Read-only can be set per session (`setDefaultReadOnly`), per query
|
||||
(`Query.setReadOnly(true)`, before execution) or per entity.
|
||||
([Hibernate 4.3 manual ch. 12, 12.1.2-12.1.3](https://docs.hibernate.org/orm/4.3/manual/en-US/html/ch12.html)) [fetched]
|
||||
- Write behaviour: changes to read-only entities are ignored at flush, no
|
||||
exception ("Hibernate will not update simple properties or updatable
|
||||
single-ended associations"); "A read-only entity can be deleted." (ch. 12.1) [fetched]
|
||||
- `@Immutable`: "Changes made in memory to the state of an immutable entity are
|
||||
never synchronized to the database. The changes are ignored, with no exception
|
||||
thrown." Immutable collections, by contrast, throw `HibernateException` on add
|
||||
or remove. ([@Immutable javadoc 6.2](https://docs.hibernate.org/orm/6.2/javadocs/org/hibernate/annotations/Immutable.html)) [fetched]
|
||||
- Bulk HQL updates of immutable entities: setting
|
||||
`hibernate.query.immutable_entity_update_query_handling_mode` chooses `warning`
|
||||
(default) or `exception`. ([QuerySettings 6.6](https://docs.hibernate.org/orm/6.6/javadocs/org/hibernate/cfg/QuerySettings.html)) [search]
|
||||
- Pitfall: the default for entity changes is silent loss; the read-only state is
|
||||
per session, so the same row is writable in another session.
|
||||
|
||||
### ActiveRecord: readonly?
|
||||
|
||||
- Signal shape: **property** on the record. `readonly?` "returns true if the
|
||||
record is marked as read-only"; `readonly!` marks it.
|
||||
([ActiveRecord::Core](https://api.rubyonrails.org/classes/ActiveRecord/Core.html)) [fetched]
|
||||
- Relation-level: `User.readonly` marks every loaded record;
|
||||
`users.first.save # => ActiveRecord::ReadOnlyRecord: User is marked as readonly`.
|
||||
([QueryMethods#readonly](https://api.rubyonrails.org/classes/ActiveRecord/QueryMethods.html)) [fetched]
|
||||
- Write behaviour: save, update or destroy raise `ActiveRecord::ReadOnlyRecord`. [fetched]
|
||||
- The flag is set when the record is loaded and lives on the in-memory object; it
|
||||
is not re-derived from the database or the model at write time. [memory]
|
||||
|
||||
## 2. Document stores and embedded databases
|
||||
|
||||
### Realm (Kotlin SDK): frozen objects
|
||||
|
||||
- Signal shape: **property** functions on the object.
|
||||
`BaseRealmObject.isFrozen()`: "A frozen object is tied to a specific version of
|
||||
the data in the realm and fields retrieved from this object instance will not
|
||||
update"; also `isManaged()` and `isValid()` ("the Realm is open and the
|
||||
underlying object has not been deleted").
|
||||
([BaseRealmObjectExt.kt L41-71](https://github.com/realm/realm-kotlin/blob/main/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/ext/BaseRealmObjectExt.kt)) [fetched]
|
||||
- Recovery path: `MutableRealm.findLatest(obj)` returns a live, writable copy
|
||||
inside a write block, or `null` "if the object has been deleted".
|
||||
([MutableRealm.kt L50-56](https://github.com/realm/realm-kotlin/blob/main/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/MutableRealm.kt)) [fetched]
|
||||
- Write behaviour: writing to a frozen object throws. Early SDKs threw a generic
|
||||
"Wrong transactional state ..." message; issue #297 asked for "Frozen objects
|
||||
cannot be modified" plus a pointer to `findLatest`.
|
||||
([realm-kotlin#297](https://github.com/realm/realm-kotlin/issues/297)) [fetched]
|
||||
- Pitfall: `isFrozen()` is stable for a given instance, but `isValid()` is live
|
||||
and can flip when another writer deletes the object.
|
||||
|
||||
### Couchbase Lite
|
||||
|
||||
- Signal shape: **type**. "By default, a document is immutable when it is read
|
||||
from the database. Use the Document.ToMutable() to create an updatable
|
||||
instance." `Document` versus `MutableDocument` makes the distinction static.
|
||||
([Couchbase Lite C# Documents](https://docs.couchbase.com/couchbase-lite/current/csharp/document.html), "Mutability") [fetched]
|
||||
- Write conflicts with other writers are handled at save by concurrency control
|
||||
(last-write-wins or fail-on-conflict) or a conflict handler. [memory]
|
||||
|
||||
### LiteDB
|
||||
|
||||
- Signal shape: **store-level option** only. Connection string `ReadOnly`
|
||||
("Open datafile in read-only mode"). No per-document read-only state found.
|
||||
([LiteDB connection string](https://www.litedb.org/docs/connection-string/)) [fetched]
|
||||
|
||||
### SQLite
|
||||
|
||||
- Signal shape: **store query**. `sqlite3_db_readonly(D,N)` "returns 1 if the
|
||||
database N of connection D is read-only, 0 if it is read/write, or -1 if N is
|
||||
not the name of a database". ([sqlite3_db_readonly](https://www.sqlite.org/c3ref/db_readonly.html)) [fetched]
|
||||
- `sqlite3_stmt_readonly()` answers whether a prepared statement writes. [memory]
|
||||
|
||||
### Core Data
|
||||
|
||||
- Signal shape: **store property** plus an open option.
|
||||
`NSPersistentStore.isReadOnly`: "A Boolean value that indicates whether the
|
||||
persistent store is read-only" (get/set);
|
||||
`NSReadOnlyPersistentStoreOption`: "A flag that indicates whether a store is
|
||||
treated as read-only", default false.
|
||||
([isReadOnly](https://developer.apple.com/documentation/coredata/nspersistentstore/isreadonly),
|
||||
[NSReadOnlyPersistentStoreOption](https://developer.apple.com/documentation/coredata/nsreadonlypersistentstoreoption)) [fetched]
|
||||
- Granularity: store, not object. An object's store is reachable through its
|
||||
`objectID.persistentStore`. [memory]
|
||||
|
||||
## 3. Serializers and schema evolution: unknown or foreign data
|
||||
|
||||
### Protocol Buffers
|
||||
|
||||
- Unknown fields: "Proto3 messages preserve unknown fields and include them
|
||||
during parsing and in the serialized output." They are lost on JSON
|
||||
serialization or when copying field by field into a new message.
|
||||
([proto3 guide, Unknown Fields](https://protobuf.dev/programming-guides/proto3/)) [fetched]
|
||||
- Signal shape for unknown fields: **property/list** on the message,
|
||||
`getUnknownFields()` returning an `UnknownFieldSet` (Java). [memory]
|
||||
- `Any` with a foreign type: **query** `any.is(Foo.class)` returns a boolean;
|
||||
`unpack(Class)` throws `InvalidProtocolBufferException`.
|
||||
([Any javadoc](https://protobuf.dev/reference/java/api-docs/com/google/protobuf/Any.html)) [fetched]
|
||||
- Granularity: fields inside a known message, or one embedded payload; no
|
||||
ownership or write-permission concept.
|
||||
|
||||
### Apache Avro
|
||||
|
||||
- Writer fields absent from the reader schema are dropped: "the writer's value
|
||||
for that field is ignored". A union branch the reader lacks: "if none match, an
|
||||
error is signalled". ([Avro 1.11.1 spec, Schema Resolution](https://avro.apache.org/docs/1.11.1/specification/)) [fetched]
|
||||
- Signal shape: **exception only** for unresolvable data; nothing marks a record
|
||||
as carrying dropped fields.
|
||||
|
||||
### Unity SerializeReference: missing types (closest found to CultCache)
|
||||
|
||||
- A host asset holds managed references whose recorded type (assembly,
|
||||
namespace, class) is not in the current project.
|
||||
- Signal shape: **query** returning a bool and a **list**.
|
||||
`SerializationUtility.HasManagedReferencesWithMissingTypes(Object obj)`: "returns
|
||||
true if one or more managed references is missing its type."
|
||||
`GetManagedReferencesWithMissingTypes` returns the
|
||||
`ManagedReferenceMissingType` entries: `assemblyName`, `className`,
|
||||
`namespaceName`, `referenceId`, `serializedData`.
|
||||
([HasManagedReferencesWithMissingTypes](https://docs.unity3d.com/6000.0/Documentation/ScriptReference/SerializationUtility.HasManagedReferencesWithMissingTypes.html),
|
||||
[ManagedReferenceMissingType](https://docs.unity3d.com/6000.0/Documentation/ScriptReference/ManagedReferenceMissingType.html)) [fetched]
|
||||
- Behaviour: the field stays null, the persisted state is retained on resave, and
|
||||
if the type returns later, "the state of the Managed Reference object can be
|
||||
recovered". `ClearManagedReferenceWithMissingType` removes one deliberately.
|
||||
([GetManagedReferencesWithMissingTypes](https://docs.unity3d.com/ScriptReference/SerializationUtility.GetManagedReferencesWithMissingTypes.html)) [search]
|
||||
- Both calls are made on demand against a host object; the docs do not say the
|
||||
list is cached at load. A sibling `PrefabUtility.HasManagedReferencesWithMissingTypes`
|
||||
exists for prefab instances. [search]
|
||||
|
||||
### .NET DataContractSerializer: IExtensibleDataObject
|
||||
|
||||
- Unknown members round-trip through an opaque `ExtensionDataObject` property on
|
||||
the object. It cannot be inspected, so it is a carrier, not a signal. [memory]
|
||||
|
||||
## 4. File systems and object stores
|
||||
|
||||
### POSIX access(2) and TOCTOU
|
||||
|
||||
- Signal shape: **query** on the path. `access(path, W_OK)` fails with `EROFS` on
|
||||
a read-only file system. The check uses the real UID/GID, "rather than the
|
||||
effective IDs as is done when actually attempting an operation".
|
||||
([access(2)](https://man7.org/linux/man-pages/man2/access.2.html)) [fetched]
|
||||
- Pitfall: checking before `open(2)` "creates a security hole" because the state
|
||||
can change between check and use (access(2) NOTES). [fetched]
|
||||
- Python's `os.access` docs repeat the warning and show the EAFP alternative: try
|
||||
the `open` and catch `PermissionError`.
|
||||
([os.access](https://docs.python.org/3/library/os.html#os.access)) [fetched]
|
||||
- CWE-367 defines TOCTOU as checking "the state of a resource before using that
|
||||
resource, but the resource's state can change between the check and the use".
|
||||
Mitigations listed: drop the check, make check and use atomic, lock before the
|
||||
check, recheck after use, shrink the window.
|
||||
([CWE-367](https://cwe.mitre.org/data/definitions/367.html)) [fetched]
|
||||
|
||||
### Amazon S3 Object Lock
|
||||
|
||||
- Signal shape: **metadata on the object version**, read with HEAD/GET.
|
||||
`HeadObject` returns `x-amz-object-lock-mode` (`GOVERNANCE | COMPLIANCE`),
|
||||
`x-amz-object-lock-retain-until-date` and `x-amz-object-lock-legal-hold`,
|
||||
"only returned if the requester has the `s3:GetObjectRetention` permission"
|
||||
(legal hold: `s3:GetObjectLegalHold`). Dedicated calls exist as well
|
||||
(`GetObjectRetention`, `GetObjectLegalHold`).
|
||||
([HeadObject](https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html)) [fetched]
|
||||
- Write behaviour: a versioned DELETE of a locked version returns `403 Access
|
||||
Denied`; a simple DELETE succeeds and adds a delete marker. A PUT to the same
|
||||
key succeeds and creates a new version; the old version stays locked. Governance
|
||||
mode can be bypassed with `s3:BypassGovernanceRetention` plus the
|
||||
`x-amz-bypass-governance-retention:true` header.
|
||||
([Object Lock](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html), "How deletes work", "Retention modes") [fetched]
|
||||
- Pitfalls: the signal depends on read permission (absent header does not mean
|
||||
unlocked); legal holds can be "freely placed and removed" by anyone with
|
||||
`s3:PutObjectLegalHold`, so the answer can change between HEAD and DELETE; under
|
||||
variable retention the retain-until date "moves forward as time passes" while an
|
||||
event hold is on. (Object Lock page, "Legal holds", "Retain-until-date behavior") [fetched]
|
||||
|
||||
### Git LFS lockable files
|
||||
|
||||
- Ownership signal enforced through the file system: files marked lockable "are
|
||||
made read-only in the working copy when not locked by the current user"
|
||||
(`lfs.setlockablereadonly`, default true).
|
||||
([git-lfs-config](https://github.com/git-lfs/git-lfs/blob/main/docs/man/git-lfs-config.adoc), L448-455) [fetched]
|
||||
- The authoritative check is server-side at push: Git LFS "verifies that pushes
|
||||
don't alter files locked by others".
|
||||
([git-lfs-lock](https://github.com/git-lfs/git-lfs/blob/main/docs/man/git-lfs-lock.adoc)) [fetched]
|
||||
- Pitfall: the read-only bit reflects lock state as of the last checkout or lock
|
||||
command; `chmod` defeats it locally, and the push check decides. [memory for the
|
||||
staleness; the push check is fetched]
|
||||
|
||||
## 5. Collections (the type-system version of the same question)
|
||||
|
||||
- .NET `ICollection<T>.IsReadOnly`: a **property**. Read-only here "does not
|
||||
indicate whether individual elements of the collection can be modified"; an
|
||||
array cast to `ICollection<T>` reports true while its elements stay writable.
|
||||
Mutators throw `NotSupportedException`.
|
||||
([ICollection<T>.IsReadOnly](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.icollection-1.isreadonly)) [fetched; the exception is memory]
|
||||
- Java `Collection`: **exception only**. Mutators are "optional operations" that
|
||||
throw `UnsupportedOperationException`; the interface has no query for
|
||||
modifiability. ([Collection, Java 21](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Collection.html)) [fetched]
|
||||
|
||||
## 6. Summary table
|
||||
|
||||
| System | Granularity | Signal shape | Write when read-only | Can the answer go stale? |
|
||||
|---|---|---|---|---|
|
||||
| EF Core no-tracking | entity instance | query (`Entry(e).State`) | silent no-op | yes, attach/detach changes it |
|
||||
| EF Core keyless | entity type | model query (`IsKeyless`) [memory] | throws on attach/update | no (model is fixed after build) |
|
||||
| Hibernate read-only | entity in session | query (`Session.isReadOnly(e)`) | ignored at flush; delete allowed | per session; settable |
|
||||
| Hibernate @Immutable | entity type | annotation | ignored, no exception; bulk HQL warns or throws | no |
|
||||
| ActiveRecord | record | property (`readonly?`) | raises `ReadOnlyRecord` | set at load, not re-derived [memory] |
|
||||
| Realm frozen | object version | property (`isFrozen()`, `isValid()`) | throws; `findLatest` for a writable copy | `isValid` is live |
|
||||
| Couchbase Lite | document | type (`Document` vs `MutableDocument`) | n/a, no mutators | no |
|
||||
| LiteDB, SQLite, Core Data | store or database | option plus store query/property | refused by the store | rarely |
|
||||
| protobuf unknown fields / Any | field / payload | property list [memory] / `is()` query | preserved / unpack throws | no |
|
||||
| Avro | field / union branch | exception only | field dropped / error | no |
|
||||
| Unity missing types | managed reference in a host | query (bool) plus list (key, type name, data) | data preserved; recoverable when type returns | answered on demand |
|
||||
| POSIX access(2) | path | query | open fails | yes, TOCTOU (documented) |
|
||||
| S3 Object Lock | object version | metadata via HEAD/GET | versioned delete 403; PUT makes a new version | yes, holds and event holds change |
|
||||
| Git LFS lockable | file | file-system read-only bit | push refused by server | yes, as of last checkout |
|
||||
| .NET ICollection | collection | property | `NotSupportedException` | no |
|
||||
| Java Collection | collection | exception only | `UnsupportedOperationException` | n/a |
|
||||
|
||||
Patterns observed, stated as facts across the sources above:
|
||||
|
||||
- Every system that offers an advance signal still has the write path decide:
|
||||
EF Core, Realm, ActiveRecord, S3, Git LFS and POSIX all refuse or ignore at the
|
||||
write regardless of what a check said.
|
||||
- Where the read-only state can change after it was observed (POSIX permissions,
|
||||
S3 holds, Git LFS locks, EF Core attach/detach), the documentation either warns
|
||||
of TOCTOU (POSIX, Python, CWE-367) or places the authoritative check at the
|
||||
write (S3, Git LFS push).
|
||||
- Silent-ignore write behaviour (Hibernate entities, EF Core no-tracking) is the
|
||||
case where the advance signal is the only signal; Hibernate added an opt-in
|
||||
exception mode for bulk updates.
|
||||
- The closest analog to records under an unregistered schema id (Unity missing
|
||||
types) exposes both an on-demand bool query and an on-demand list carrying the
|
||||
reference id and the recorded type name, and recovers the data when the type
|
||||
returns.
|
||||
- Store-level read-only (LiteDB, SQLite, Core Data, and CultCache's own
|
||||
`CacheBackingStore.IsReadOnly`) is a property or query on the store, not on the
|
||||
record.
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
# Windows store replace: prior art and probe (2026-10-03)
|
||||
|
||||
This research backs `variants:question:windows-store-replace-primitive`. It came from an Imagination pass during the variants campaign. The probe source and raw results lived in that session's scratchpad (`win-replace/probe/Replacers/Replacers.cs`, `result-net10.txt`, `result-mono.txt`).
|
||||
|
||||
## Problem
|
||||
|
||||
On Windows, CultCache C# replaces a single-file store with `File.Replace`, which calls Win32 `ReplaceFile`. While that replace runs, a lock-free reader can find nothing at the path, and the store opens as empty. Under the h1 cut's probe on Starfire, 1,722 of 17,313 opens came back empty. On Linux, which replaces with `rename`, 0 of 6,252 did.
|
||||
|
||||
## Probe
|
||||
|
||||
The probe copies the C# write path:
|
||||
- `<file>.lock` opened with `FileShare.None`;
|
||||
- a GUID temp file written with WriteThrough and `Flush(true)`;
|
||||
- the replace itself.
|
||||
|
||||
It copies the read path too: an attribute check, then an open with `ReadWrite|Delete`. A writer thread and a tight reader loop share one process. The store sits on NTFS (C:) with Defender real-time protection on. A hash check found no torn reads.
|
||||
|
||||
### Empty-store opens per primitive
|
||||
|
||||
| Primitive | .NET 10 (2,000 writes) | Unity Mono 6.13 (1,000 writes) | Notes |
|
||||
|---|---|---|---|
|
||||
| `File.Replace` (`ReplaceFile`) | 46,336 / 244,197 (19%) | 15,915 / 48,298 (33%) | Readers also hit sharing violations: 2,157 on .NET, 5 on Mono |
|
||||
| `MoveFileExW(REPLACE_EXISTING\|WRITE_THROUGH)` | 0 / 417,115 | 0 / 39,094 | 1,145 of 2,000 first writer attempts failed with error 5 (access denied); all landed after retries; 2x slower |
|
||||
| `SetFileInformationByHandle(FileRenameInfoEx, REPLACE_IF_EXISTS\|POSIX_SEMANTICS)` | 0 / 9,756 | 0 / 5,282 | 0 errors, fastest (7.2 s). The reader sampled sparsely, about 5 opens per write against 120-200 for the others |
|
||||
| Reader waits on `<file>.lock` when the path is empty | 50 / 12,660, all recovered | 37 / 2,275, all recovered | Reader waits up to 1,584 ms (mean 371 ms) |
|
||||
|
||||
### A reader holding the store open during one replace, no retry
|
||||
|
||||
| Holder's share mode | `File.Replace` | `MoveFileExW` | POSIX rename |
|
||||
|---|---|---|---|
|
||||
| `ReadWrite\|Delete` (C# readers, Rust std, libuv) | ok | error 5 | ok |
|
||||
| `ReadWrite` (CRT and Python `open`) | error 32 | error 5 | error 32 |
|
||||
| `Read` | error 32 | error 5 | error 32 |
|
||||
|
||||
When a replace succeeded, the holder kept reading the old bytes, and a new open got the new bytes.
|
||||
|
||||
## Primary sources
|
||||
|
||||
- **ReplaceFileW.** It renames the original aside and then moves the replacement in, which is the empty window. It opens the replacement with no sharing, and it makes no atomicity claim. https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-replacefilew
|
||||
- **FILE_RENAME_INFORMATION.** Under classic semantics, a rename fails if the target exists and has open handles; that is `MoveFileExW`'s error 5. POSIX semantics replaces a target that has open handles, and is available from Windows 10 1607. https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/ns-ntifs-_file_rename_information
|
||||
|
||||
## What established software does on Windows
|
||||
|
||||
| Software | Replace primitive on Windows |
|
||||
|---|---|
|
||||
| .NET `File.Replace` | `ReplaceFile` |
|
||||
| .NET `File.Move(overwrite)` | `MoveFileExW(COPY_ALLOWED\|REPLACE_EXISTING)`; not in netstandard2.1 |
|
||||
| Rust std `rename` | `MoveFileExW`, then POSIX rename if that returns access denied |
|
||||
| git-for-windows `mingw_rename` | POSIX rename first, then `MoveFileExW`, then clears read-only and retries |
|
||||
| Python `os.replace`, libuv (Node) | `MoveFileExW(REPLACE_EXISTING)` (Python from bug-tracker history) |
|
||||
| Postgres | `MoveFileEx` with 100 retries, 100 ms apart, on errors 5, 32 and 33 (antivirus named) |
|
||||
| SQLite, LMDB | Never rename the database. SQLite writes in place under a journal or WAL, and retries I/O on lock errors. LMDB uses copy-on-write pages and a meta-page switch, so readers take no locks |
|
||||
| Mono `File.Replace` | `ReplaceFileW` |
|
||||
| IL2CPP (Unity 6000.4.2f1) | `os/Win32/File.cpp:263` calls `ReplaceFileW`, so built players have the same window |
|
||||
|
||||
C# is the only CultCache runtime that uses `ReplaceFile`, and the only one with the empty window. CultCache Rust calls `MoveFileExW` directly.
|
||||
|
||||
## Portability of a P/Invoke path
|
||||
|
||||
- **netstandard2.1:** builds clean with the three kernel32 calls. A runtime OS check is enough; no platform split is needed.
|
||||
- **Unity Mono:** ran under the Editor's `mono.exe`.
|
||||
- **IL2CPP:** not run. Its Win32 loader hardcodes `CreateFileW`, `MoveFileExW` and `SetFileInformationByHandle`.
|
||||
- **GDK (`IL2CPP_TARGET_WINDOWS_GAMES`):** that loader table is absent, so behaviour is unverified. Catch `DllNotFoundException` and `EntryPointNotFoundException`, and fall back.
|
||||
- **FAT, exFAT and possibly SMB:** probably lack POSIX rename (unverified), so the `MoveFileExW` fallback is needed there.
|
||||
- **What leaving `ReplaceFile` drops:** creation time, DACL, object id and alternate streams. CultCache uses none of them.
|
||||
|
||||
## Caveats
|
||||
|
||||
- The POSIX-rename reader was sparse. 0 of 9,756 is still strong: about 1,850 empty opens would be expected at `File.Replace`'s rate.
|
||||
- All runs were in one process.
|
||||
- Mono reported `arch=X86`; the rename succeeded, so the struct layout was right.
|
||||
|
||||
What would change the conclusion: a cross-process rerun at h1's reader density that shows a window under POSIX rename, or an IL2CPP or GDK player where the P/Invoke does not resolve.
|
||||
|
|
@ -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,400 +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 | Published by |
|
||||
| --- | --- | --- |
|
||||
| `org.gamecult.cultlib` | Unity UPM | `scripts/build-unity-package.ps1`, consumed by git tag |
|
||||
| `org.gamecult.caching.unity` | Unity UPM | tagged directly (no build script; see "Coverage gaps") |
|
||||
| `org.gamecult.cultmath` | Unity UPM | `packages/cultmath/scripts/build-unity-package.ps1`, consumed by git tag |
|
||||
| `@gamecult/cultcache-ts` | npm | `.github/workflows/publish-packages.yml` |
|
||||
| `cultcache-py` | PyPI | `.github/workflows/publish-packages.yml` |
|
||||
| `cultnet-py` | PyPI | `.github/workflows/publish-packages.yml` |
|
||||
| `cultmesh-py` | PyPI | `.github/workflows/publish-packages.yml` |
|
||||
|
||||
Each package's tag prefix, changelog path and, for the packages that ship
|
||||
assemblies, the directory of tracked DLLs are declared in
|
||||
`scripts/release-packages.mjs`, the one copy. The check reads them by package
|
||||
name; no release script or workflow passes one.
|
||||
|
||||
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.
|
||||
|
||||
Releases are tags named `<tagPrefix>-v<version>`, with the prefix declared in
|
||||
`scripts/release-packages.mjs`. The check refuses a declaration in which two
|
||||
packages share a prefix or an entry lacks a string prefix or changelog.
|
||||
|
||||
## 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.3.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 (the check also reads
|
||||
`## <version>`, and either form followed by Keep a Changelog's
|
||||
` - YYYY-MM-DD` date, such as `## [0.4.0] - 2026-10-03`) 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.
|
||||
|
||||
### First release of a package
|
||||
|
||||
A package with no `CHANGELOG.md` yet cannot release: the check refuses with
|
||||
"changelog not found". Its first release writes the file at the declared path,
|
||||
`packages/<package>/CHANGELOG.md`, with one section for the version being
|
||||
released. That version must still be exactly the next one after the package's
|
||||
nearest ancestor tag. As of 2026-10-03 the valid next versions are:
|
||||
|
||||
- `cultcache-ts`, after `cultcache-ts-v0.14.0`: `0.14.1`, `0.15.0` or `1.0.0`;
|
||||
- `cultcache-py`, after `cultcache-py-v0.3.0`: `0.3.1`, `0.4.0` or `1.0.0`;
|
||||
- `cultnet-py` and `cultmesh-py`, which have no tags: `0.1.0`, their manifest
|
||||
version, as a first release. Run it from a clone with the tags fetched, or
|
||||
the check cannot tell a first release from a missing record.
|
||||
|
||||
On `0.y.z` a `### Breaking` section needs at least a minor bump.
|
||||
|
||||
## Enforcement
|
||||
|
||||
`scripts/check-changelog-semver.mjs` is the mechanical check. Given a
|
||||
package name and the version being released, it reads the package's
|
||||
declaration from `scripts/release-packages.mjs` in `--cwd` (an unknown package
|
||||
or a missing declaration refuses), and:
|
||||
|
||||
1. derives the release record from `git tag -l` in `--cwd`. A same-prefix tag
|
||||
is one that starts `<prefix>-v`; one that is not
|
||||
`<prefix>-vMAJOR.MINOR.PATCH` refuses, naming the tag, and is never
|
||||
skipped. The previous release is the newest same-prefix tag strictly older
|
||||
than the version being released, by version order, that is also an ancestor
|
||||
of the commit being checked: a backport tagged later on another branch is
|
||||
not part of that history, so it never moves a rebuild's predecessor. A tag
|
||||
equal to the version does not exempt
|
||||
it and does not count as a previous release: there is no "already
|
||||
published" skip, so a rebuild or a CI re-run at a tagged version is
|
||||
measured against that version's predecessor, exactly as at release, and
|
||||
gets the same verdict. With no older tag, a newer one refuses (the version
|
||||
is older than every release), and a repository that holds no tag other than
|
||||
the version's own refuses, because a first release cannot be told from a
|
||||
missing record. Older tags of which none is an ancestor leave the
|
||||
predecessor unknown and refuse. Otherwise it is a first release, which no
|
||||
caller declares. A package that declares an assemblies directory is a first
|
||||
release only when no tag in the repository, under any prefix, tracks a
|
||||
managed assembly under that directory: such a tag means the package has
|
||||
shipped, so the declared prefix is wrong and the check refuses, naming the
|
||||
tag and the assembly. A tag snapshots the whole tree of this monorepo, so a
|
||||
tag tracking a package's directory proves nothing; only a managed assembly in
|
||||
the declared directory does. This is read from git, never from the changelog's
|
||||
prose. The version's own tag does not count, so a first release can be
|
||||
rebuilt. Only the declared directory counts: a package whose directory moved
|
||||
after its releases has none of its old path's tags counted, so its release
|
||||
under a misspelled prefix reads as new, and the move itself is caught only
|
||||
under the right prefix, where the declared directory holds no managed assembly
|
||||
at the previous tag. A package that declares no assemblies directory (the
|
||||
Python and TypeScript packages and the Caching Unity package) has no such
|
||||
proof: with no tag of its own prefix it is a first release. The suite test
|
||||
that pins every declared prefix guards it: each prefix is declared once, each
|
||||
workflow tag trigger must be a declared prefix, and each such package's prefix
|
||||
must be a workflow trigger or, for the Caching Unity package the workflow does
|
||||
not build, the prefix of tags that hold its own manifest at the tag's own
|
||||
version, so a typo or a copied prefix fails the suite. Nothing releases
|
||||
without the suite passing; see below for how each release path runs it.
|
||||
Releases run from a full clone with every tag (`fetch-depth: 0` in CI), the
|
||||
recorded `partial-tags-first-release`.
|
||||
Tags that cannot be read (a `--cwd` that is not a git repository, or a tag
|
||||
whose tree cannot be listed) refuse;
|
||||
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. when the release script supplies built assemblies (below), treats the
|
||||
release as breaking if the public API measurably lost or changed a member
|
||||
against the previous tag, whether or not the changelog says so; a
|
||||
measured break with no `### Breaking` section is refused, naming the
|
||||
count, the first diagnostics, and the heading that must declare them;
|
||||
5. if the release is breaking (a `### Breaking` section, a measured break, or
|
||||
both), requires the bump to be at least major (or, pre-1.0, at least
|
||||
minor); a `### Breaking` section with nothing measured is valid, because
|
||||
behavioural breaks are invisible to a signature comparison;
|
||||
6. fails loudly, naming the package, the previous and new version, and (when
|
||||
relevant) the changelog heading or the measured diagnostics that
|
||||
triggered the requirement.
|
||||
|
||||
Additions are never a rule: adding a public member is not breaking (see
|
||||
above), and the check neither refuses nor demands a bump for one.
|
||||
|
||||
### Measured input
|
||||
|
||||
A package whose declaration names an `assemblies` directory is measured, and
|
||||
the release must hand over one `--api-built <dll>` per assembly it built (plus
|
||||
`--api-refs <dir>` for directories that hold the built assemblies'
|
||||
dependencies). A package with no such directory takes no `--api-built`, and one
|
||||
that has it cannot omit it. The measured set is every managed assembly tracked
|
||||
under that directory, at any depth, at the previous tag, read as git blobs (no
|
||||
worktree, no network, unaffected by what the working tree tracks now) and keyed
|
||||
by file name. A DLL is managed when its PE header carries a CLI header; a
|
||||
native DLL has none and is not measured, and a tracked `.dll` that is not a PE
|
||||
image refuses. Two managed assemblies with one name refuse. The checker
|
||||
compares each built assembly with its namesake using
|
||||
`Microsoft.DotNet.ApiCompat.Tool`; each `CPnnnn` diagnostic is one measured
|
||||
break. Every managed assembly the previous tag tracked that the build no
|
||||
longer hands over is itself a measured break, whatever its name or place, so a
|
||||
release script passes every assembly it ships. A built assembly with no
|
||||
namesake at the tag is an addition and is not examined.
|
||||
|
||||
Measuring nothing is a refusal, never a pass. The check refuses, naming what
|
||||
is missing, when the previous tag tracks no managed assembly under the
|
||||
declared directory (a wrong or moved directory, or a tag that predates it);
|
||||
when git cannot read the tracked assemblies; and when the tool fails to
|
||||
measure (not installed, an unreadable assembly, a crash: a non-zero exit with
|
||||
no diagnostic line). A first release measures nothing.
|
||||
|
||||
The tool and the `NETStandard.Library.Ref` 2.1.0 reference assemblies are
|
||||
pinned in `scripts/api-gate/` (`.config/dotnet-tools.json` and
|
||||
`ApiRefs.csproj`) and restored there by the check itself, so a release does
|
||||
not depend on any other tool in the repository's manifest. NuGet is needed
|
||||
until both are cached under `scripts/api-gate`. Both sides of every
|
||||
comparison resolve netstandard types through those reference assemblies, and the
|
||||
new side also through the built DLL's own directory and every `--api-refs`
|
||||
directory. No suppression file is kept: a suppression would be a second, silent
|
||||
declaration of a break, so a break is declared only under `### Breaking`.
|
||||
|
||||
A passing measured run prints that unmeasured changes still need declaring.
|
||||
|
||||
#### Unmeasured changes
|
||||
|
||||
ApiCompat does not report these changes to a public member, and the check
|
||||
therefore passes them. Each is a breaking change under the rules above and is
|
||||
declared under `### Breaking`, as behavioural breaks are:
|
||||
|
||||
- a member gaining or losing `static`;
|
||||
- a parameter gaining or losing `ref`, `out` or `in`;
|
||||
- a `const` field's value changing;
|
||||
- an enum member's underlying value changing;
|
||||
- a field gaining or losing `readonly`;
|
||||
- a default parameter value changing.
|
||||
|
||||
The release scripts run the check once the assemblies under judgement exist,
|
||||
after the build and before anything is copied into the package.
|
||||
|
||||
Run it directly: `node scripts/check-changelog-semver.mjs --package <name>
|
||||
--version <x.y.z> [--cwd <dir>] [--api-built <dll>... [--api-refs <dir>...]]`.
|
||||
Any other option is a usage error (exit 2). 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`, through
|
||||
`scripts/release-after-suite.mjs`, for `cultcache-ts`, `cultcache-py`,
|
||||
`cultnet-py`, and `cultmesh-py`, with `fetch-depth: 0` so the checkout holds
|
||||
the tags;
|
||||
- `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`.
|
||||
|
||||
Each of those runs the check's own tests before it releases. The Unity release
|
||||
scripts run them before they call the check, and the tests pin that order. In
|
||||
`publish-packages.yml` the release step runs `scripts/release-after-suite.mjs`
|
||||
with the run's git ref. From one process it checks that a tag ref is the
|
||||
package's declared prefix and manifest version, runs these tests, runs this
|
||||
check for the tagged version, runs the package's own tests (`npm test`, or the
|
||||
Python package's unit tests), and only then the release action: `npm publish`
|
||||
for `cultcache-ts`, which it refuses on any ref but a release tag, and for the
|
||||
Python packages the sdist and wheel build, whose output is all the PyPI upload
|
||||
step uploads. The checks run with the known test-runner and token variables
|
||||
removed (`NODE_OPTIONS`, `NODE_TEST_CONTEXT`, any other `NODE_TEST_*` variable,
|
||||
and `NODE_AUTH_TOKEN`), so none of those can make the tests run nothing and
|
||||
pass, and only the release action receives the token. That list names known
|
||||
variables; it does not prove that no other inherited variable can affect a
|
||||
check. The npm Publish step and the Python sdist and wheel build step start the
|
||||
guard under `env -u NODE_OPTIONS`, so a `NODE_OPTIONS` preload set in the
|
||||
workflow's or job's `env:` cannot run code inside the guard before its first
|
||||
line.
|
||||
The gate is that call, not a step condition, so no `if:` or `continue-on-error`
|
||||
can release what a check refused. The tests compare each job's release steps
|
||||
with their expected text exactly, less the steps' `if:` lines. Removing the call
|
||||
is still possible, but only as a visible edit to those steps and to the tests.
|
||||
|
||||
The guard and that pin stop accidental workflow edits. They do not stop a
|
||||
deliberate one: an extra step that publishes, a workflow-level `defaults:` or
|
||||
`env:`, or a fake `node` on `$GITHUB_PATH` all pass them. Deliberate edits are
|
||||
review's job, as ruled in `suite-gate-structural-not-parsed`, and the pin
|
||||
does not try to parse the workflow for them. For the same reason it has no ban
|
||||
on `shell` or `defaults`: such a ban defends only against a deliberate edit,
|
||||
and it also refused harmless settings such as `defaults.run.working-directory`.
|
||||
A step-level `shell:` on a release step still fails the exact comparison.
|
||||
A recommendation for the operator, not something this repository can set: the
|
||||
Python job already runs in the `pypi` GitHub environment, as PyPI trusted
|
||||
publishing expects. Putting the npm job in an environment with required
|
||||
reviewers would make every npm release wait for a person's approval. That is a
|
||||
GitHub settings change.
|
||||
|
||||
`org.gamecult.cultlib` compiles CultMath from source and ships beside
|
||||
`org.gamecult.cultmath`, so its build refuses unless it declares exactly the
|
||||
CultMath package's version and every `CultMath.dll` it built equals the one
|
||||
that package tracks, byte for byte. Any CultMath source change therefore means:
|
||||
release `org.gamecult.cultmath`, declare its new version in
|
||||
`org.gamecult.cultlib`, then release `org.gamecult.cultlib`. The bytes also
|
||||
depend on the checkout and the toolchain. `.gitattributes` pins CultMath
|
||||
source to CRLF on every checkout, because the tracked DLL was built from CRLF
|
||||
source and line endings reach the DLL through the pdb's document checksums; a
|
||||
clone checked out before that rule may still hold LF files (`git ls-files
|
||||
--eol packages/cultmath/src` shows `w/lf`) until they are checked out again.
|
||||
The build needs a git checkout, and refuses up front without `.git`: the repo
|
||||
root maps to `/_/` through git, so an archive or source zip would write its
|
||||
own path into the DLL. No `global.json` pins the .NET SDK, so the same refusal
|
||||
at unchanged source and CRLF checkout means this SDK or host differs from the
|
||||
one that built the tracked DLL.
|
||||
|
||||
### 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.
|
||||
|
||||
What stays unmeasured: behaviour behind an unchanged signature, wire formats,
|
||||
native exports of the QUIC bridge, and the Caching Unity package's source
|
||||
(it has no assembly). Those rest on the `### Breaking` declaration.
|
||||
|
||||
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`, and `cultmesh-py`
|
||||
currently have **no `CHANGELOG.md` file at all**; the check refuses their next
|
||||
tagged release ("changelog not found at <declared path>") until each
|
||||
package's file exists. A release with no changelog is not a smaller problem
|
||||
than a mislabelled one. The next release of each creates its file:
|
||||
`packages/cultcache-ts/CHANGELOG.md` arrives with the document-variants work
|
||||
(`hands/variants-c2a`), and whoever cuts the next release of `cultcache-py`,
|
||||
`cultnet-py` or `cultmesh-py` creates that package's file. Backfilling
|
||||
changelog content for past releases would mean inventing release-note prose
|
||||
for history nobody here knows first-hand, so the gap stays 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",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import json
|
|||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, TypeVar
|
||||
from typing import Any
|
||||
|
||||
from .backing_store import (
|
||||
CultCacheEnvelope,
|
||||
|
|
@ -14,9 +14,6 @@ from .backing_store import (
|
|||
)
|
||||
|
||||
STORE_FORMAT_VERSION = "cultcache.store.v1"
|
||||
_STORE_FORMAT_PREFIX = "cultcache.store."
|
||||
_PERSISTED_RECORD_SLOTS = 4
|
||||
_Contents = TypeVar("_Contents")
|
||||
|
||||
|
||||
class JsonLinesBackingStore:
|
||||
|
|
@ -26,11 +23,10 @@ class JsonLinesBackingStore:
|
|||
|
||||
def pull_all(self) -> list[CultCacheEnvelope]:
|
||||
with self._lock:
|
||||
text = _read_store(self.path, lambda path: path.read_text(encoding="utf-8"))
|
||||
if text is None:
|
||||
if not self.path.exists():
|
||||
return []
|
||||
envelopes: list[CultCacheEnvelope] = []
|
||||
for line_number, line in enumerate(text.splitlines(), start=1):
|
||||
for line_number, line in enumerate(self.path.read_text(encoding="utf-8").splitlines(), start=1):
|
||||
if not line.strip():
|
||||
continue
|
||||
raw = json.loads(line)
|
||||
|
|
@ -90,9 +86,10 @@ class SingleFileMessagePackBackingStore:
|
|||
def pull_all(self) -> list[CultCacheEnvelope]:
|
||||
msgpack = self._msgpack()
|
||||
with self._lock:
|
||||
# An empty file is not a store (no writer leaves one), so it is decoded and refused.
|
||||
data = _read_store(self.path, Path.read_bytes)
|
||||
if data is None:
|
||||
if not self.path.exists():
|
||||
return []
|
||||
data = self.path.read_bytes()
|
||||
if not data:
|
||||
return []
|
||||
decoded = msgpack.unpackb(data, raw=False)
|
||||
snapshot = _decode_v1_snapshot(decoded)
|
||||
|
|
@ -155,46 +152,8 @@ def _encode_v1_snapshot(envelopes: list[CultCacheEnvelope]) -> list[Any]:
|
|||
return [STORE_FORMAT_VERSION, catalog, records]
|
||||
|
||||
|
||||
def _read_store(path: Path, read: Callable[[Path], _Contents]) -> _Contents | None:
|
||||
"""Reads a store file, or returns None when nothing is at its path.
|
||||
|
||||
Only nothing is absent. A dangling symbolic link is something: reading it as
|
||||
empty would let the writer replace the link and move the store off its volume.
|
||||
Any other failure to reach the file stays an OSError.
|
||||
"""
|
||||
try:
|
||||
return read(path)
|
||||
except FileNotFoundError:
|
||||
if path.is_symlink():
|
||||
raise
|
||||
return None
|
||||
|
||||
|
||||
def _describe_identity(value: Any) -> str:
|
||||
"""A record key or schema id as a refusal may show it: a string is an identity and is
|
||||
named; anything else is described by its type, since the value is the store's."""
|
||||
if isinstance(value, str):
|
||||
return repr(value)
|
||||
return f"<{type(value).__name__}>"
|
||||
|
||||
|
||||
def _describe_header(header: str) -> str:
|
||||
"""Echoes a header only in the shape cultcache.store.v<digits>; the bytes are the store's."""
|
||||
version = header[len(_STORE_FORMAT_PREFIX) + 1:] if header.startswith(_STORE_FORMAT_PREFIX + "v") else ""
|
||||
if version.isascii() and version.isdigit():
|
||||
return repr(header)
|
||||
return f"an unrecognised {_STORE_FORMAT_PREFIX}* header of {len(header.encode('utf-8'))} bytes"
|
||||
|
||||
|
||||
def _decode_v1_snapshot(decoded: Any) -> list[CultCacheEnvelope] | None:
|
||||
if not isinstance(decoded, list) or not decoded:
|
||||
return None
|
||||
if isinstance(decoded[0], str) and decoded[0].startswith(_STORE_FORMAT_PREFIX) and decoded[0] != STORE_FORMAT_VERSION:
|
||||
raise ValueError(
|
||||
f"CultCache store format {_describe_header(decoded[0])} is not one this runtime reads; "
|
||||
f"it reads {STORE_FORMAT_VERSION!r} only"
|
||||
)
|
||||
if decoded[0] != STORE_FORMAT_VERSION:
|
||||
if not isinstance(decoded, list) or not decoded or decoded[0] != STORE_FORMAT_VERSION:
|
||||
return None
|
||||
if len(decoded) < 3 or not isinstance(decoded[1], list) or not isinstance(decoded[2], list):
|
||||
raise ValueError("CultCache v1 snapshot must contain a schema catalog and record array")
|
||||
|
|
@ -210,13 +169,7 @@ def _decode_v1_snapshot(decoded: Any) -> list[CultCacheEnvelope] | None:
|
|||
for raw_record in decoded[2]:
|
||||
if not isinstance(raw_record, list) or len(raw_record) < 4:
|
||||
raise ValueError("CultCache persisted records must be MessagePack arrays")
|
||||
if len(raw_record) > _PERSISTED_RECORD_SLOTS:
|
||||
raise ValueError(
|
||||
f"CultCache record {_describe_identity(raw_record[0])} (schema {_describe_identity(raw_record[1])}) "
|
||||
f"has {len(raw_record)} slots, more than "
|
||||
f"the {_PERSISTED_RECORD_SLOTS} of a {STORE_FORMAT_VERSION} record, so this is not a valid store"
|
||||
)
|
||||
key, schema_id, stored_at, payload = raw_record
|
||||
key, schema_id, stored_at, payload = raw_record[:4]
|
||||
if not isinstance(key, str) or not key:
|
||||
raise ValueError("CultCache persisted records must declare a key")
|
||||
if not isinstance(schema_id, str) or not schema_id:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
|
@ -803,168 +802,5 @@ class CultCacheTests(unittest.TestCase):
|
|||
self.assertEqual(cache.get_required_envelope(document, "item:one").key, "item:one")
|
||||
|
||||
|
||||
# Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests.
|
||||
_VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "document-variants-c0"
|
||||
_ITEM_SCHEMA_ID = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788"
|
||||
|
||||
def _pull_vector(self, name: str) -> list[CultCacheEnvelope]:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store_path = Path(tmp) / "store.msgpack"
|
||||
store_path.write_bytes((self._VECTORS / name).read_bytes())
|
||||
return SingleFileMessagePackBackingStore(store_path).pull_all()
|
||||
|
||||
def test_single_file_refuses_unknown_header_by_name(self) -> None:
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
self._pull_vector("unknown-header.msgpack")
|
||||
self.assertIn("cultcache.store.v9", str(caught.exception))
|
||||
|
||||
def test_single_file_refuses_extra_record_slot_naming_the_record(self) -> None:
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
self._pull_vector("extra-slot-full-payload.msgpack")
|
||||
self.assertIn("item:anvil", str(caught.exception))
|
||||
self.assertIn(self._ITEM_SCHEMA_ID, str(caught.exception))
|
||||
|
||||
def test_single_file_refuses_variant_store_by_version_or_record(self) -> None:
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
self._pull_vector("variant-v2.msgpack")
|
||||
message = str(caught.exception)
|
||||
self.assertTrue("cultcache.store.v2" in message or "item:anvil-big" in message, message)
|
||||
|
||||
def test_single_file_refuses_the_csharp_written_variant_store_by_version_or_record(self) -> None:
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
self._pull_vector("../document-variants-c1/variant-store.msgpack")
|
||||
message = str(caught.exception)
|
||||
self.assertTrue("cultcache.store.v2" in message or "laser-big" in message, message)
|
||||
|
||||
def test_v1_store_written_at_the_base_commit_still_reads_byte_for_byte(self) -> None:
|
||||
envelopes = self._pull_vector("v1-base.msgpack")
|
||||
self.assertEqual([(e.key, e.type) for e in envelopes], [("alpha", "vectors.item"), ("beta", "vectors.item")])
|
||||
self.assertEqual(envelopes[0].payload, b"\x92\xa5alpha\x01")
|
||||
self.assertEqual(envelopes[1].payload, b"\x92\xa4beta\x02")
|
||||
|
||||
def test_single_file_refusals_say_what_was_found_without_blaming_variants(self) -> None:
|
||||
with self.assertRaises(ValueError) as unsupported:
|
||||
self._pull_vector("unknown-header.msgpack")
|
||||
self.assertIn("not one this runtime reads", str(unsupported.exception))
|
||||
self.assertNotIn("variant", str(unsupported.exception))
|
||||
with self.assertRaises(ValueError) as invalid:
|
||||
self._pull_vector("extra-slot-full-payload.msgpack")
|
||||
self.assertIn("not a valid store", str(invalid.exception))
|
||||
self.assertNotIn("variant", str(invalid.exception))
|
||||
|
||||
def test_single_file_only_a_missing_store_reads_as_empty(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
self.assertEqual(SingleFileMessagePackBackingStore(Path(tmp) / "store.cc").pull_all(), [])
|
||||
|
||||
# An existing empty file is not a store: no CultCache writer leaves one.
|
||||
empty = Path(tmp) / "empty.cc"
|
||||
empty.write_bytes(b"")
|
||||
with self.assertRaises(ValueError):
|
||||
SingleFileMessagePackBackingStore(empty).pull_all()
|
||||
self.assertEqual(empty.read_bytes(), b"")
|
||||
|
||||
# An empty array is the legacy envelope array with no envelopes: no header, no format claimed.
|
||||
legacy_empty = Path(tmp) / "legacy-empty.cc"
|
||||
legacy_empty.write_bytes(b"\x90")
|
||||
self.assertEqual(SingleFileMessagePackBackingStore(legacy_empty).pull_all(), [])
|
||||
|
||||
def test_single_file_refusals_never_echo_a_value_from_the_store(self) -> None:
|
||||
import msgpack # type: ignore
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "store.cc"
|
||||
cases = [
|
||||
(["cultcache.store.v12", [], []], True),
|
||||
(["cultcache.store.SECRET-HEADER", [], []], False),
|
||||
(["cultcache.store.v12SECRET", [], []], False),
|
||||
(["cultcache.store.v", [], []], False),
|
||||
(["cultcache.store.v1", ["SECRET-CATALOG-TEXT"], []], None),
|
||||
]
|
||||
for value, echoed in cases:
|
||||
path.write_bytes(msgpack.packb(value, use_bin_type=True))
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
SingleFileMessagePackBackingStore(path).pull_all()
|
||||
message = str(caught.exception)
|
||||
if echoed:
|
||||
self.assertIn(repr(value[0]), message)
|
||||
else:
|
||||
self.assertNotIn("SECRET", message)
|
||||
if echoed is False:
|
||||
self.assertNotIn(repr(value[0]), message)
|
||||
self.assertIn(f"of {len(value[0])} bytes", message)
|
||||
|
||||
def test_single_file_record_refusal_names_only_a_string_key(self) -> None:
|
||||
import msgpack # type: ignore
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "store.cc"
|
||||
for key, schema in ((["SECRET-IN-LIST"], "s"), (b"SECRET-BYTES", "s"), ("k", {"SECRET": 1})):
|
||||
path.write_bytes(msgpack.packb(
|
||||
["cultcache.store.v1", [], [[key, schema, "t", b"\x90", "extra"]]], use_bin_type=True))
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
SingleFileMessagePackBackingStore(path).pull_all()
|
||||
self.assertNotIn("SECRET", str(caught.exception))
|
||||
self.assertIn("not a valid store", str(caught.exception))
|
||||
|
||||
# What a write does through a symbolic link today. R3 decides whether a write resolves
|
||||
# the link or refuses it; this pins the current behaviour so that change is deliberate.
|
||||
@unittest.skipIf(os.name != "posix", "symbolic links need POSIX here")
|
||||
def test_r3_decides_resolve_or_refuse_a_push_through_a_live_link_replaces_the_link(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
volume = Path(tmp) / "volume"
|
||||
volume.mkdir()
|
||||
target = volume / "store.cc"
|
||||
envelope = CultCacheEnvelope(key="a", type="t", payload=b"\x90", stored_at="2026-09-30T00:00:00Z")
|
||||
SingleFileMessagePackBackingStore(target).push(envelope)
|
||||
before = target.read_bytes()
|
||||
link = Path(tmp) / "store.cc"
|
||||
link.symlink_to(target)
|
||||
SingleFileMessagePackBackingStore(link).push(replace(envelope, key="b"))
|
||||
self.assertFalse(link.is_symlink())
|
||||
self.assertTrue(link.is_file())
|
||||
self.assertEqual(target.read_bytes(), before)
|
||||
|
||||
@unittest.skipIf(os.name != "posix", "symbolic links need POSIX here")
|
||||
def test_a_dangling_symlink_at_a_store_path_is_an_os_error_and_nothing_is_written(self) -> None:
|
||||
for store_type in (SingleFileMessagePackBackingStore, JsonLinesBackingStore):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
volume = Path(tmp) / "volume"
|
||||
volume.mkdir()
|
||||
path = Path(tmp) / "store.cc"
|
||||
path.symlink_to(volume / "store.cc")
|
||||
store = store_type(path)
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
store.pull_all()
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
store.push(CultCacheEnvelope(key="k", type="t", payload=b"\x90", stored_at="2026-09-30T00:00:00Z"))
|
||||
self.assertTrue(path.is_symlink())
|
||||
self.assertEqual(list(volume.iterdir()), [])
|
||||
|
||||
@unittest.skipIf(os.name != "posix", "symlink loops and file-as-parent errors are POSIX errno cases")
|
||||
def test_json_lines_store_that_cannot_be_reached_is_an_os_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
self.assertEqual(JsonLinesBackingStore(Path(tmp) / "missing.jsonl").pull_all(), [])
|
||||
loop = Path(tmp) / "loop.jsonl"
|
||||
loop.symlink_to(loop)
|
||||
parent = Path(tmp) / "body"
|
||||
parent.write_bytes(b"not a directory")
|
||||
for path in (loop, parent / "store.jsonl"):
|
||||
with self.assertRaises(OSError) as caught:
|
||||
JsonLinesBackingStore(path).pull_all()
|
||||
self.assertNotIsInstance(caught.exception, FileNotFoundError)
|
||||
|
||||
@unittest.skipIf(os.name != "posix", "symlink loops and file-as-parent errors are POSIX errno cases")
|
||||
def test_single_file_store_that_cannot_be_reached_is_an_os_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
loop = Path(tmp) / "loop.cc"
|
||||
loop.symlink_to(loop)
|
||||
parent = Path(tmp) / "body"
|
||||
parent.write_bytes(b"not a directory")
|
||||
for path in (loop, parent / "store.cc"):
|
||||
with self.assertRaises(OSError) as caught:
|
||||
SingleFileMessagePackBackingStore(path).pull_all()
|
||||
self.assertNotIsInstance(caught.exception, FileNotFoundError)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -293,61 +293,6 @@ writes. That protects ordinary multi-process access to the same file. It is
|
|||
still not a multi-master replication protocol, and it is not a substitute for a
|
||||
coordinator when higher-level write ordering matters.
|
||||
|
||||
A write that fails part way, for example on a full disk, removes its staging
|
||||
file and leaves the store as it was. A read that fails tells you why through
|
||||
the error chain:
|
||||
|
||||
```rust
|
||||
use cultcache_rs::{CacheBackingStore, CultCacheStoreUnreadable, CultCacheStoreUnreadableKind, SingleFileMessagePackBackingStore};
|
||||
|
||||
let store = SingleFileMessagePackBackingStore::new("cache.cc");
|
||||
if let Err(error) = store.pull_all() {
|
||||
match error.downcast_ref::<CultCacheStoreUnreadable>().map(|unreadable| unreadable.kind) {
|
||||
// The bytes were read and are not a store: truncated, garbled, or invalid.
|
||||
Some(CultCacheStoreUnreadableKind::Undecodable) => {}
|
||||
// The header names a format this runtime does not read, such as a newer one.
|
||||
Some(CultCacheStoreUnreadableKind::UnsupportedFormat) => {}
|
||||
// The bytes could not be read at all: a std::io::Error is in the chain.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
An I/O failure never carries `CultCacheStoreUnreadable`, and the chain under
|
||||
`CultCacheStoreUnreadable` never holds a `std::io::Error`.
|
||||
|
||||
Only a store file that does not exist (`std::io::ErrorKind::NotFound`) reads as
|
||||
an empty store. Any other failure to reach it, such as a symlink loop, a file
|
||||
where a parent directory should be, or a missing permission, is an I/O error.
|
||||
A store file that exists and holds zero bytes is `Undecodable`: no CultCache
|
||||
writer leaves one. A symbolic link whose target is gone is not a missing store:
|
||||
reading it is an I/O error, so a read-then-write (`push`, `delete`, the exchanges)
|
||||
fails before writing. Writes do not yet follow links: a write through a live link,
|
||||
and a `push_all` through a dangling one, replace the link with a regular file and
|
||||
leave its target alone. A refusal names no
|
||||
value read from the file; it names a record only by its key and schema id, and
|
||||
a header only when it has the shape `cultcache.store.v<digits>`.
|
||||
|
||||
A write that fails says what it left on disk:
|
||||
|
||||
```rust
|
||||
use cultcache_rs::{CultCacheStoreWriteFailed, CultCacheStoreWriteFailedKind};
|
||||
|
||||
fn outcome(error: &anyhow::Error) {
|
||||
match error.downcast_ref::<CultCacheStoreWriteFailed>().map(|failed| failed.kind) {
|
||||
// The entries cannot be stored; retrying them fails the same way.
|
||||
Some(CultCacheStoreWriteFailedKind::Rejected) => {}
|
||||
// Staging, fsync or rename failed: the store file holds what it held before,
|
||||
// and a retry may succeed once the fault (a full disk, say) clears.
|
||||
Some(CultCacheStoreWriteFailedKind::NotReplaced) => {}
|
||||
// The rename landed and the directory sync failed: the new snapshot is in
|
||||
// place but may not survive a crash.
|
||||
Some(CultCacheStoreWriteFailedKind::ReplacedNotDurable) => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Near-Term Ergonomic Improvements
|
||||
|
||||
1. **Derive macro**
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import { decode, encode } from "@msgpack/msgpack";
|
||||
import { z } from "zod";
|
||||
|
||||
import { STORE_FORMAT_VERSION, isV1Snapshot, requireV1RecordSlots } from "./store-format";
|
||||
import type {
|
||||
CacheBackingStore,
|
||||
CultCacheEnvelope,
|
||||
|
|
@ -13,6 +12,8 @@ import type {
|
|||
PushAllOptions,
|
||||
} from "./types";
|
||||
|
||||
const STORE_FORMAT_VERSION = "cultcache.store.v1";
|
||||
|
||||
const envelopeSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
type: z.string().min(1),
|
||||
|
|
@ -41,57 +42,64 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
|
|||
}
|
||||
|
||||
async pullAll(): Promise<CultCacheEnvelope[]> {
|
||||
// An empty file is not a store, since no CultCache writer leaves one, so it is
|
||||
// decoded and refused. Only a read that finds nothing at the path is an empty store.
|
||||
const data = await readStore(this.filePath);
|
||||
if (data === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const decoded = decode(data);
|
||||
const snapshot = decodeSnapshot(decoded);
|
||||
if (snapshot) {
|
||||
return snapshot.records.map((record) => {
|
||||
const catalogEntry = resolveCatalogEntryForRecord(record, snapshot.catalogBySchemaId);
|
||||
if (!catalogEntry) {
|
||||
throw new Error(`CultCache persisted record "${record.key}" references missing schema id "${record.schemaId}".`);
|
||||
}
|
||||
|
||||
return {
|
||||
key: record.key,
|
||||
type: catalogEntry.schemaName,
|
||||
schemaId: record.schemaId,
|
||||
catalogEntry,
|
||||
payload: record.payload,
|
||||
storedAt: record.storedAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const legacy = decodeLegacyEnvelopeArray(decoded);
|
||||
if (legacy) {
|
||||
let repairedLegacyPayload = false;
|
||||
const normalized = legacy.map((entry) => {
|
||||
const payload = normalizePayload(entry.payload);
|
||||
if (payload !== entry.payload) {
|
||||
repairedLegacyPayload = true;
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
payload,
|
||||
};
|
||||
});
|
||||
const parsed = envelopeArraySchema.parse(normalized) as CultCacheEnvelope[];
|
||||
|
||||
if (repairedLegacyPayload) {
|
||||
await this.#writeAll(parsed);
|
||||
try {
|
||||
const data = await readFile(this.filePath);
|
||||
if (data.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
const decoded = decode(data);
|
||||
const snapshot = decodeSnapshot(decoded);
|
||||
if (snapshot) {
|
||||
return snapshot.records.map((record) => {
|
||||
const catalogEntry = resolveCatalogEntryForRecord(record, snapshot.catalogBySchemaId);
|
||||
if (!catalogEntry) {
|
||||
throw new Error(`CultCache persisted record "${record.key}" references missing schema id "${record.schemaId}".`);
|
||||
}
|
||||
|
||||
throw new Error(`CultCache file ${this.filePath} is not a recognized CultCache MessagePack store.`);
|
||||
return {
|
||||
key: record.key,
|
||||
type: catalogEntry.schemaName,
|
||||
schemaId: record.schemaId,
|
||||
catalogEntry,
|
||||
payload: record.payload,
|
||||
storedAt: record.storedAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const legacy = decodeLegacyEnvelopeArray(decoded);
|
||||
if (legacy) {
|
||||
let repairedLegacyPayload = false;
|
||||
const normalized = legacy.map((entry) => {
|
||||
const payload = normalizePayload(entry.payload);
|
||||
if (payload !== entry.payload) {
|
||||
repairedLegacyPayload = true;
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
payload,
|
||||
};
|
||||
});
|
||||
const parsed = envelopeArraySchema.parse(normalized) as CultCacheEnvelope[];
|
||||
|
||||
if (repairedLegacyPayload) {
|
||||
await this.#writeAll(parsed);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
throw new Error(`CultCache file ${this.filePath} is not a recognized CultCache MessagePack store.`);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async push(entry: CultCacheEnvelope): Promise<void> {
|
||||
|
|
@ -117,8 +125,16 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
|
|||
|
||||
async pushAll(entries: CultCacheEnvelope[], options: PushAllOptions = {}): Promise<void> {
|
||||
await this.#enqueue(async () => {
|
||||
if (options.soft && (await readStore(this.filePath)) !== undefined) {
|
||||
return;
|
||||
if (options.soft) {
|
||||
try {
|
||||
await readFile(this.filePath);
|
||||
return;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.#writeAll(entries);
|
||||
|
|
@ -158,33 +174,6 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a store file, or returns undefined when nothing is at its path. A dangling symbolic
|
||||
* link is something: reading it as empty would let the writer rename a file over the link
|
||||
* and move the store off its volume. Any other failure to reach the file is thrown.
|
||||
*/
|
||||
async function readStore(path: string): Promise<Uint8Array | undefined> {
|
||||
try {
|
||||
return await readFile(path);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await lstat(path);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw Object.assign(new Error(`CultCache store ${path} is a symbolic link whose target does not exist.`), { code: "ENOENT" });
|
||||
}
|
||||
|
||||
async function renameWithRetry(source: string, destination: string): Promise<void> {
|
||||
let delayMs = 10;
|
||||
let lastError: unknown;
|
||||
|
|
@ -296,7 +285,7 @@ function encodeCatalogMember(member: CultCacheSchemaCatalogMember): unknown[] {
|
|||
}
|
||||
|
||||
function decodeSnapshot(decoded: unknown): DecodedSnapshot | undefined {
|
||||
if (!isV1Snapshot(decoded)) {
|
||||
if (!Array.isArray(decoded) || decoded.length === 0 || decoded[0] !== STORE_FORMAT_VERSION) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
|
@ -447,7 +436,6 @@ function decodeRecord(value: unknown): PersistedRecord {
|
|||
throw new Error("CultCache persisted records must be MessagePack arrays.");
|
||||
}
|
||||
|
||||
requireV1RecordSlots(value);
|
||||
const [key = "", schemaId = "", storedAt = "", payload = new Uint8Array()] = value;
|
||||
if (!isNonEmptyString(key) || !isNonEmptyString(schemaId) || !isNonEmptyString(storedAt)) {
|
||||
throw new Error("CultCache persisted records must declare key, schemaId, and storedAt.");
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
export const STORE_FORMAT_VERSION = "cultcache.store.v1";
|
||||
|
||||
const STORE_FORMAT_PREFIX = "cultcache.store.";
|
||||
const PERSISTED_RECORD_SLOTS = 4;
|
||||
|
||||
/**
|
||||
* True for a v1 snapshot, false for anything that is not a CultCache store header (the legacy
|
||||
* envelope array). Any other `cultcache.store.*` header is refused by name.
|
||||
*/
|
||||
export function isV1Snapshot(decoded: unknown): decoded is unknown[] {
|
||||
if (!Array.isArray(decoded) || typeof decoded[0] !== "string" || !decoded[0].startsWith(STORE_FORMAT_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (decoded[0] !== STORE_FORMAT_VERSION) {
|
||||
throw new Error(
|
||||
`CultCache store format ${describeHeader(decoded[0])} is not one this runtime reads; it reads "${STORE_FORMAT_VERSION}" only.`,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A store header as a refusal may show it: echoed in the shape `cultcache.store.v<digits>`,
|
||||
* and otherwise described only by its length, since the bytes are the store's.
|
||||
*/
|
||||
function describeHeader(header: string): string {
|
||||
return /^cultcache\.store\.v[0-9]+$/u.test(header)
|
||||
? `"${header}"`
|
||||
: `an unrecognised ${STORE_FORMAT_PREFIX}* header of ${new TextEncoder().encode(header).length} bytes`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A record key or schema id as a refusal may show it: a string is an identity and is named;
|
||||
* anything else is described by its type, since the value is the store's.
|
||||
*/
|
||||
function describeIdentity(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return `"${value}"`;
|
||||
}
|
||||
|
||||
const kind = Array.isArray(value)
|
||||
? "array"
|
||||
: value instanceof Uint8Array
|
||||
? "bytes"
|
||||
: value === null
|
||||
? "nil"
|
||||
: typeof value;
|
||||
return `<${kind}>`;
|
||||
}
|
||||
|
||||
/** Refuses a persisted record with more slots than v1 defines, naming its key and schema id. */
|
||||
export function requireV1RecordSlots(record: unknown[]): void {
|
||||
if (record.length > PERSISTED_RECORD_SLOTS) {
|
||||
throw new Error(
|
||||
`CultCache record ${describeIdentity(record[0])} (schema ${describeIdentity(record[1])}) has ${record.length} slots, more than ` +
|
||||
`the ${PERSISTED_RECORD_SLOTS} of a ${STORE_FORMAT_VERSION} record, so this is not a valid store.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|||
import { exec, execFile } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { existsSync } from "node:fs";
|
||||
import { access, lstat, mkdir, mkdtemp, readdir, readFile, symlink, writeFile } from "node:fs/promises";
|
||||
import { access, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { dirname, join, resolve, delimiter } from "node:path";
|
||||
import { test } from "node:test";
|
||||
|
|
@ -1413,178 +1413,3 @@ async function runJsonCommand(
|
|||
|
||||
return JSON.parse(trimmed.split(/\r?\n/).at(-1) as string);
|
||||
}
|
||||
|
||||
// Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests.
|
||||
const variantVectors = resolve(cultLibRoot, "tests", "vectors", "document-variants-c0");
|
||||
const itemSchemaId = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788";
|
||||
|
||||
async function pullVector(name: string): Promise<CultCacheEnvelope[]> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "cultcache-vector-"));
|
||||
const file = join(dir, "store.msgpack");
|
||||
await writeFile(file, await readFile(join(variantVectors, name)));
|
||||
return new SingleFileMessagePackBackingStore(file).pullAll();
|
||||
}
|
||||
|
||||
test("SingleFileMessagePackBackingStore refuses an unknown store header by name", async () => {
|
||||
await assert.rejects(() => pullVector("unknown-header.msgpack"), /cultcache\.store\.v9/u);
|
||||
});
|
||||
|
||||
test("SingleFileMessagePackBackingStore refuses an extra record slot naming the record", async () => {
|
||||
await assert.rejects(
|
||||
() => pullVector("extra-slot-full-payload.msgpack"),
|
||||
(error: Error) => error.message.includes("item:anvil") && error.message.includes(itemSchemaId),
|
||||
);
|
||||
});
|
||||
|
||||
test("SingleFileMessagePackBackingStore refuses a variant store by version or record", async () => {
|
||||
await assert.rejects(
|
||||
() => pullVector("variant-v2.msgpack"),
|
||||
(error: Error) => error.message.includes("cultcache.store.v2") || error.message.includes("item:anvil-big"),
|
||||
);
|
||||
});
|
||||
|
||||
test("SingleFileMessagePackBackingStore refuses the C#-written variant store by version or record", async () => {
|
||||
await assert.rejects(
|
||||
() => pullVector("../document-variants-c1/variant-store.msgpack"),
|
||||
(error: Error) => error.message.includes("cultcache.store.v2") || error.message.includes("laser-big"),
|
||||
);
|
||||
});
|
||||
|
||||
test("SingleFileMessagePackBackingStore refusals say what was found without blaming variants", async () => {
|
||||
await assert.rejects(
|
||||
() => pullVector("unknown-header.msgpack"),
|
||||
(error: Error) => error.message.includes("not one this runtime reads") && !error.message.includes("variant"),
|
||||
);
|
||||
await assert.rejects(
|
||||
() => pullVector("extra-slot-full-payload.msgpack"),
|
||||
(error: Error) => error.message.includes("not a valid store") && !error.message.includes("variant"),
|
||||
);
|
||||
});
|
||||
|
||||
test("SingleFileMessagePackBackingStore reads only a missing store as empty", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "cultcache-absent-"));
|
||||
assert.deepEqual(await new SingleFileMessagePackBackingStore(join(dir, "store.cc")).pullAll(), []);
|
||||
|
||||
// An existing empty file is not a store: no CultCache writer leaves one.
|
||||
const empty = join(dir, "empty.cc");
|
||||
await writeFile(empty, new Uint8Array());
|
||||
await assert.rejects(() => new SingleFileMessagePackBackingStore(empty).pullAll());
|
||||
assert.equal((await readFile(empty)).length, 0);
|
||||
|
||||
// An empty array is the legacy envelope array with no envelopes: no header, no format claimed.
|
||||
const legacyEmpty = join(dir, "legacy-empty.cc");
|
||||
await writeFile(legacyEmpty, Uint8Array.of(0x90));
|
||||
assert.deepEqual(await new SingleFileMessagePackBackingStore(legacyEmpty).pullAll(), []);
|
||||
});
|
||||
|
||||
test("SingleFileMessagePackBackingStore reports a store it cannot reach as an I/O error", { skip: process.platform === "win32" }, async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "cultcache-unreachable-"));
|
||||
const loop = join(dir, "loop.cc");
|
||||
await symlink(loop, loop);
|
||||
const parent = join(dir, "body");
|
||||
await writeFile(parent, "not a directory");
|
||||
for (const [path, code] of [[loop, "ELOOP"], [join(parent, "store.cc"), "ENOTDIR"]] as const) {
|
||||
await assert.rejects(
|
||||
() => new SingleFileMessagePackBackingStore(path).pullAll(),
|
||||
(error: NodeJS.ErrnoException) => error.code === code,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("SingleFileMessagePackBackingStore refusals never echo a value from the store", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "cultcache-canary-"));
|
||||
const file = join(dir, "store.cc");
|
||||
const cases: Array<[unknown[], boolean | undefined]> = [
|
||||
[["cultcache.store.v12", [], []], true],
|
||||
[["cultcache.store.SECRET-HEADER", [], []], false],
|
||||
[["cultcache.store.v12SECRET", [], []], false],
|
||||
[["cultcache.store.v", [], []], false],
|
||||
[["cultcache.store.v1", ["SECRET-CATALOG-TEXT"], []], undefined],
|
||||
];
|
||||
for (const [value, echoed] of cases) {
|
||||
await writeFile(file, encode(value));
|
||||
const header = value[0] as string;
|
||||
await assert.rejects(
|
||||
() => new SingleFileMessagePackBackingStore(file).pullAll(),
|
||||
(error: Error) => {
|
||||
if (echoed) {
|
||||
return error.message.includes(`"${header}"`);
|
||||
}
|
||||
return !error.message.includes("SECRET")
|
||||
&& (echoed === undefined || (!error.message.includes(`"${header}"`) && error.message.includes(`of ${header.length} bytes`)));
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("SingleFileMessagePackBackingStore refuses a dangling symlink at its path and writes nothing", { skip: process.platform === "win32" }, async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "cultcache-dangling-"));
|
||||
const volume = join(dir, "volume");
|
||||
await mkdir(volume);
|
||||
const file = join(dir, "store.cc");
|
||||
await symlink(join(volume, "store.cc"), file);
|
||||
const store = new SingleFileMessagePackBackingStore(file);
|
||||
const envelope = { key: "k", type: "t", payload: Uint8Array.of(0x90), storedAt: "2026-09-30T00:00:00Z" };
|
||||
await assert.rejects(() => store.pullAll(), (error: NodeJS.ErrnoException) => error.code === "ENOENT");
|
||||
await assert.rejects(() => store.push(envelope), (error: NodeJS.ErrnoException) => error.code === "ENOENT");
|
||||
await assert.rejects(() => store.pushAll([envelope], { soft: true }), (error: NodeJS.ErrnoException) => error.code === "ENOENT");
|
||||
assert.ok((await lstat(file)).isSymbolicLink());
|
||||
assert.deepEqual(await readdir(volume), []);
|
||||
});
|
||||
|
||||
test("SingleFileMessagePackBackingStore record refusal names only a string key", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "cultcache-record-key-"));
|
||||
const file = join(dir, "store.cc");
|
||||
const secret = new TextEncoder().encode("SECRET-BYTES");
|
||||
for (const [key, schema] of [[["SECRET-IN-ARRAY"], "s"], [secret, "s"], ["k", { SECRET: 1 }]] as const) {
|
||||
await writeFile(file, encode(["cultcache.store.v1", [], [[key, schema, "t", Uint8Array.of(0x90), "extra"]]]));
|
||||
await assert.rejects(
|
||||
() => new SingleFileMessagePackBackingStore(file).pullAll(),
|
||||
(error: Error) => !error.message.includes("SECRET") && !error.message.includes("83,69,67") && error.message.includes("not a valid store"),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// What a write does through a symbolic link today. R3 decides whether a write resolves the link
|
||||
// or refuses it; these pin the current behaviour so that change is deliberate.
|
||||
test("R3 decides resolve-or-refuse: a push through a live link replaces the link", { skip: process.platform === "win32" }, async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "cultcache-live-link-"));
|
||||
const volume = join(dir, "volume");
|
||||
await mkdir(volume);
|
||||
const target = join(volume, "store.cc");
|
||||
const envelope = { key: "a", type: "t", payload: Uint8Array.of(0x90), storedAt: "2026-09-30T00:00:00Z" };
|
||||
await new SingleFileMessagePackBackingStore(target).push(envelope);
|
||||
const before = await readFile(target);
|
||||
const link = join(dir, "store.cc");
|
||||
await symlink(target, link);
|
||||
await new SingleFileMessagePackBackingStore(link).push({ ...envelope, key: "b" });
|
||||
assert.ok((await lstat(link)).isFile());
|
||||
assert.deepEqual(await readFile(target), before);
|
||||
});
|
||||
|
||||
test("R3 decides resolve-or-refuse: a pushAll through a dangling link replaces the link", { skip: process.platform === "win32" }, async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "cultcache-dangling-push-all-"));
|
||||
const volume = join(dir, "volume");
|
||||
await mkdir(volume);
|
||||
const link = join(dir, "store.cc");
|
||||
await symlink(join(volume, "store.cc"), link);
|
||||
await new SingleFileMessagePackBackingStore(link).pushAll([{ key: "a", type: "t", payload: Uint8Array.of(0x90), storedAt: "2026-09-30T00:00:00Z" }]);
|
||||
assert.ok((await lstat(link)).isFile());
|
||||
assert.deepEqual(await readdir(volume), []);
|
||||
});
|
||||
|
||||
test("CultCache inspector refuses the same vectors by name", async () => {
|
||||
const inspect = async (name: string) => inspectCultCacheBytes(name, await readFile(join(variantVectors, name)));
|
||||
await assert.rejects(() => inspect("unknown-header.msgpack"), /cultcache\.store\.v9/u);
|
||||
await assert.rejects(
|
||||
() => inspect("extra-slot-full-payload.msgpack"),
|
||||
(error: Error) => error.message.includes("item:anvil") && error.message.includes(itemSchemaId),
|
||||
);
|
||||
});
|
||||
|
||||
test("a v1 store written at the base commit still reads byte for byte", async () => {
|
||||
const envelopes = await pullVector("v1-base.msgpack");
|
||||
assert.deepEqual(envelopes.map((entry) => [entry.key, entry.type]), [["alpha", "vectors.item"], ["beta", "vectors.item"]]);
|
||||
assert.deepEqual([...envelopes[0]!.payload], [0x92, 0xa5, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x01]);
|
||||
assert.deepEqual([...envelopes[1]!.payload], [0x92, 0xa4, 0x62, 0x65, 0x74, 0x61, 0x02]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,36 +26,13 @@ shader semantics.
|
|||
acceleration and semi-implicit Euler integration
|
||||
- `math` intrinsics: `radians`, `degrees`, `abs`, `floor`, `ceil`, `frac`,
|
||||
`min`, `max`, `clamp`, `saturate`, `lerp`, `step`, `smoothstep`, `dot`,
|
||||
`cross`, `length`, `distance`, `normalize`, `reflect`, `csum`, `erf`,
|
||||
`erfinv` (scalar only), exponential
|
||||
`cross`, `length`, `distance`, `normalize`, `reflect`, `csum`, exponential
|
||||
`decay`/`damp`, intercept/segment helpers, Catmull-Rom, Bezier curves, and
|
||||
deterministic value-noise primitives
|
||||
- `shaders/CultMath.hlsl`, a canonical HLSL mirror include for shader-side
|
||||
parity. HLSL already owns `float2`, `float3`, and `float4`; the include
|
||||
exposes `cultmath_*` functions for shared semantics HLSL does not provide.
|
||||
It includes `shaders/CultMath.Phacelle.hlsl` (MPL-2.0) and
|
||||
`shaders/CultMath.Interval.hlsl` from the same directory, so ship all three.
|
||||
- interval arithmetic for culling empty space with a proof: `iv_*` functions
|
||||
over `float2(lo, hi)` (`iv_add`, `iv_mul`, `iv_exp`, `iv_smoothstep`, ...)
|
||||
and `iv_snoise_ball`/`iv_fbm_ball`, which bound `snoise` and `fbm_grad` over a
|
||||
ball, and `iv_frustum_ball`, the ball around a screen tile's rays over a depth
|
||||
segment, so one probe serves the whole tile (its radius carries its own
|
||||
float32 rounding bound). Every one returns an interval
|
||||
containing the function's value at every point of its input, so a raymarch
|
||||
can skip a segment whose `hi` is below its cutoff; `cultmath_iv_*` in HLSL.
|
||||
The bounds compose with a consumer's own analytic envelope (a height fog, a
|
||||
carved sphere): its interval over the segment proves empty space in one probe,
|
||||
and the noise ball is consulted only near the surface. See `docs/design.md`,
|
||||
"Intervals".
|
||||
- `shaders/CultMath.glsl` (MIT), the same `cultmath_*` library in GLSL ES 3.00
|
||||
for WebGL2, generated from `CultMath.hlsl` (never edited by hand), and
|
||||
`shaders/CultMath.Phacelle.glsl` (MPL-2.0), generated from
|
||||
`CultMath.Phacelle.hlsl`, which holds `cultmath_phacelle` alone. GLSL has no
|
||||
`#include`: a WebGL consumer concatenates `CultMath.glsl` after its own
|
||||
`#version 300 es` line and `precision highp float; precision highp int;`
|
||||
(the library declares no precision, and its hashes need 32-bit integers),
|
||||
then `CultMath.Phacelle.glsl` only if it calls `cultmath_phacelle`, then its
|
||||
own shader source.
|
||||
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.
|
||||
|
|
@ -82,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,38 +6,6 @@
|
|||
simplex-noise functions by Ian McEwan and Ashima Arts, as distributed by Stefan
|
||||
Gustavson's `webgl-noise` project and Unity Mathematics.
|
||||
|
||||
`math.snoise(float3)` and `cultmath_snoise(float3)` follow `src/noise3D.glsl` and
|
||||
`math.snoise(float2)` follows `src/noise2D.glsl` of stegu/webgl-noise at commit
|
||||
`22434e04d7753f7e949e8d724ab3da2864c17a0f` (the simplex-boundary fix is
|
||||
`21d9fe23d7`, scale `ff3b5d34ea`, permutation `a3e6d57095`).
|
||||
|
||||
`math.snoise_grad`/`cultmath_snoise_grad` (and the `fbm_grad`/`ridged_grad`
|
||||
octave sums built on them) follow the analytic-gradient differentiation from
|
||||
the same project's `src/noise3Dgrad.glsl`, with the same kernel radius,
|
||||
permutation and scale as `snoise(float3)`.
|
||||
|
||||
- Copyright 2011 Ashima Arts.
|
||||
- Upstream: <https://github.com/ashima/webgl-noise>
|
||||
- License: MIT.
|
||||
|
||||
## Phacelle Noise
|
||||
|
||||
`math.phacelle` and `cultmath_phacelle` follow Rune Skovbo Johansen's Phacelle
|
||||
noise (Shadertoy `t3dyWl`; erosion filter `wXcfWn`; blog, March 2026), read
|
||||
from the C# port `PhacelleNoise` in `lpmitchell/AdvancedTerrainErosion`
|
||||
(`AdvancedTerrainErosion.cs`, converted to burstable C# by Luke Mitchell,
|
||||
2026). The cell weight, the phase blend and the normalization follow it. The
|
||||
3D-cell generalisation, the caller-supplied stripe wave vector, the exact
|
||||
gradient and the exact pruning are new here.
|
||||
|
||||
- Copyright (c) 2025 Rune Skovbo Johansen.
|
||||
- Upstream: <https://github.com/lpmitchell/AdvancedTerrainErosion>
|
||||
- License: MPL-2.0 (<https://mozilla.org/MPL/2.0/>). MPL-2.0 is file-level, so it
|
||||
covers exactly the files that carry the port and nothing else:
|
||||
`src/CultMath/math.Phacelle.cs`, `src/CultMath/CultPhasor.cs`,
|
||||
`shaders/CultMath.Phacelle.hlsl` and the Unity copy of that include, and
|
||||
`shaders/CultMath.Phacelle.glsl`, which is generated from that include. Each
|
||||
carries the MPL-2.0 header and this attribution. The rest of CultMath, the
|
||||
generated GLSL library included, is MIT, so the `GameCult.Math` NuGet
|
||||
package and the `org.gamecult.cultmath` Unity package both declare
|
||||
`MIT AND MPL-2.0` and ship this file.
|
||||
|
|
|
|||
|
|
@ -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,328 +103,9 @@ compiled under `using static CultMath.math;`, so its intrinsics are C# `math`
|
|||
itself: the test proves the composition in the text, not the intrinsic rules,
|
||||
which only `HlslSemanticsTests` pins.
|
||||
|
||||
The comparison also allows exactly one struct return shape: a mirror function
|
||||
whose HLSL return type is a plain struct of `float`/`floatN`/`int`/`intN`
|
||||
fields is compared to its C# counterpart field by field, recursing into each
|
||||
field's own components, down to bit-for-bit scalars, matching each leaf's
|
||||
declaration path (field names, not just position) and declared type as well
|
||||
as its value. `CultCellular` (`math.cs`, `cellular`) and `CultPhasor`
|
||||
(`math.Phacelle.cs`, `phacelle`) are its consumers. A struct return with a mismatched, reordered-by-name, or
|
||||
retyped field does not get a second comparison path or a shape of its own;
|
||||
it fails the same walk that already handles a bare vector return.
|
||||
|
||||
### GLSL Target: Source Transformations
|
||||
|
||||
`shaders/CultMath.glsl` is a GLSL ES 3.00 (WebGL2) library generated from
|
||||
`shaders/CultMath.hlsl` by `GlslLowering` (in the test project), and
|
||||
`shaders/CultMath.Phacelle.glsl` is generated beside it from
|
||||
`shaders/CultMath.Phacelle.hlsl`. Both are derived files: never edit them by
|
||||
hand. Regenerate them, and the golden fixture
|
||||
`tests/CultMath.Tests/fixtures/glsl-parity.json`, with:
|
||||
|
||||
```powershell
|
||||
$env:CULTMATH_WRITE_GLSL = "1"; dotnet test packages/cultmath/tests/CultMath.Tests --filter GlslMirrorTests
|
||||
```
|
||||
|
||||
`GlslMirrorTests.CommittedGlslEqualsLowering` fails on any difference between
|
||||
either committed file and the lowering of the committed HLSL. These
|
||||
transformations, in order, are the complete list:
|
||||
|
||||
1. Each `#include "name"` is replaced in place by the included file, by the
|
||||
same resolver the C# mirror uses; GLSL has no `#include`. The exception is
|
||||
an include under a file-level licence of its own, listed in one table,
|
||||
`GlslLowering.SeparateFiles`: its line is dropped and the file is lowered on
|
||||
its own, by the same steps, into its own output. The table has one entry,
|
||||
`CultMath.Phacelle.hlsl` to `CultMath.Phacelle.glsl`, because Phacelle is
|
||||
MPL-2.0 and MPL-2.0 is file-level: kept separate, it leaves
|
||||
`CultMath.glsl` MIT. Nothing else in the lowering is keyed by name.
|
||||
2. Include guards are dropped, and so are the functions taking
|
||||
`Texture2D`/`SamplerState`; the host shader samples its own textures.
|
||||
3. Type names: `floatN`, `intN`, `uintN` and `boolN` become `vecN`, `ivecN`,
|
||||
`uvecN` and `bvecN`.
|
||||
4. File-scope `static const` becomes `const`.
|
||||
5. C-style casts `(float)x`, `(int)x` and `(uint)x` become `float(x)`, `int(x)`
|
||||
and `uint(x)`. The operand is found by a balanced-bracket scanner:
|
||||
`(int)((state >> 28) + 4u)` nests parentheses.
|
||||
6. Intrinsics are renamed: `lerp` to `mix`, `frac` to `fract`, `rsqrt` to
|
||||
`inversesqrt`, `asuint` to `floatBitsToUint`, `asfloat` to
|
||||
`uintBitsToFloat`, and `saturate(x)` becomes `clamp(x, 0.0, 1.0)`.
|
||||
7. Float literals pass through unchanged.
|
||||
8. Each output is wrapped in a guard named for its file: `CULTMATH_GLSL`,
|
||||
`CULTMATH_PHACELLE_GLSL`.
|
||||
9. The first line names the file as generated, its sources and its licence,
|
||||
and says how to regenerate it; `CultMath.glsl`'s also says which
|
||||
functions live in the separate file.
|
||||
|
||||
GLSL ES 3.00 is stricter than HLSL: no implicit conversions between int, uint
|
||||
and float, no C-style casts, and no `step(genType, float)` overload. Those gaps
|
||||
are closed in the HLSL, never in the lowering: the HLSL is written in the subset
|
||||
both languages share, with edits that are legal HLSL and compute the same bits
|
||||
(`step(h, float4(0.0, 0.0, 0.0, 0.0))` rather than `step(h, 0.0)`, `+ 4u` where
|
||||
a uint is added, `(float)((uint)hash.x >> 8)` before a float multiply). The C#
|
||||
mirror and the FXC and dxc compiles keep those edits honest. A body that needs a
|
||||
transformation outside the list fails `GlslMirrorTests.NoHlslOnlyTokensSurvive`
|
||||
rather than gaining a special case.
|
||||
|
||||
The library declares no precision. The host shader does, and it must be
|
||||
`precision highp float; precision highp int;`: the PCG hashes need 32-bit
|
||||
integers. `tools/compile-glsl.ps1` writes such a wrapper (its own `#version
|
||||
300 es` line, the precision, the library by string concatenation, and one call
|
||||
of every public function) and compiles it with glslang, which
|
||||
`tools/get-glslang.ps1` fetches pinned. It runs twice: over `CultMath.glsl`
|
||||
alone, and with `-Phacelle` over `CultMath.glsl` followed by
|
||||
`CultMath.Phacelle.glsl`, the order a consumer that calls `cultmath_phacelle`
|
||||
concatenates them in. Two negative controls must fail: `-Body` with an HLSL
|
||||
token (`float x = lerp(0.0, 1.0, 0.5);`), and `-Body` calling
|
||||
`cultmath_phacelle` without `-Phacelle`, which proves the MIT file does not
|
||||
carry it. The Unity package ships neither GLSL file.
|
||||
|
||||
Parity runs in two legs, each with one owner: HLSL against C# by the mirror
|
||||
test, bit for bit; GLSL against C# by the golden fixture, which records 256
|
||||
seeded cases per function family as float32 bit patterns, evaluated by the
|
||||
consumer on WebGL2 and read back. The families `pcg3d` and `pcg4d` are marked
|
||||
exact; the float families are ulp-bounded, the bound measured on the device.
|
||||
|
||||
`iv_frustum_ball` is ulp-bounded, not exact: it calls `sqrt`, which GLSL ES
|
||||
3.00 does not require to be correctly rounded, and WebGL2 compilers may
|
||||
reassociate. On a GTX 1070 under ANGLE's D3D11 backend its radius differs from
|
||||
C# by up to 2 ulp in 43 of 256 cases, sometimes smaller. What the march needs
|
||||
is enclosure, and what culling needs is a ball no wider than its rounding
|
||||
widening, so the family's fixture entry carries a `check` the consumer applies
|
||||
to every case: from the case's arguments `(mx, my, z0, z1, fp, warp)`, compute
|
||||
in double `zm = (z0 + z1) / 2`, the centre `c = (mx zm, my zm, zm)`, its
|
||||
`|c|_1 = |mx zm| + |my zm| + |zm|`, the radius
|
||||
`r = (z1 - z0) / 2 sqrt(mx^2 + my^2 + 1) + z1 fp + warp` and
|
||||
`d = |GPU centre - c|`, and require
|
||||
`r + d <= GPU radius <= r + d + 2^-19 (r + |c|_1)`. The lower side is
|
||||
enclosure; the upper side is the bound `NoiseBoundTests` pins in C#, so a
|
||||
lowering that inflates the ball fails too. Only those two bounds fail the
|
||||
check; the largest ulp distance is reported as for any ulp-bounded family.
|
||||
`GoldenFixtureMatchesCSharp` applies the same check to C#'s own results.
|
||||
|
||||
`GoldenFixtureMatchesCSharp` evaluates C# on the committed fixture's own
|
||||
arguments. Arguments are drawn only when the fixture is regenerated
|
||||
(`CULTMATH_WRITE_GLSL=1`), because the draw itself calls `MathF.Pow`, whose
|
||||
bits differ by OS. `iv_exp` and `phacelle` take their C# results from the
|
||||
platform's `exp`, `sin` and `cos`, which also differ by OS, so their entries
|
||||
name the platform that generated them and the test compares them case by case
|
||||
against the running platform, printing the largest distance. `iv_exp`
|
||||
("ulp-bounded, platform exp") must agree within one ulp. `phacelle`
|
||||
("ulp-bounded, platform exp, sin, cos; across platforms |diff| <= 2^-20
|
||||
max(|v|, 1)") must agree within `2^-20 max(|v|, 1)` per component, `v` being
|
||||
the fixture's value: its sums of `exp`, `sin` and `cos` cancel near zero, where
|
||||
ulps mean nothing, and Windows differs from the Linux fixture by up to
|
||||
`3.6e-7 max(|v|, 1)`. Every other family is compared as text. `phacelle` is in
|
||||
`CultMath.Phacelle.glsl`, so the evaluator concatenates
|
||||
`CultMath.Phacelle.glsl` after `CultMath.glsl` for that family, with
|
||||
normalization 0.5. The site does not vendor the MPL file and skips the family.
|
||||
Its evaluator is the cultmath-tapes follow-up `glsl-browser-parity-run`, owned
|
||||
by the next CultMath parity cut: a repeatable CultLib WebGL2 run of every
|
||||
fixture family, `phacelle` included. Until it lands, the one GLSL evaluation of
|
||||
`phacelle` is a single probe (GTX 1070, ANGLE D3D11: within `3.1e-6 max(|v|, 1)`
|
||||
of C#). Asura's `gpu-snoise-parity` is a different check, an FXC and Unity
|
||||
readback of `snoise`, and evaluates no GLSL.
|
||||
|
||||
## Invariant 8: Value-and-Gradient Primitives
|
||||
|
||||
A primitive tagged invariant 8 returns its value and analytic gradient
|
||||
together, laid out as `float4(gradient.xyz, value.w)`, with no value-only twin
|
||||
(a value-only form would be a second path with no consumer). `smin_grad` and
|
||||
`cellular` are the first primitives in this family:
|
||||
|
||||
- `smin_grad(float4 a, float4 b, float k)` is Inigo Quilez's quadratic-polynomial
|
||||
smooth minimum ("smooth minimum",
|
||||
<https://iquilezles.org/articles/smin/>), carried to value-and-gradient
|
||||
form. Its blend factor `h` is affine in `b.w - a.w`, so differentiating the
|
||||
value with respect to position, the terms carrying `dh/dp` cancel exactly;
|
||||
the surviving gradient is `lerp(∇b, ∇a, h)`, the same `h` the value uses.
|
||||
This is the analytic gradient, not an approximation, and it is continuous
|
||||
across the `|a.w - b.w| = k` seam where `h` saturates to 0 or 1.
|
||||
- `cellular(float3 p)` is Worley's cellular texture basis function ("A
|
||||
Cellular Texture Basis Function", SIGGRAPH 1996), searched over the
|
||||
jittered 5×5×5 neighbourhood of feature points selected by `pcg3d` (never
|
||||
the sin-based `hash`), with cells whose position-only lower bound already
|
||||
exceeds the running F2 skipped before their hash is even computed. It
|
||||
returns `CultCellular { nearest, edge, id }`: `nearest` is `(∇F1, F1)`,
|
||||
`edge` is `(∇F2 - ∇F1, F2 - F1)`, and `id` is the nearest cell's `pcg4d`
|
||||
hash (over the cell coordinate alone, never the `pcg3d` hash that produced
|
||||
its jitter) mapped to `[0, 1)`. `∇F1` and `∇F2` are each undefined exactly
|
||||
at their own feature point (`F1 = 0` or `F2 = 0`); `cellular` follows the
|
||||
repo's existing degenerate-normal convention
|
||||
(`GameCult.Geometry.CultGeometryIsoSurface.EmitOrientedTriangle`, which
|
||||
guards a zero-length normal and returns the zero vector instead of the NaN
|
||||
a bare `normalize` gives there) and returns the zero vector rather than NaN
|
||||
for either. `F1 = 0` is ordinary (`p` sits on a feature point); `F2 = 0`
|
||||
needs two distinct cells' `pcg3d`-jittered feature points to land on the
|
||||
same float32 value in every component, which is unreachable at ordinary
|
||||
magnitudes but confirmed reachable (see "Precision domain" below). The
|
||||
identity of the nearest and second-nearest feature point changes
|
||||
discontinuously across the `F1 = F2` set (a genuine kink, not a numerical
|
||||
artifact), so `∇F1` and `∇F2` are only continuous away from that set.
|
||||
|
||||
The 5×5×5 radius is exact, not merely safer than the classic 3×3×3 radius:
|
||||
for a query at position `u` inside its own cell (`u` in `[0, 1)^3` per
|
||||
component) and a candidate feature at integer cell offset `d` with jitter
|
||||
`j` in `[0, 1)^3`, the per-axis displacement `(u_i - j_i) - d_i` is always
|
||||
strictly greater in magnitude than `max(0, |d_i| - 1)`, because
|
||||
`u_i - j_i` is always strictly inside `(-1, 1)`. So any cell with `|d_i| >=
|
||||
3` on some axis is strictly farther than 2 from the query. Two real,
|
||||
always-present candidates bound `F1` and `F2`: the query's own cell, whose
|
||||
distance is always strictly less than `sqrt(3)` (every axis strictly under
|
||||
1); and the near-side neighbour on whichever single axis has `u_i` closest
|
||||
to a cell boundary. Let `m = min(u_i, 1 - u_i)` on that chosen axis, so
|
||||
`m` is in `[0, 1/2]` by construction. Stepping one cell towards that
|
||||
nearer boundary puts the query within `1 + m` of the far face on the
|
||||
offset axis and within `1 - m` of the near face on each of the other two
|
||||
axes, so that neighbour's squared distance is at most
|
||||
`(1 + m)^2 + 2*(1 - m)^2 = 3 - 2m + 3m^2 <= 3` for every `m` in `[0, 1/2]`
|
||||
(3 at `m = 0`, dipping to a minimum of `8/3 ~= 2.667` at `m = 1/3`, then
|
||||
rising back to 2.75 at `m = 1/2`), and strictly below 3 because
|
||||
the jitter never reaches exactly 0 or 1. `F2` is at most the larger of
|
||||
these two real candidates (any two real candidates upper-bound the
|
||||
2nd-smallest value over the full infinite candidate set), so `F1 <
|
||||
sqrt(3)` and `F2 < sqrt(3)` always, comfortably under the radius-2
|
||||
exclusion distance of 2, with margin `2 - sqrt(3) ~= 0.268` (Soul's
|
||||
adversarial check, cut 2a-i: the worst realizable in-box `F2` is exactly
|
||||
`sqrt(3)`, reached only in the unattainable limit at a cell corner). 3×3×3's
|
||||
matching argument only reaches radius 1, whose excluded cells start at
|
||||
distance 1, short of the `sqrt(3)` ceiling — the gap its measured failures
|
||||
(9 of 400,000 points in `[-50, 50]^3`, worst error 0.088) live in.
|
||||
|
||||
An earlier version of this proof picked the near-side neighbour on a fixed
|
||||
axis instead of whichever axis sits closest to its own boundary, and that
|
||||
version is wrong (Soul, cut 2a-i): `u = (0.5, 0, 0)` with the x-neighbour's
|
||||
jittered feature at `(-1, 1 - 2^-24, 1 - 2^-24)` gives a distance of about
|
||||
2.06, outside the radius-2 exclusion the proof needs — `u.x = 0.5` is the
|
||||
point on the cell farthest from any boundary, so `x` was the wrong axis to
|
||||
fix there. Choosing the axis by proximity to its own boundary is what keeps
|
||||
`m <= 1/2` and the bound at `sqrt(3)`.
|
||||
|
||||
`CellularAndSminGradTests` pins both the prune's bit-identical equivalence
|
||||
to an unpruned 5×5×5 reference and the search's exact agreement with an
|
||||
independent 7×7×7 brute-force oracle.
|
||||
|
||||
Precision domain: `cellular`'s exactness above is a statement about the
|
||||
search radius, not about float32. Past `|p|` of roughly `2^23`, adjacent
|
||||
integer cells stop being distinguishable from their neighbours at float32
|
||||
precision in some directions, and distinct cells' jittered features can
|
||||
round onto the same value, making `F1 = F2` ties (and, per-component,
|
||||
`F2 = 0`) increasingly common from about `1e6` and confirmed at `2^24`
|
||||
(measured: 4096 of 4096 sampled integer points at `2^24` hit this before
|
||||
the guard above). `cellular` stays finite there; it does not stay accurate
|
||||
in the sense of matching the idealized real-valued Worley field.
|
||||
|
||||
The lower-bound prune's bit-identity to the unpruned 5×5×5 search also has
|
||||
a domain: it holds for `|p| < 2^25`. Above that, `cell + d` (an integer
|
||||
cell coordinate plus a small integer offset) starts to round in float32,
|
||||
so a pruned cell's own recomputed offset can land on a different neighbour
|
||||
than the unpruned loop visits at that same nominal `(dx, dy, dz)`, and the
|
||||
two search paths diverge (Soul, cut 2a-i: measured 180 of 1,100,000 points
|
||||
differ at `|p| ~= 3.4e7`). This is a float32-precision limit on the prune's
|
||||
equivalence proof, not a bug in the prune itself: below `2^25` the two
|
||||
paths are proven bit-identical (`PrunedSearchIsBitIdenticalToTheUnprunedFiveCubedSearch`,
|
||||
which samples `[-50, 50]^3`, is comfortably inside that domain).
|
||||
|
||||
- `snoise(float3)` follows webgl-noise `src/noise3D.glsl` as of
|
||||
stegu/webgl-noise `22434e04d7`: kernel radius squared `0.5`, output scale
|
||||
`105`, permutation `mod289((34x+10)x)`. Upstream's `21d9fe23d7` (2020-10-14,
|
||||
"an age-old bug that caused slight discontinuities along simplex
|
||||
boundaries") moved the radius from `0.6` to `0.5`; at `0.6` a lattice vertex
|
||||
outside the four summed corners can lie inside the kernel, leaving a value
|
||||
jump at the cell boundary (3.1e-3 over the committed probe's 400 lines). `ff3b5d34ea` set the scale
|
||||
to `105` and `a3e6d57095` (2021-06-30) the permutation; the 2D `snoise` shares
|
||||
that permutation helper, so its values changed too, while its own kernel
|
||||
(`0.5`) and scale (`130`) already matched upstream.
|
||||
`NoiseGradTests.SnoiseAndItsGradientAreContinuousAcrossSimplexCellBoundaries`
|
||||
pins the continuity.
|
||||
- `snoise_grad(float3 p)` carries `snoise(float3)` to value-and-gradient form,
|
||||
following the differentiation in webgl-noise `src/noise3Dgrad.glsl` (Ashima
|
||||
Arts / Ian McEwan, MIT), which uses the same radius, permutation and scale as
|
||||
`snoise(float3)`: per corner, `m0 = max(0.5 - dot(x,x), 0)`, so
|
||||
`d(m0^4 * dot(p,x))/dx = -8*m0^3*dot(p,x)*x + m0^4*p`, summed over the four
|
||||
corners and scaled by the same constant as the value, so `.w` is exactly
|
||||
`snoise(float3)`. `fbm_grad` and
|
||||
`ridged_grad` are octave sums of `snoise_grad`: each octave samples at
|
||||
`p * frequency`, so by the chain rule its gradient scales by that same
|
||||
frequency, and both amplitude (`gain`) and frequency (`lacunarity`) compound
|
||||
per octave rather than applying once to the whole sum. `ridged_grad` folds
|
||||
each octave about zero (`Σ aᵢ(1 − |nᵢ|)`, gradient `−Σ aᵢfᵢ sign(nᵢ)∇nᵢ`,
|
||||
Musgrave's ridged multifractal, *Texturing and Modeling* ch. 16); the crease
|
||||
at `nᵢ = 0` is deliberate (the operator's dune term, 2026-09-25), not a bug.
|
||||
Both clamp `octaves` to `[0, 16]`: `HlslSourceCompatibilityTests`'s bit-parity
|
||||
oracle drives every mirrored `int` parameter across the full int32 range,
|
||||
which is the right domain for `pcg3d`/`pcg4d`'s O(1) hash inputs but would
|
||||
turn an unclamped octave count into a multi-billion-iteration loop for these
|
||||
two mirrors.
|
||||
- `phacelle(float3 p, float3 side, float offset, float normalization)` is Rune
|
||||
Skovbo Johansen's Phacelle noise ("phase + cell": a stripe pattern built by
|
||||
blending one cosine/sine wave per jittered cell), generalised to 3D cells. It
|
||||
returns `CultPhasor { cos, sin }`, each `(∇, value)`. `side` is the stripe
|
||||
wave vector, whose length is 2π times the stripe frequency; in 3D the
|
||||
perpendicular to the flow is not unique, so the caller chooses it (on a unit
|
||||
sphere, `cross(p̂, flow)`). `offset` is the phase in cycles. It visits 4×4×4
|
||||
cells with `pcg3d`-hashed jitter in `[-0.5, 0.5)`, weights each wave by
|
||||
`max(0, exp(-2d²) - 0.01111)`, and normalizes the blended phasor as upstream
|
||||
does (length 1 where the raw length is at least `1 - normalization`, scaled
|
||||
by `1 / (1 - normalization)` below). The gradient is exact, weight
|
||||
derivatives and normalization included, rather than upstream's
|
||||
`∇cos ≈ -sin·side`, which ignores the weight gradient. It is discontinuous
|
||||
where the raw length crosses the normalization threshold and where a cell's
|
||||
weight leaves its support (d² = 2.24995, distance 1.49998); the value is
|
||||
continuous at both. At the support edge `∇w` jumps from `-4·0.01111·v` to 0,
|
||||
a per-cell jump of length `0.0444·|v|` (0.0667 at `|v| = 1.5`); measured over
|
||||
random points, the output gradient jumps by a median 1.5% of its length and
|
||||
at most about 27%. `normalization` is meant to lie in `[0, 1]`: 1 or more
|
||||
saturates (every output is stretched to length 1), a negative value scales
|
||||
every output down by `1 / (1 - normalization)`, and the normalized gradient
|
||||
scales as `1 / |I|` for the blended phasor `I`, so it grows where the blend
|
||||
nearly cancels. The total weight `W` is positive for every `pcg3d` input (the
|
||||
smallest found by hill-climbing is 0.056); `W = 0`, and a NaN output, needs
|
||||
adversarial jitter no hash yields.
|
||||
Cells whose per-axis lower bound `Σ max(0, |local - g| - 0.5)²` is at least
|
||||
2.25 lie outside the support and have weight exactly 0, so they are skipped,
|
||||
which changes no output bit. Cells whose bound sits just under 2.25 can still
|
||||
carry a nonzero weight, so the cut cannot be lowered; `PhacelleTests` pins
|
||||
the cut with points next to such cells, against an internal overload with the
|
||||
prune disabled, and the HLSL mirror test pins it on the shader side. The prune
|
||||
keeps about 45 of the 64 cells on average (a saving of about 30%); about 14
|
||||
have a nonzero weight. The port sits in its own MPL-2.0 files
|
||||
(`math.Phacelle.cs`, `CultPhasor.cs`, `CultMath.Phacelle.hlsl`); see
|
||||
`THIRD-PARTY-NOTICES.md`. No 2D variant exists; nothing consumes one.
|
||||
|
||||
Integer hashing uses the PCG hashes from Jarzynski and Olano, "Hash Functions
|
||||
for GPU Rendering" (JCGT 9(3), 2020): `pcg(uint)` is O'Neill's RXS-M-XS 32/32
|
||||
permutation over one LCG step, and `pcg3d`/`pcg4d` are the paper's (3 → 3) and
|
||||
(4 → 4) hashes. They use only uint multiply, add, xor, and shifts. dxc wraps
|
||||
uint multiply and add modulo 2^32 exactly as C# unchecked arithmetic does, so
|
||||
they are integer-exact in HLSL and on the CPU, GPU included, unlike the
|
||||
`sin`-based `hash`. CultMath has no uint vectors, so `pcg3d(int3)` and
|
||||
`pcg4d(int4)` carry uint bit patterns in int components. The `float2`/`float3`/`float4`
|
||||
overloads hash the IEEE-754 bits (`asuint`), so -0 and 0 hash differently; they
|
||||
exist for callers that already hold a hash key as float bits, and `pcg3d(float2)`
|
||||
holds z at 0, which is the paper's route for (2 → N) inputs. Seeds and other
|
||||
scalar uses take one output component.
|
||||
|
||||
`cellular`'s jitter and id always hash the integer cell coordinate directly
|
||||
(`pcg3d(int3(neighbor))`, `pcg4d(int4(int3(cellId), 0))`), never a cell
|
||||
coordinate's float32 bit pattern. Hashing the bit pattern of an
|
||||
integer-valued float is not the same permutation as hashing the integer: two
|
||||
adjacent cells' bit patterns share more structure (both have the same
|
||||
exponent and a mantissa that differs by one increment) than two adjacent
|
||||
integers do going into `pcg3d`'s own mixing, and that residual structure
|
||||
survived into the jitter. Hashing the integer cell coordinate directly is
|
||||
exact and runtime-independent, and it is the HLSL mirror's own hash, so C#
|
||||
and shader agree bit-for-bit. What pins this is the oracle and mirror tests,
|
||||
not a uniformity measurement: reverting to hashing the float bits (or the
|
||||
sin-based hash) is caught by `ProductionSearchMatchesTheBruteForceOracleExactly`,
|
||||
`EveryMirrorFunctionMatchesCSharpMath`, `F1NeverExceedsF2`, and the other
|
||||
tests that key off the integer-hash fixtures (cut 2a-i). `CellularAndSminGradTests`
|
||||
(`JitterAndIdPassAChiSquareUniformityTest`) checks the uniformity of the hash
|
||||
as `cellular` uses it: the jitter marginals and the id pass a chi-square test
|
||||
at a stated significance against `pcg3d(int3(...))`.
|
||||
|
||||
Where HLSL is silent, CultMath keeps its own decisions and does not defer to
|
||||
Unity.Mathematics: `hash` returns float, and `Random` is CultMath's own xorshift32. Engine-shaped
|
||||
Unity.Mathematics: `normalize` is safe (divides by `max(length, 1e-20)`),
|
||||
`hash` returns float, and `Random` is CultMath's own xorshift32. Engine-shaped
|
||||
helpers that HLSL lacks (`float2x2.Rotate`, `float3x3.Euler`, `quaternion`,
|
||||
`snoise`) enter only when a consumer needs them, with their semantics stated in
|
||||
code.
|
||||
|
|
@ -453,235 +114,6 @@ Known C# friction: under `using static CultMath.math;` the constructor
|
|||
functions (`float3(...)`, `float3x3(...)`) hide the type names in member access,
|
||||
so static members need a qualified type (`CultMath.float3x3.Euler(...)`).
|
||||
|
||||
## Intervals
|
||||
|
||||
`math.Interval.cs` and `shaders/CultMath.Interval.hlsl` (included from
|
||||
`CultMath.hlsl` after the Phacelle include) carry interval arithmetic for
|
||||
culling empty space with a proof. Their invariant, **intervals-enclose**: every
|
||||
interval function returns a `[lo, hi]` that contains `f(x)` for every `x` in
|
||||
its input region, where `f(x)` is the same function evaluated pointwise in
|
||||
float32. A consumer that skips a region because `hi < cutoff` has skipped
|
||||
nothing that was there.
|
||||
|
||||
Representation: an interval is `float2(lo, hi)` with `lo <= hi`, both finite.
|
||||
There is no empty interval; a caller that needs "provably empty" tests
|
||||
`hi < cutoff`. `iv_point(x)` is `float2(x, x)`.
|
||||
|
||||
Ops: `iv_point`, `iv_add`, `iv_sub`, `iv_neg`, `iv_mul` (the least and greatest
|
||||
of the four corner products), `iv_scale(iv, float)`, `iv_abs` and `iv_sqr`
|
||||
(an interval straddling zero starts at 0), `iv_min`, `iv_max`, `iv_sqrt`
|
||||
(`lo` clamped at 0), `iv_exp`, `iv_clamp(iv, float, float)`, `iv_saturate`,
|
||||
`iv_smoothstep(float, float, iv)` (its ordered endpoints) and
|
||||
`iv_lerp(iv, iv, float)`. No others until a consumer names one. Each has an
|
||||
HLSL mirror named `cultmath_iv_*`, compared bit for bit by
|
||||
`HlslSourceCompatibilityTests`.
|
||||
|
||||
The ulp-widening rule. Each bound is computed with the pointwise function's
|
||||
own float32 operations. IEEE round-to-nearest is monotone, so an op that is
|
||||
monotone on its region encloses its float32 evaluation exactly, with no
|
||||
widening; that covers every op above except two, which widen by exactly one
|
||||
ulp, in the implementation and never in the test:
|
||||
|
||||
- `iv_exp`: `exp` is not required to be correctly rounded, so a libm need not
|
||||
be monotone. .NET's `MathF.Exp` on Linux steps down 0 times over every float
|
||||
in [-87, 88.7] (`IntervalTests.MeasureMonotonicity`), so on that CPU the
|
||||
widening is a margin; it is there for other libms and GPUs.
|
||||
- `iv_smoothstep`: `t * t * (3 - 2t)` in float32 steps down 337,095 times over
|
||||
the floats in [0, 1], each time by at most 1 ulp (`MeasureMonotonicity`).
|
||||
Since `t = saturate((x - minimum) / (maximum - minimum))` is itself monotone
|
||||
in `x`, that covers every `smoothstep`.
|
||||
|
||||
`iv_lerp` is the natural interval extension of `x + (y - x) * amount`, which is
|
||||
how `lerp` evaluates; every step is monotone, so it encloses with no widening
|
||||
for any `amount`, and it is tight only when `a` is a point, because `x` appears
|
||||
twice.
|
||||
|
||||
`IntervalTests` runs every op over 10,000 seeded intervals (centres in
|
||||
[-20, 20], widths log-uniform in [1e-4, 10], every eighth a point) and 16
|
||||
points inside each, endpoints and zero included, with no tolerance; and for
|
||||
every op it raises `lo` and lowers `hi` by one ulp and requires the harness to
|
||||
catch each. A new op is one entry in that table.
|
||||
|
||||
Noise bounds. `iv_snoise_ball(c, r)` is `[n - L r, n + L r]` intersected with
|
||||
`[-1, 1]`, where `n = snoise(c)` and `L = SNOISE_LIPSCHITZ`: one `snoise`, value
|
||||
only. No interval function reads `snoise_grad`, and none has a value-only or
|
||||
gradient twin. `iv_fbm_ball(c, r, octaves, lacunarity, gain)` is the octave sum
|
||||
of `iv_snoise_ball(c * f_i, r * |f_i|)` scaled by `a_i`, compounding frequency
|
||||
and amplitude exactly as `fbm_grad` does, octaves clamped to [0, 16]. A domain
|
||||
warp that moves a point by at most `D` is enclosed by the radius `r + D`
|
||||
(`NoiseBoundTests.WarpedPointsStayEnclosed`); this is what the gamecult.org
|
||||
ground's march rests on.
|
||||
|
||||
`iv_frustum_ball(m_c, z0, z1, footprintPerDepth, warp)` is the ball one probe
|
||||
serves a whole screen tile with. In a pinhole camera frame looking down `+z`,
|
||||
where the ray of slope `m` is the points `(m z, z)`, it encloses every point of
|
||||
every ray whose slope lies within `footprintPerDepth` of `m_c` (`N / (sqrt(2) f)`
|
||||
for an `N x N` tile at focal length `f` pixels: the full pixel footprint, so
|
||||
sub-pixel jitter is covered), over depths `[z0, z1]`, each point moved by a warp
|
||||
of length at most `warp`. The centre is `(m_c z_m, z_m)`, `z_m` the mid-depth;
|
||||
the radius is `((z1 - z0) / 2) |(m_c, 1)| + z1 footprintPerDepth + warp`. A point
|
||||
of slope `m_c + e` at depth `z` is the centre plus `(z - z_m)(m_c, 1)` plus
|
||||
`z (e, 0)`. The returned ball is float32, and on a degenerate (`z0 == z1`),
|
||||
thin or far segment the rounding of the centre alone exceeds the gap the
|
||||
triangle inequality leaves, so the radius is widened by
|
||||
`(|c|_1 + radius) 2^-20`. With `u = 2^-24`, each centre component carries at most
|
||||
two roundings (`z_m`, then the product), so the float centre is within
|
||||
`2u |c|_1` of the exact one; the radius carries at most seven (`z1 - z0`, the
|
||||
axis length at `3u`, the product, `z1 footprintPerDepth`, the sum, `+ warp`), so
|
||||
the exact radius is at most `(1 + 7u)` times the float one; the widening rounds
|
||||
once more. `2^-20 = 16u` covers `8u (|c|_1 + radius)` twice over, and fma
|
||||
contraction only removes roundings. The axis count assumes a correctly rounded
|
||||
`sqrt`, which IEEE gives C#. GLSL ES 3.00 does not: it inherits `sqrt`'s
|
||||
precision from `inversesqrt`, and D3D-backed compilers may reassociate. So the
|
||||
bound is proven for C# and HLSL on IEEE hardware, and on GLSL it is measured:
|
||||
on a GTX 1070 under ANGLE's D3D11 backend the fixture's 256 cases keep a
|
||||
minimum relative slack of `1.98e-6` (about `33u`), the same as C#'s. The
|
||||
fixture's enclosure check is what measures it on each device. The caller rotates and translates the centre into world space and scales
|
||||
centre and radius by its noise frequency; the rounding of that transform is the
|
||||
caller's. `NoiseBoundTests.TileBallEnclosesEveryRaySegment` checks 2,000 seeded
|
||||
tiles x 64 points of the r2 domain and 2,000 tiles x 8 corner points of each of
|
||||
seven extreme families (`z0 == z1`, `z1 - z0 = 1e-6 z1`, `|m_c|` to 1000, `z` to
|
||||
`1e7`, `z0 = 0` with `z1` to `1e-3`, `f` down to 1, warp to `1e6`), corner flows
|
||||
full-length and outward, distances in double, with no tolerance. It pins the ball
|
||||
to the derivation both ways (`r <= ball.w <= r + 2^-19 (r + |c|_1)`), so a ball
|
||||
that forgets the widening fails and one grown past it fails too. A moving camera needs nothing
|
||||
more: each frame's probes use that frame's camera, and no ball, mask or probe
|
||||
result is carried from one frame to the next.
|
||||
|
||||
The noise ball alone proves little: at `L` near 10 it bounds `snoise` to a width
|
||||
of `2 L r`, which covers all of `[-1, 1]` once `L r` reaches 1. Empty space
|
||||
becomes provable through an analytic envelope. The consumer's density is a
|
||||
pre-distortion SDF (a height fog, a well map, a carved sphere) that noise
|
||||
displaces only near its surface, and the consumer composes the bound from `iv_*`
|
||||
ops. The envelope is taken over the slice's exact box (`iv_mul` of the depth
|
||||
range by each slope range, then the SDF's own monotone pieces), together with its
|
||||
fade. The noise is `iv_snoise_ball` over `iv_frustum_ball`, scaled by the noise
|
||||
frequency. The warp moves only the noise argument, so it enlarges the ball and
|
||||
the envelope's box takes none of it. Where the fade's upper end is 0 the noise
|
||||
term is identically 0 and no `snoise` is evaluated. Where the envelope already
|
||||
proves the slice empty with the noise at its full range, the ball is not
|
||||
evaluated either. A probe is worth attempting only when the best result any
|
||||
centre value could give would prove the slice, and a ball with `L r >= 2` is
|
||||
`[-1, 1]` whatever `snoise(c)` is, so it costs nothing. CultMath owns none of the
|
||||
envelope: the interval files have no envelope function, and each consumer composes
|
||||
its own. `NoiseBoundTests.EnvelopeBoundEnclosesDensity` checks that composition,
|
||||
the one the site and Aetheria use, over 2,000 seeded slices x 64 warped points
|
||||
with no tolerance.
|
||||
|
||||
`L` has provenance, not a proof. `NoiseBoundTests.MeasureLipschitz` (slow,
|
||||
explicit) takes the largest `|snoise_grad|` over 1e6 seeded points in
|
||||
[-256, 256]^3 (7.1933103), refines the 1e4 largest by gradient ascent
|
||||
(9.181147), and multiplies by 1.10: `L = 10.099261`. It also reaches
|
||||
`|snoise| = 0.9718335` by the same ascent on the value, which is what lets the
|
||||
bound intersect with `[-1, 1]`. `LipschitzConstantPinsSampledGradients`
|
||||
checks 1e5 more seeded points against `L` and climbs again from the start
|
||||
`MeasureLipschitz` prints as its witness, failing if `L` is not 1.10 times that
|
||||
maximum to within half a percent, so a 1% move of `L` fails. The enclosure tests
|
||||
over 2,000 balls x 64 points are the defence. A change to the `snoise` kernel
|
||||
must re-run `MeasureLipschitz` and re-pin `L` and the witness.
|
||||
|
||||
What the ball bounds guarantee: enclosure of `snoise` and `fbm_grad(...).w` over
|
||||
the ball, in float32. What they do not: anything about tapes, pruning, choice
|
||||
tracking or affine forms; those are later steps of
|
||||
`docs/cultmath-tape-target.md` (CultLib root). How loose they are is a number:
|
||||
`NoiseBoundTests.TightnessReport` prints the mean interval width over the
|
||||
sampled range: 5.08 for `iv_snoise_ball` at `r = 0.05`, 1.96 at 0.25 and 1.28
|
||||
at 1.0; 6.64 and 2.82 for 4-octave `iv_fbm_ball` at 0.05 and 0.25.
|
||||
|
||||
The saving is measured where there is empty space to find.
|
||||
`NoiseBoundTests.IntervalSkipHalvesEvaluations` draws screen tiles, each with
|
||||
its own camera, and runs one pre-pass per tile over the dense grid's cell ranges:
|
||||
it starts with the whole grid, skips and doubles a range it proves empty, halves
|
||||
one it cannot, and runs to the grid's end. It then draws the tile's `N^2` ray
|
||||
slopes, one per pixel, jittered inside it. Each ray is marched three times with
|
||||
the same integrator and the same early-out at transmittance 0.02: over every
|
||||
cell (dense), over the tile's unproven cells (the tile march), and over the
|
||||
oracle mask, the cells whose mid-depth density is nonzero for at least one of
|
||||
the tile's rays. The tile march and the oracle march integrate the dense march's
|
||||
nonzero cells, so their transmittance agrees with it exactly in every
|
||||
configuration. The oracle is a ceiling the pre-pass cannot move: dense cost over
|
||||
oracle cost. Against it the test prints the efficiency (oracle cost over the
|
||||
tile march's cost with its probes) and the cull fraction (the oracle-empty cells
|
||||
the pre-pass proves empty). The probe overhead is the tile march's cost over that
|
||||
cost plus the probes'. Cost is counted in `snoise` evaluations, with an envelope
|
||||
evaluation or probe weighted 0.2. That weight is an estimate, and both counts are
|
||||
printed. The flow is not counted. At `N = 8`, in Aetheria's units on its
|
||||
256-cell quadratic grid out to the Main Camera's far plane, 2048:
|
||||
|
||||
- (a) Height fog, camera above the safety band, rays level and up: the whole
|
||||
grid is one envelope probe per tile, against 256 envelope evaluations per ray,
|
||||
16384x. Every cell is oracle-empty and the pre-pass proves them all.
|
||||
- (b) Inside the fog: 1.15x, and 0.99x with the warp. Most cells are fog, and
|
||||
the pre-pass probes each one.
|
||||
- (c) The zone bowl and four wells, the camera gazing across. With no warp:
|
||||
2.74x, oracle ceiling 7.26x, efficiency 0.378, cull fraction 0.952, probe
|
||||
overhead 0.961. With the warp `D = 60`: 1.39x, oracle ceiling 7.17x,
|
||||
efficiency 0.194, cull fraction 0.842, probe overhead 0.993. With the warp the
|
||||
pre-pass cuts envelope evaluations from 177.46 to 33.49 per ray, but the
|
||||
oracle march costs 0.194 of the tile march, so about four fifths of the tile
|
||||
march's cost goes to cells no ray samples nonzero: the warp bound swamps the
|
||||
noise ball, so the ball cannot prove those cells empty. That gap belongs to
|
||||
the tape target's affine forms, not to a better pre-pass.
|
||||
- (d) r1's uniform slab, which has no envelope: 1.23x, and 1.48x with no warp.
|
||||
- (e) The void at the shipped point (ruling `operator-shipped-void`): `Rh = 198`,
|
||||
`S = 50`, `Rc = Rh + S = 248`, `e = -ln 1.5 / ln(1 - (Rh / Rc)^2)`, the
|
||||
shipped camera, 200 tiles. The fixed-step reference takes 36.34 steps per
|
||||
pixel. The same cells masked by the pre-pass take 20.13, with identical
|
||||
transmittance. The footprint-aware march takes 13.54: 2.68x fewer. `snoise`
|
||||
per pixel goes from 32.98 to 26.69, and combined cost from 40.25 to 29.52 with
|
||||
the probes, 1.36x. Every one of the 200 tiles proves a body. Before the body
|
||||
both marches are measured against a converged one (midpoint quadrature at
|
||||
`h = 0.5` over every cell, trusting no pre-pass): the relative optical-depth
|
||||
error at the body start is at most 0.0608 for the fixed-step march and 0.08639
|
||||
for the footprint march, 1.42x. Over the grid of hollow radius, camera offset
|
||||
and ramp width the steps ratio runs from 1.57x (`Rh = 100`, low, `S = 150`) to
|
||||
27.45x (`Rh = 1000`, centred, `S = 10`), the cost ratio from 1.26x to 3.89x,
|
||||
and the depth-error ratio from 0.85 to 4.72 (`Rh = 200`, centred, `S = 150`,
|
||||
where the errors are 0.01504 and 0.003188). LOD-far takes 14.19x fewer steps;
|
||||
its depth errors, 0.2849 and 0.205, are not comparable, because its
|
||||
fixed-step reference keeps the octaves the footprint march truncates.
|
||||
|
||||
The analytic body (ruling `operator-analytic-wall-body`). When a single dense
|
||||
cell's own bound is a positive point, the pre-pass spends one more probe over
|
||||
the rest of the grid. If that bound is a positive point too, every point of
|
||||
every ray of the tile from there on has exactly that density, so the footprint
|
||||
march takes one closed-form step, `exp(-BodyDepth)`, from the body start to the
|
||||
grid's end and stops. `BodyDepth` is density x extinction x length x `|dir|`.
|
||||
`NoiseBoundTests.AnalyticBodyMatchesFineMarch` pins it: the bound, the mask and
|
||||
the pointwise densities agree exactly at the body, and `BodyDepth` equals the
|
||||
summed fixed-step depth of the body cells within 1e-4 relative. The comparison
|
||||
is in the depth domain, so LOD-far's deep bodies do not underflow.
|
||||
|
||||
The footprint-truncated void weights its octaves by `w_i(z)`, decreasing in `z`.
|
||||
Its bound takes each weight over `[w_i(z1), 1]` rather than
|
||||
`[w_i(z1), w_i(z0)]`, because one bound serves both the full field (`w = 1`) and
|
||||
the truncated one. `[w_i(z1), 1]` contains both, and `iv_mul` is monotone in its
|
||||
interval argument, so the composed interval encloses both fields.
|
||||
`NoiseBoundTests.WeightBoundEnclosesOneSignedNoise` checks it on LOD-far over
|
||||
2,000 segments: each weight lies in its bound, and `w(z) n` lies in
|
||||
`iv_mul(bound, n)` for a one-signed `n`. A lower end of `w(z0)` fails it.
|
||||
|
||||
The contracts, at `N = 8`, the lower of warp 0 and `D`:
|
||||
|
||||
- (a) at least 2x combined cost;
|
||||
- (c) (ruling `wells-overhead-plus-floor`): probe overhead, the tile march over
|
||||
the tile march plus its probes, at least 0.90; and, hard, the pre-pass proves
|
||||
at least half of the oracle-empty cells empty, so a pre-pass that skips
|
||||
nothing fails. The oracle ceiling, the efficiency against it and the cull
|
||||
fraction are printed, not asserted;
|
||||
- (e) at the shipped void: the masked fixed-step march agrees with the reference
|
||||
exactly; the footprint march's optical depth at the body start is off the
|
||||
converged march's by at most twice the fixed-step march's error; and it takes
|
||||
at most half the reference's steps per pixel.
|
||||
|
||||
This run meets every contract: (a) 16384x, (c) probe overhead 0.961 and cull
|
||||
fraction 0.842, (e) 2.68x fewer steps and a depth error 1.42x the fixed-step
|
||||
march's. The fields, the grids and the 0.2 weight are not tuned toward the
|
||||
contracts.
|
||||
|
||||
Consumers: the gamecult.org ground shader (through the GLSL lowering) and
|
||||
Aetheria's nebula raymarch at its CultMath pin bump.
|
||||
|
||||
## Rules
|
||||
|
||||
- Keep type names HLSL-shaped: `float2`, `float3`, `float4`, `float3x3`, and `math`.
|
||||
|
|
|
|||
|
|
@ -41,27 +41,6 @@ foreach ($path in @($freshAssembly, $freshSymbols)) {
|
|||
}
|
||||
}
|
||||
|
||||
# docs/semver-policy.md (CultLib root, two levels up from this script): refuse a release whose version
|
||||
# does not tell the truth. The check measures the freshly built CultMath.dll's public API against the
|
||||
# DLL the previous cultmath-unity tag tracked (read from git), so it runs once the build exists.
|
||||
# cultmath is pre-1.0 (0.y.z): a break 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
|
||||
# The suite pins the checker's rules and the declared tag prefixes, so it runs first.
|
||||
& node --test (Join-Path $cultLibRoot "scripts\check-changelog-semver.test.mjs")
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "The release check suite failed (scripts/check-changelog-semver.test.mjs); not releasing."
|
||||
}
|
||||
& node (Join-Path $cultLibRoot "scripts\check-changelog-semver.mjs") `
|
||||
--package "org.gamecult.cultmath" `
|
||||
--version $cultmathVersion `
|
||||
--cwd $cultLibRoot `
|
||||
--api-built $freshAssembly
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "CultMath Unity package failed the semver policy check (see docs/semver-policy.md)."
|
||||
}
|
||||
|
||||
$requiredTemplateFiles = @(
|
||||
"LICENSE.md",
|
||||
"LICENSE.md.meta",
|
||||
|
|
@ -80,11 +59,7 @@ $requiredTemplateFiles = @(
|
|||
"Runtime\UnityBridge\UnityConversions.cs",
|
||||
"Runtime\UnityBridge\UnityConversions.cs.meta",
|
||||
"Shaders\CultMath.hlsl",
|
||||
"Shaders\CultMath.hlsl.meta",
|
||||
"Shaders\CultMath.Phacelle.hlsl",
|
||||
"Shaders\CultMath.Phacelle.hlsl.meta",
|
||||
"Shaders\CultMath.Interval.hlsl",
|
||||
"Shaders\CultMath.Interval.hlsl.meta"
|
||||
"Shaders\CultMath.hlsl.meta"
|
||||
)
|
||||
foreach ($relativePath in $requiredTemplateFiles) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $templateRoot $relativePath) -PathType Leaf)) {
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
#ifndef CULTMATH_INTERVAL_HLSL
|
||||
#define CULTMATH_INTERVAL_HLSL
|
||||
|
||||
// Interval arithmetic over float2(lo, hi), lo <= hi, both finite. Every cultmath_iv_* function returns
|
||||
// an interval containing f(x) for every x in its input intervals, mirroring math.Interval.cs bit for
|
||||
// bit; see that file and docs/design.md ("Intervals") for each op's enclosure and its ulp widening.
|
||||
// Included by CultMath.hlsl after its own definitions of cultmath_smoothstep and cultmath_snoise.
|
||||
|
||||
// Lipschitz constant of cultmath_snoise(float3); provenance in math.Interval.cs (SNOISE_LIPSCHITZ).
|
||||
static const float CULTMATH_SNOISE_LIPSCHITZ = 10.099261;
|
||||
|
||||
float2 cultmath_iv_point(float value) { return float2(value, value); }
|
||||
float2 cultmath_iv_add(float2 a, float2 b) { return float2(a.x + b.x, a.y + b.y); }
|
||||
float2 cultmath_iv_sub(float2 a, float2 b) { return float2(a.x - b.y, a.y - b.x); }
|
||||
float2 cultmath_iv_neg(float2 a) { return float2(-a.y, -a.x); }
|
||||
|
||||
float2 cultmath_iv_mul(float2 a, float2 b)
|
||||
{
|
||||
float p0 = a.x * b.x;
|
||||
float p1 = a.x * b.y;
|
||||
float p2 = a.y * b.x;
|
||||
float p3 = a.y * b.y;
|
||||
return float2(min(min(p0, p1), min(p2, p3)), max(max(p0, p1), max(p2, p3)));
|
||||
}
|
||||
|
||||
float2 cultmath_iv_scale(float2 a, float s) { return s >= 0.0 ? float2(a.x * s, a.y * s) : float2(a.y * s, a.x * s); }
|
||||
|
||||
float2 cultmath_iv_abs(float2 a)
|
||||
{
|
||||
if (a.x >= 0.0)
|
||||
return a;
|
||||
if (a.y <= 0.0)
|
||||
return float2(-a.y, -a.x);
|
||||
return float2(0.0, max(-a.x, a.y));
|
||||
}
|
||||
|
||||
float2 cultmath_iv_min(float2 a, float2 b) { return float2(min(a.x, b.x), min(a.y, b.y)); }
|
||||
float2 cultmath_iv_max(float2 a, float2 b) { return float2(max(a.x, b.x), max(a.y, b.y)); }
|
||||
|
||||
float2 cultmath_iv_sqr(float2 a)
|
||||
{
|
||||
float l = a.x * a.x;
|
||||
float h = a.y * a.y;
|
||||
if (a.x >= 0.0)
|
||||
return float2(l, h);
|
||||
if (a.y <= 0.0)
|
||||
return float2(h, l);
|
||||
return float2(0.0, max(l, h));
|
||||
}
|
||||
|
||||
float2 cultmath_iv_sqrt(float2 a) { return float2(sqrt(max(a.x, 0.0)), sqrt(max(a.y, 0.0))); }
|
||||
|
||||
// Widened by one ulp on each side: exp is not required to be correctly rounded.
|
||||
float2 cultmath_iv_exp(float2 a)
|
||||
{
|
||||
float lo = exp(a.x);
|
||||
float hi = exp(a.y);
|
||||
return float2(lo > 0.0 ? asfloat(asuint(lo) - 1u) : 0.0, asfloat(asuint(hi) + 1u));
|
||||
}
|
||||
|
||||
float2 cultmath_iv_clamp(float2 a, float minimum, float maximum)
|
||||
{
|
||||
return float2(cultmath_clamp(a.x, minimum, maximum), cultmath_clamp(a.y, minimum, maximum));
|
||||
}
|
||||
|
||||
float2 cultmath_iv_saturate(float2 a) { return float2(cultmath_saturate(a.x), cultmath_saturate(a.y)); }
|
||||
|
||||
// The endpoints, ordered, widened by one ulp inside [0, 1]: the float32 evaluation of smoothstep is
|
||||
// monotone only to within one ulp.
|
||||
float2 cultmath_iv_smoothstep(float minimum, float maximum, float2 a)
|
||||
{
|
||||
float s0 = cultmath_smoothstep(minimum, maximum, a.x);
|
||||
float s1 = cultmath_smoothstep(minimum, maximum, a.y);
|
||||
float lo = min(s0, s1);
|
||||
float hi = max(s0, s1);
|
||||
return float2(lo > 0.0 ? asfloat(asuint(lo) - 1u) : 0.0, hi < 1.0 ? asfloat(asuint(hi) + 1u) : 1.0);
|
||||
}
|
||||
|
||||
// The natural interval extension of x + (y - x) * amount, which is how cultmath_lerp evaluates.
|
||||
float2 cultmath_iv_lerp(float2 a, float2 b, float amount) { return cultmath_iv_add(a, cultmath_iv_scale(cultmath_iv_sub(b, a), amount)); }
|
||||
|
||||
// [n - L r, n + L r] intersected with [-1, 1], n = cultmath_snoise(centre). One snoise, value only.
|
||||
float2 cultmath_iv_snoise_ball(float3 centre, float radius)
|
||||
{
|
||||
float n = cultmath_snoise(centre);
|
||||
float e = CULTMATH_SNOISE_LIPSCHITZ * radius;
|
||||
return float2(max(n - e, -1.0), min(n + e, 1.0));
|
||||
}
|
||||
|
||||
// Octave sum of cultmath_iv_snoise_ball, compounding frequency and amplitude as the value-and-gradient fBm does.
|
||||
float2 cultmath_iv_fbm_ball(float3 centre, float radius, int octaves, float lacunarity, float gain)
|
||||
{
|
||||
octaves = clamp(octaves, 0, 16);
|
||||
float amplitude = 1.0;
|
||||
float frequency = 1.0;
|
||||
float2 sum = float2(0.0, 0.0);
|
||||
for (int i = 0; i < octaves; i++)
|
||||
{
|
||||
sum = cultmath_iv_add(sum, cultmath_iv_scale(cultmath_iv_snoise_ball(centre * frequency, radius * abs(frequency)), amplitude));
|
||||
frequency *= lacunarity;
|
||||
amplitude *= gain;
|
||||
}
|
||||
|
||||
return sum;
|
||||
}
|
||||
|
||||
// A ball (xyz centre, w radius) enclosing every point of every ray of a screen tile over the depth segment
|
||||
// [z0, z1], warped by at most warp. Camera frame: pinhole at the origin looking down +z, ray (m z, z).
|
||||
// footprintPerDepth = N / (sqrt(2) f) for an N x N tile at focal length f. The radius is widened by
|
||||
// (|c|_1 + radius) 2^-20, which covers the ball's own float32 rounding. Derivation in math.Interval.cs.
|
||||
float4 cultmath_iv_frustum_ball(float2 centreSlope, float z0, float z1, float footprintPerDepth, float warp)
|
||||
{
|
||||
float zm = (z0 + z1) * 0.5;
|
||||
float axis = sqrt(centreSlope.x * centreSlope.x + centreSlope.y * centreSlope.y + 1.0);
|
||||
float cx = centreSlope.x * zm;
|
||||
float cy = centreSlope.y * zm;
|
||||
float radius = (z1 - z0) * 0.5 * axis + z1 * footprintPerDepth + warp;
|
||||
radius += (abs(cx) + abs(cy) + abs(zm) + radius) * 9.5367431640625e-7;
|
||||
return float4(cx, cy, zm, radius);
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 4d8a899561654ac990cab0bf48658cc1
|
||||
ShaderIncludeImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
// Generated from shaders/CultMath.Phacelle.hlsl by GlslLowering (packages/cultmath/tests/CultMath.Tests/GlslLowering.cs). Do not edit; regenerate with CULTMATH_WRITE_GLSL=1 dotnet test --filter GlslMirrorTests. MPL-2.0; requires CultMath.glsl before it.
|
||||
#ifndef CULTMATH_PHACELLE_GLSL
|
||||
#define CULTMATH_PHACELLE_GLSL
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
|
||||
// the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Phacelle noise. Copyright (c) 2025 Rune Skovbo Johansen (Shadertoy t3dyWl; MPL-2.0), read from the
|
||||
// C# port PhacelleNoise in lpmitchell/AdvancedTerrainErosion, converted to burstable C# by Luke
|
||||
// Mitchell (2026). Modified by GameCult: 3D cells, a caller-supplied stripe wave vector, an exact
|
||||
// gradient and exact pruning. This file is an MPL-2.0 file; CultMath.hlsl, which includes it after
|
||||
// its own definitions of CULTMATH_TAU and cultmath_pcg3d, is MIT (see THIRD-PARTY-NOTICES.md).
|
||||
|
||||
// Invariant 8's struct return shape for cultmath_phacelle, compared field by field and bit for bit
|
||||
// against CultMath.CultPhasor, same field order.
|
||||
struct CultPhasor
|
||||
{
|
||||
vec4 cos;
|
||||
vec4 sin;
|
||||
};
|
||||
|
||||
// Johansen's Phacelle noise on 3D cells. See math.phacelle's comment (math.Phacelle.cs) for the stripe wave
|
||||
// vector, the weight, the normalization, the exact gradient and why the prune is exact. Mirrors
|
||||
// the C# body operation for operation; the C#-only internal overload that switches the prune off
|
||||
// has no shader twin.
|
||||
CultPhasor cultmath_phacelle(vec3 p, vec3 side, float offset, float normalization)
|
||||
{
|
||||
vec3 cell = floor(p);
|
||||
vec3 local = p - cell;
|
||||
float phaseOffset = offset * CULTMATH_TAU;
|
||||
float sumW = 0.0;
|
||||
float sumCos = 0.0;
|
||||
float sumSin = 0.0;
|
||||
vec3 gradW = vec3(0.0, 0.0, 0.0);
|
||||
vec3 gradCos = vec3(0.0, 0.0, 0.0);
|
||||
vec3 gradSin = vec3(0.0, 0.0, 0.0);
|
||||
|
||||
for (int dz = -1; dz <= 2; dz++)
|
||||
{
|
||||
float gz = max(0.0, abs(local.z - float(dz)) - 0.5);
|
||||
for (int dy = -1; dy <= 2; dy++)
|
||||
{
|
||||
float gy = max(0.0, abs(local.y - float(dy)) - 0.5);
|
||||
for (int dx = -1; dx <= 2; dx++)
|
||||
{
|
||||
float gx = max(0.0, abs(local.x - float(dx)) - 0.5);
|
||||
if (gx * gx + gy * gy + gz * gz >= 2.25)
|
||||
continue;
|
||||
|
||||
vec3 gridStep = vec3(dx, dy, dz);
|
||||
ivec3 hash = cultmath_pcg3d(ivec3(cell + gridStep));
|
||||
vec3 jitter = vec3(
|
||||
float(uint(hash.x) >> 8) * (1.0 / 16777216.0) - 0.5,
|
||||
float(uint(hash.y) >> 8) * (1.0 / 16777216.0) - 0.5,
|
||||
float(uint(hash.z) >> 8) * (1.0 / 16777216.0) - 0.5);
|
||||
vec3 v = local - gridStep - jitter;
|
||||
float falloff = exp(-2.0 * dot(v, v));
|
||||
float w = max(0.0, falloff - 0.01111);
|
||||
vec3 dw = w > 0.0 ? (-4.0 * falloff) * v : vec3(0.0, 0.0, 0.0);
|
||||
float phase = dot(v, side) + phaseOffset;
|
||||
float c = cos(phase);
|
||||
float s = sin(phase);
|
||||
|
||||
sumW += w;
|
||||
sumCos += w * c;
|
||||
sumSin += w * s;
|
||||
gradW += dw;
|
||||
gradCos += dw * c - (w * s) * side;
|
||||
gradSin += dw * s + (w * c) * side;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
float invW = 1.0 / sumW;
|
||||
float rawCos = sumCos * invW;
|
||||
float rawSin = sumSin * invW;
|
||||
vec3 gradRawCos = (gradCos - rawCos * gradW) * invW;
|
||||
vec3 gradRawSin = (gradSin - rawSin * gradW) * invW;
|
||||
float rawLength = sqrt(rawCos * rawCos + rawSin * rawSin);
|
||||
float floorLength = 1.0 - normalization;
|
||||
|
||||
CultPhasor result;
|
||||
if (rawLength > floorLength)
|
||||
{
|
||||
float outCos = rawCos / rawLength;
|
||||
float outSin = rawSin / rawLength;
|
||||
vec3 gradLength = (rawCos * gradRawCos + rawSin * gradRawSin) / rawLength;
|
||||
result.cos = vec4((gradRawCos - outCos * gradLength) / rawLength, outCos);
|
||||
result.sin = vec4((gradRawSin - outSin * gradLength) / rawLength, outSin);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.cos = vec4(gradRawCos / floorLength, rawCos / floorLength);
|
||||
result.sin = vec4(gradRawSin / floorLength, rawSin / floorLength);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
#ifndef CULTMATH_PHACELLE_HLSL
|
||||
#define CULTMATH_PHACELLE_HLSL
|
||||
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
|
||||
// the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Phacelle noise. Copyright (c) 2025 Rune Skovbo Johansen (Shadertoy t3dyWl; MPL-2.0), read from the
|
||||
// C# port PhacelleNoise in lpmitchell/AdvancedTerrainErosion, converted to burstable C# by Luke
|
||||
// Mitchell (2026). Modified by GameCult: 3D cells, a caller-supplied stripe wave vector, an exact
|
||||
// gradient and exact pruning. This file is an MPL-2.0 file; CultMath.hlsl, which includes it after
|
||||
// its own definitions of CULTMATH_TAU and cultmath_pcg3d, is MIT (see THIRD-PARTY-NOTICES.md).
|
||||
|
||||
// Invariant 8's struct return shape for cultmath_phacelle, compared field by field and bit for bit
|
||||
// against CultMath.CultPhasor, same field order.
|
||||
struct CultPhasor
|
||||
{
|
||||
float4 cos;
|
||||
float4 sin;
|
||||
};
|
||||
|
||||
// Johansen's Phacelle noise on 3D cells. See math.phacelle's comment (math.Phacelle.cs) for the stripe wave
|
||||
// vector, the weight, the normalization, the exact gradient and why the prune is exact. Mirrors
|
||||
// the C# body operation for operation; the C#-only internal overload that switches the prune off
|
||||
// has no shader twin.
|
||||
CultPhasor cultmath_phacelle(float3 p, float3 side, float offset, float normalization)
|
||||
{
|
||||
float3 cell = floor(p);
|
||||
float3 local = p - cell;
|
||||
float phaseOffset = offset * CULTMATH_TAU;
|
||||
float sumW = 0.0;
|
||||
float sumCos = 0.0;
|
||||
float sumSin = 0.0;
|
||||
float3 gradW = float3(0.0, 0.0, 0.0);
|
||||
float3 gradCos = float3(0.0, 0.0, 0.0);
|
||||
float3 gradSin = float3(0.0, 0.0, 0.0);
|
||||
|
||||
for (int dz = -1; dz <= 2; dz++)
|
||||
{
|
||||
float gz = max(0.0, abs(local.z - (float)dz) - 0.5);
|
||||
for (int dy = -1; dy <= 2; dy++)
|
||||
{
|
||||
float gy = max(0.0, abs(local.y - (float)dy) - 0.5);
|
||||
for (int dx = -1; dx <= 2; dx++)
|
||||
{
|
||||
float gx = max(0.0, abs(local.x - (float)dx) - 0.5);
|
||||
if (gx * gx + gy * gy + gz * gz >= 2.25)
|
||||
continue;
|
||||
|
||||
float3 gridStep = float3(dx, dy, dz);
|
||||
int3 hash = cultmath_pcg3d(int3(cell + gridStep));
|
||||
float3 jitter = float3(
|
||||
(float)((uint)hash.x >> 8) * (1.0 / 16777216.0) - 0.5,
|
||||
(float)((uint)hash.y >> 8) * (1.0 / 16777216.0) - 0.5,
|
||||
(float)((uint)hash.z >> 8) * (1.0 / 16777216.0) - 0.5);
|
||||
float3 v = local - gridStep - jitter;
|
||||
float falloff = exp(-2.0 * dot(v, v));
|
||||
float w = max(0.0, falloff - 0.01111);
|
||||
float3 dw = w > 0.0 ? (-4.0 * falloff) * v : float3(0.0, 0.0, 0.0);
|
||||
float phase = dot(v, side) + phaseOffset;
|
||||
float c = cos(phase);
|
||||
float s = sin(phase);
|
||||
|
||||
sumW += w;
|
||||
sumCos += w * c;
|
||||
sumSin += w * s;
|
||||
gradW += dw;
|
||||
gradCos += dw * c - (w * s) * side;
|
||||
gradSin += dw * s + (w * c) * side;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
float invW = 1.0 / sumW;
|
||||
float rawCos = sumCos * invW;
|
||||
float rawSin = sumSin * invW;
|
||||
float3 gradRawCos = (gradCos - rawCos * gradW) * invW;
|
||||
float3 gradRawSin = (gradSin - rawSin * gradW) * invW;
|
||||
float rawLength = sqrt(rawCos * rawCos + rawSin * rawSin);
|
||||
float floorLength = 1.0 - normalization;
|
||||
|
||||
CultPhasor result;
|
||||
if (rawLength > floorLength)
|
||||
{
|
||||
float outCos = rawCos / rawLength;
|
||||
float outSin = rawSin / rawLength;
|
||||
float3 gradLength = (rawCos * gradRawCos + rawSin * gradRawSin) / rawLength;
|
||||
result.cos = float4((gradRawCos - outCos * gradLength) / rawLength, outCos);
|
||||
result.sin = float4((gradRawSin - outSin * gradLength) / rawLength, outSin);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.cos = float4(gradRawCos / floorLength, rawCos / floorLength);
|
||||
result.sin = float4(gradRawSin / floorLength, rawSin / floorLength);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: e07a3b95c1d84f2aa6b83d4c5f19a0d7
|
||||
ShaderIncludeImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -1,660 +0,0 @@
|
|||
// Generated from shaders/CultMath.hlsl and CultMath.Interval.hlsl by GlslLowering (packages/cultmath/tests/CultMath.Tests/GlslLowering.cs). Do not edit; regenerate with CULTMATH_WRITE_GLSL=1 dotnet test --filter GlslMirrorTests. MIT. cultmath_phacelle is in CultMath.Phacelle.glsl, to be concatenated after this file by consumers that call it.
|
||||
#ifndef CULTMATH_GLSL
|
||||
#define CULTMATH_GLSL
|
||||
// CultMath HLSL mirror.
|
||||
//
|
||||
// HLSL already owns the vec2/vec3/vec4 type names and most primitive
|
||||
// intrinsics. This include names the CultMath semantic surface explicitly so
|
||||
// shader code, C# wrappers, and Rust/native parity fixtures can share the same
|
||||
// small vocabulary without local helper drift.
|
||||
|
||||
const float CULTMATH_PI = 3.14159265358979323846;
|
||||
const float CULTMATH_TAU = 6.28318530717958647692;
|
||||
const float CULTMATH_HALF_PI = 1.57079632679489661923;
|
||||
|
||||
float cultmath_radians(float degrees_value) { return degrees_value * (CULTMATH_PI / 180.0); }
|
||||
float cultmath_degrees(float radians_value) { return radians_value * (180.0 / CULTMATH_PI); }
|
||||
|
||||
float cultmath_frac(float value) { return value - floor(value); }
|
||||
vec2 cultmath_frac(vec2 value) { return value - floor(value); }
|
||||
vec3 cultmath_frac(vec3 value) { return value - floor(value); }
|
||||
vec4 cultmath_frac(vec4 value) { return value - floor(value); }
|
||||
|
||||
float cultmath_clamp(float value, float minimum, float maximum) { return min(max(value, minimum), maximum); }
|
||||
vec2 cultmath_clamp(vec2 value, vec2 minimum, vec2 maximum) { return min(max(value, minimum), maximum); }
|
||||
vec3 cultmath_clamp(vec3 value, vec3 minimum, vec3 maximum) { return min(max(value, minimum), maximum); }
|
||||
vec4 cultmath_clamp(vec4 value, vec4 minimum, vec4 maximum) { return min(max(value, minimum), maximum); }
|
||||
|
||||
// DXIL Saturate is FMin(1, FMax(0, x)); dxc lowers min/max to the same FMin/FMax, NaN rules included.
|
||||
float cultmath_saturate(float value) { return min(1.0, max(0.0, value)); }
|
||||
vec2 cultmath_saturate(vec2 value) { return min(vec2(1.0, 1.0), max(vec2(0.0, 0.0), value)); }
|
||||
vec3 cultmath_saturate(vec3 value) { return min(vec3(1.0, 1.0, 1.0), max(vec3(0.0, 0.0, 0.0), value)); }
|
||||
vec4 cultmath_saturate(vec4 value) { return min(vec4(1.0, 1.0, 1.0, 1.0), max(vec4(0.0, 0.0, 0.0, 0.0), value)); }
|
||||
|
||||
float cultmath_lerp(float start, float end, float amount) { return start + (end - start) * amount; }
|
||||
vec2 cultmath_lerp(vec2 start, vec2 end, vec2 amount) { return start + (end - start) * amount; }
|
||||
vec3 cultmath_lerp(vec3 start, vec3 end, vec3 amount) { return start + (end - start) * amount; }
|
||||
vec4 cultmath_lerp(vec4 start, vec4 end, vec4 amount) { return start + (end - start) * amount; }
|
||||
|
||||
float cultmath_step(float edge, float value) { return value < edge ? 0.0 : 1.0; }
|
||||
vec2 cultmath_step(vec2 edge, vec2 value) { return vec2(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y)); }
|
||||
vec3 cultmath_step(vec3 edge, vec3 value) { return vec3(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z)); }
|
||||
vec4 cultmath_step(vec4 edge, vec4 value) { return vec4(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z), cultmath_step(edge.w, value.w)); }
|
||||
|
||||
float cultmath_smoothstep(float minimum, float maximum, float value)
|
||||
{
|
||||
float t = cultmath_saturate((value - minimum) / (maximum - minimum));
|
||||
return t * t * (3.0 - 2.0 * t);
|
||||
}
|
||||
|
||||
vec2 cultmath_smoothstep(vec2 minimum, vec2 maximum, vec2 value)
|
||||
{
|
||||
vec2 t = cultmath_saturate((value - minimum) / (maximum - minimum));
|
||||
return t * t * (vec2(3.0, 3.0) - vec2(2.0, 2.0) * t);
|
||||
}
|
||||
|
||||
vec3 cultmath_smoothstep(vec3 minimum, vec3 maximum, vec3 value)
|
||||
{
|
||||
vec3 t = cultmath_saturate((value - minimum) / (maximum - minimum));
|
||||
return t * t * (vec3(3.0, 3.0, 3.0) - vec3(2.0, 2.0, 2.0) * t);
|
||||
}
|
||||
|
||||
vec4 cultmath_smoothstep(vec4 minimum, vec4 maximum, vec4 value)
|
||||
{
|
||||
vec4 t = cultmath_saturate((value - minimum) / (maximum - minimum));
|
||||
return t * t * (vec4(3.0, 3.0, 3.0, 3.0) - vec4(2.0, 2.0, 2.0, 2.0) * t);
|
||||
}
|
||||
|
||||
float cultmath_smoothstep01(float value) { return cultmath_smoothstep(0.0, 1.0, value); }
|
||||
|
||||
float cultmath_smootherstep(float value)
|
||||
{
|
||||
value = cultmath_saturate(value);
|
||||
return value * value * value * (value * (value * 6.0 - 15.0) + 10.0);
|
||||
}
|
||||
|
||||
vec2 cultmath_snoise_mod289(vec2 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; }
|
||||
vec3 cultmath_snoise_mod289(vec3 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; }
|
||||
vec4 cultmath_snoise_mod289(vec4 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; }
|
||||
vec3 cultmath_snoise_permute(vec3 value) { return cultmath_snoise_mod289(((value * 34.0) + 10.0) * value); }
|
||||
vec4 cultmath_snoise_permute(vec4 value) { return cultmath_snoise_mod289(((value * 34.0) + 10.0) * value); }
|
||||
|
||||
// Ashima Arts / Ian McEwan 3D simplex noise (MIT), same float32 evaluation
|
||||
// order as the C# math.snoise(vec3) mirror.
|
||||
float cultmath_snoise(vec3 value)
|
||||
{
|
||||
vec2 c = vec2(1.0 / 6.0, 1.0 / 3.0);
|
||||
vec3 i = floor(value + dot(value, c.yyy));
|
||||
vec3 x0 = value - i + dot(i, c.xxx);
|
||||
vec3 g = step(x0.yzx, x0.xyz);
|
||||
vec3 l = 1.0 - g;
|
||||
vec3 i1 = min(g.xyz, l.zxy);
|
||||
vec3 i2 = max(g.xyz, l.zxy);
|
||||
vec3 x1 = x0 - i1 + c.xxx;
|
||||
vec3 x2 = x0 - i2 + c.yyy;
|
||||
vec3 x3 = x0 - 0.5;
|
||||
|
||||
i = cultmath_snoise_mod289(i);
|
||||
vec4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute(
|
||||
i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0));
|
||||
|
||||
const float n_ = 0.142857142857;
|
||||
vec3 ns = vec3(2.0 * n_, 0.5 * n_ - 1.0, n_);
|
||||
vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
|
||||
vec4 x_ = floor(j * ns.z);
|
||||
vec4 y_ = floor(j - 7.0 * x_);
|
||||
vec4 x = x_ * ns.x + ns.yyyy;
|
||||
vec4 y = y_ * ns.x + ns.yyyy;
|
||||
vec4 h = 1.0 - abs(x) - abs(y);
|
||||
|
||||
vec4 b0 = vec4(x.xy, y.xy);
|
||||
vec4 b1 = vec4(x.zw, y.zw);
|
||||
vec4 s0 = floor(b0) * 2.0 + 1.0;
|
||||
vec4 s1 = floor(b1) * 2.0 + 1.0;
|
||||
vec4 sh = -step(h, vec4(0.0, 0.0, 0.0, 0.0));
|
||||
vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
|
||||
vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
|
||||
|
||||
vec3 p0 = vec3(a0.xy, h.x);
|
||||
vec3 p1 = vec3(a0.zw, h.y);
|
||||
vec3 p2 = vec3(a1.xy, h.z);
|
||||
vec3 p3 = vec3(a1.zw, h.w);
|
||||
vec4 norm = 1.79284291400159 - 0.85373472095314 * vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3));
|
||||
p0 *= norm.x;
|
||||
p1 *= norm.y;
|
||||
p2 *= norm.z;
|
||||
p3 *= norm.w;
|
||||
|
||||
vec4 m = max(0.5 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
|
||||
m = m * m;
|
||||
return 105.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
|
||||
}
|
||||
|
||||
float cultmath_snoise(vec2 value)
|
||||
{
|
||||
vec4 c = vec4(
|
||||
0.211324865405187,
|
||||
0.366025403784439,
|
||||
-0.577350269189626,
|
||||
0.024390243902439);
|
||||
vec2 i = floor(value + dot(value, c.yy));
|
||||
vec2 x0 = value - i + dot(i, c.xx);
|
||||
vec2 i1 = x0.x > x0.y ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
|
||||
vec4 x12 = vec4(x0.x + c.x - i1.x, x0.y + c.x - i1.y, x0.x + c.z, x0.y + c.z);
|
||||
|
||||
i = cultmath_snoise_mod289(i);
|
||||
vec3 p = cultmath_snoise_permute(
|
||||
cultmath_snoise_permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
|
||||
vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
|
||||
m *= m;
|
||||
m *= m;
|
||||
|
||||
vec3 x = 2.0 * fract(p * c.w) - 1.0;
|
||||
vec3 h = abs(x) - 0.5;
|
||||
vec3 ox = floor(x + 0.5);
|
||||
vec3 a0 = x - ox;
|
||||
m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
|
||||
|
||||
vec3 g = vec3(
|
||||
a0.x * x0.x + h.x * x0.y,
|
||||
a0.y * x12.x + h.y * x12.y,
|
||||
a0.z * x12.z + h.z * x12.w);
|
||||
return 130.0 * dot(m, g);
|
||||
}
|
||||
|
||||
// Analytic gradient of cultmath_snoise(vec3) (invariant 8), following webgl-noise
|
||||
// src/noise3Dgrad.glsl (Ashima Arts / McEwan, MIT); same kernel, permutation and scale as
|
||||
// cultmath_snoise(vec3) (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(vec3)'s own derivation
|
||||
// verbatim.
|
||||
vec4 cultmath_snoise_grad(vec3 value)
|
||||
{
|
||||
vec2 c = vec2(1.0 / 6.0, 1.0 / 3.0);
|
||||
vec3 i = floor(value + dot(value, c.yyy));
|
||||
vec3 x0 = value - i + dot(i, c.xxx);
|
||||
vec3 g = step(x0.yzx, x0.xyz);
|
||||
vec3 l = 1.0 - g;
|
||||
vec3 i1 = min(g.xyz, l.zxy);
|
||||
vec3 i2 = max(g.xyz, l.zxy);
|
||||
vec3 x1 = x0 - i1 + c.xxx;
|
||||
vec3 x2 = x0 - i2 + c.yyy;
|
||||
vec3 x3 = x0 - 0.5;
|
||||
|
||||
i = cultmath_snoise_mod289(i);
|
||||
vec4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute(
|
||||
i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0));
|
||||
|
||||
const float n_ = 0.142857142857;
|
||||
vec3 ns = vec3(2.0 * n_, 0.5 * n_ - 1.0, n_);
|
||||
vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
|
||||
vec4 x_ = floor(j * ns.z);
|
||||
vec4 y_ = floor(j - 7.0 * x_);
|
||||
vec4 x = x_ * ns.x + ns.yyyy;
|
||||
vec4 y = y_ * ns.x + ns.yyyy;
|
||||
vec4 h = 1.0 - abs(x) - abs(y);
|
||||
|
||||
vec4 b0 = vec4(x.xy, y.xy);
|
||||
vec4 b1 = vec4(x.zw, y.zw);
|
||||
vec4 s0 = floor(b0) * 2.0 + 1.0;
|
||||
vec4 s1 = floor(b1) * 2.0 + 1.0;
|
||||
vec4 sh = -step(h, vec4(0.0, 0.0, 0.0, 0.0));
|
||||
vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
|
||||
vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
|
||||
|
||||
vec3 p0 = vec3(a0.xy, h.x);
|
||||
vec3 p1 = vec3(a0.zw, h.y);
|
||||
vec3 p2 = vec3(a1.xy, h.z);
|
||||
vec3 p3 = vec3(a1.zw, h.w);
|
||||
vec4 norm = 1.79284291400159 - 0.85373472095314 * vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3));
|
||||
p0 *= norm.x;
|
||||
p1 *= norm.y;
|
||||
p2 *= norm.z;
|
||||
p3 *= norm.w;
|
||||
|
||||
vec4 m0 = max(0.5 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
|
||||
vec4 m2 = m0 * m0;
|
||||
vec4 m3 = m2 * m0;
|
||||
vec4 m4 = m2 * m2;
|
||||
|
||||
vec4 px = vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3));
|
||||
float value2 = 105.0 * dot(m4, px);
|
||||
vec3 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 vec4(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.
|
||||
vec4 cultmath_fbm_grad(vec3 p, int octaves, float lacunarity, float gain)
|
||||
{
|
||||
octaves = clamp(octaves, 0, 16);
|
||||
float amplitude = 1.0;
|
||||
float frequency = 1.0;
|
||||
float value = 0.0;
|
||||
vec3 gradient = vec3(0.0, 0.0, 0.0);
|
||||
for (int i = 0; i < octaves; i++)
|
||||
{
|
||||
vec4 n = cultmath_snoise_grad(p * frequency);
|
||||
value += amplitude * n.w;
|
||||
gradient += amplitude * frequency * n.xyz;
|
||||
frequency *= lacunarity;
|
||||
amplitude *= gain;
|
||||
}
|
||||
|
||||
return vec4(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.
|
||||
vec4 cultmath_ridged_grad(vec3 p, int octaves, float lacunarity, float gain)
|
||||
{
|
||||
octaves = clamp(octaves, 0, 16);
|
||||
float amplitude = 1.0;
|
||||
float frequency = 1.0;
|
||||
float value = 0.0;
|
||||
vec3 gradient = vec3(0.0, 0.0, 0.0);
|
||||
for (int i = 0; i < octaves; i++)
|
||||
{
|
||||
vec4 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 vec4(gradient, value);
|
||||
}
|
||||
|
||||
float cultmath_lengthsq(vec2 value) { return dot(value, value); }
|
||||
float cultmath_lengthsq(vec3 value) { return dot(value, value); }
|
||||
float cultmath_lengthsq(vec4 value) { return dot(value, value); }
|
||||
|
||||
float cultmath_distance(vec2 left, vec2 right) { return length(left - right); }
|
||||
float cultmath_distance(vec3 left, vec3 right) { return length(left - right); }
|
||||
float cultmath_distance(vec4 left, vec4 right) { return length(left - right); }
|
||||
|
||||
vec2 cultmath_reflect(vec2 incident, vec2 normal_value)
|
||||
{
|
||||
return incident - 2.0 * dot(normal_value, incident) * normal_value;
|
||||
}
|
||||
|
||||
vec3 cultmath_reflect(vec3 incident, vec3 normal_value)
|
||||
{
|
||||
return incident - 2.0 * dot(normal_value, incident) * normal_value;
|
||||
}
|
||||
|
||||
vec2 cultmath_rotate(vec2 value, float radians_value)
|
||||
{
|
||||
float s = sin(radians_value);
|
||||
float c = cos(radians_value);
|
||||
return vec2(value.x * c - value.y * s, value.x * s + value.y * c);
|
||||
}
|
||||
|
||||
vec2 cultmath_rotate_degrees(vec2 value, float degrees_value)
|
||||
{
|
||||
return cultmath_rotate(value, cultmath_radians(degrees_value));
|
||||
}
|
||||
|
||||
float cultmath_csum(vec2 value) { return value.x + value.y; }
|
||||
float cultmath_csum(vec3 value) { return value.x + value.y + value.z; }
|
||||
float cultmath_csum(vec4 value) { return value.x + value.y + value.z + value.w; }
|
||||
|
||||
float cultmath_decay(float source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); }
|
||||
vec2 cultmath_decay(vec2 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); }
|
||||
vec3 cultmath_decay(vec3 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); }
|
||||
vec4 cultmath_decay(vec4 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); }
|
||||
|
||||
float cultmath_damp(float start, float end, float lambda_value, float dt)
|
||||
{
|
||||
return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt));
|
||||
}
|
||||
|
||||
vec2 cultmath_damp(vec2 start, vec2 end, float lambda_value, float dt)
|
||||
{
|
||||
float amount = 1.0 - exp(-lambda_value * dt);
|
||||
return cultmath_lerp(start, end, vec2(amount, amount));
|
||||
}
|
||||
|
||||
vec3 cultmath_damp(vec3 start, vec3 end, float lambda_value, float dt)
|
||||
{
|
||||
float amount = 1.0 - exp(-lambda_value * dt);
|
||||
return cultmath_lerp(start, end, vec3(amount, amount, amount));
|
||||
}
|
||||
|
||||
vec4 cultmath_damp(vec4 start, vec4 end, float lambda_value, float dt)
|
||||
{
|
||||
float amount = 1.0 - exp(-lambda_value * dt);
|
||||
return cultmath_lerp(start, end, vec4(amount, amount, amount, amount));
|
||||
}
|
||||
|
||||
float cultmath_catmullrom(float p0, float p1, float p2, float p3, float t)
|
||||
{
|
||||
float t2 = t * t;
|
||||
float t3 = t2 * t;
|
||||
return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3);
|
||||
}
|
||||
|
||||
vec2 cultmath_catmullrom(vec2 p0, vec2 p1, vec2 p2, vec2 p3, float t)
|
||||
{
|
||||
float t2 = t * t;
|
||||
float t3 = t2 * t;
|
||||
return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3);
|
||||
}
|
||||
|
||||
vec3 cultmath_catmullrom(vec3 p0, vec3 p1, vec3 p2, vec3 p3, float t)
|
||||
{
|
||||
float t2 = t * t;
|
||||
float t3 = t2 * t;
|
||||
return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3);
|
||||
}
|
||||
|
||||
vec4 cultmath_catmullrom(vec4 p0, vec4 p1, vec4 p2, vec4 p3, float t)
|
||||
{
|
||||
float t2 = t * t;
|
||||
float t3 = t2 * t;
|
||||
return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3);
|
||||
}
|
||||
|
||||
float cultmath_quadratic_bezier(float p0, float p1, float p2, float t)
|
||||
{
|
||||
t = cultmath_saturate(t);
|
||||
float inv = 1.0 - t;
|
||||
return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2;
|
||||
}
|
||||
|
||||
vec3 cultmath_quadratic_bezier(vec3 p0, vec3 p1, vec3 p2, float t)
|
||||
{
|
||||
t = cultmath_saturate(t);
|
||||
float inv = 1.0 - t;
|
||||
return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2;
|
||||
}
|
||||
|
||||
float cultmath_cubic_bezier(float p0, float p1, float p2, float p3, float t)
|
||||
{
|
||||
t = cultmath_saturate(t);
|
||||
float inv = 1.0 - t;
|
||||
return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3;
|
||||
}
|
||||
|
||||
vec3 cultmath_cubic_bezier(vec3 p0, vec3 p1, vec3 p2, vec3 p3, float t)
|
||||
{
|
||||
t = cultmath_saturate(t);
|
||||
float inv = 1.0 - t;
|
||||
return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3;
|
||||
}
|
||||
|
||||
float cultmath_hash(float value) { return cultmath_frac(sin(value) * 43758.5453); }
|
||||
float cultmath_hash(vec2 value) { return cultmath_hash(dot(value, vec2(127.1, 311.7))); }
|
||||
float cultmath_hash(vec3 value) { return cultmath_hash(dot(value, vec3(127.1, 311.7, 74.7))); }
|
||||
|
||||
// PCG hashes (Jarzynski and Olano, JCGT 2020), written over scalar uints so CultMath's ivec3/ivec4 carry the bits.
|
||||
uint cultmath_pcg(uint value)
|
||||
{
|
||||
uint state = value * 747796405u + 2891336453u;
|
||||
uint word = ((state >> int((state >> 28) + 4u)) ^ state) * 277803737u;
|
||||
return (word >> 22) ^ word;
|
||||
}
|
||||
|
||||
ivec3 cultmath_pcg3d(ivec3 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 ivec3(int(x), int(y), int(z));
|
||||
}
|
||||
|
||||
ivec3 cultmath_pcg3d(vec3 value) { return cultmath_pcg3d(ivec3(int(floatBitsToUint(value.x)), int(floatBitsToUint(value.y)), int(floatBitsToUint(value.z)))); }
|
||||
ivec3 cultmath_pcg3d(vec2 value) { return cultmath_pcg3d(vec3(value, 0.0)); }
|
||||
|
||||
ivec4 cultmath_pcg4d(ivec4 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 ivec4(int(x), int(y), int(z), int(w));
|
||||
}
|
||||
|
||||
ivec4 cultmath_pcg4d(vec4 value) { return cultmath_pcg4d(ivec4(int(floatBitsToUint(value.x)), int(floatBitsToUint(value.y)), int(floatBitsToUint(value.z)), int(floatBitsToUint(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.
|
||||
vec4 cultmath_smin_grad(vec4 a, vec4 b, float k)
|
||||
{
|
||||
float h = clamp(0.5 + 0.5 * (b.w - a.w) / k, 0.0, 1.0);
|
||||
float value = mix(b.w, a.w, h) - k * h * (1.0 - h);
|
||||
vec3 gradient = mix(b.xyz, a.xyz, h);
|
||||
return vec4(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
|
||||
{
|
||||
vec4 nearest;
|
||||
vec4 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(vec3 p)
|
||||
{
|
||||
vec3 cell = floor(p);
|
||||
float f1 = 1.0e30;
|
||||
float f2 = 1.0e30;
|
||||
vec3 c1 = vec3(0.0, 0.0, 0.0);
|
||||
vec3 c2 = vec3(0.0, 0.0, 0.0);
|
||||
vec3 cellId = vec3(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;
|
||||
|
||||
vec3 neighbor = cell + vec3(dx, dy, dz);
|
||||
ivec3 hash = cultmath_pcg3d(ivec3(neighbor));
|
||||
vec3 jitter = vec3(
|
||||
float(uint(hash.x) >> 8) * (1.0 / 16777216.0),
|
||||
float(uint(hash.y) >> 8) * (1.0 / 16777216.0),
|
||||
float(uint(hash.z) >> 8) * (1.0 / 16777216.0));
|
||||
vec3 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.
|
||||
vec3 grad1 = f1 > 0.0 ? (p - c1) / f1 : vec3(0.0, 0.0, 0.0);
|
||||
vec3 grad2 = f2 > 0.0 ? (p - c2) / f2 : vec3(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).
|
||||
ivec4 idHash = cultmath_pcg4d(ivec4(ivec3(cellId), 0));
|
||||
|
||||
CultCellular result;
|
||||
result.nearest = vec4(grad1, f1);
|
||||
result.edge = vec4(grad2 - grad1, f2 - f1);
|
||||
result.id = float(uint(idHash.w) >> 8) * (1.0 / 16777216.0);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// Interval arithmetic over vec2(lo, hi), lo <= hi, both finite. Every cultmath_iv_* function returns
|
||||
// an interval containing f(x) for every x in its input intervals, mirroring math.Interval.cs bit for
|
||||
// bit; see that file and docs/design.md ("Intervals") for each op's enclosure and its ulp widening.
|
||||
// Included by CultMath.hlsl after its own definitions of cultmath_smoothstep and cultmath_snoise.
|
||||
|
||||
// Lipschitz constant of cultmath_snoise(vec3); provenance in math.Interval.cs (SNOISE_LIPSCHITZ).
|
||||
const float CULTMATH_SNOISE_LIPSCHITZ = 10.099261;
|
||||
|
||||
vec2 cultmath_iv_point(float value) { return vec2(value, value); }
|
||||
vec2 cultmath_iv_add(vec2 a, vec2 b) { return vec2(a.x + b.x, a.y + b.y); }
|
||||
vec2 cultmath_iv_sub(vec2 a, vec2 b) { return vec2(a.x - b.y, a.y - b.x); }
|
||||
vec2 cultmath_iv_neg(vec2 a) { return vec2(-a.y, -a.x); }
|
||||
|
||||
vec2 cultmath_iv_mul(vec2 a, vec2 b)
|
||||
{
|
||||
float p0 = a.x * b.x;
|
||||
float p1 = a.x * b.y;
|
||||
float p2 = a.y * b.x;
|
||||
float p3 = a.y * b.y;
|
||||
return vec2(min(min(p0, p1), min(p2, p3)), max(max(p0, p1), max(p2, p3)));
|
||||
}
|
||||
|
||||
vec2 cultmath_iv_scale(vec2 a, float s) { return s >= 0.0 ? vec2(a.x * s, a.y * s) : vec2(a.y * s, a.x * s); }
|
||||
|
||||
vec2 cultmath_iv_abs(vec2 a)
|
||||
{
|
||||
if (a.x >= 0.0)
|
||||
return a;
|
||||
if (a.y <= 0.0)
|
||||
return vec2(-a.y, -a.x);
|
||||
return vec2(0.0, max(-a.x, a.y));
|
||||
}
|
||||
|
||||
vec2 cultmath_iv_min(vec2 a, vec2 b) { return vec2(min(a.x, b.x), min(a.y, b.y)); }
|
||||
vec2 cultmath_iv_max(vec2 a, vec2 b) { return vec2(max(a.x, b.x), max(a.y, b.y)); }
|
||||
|
||||
vec2 cultmath_iv_sqr(vec2 a)
|
||||
{
|
||||
float l = a.x * a.x;
|
||||
float h = a.y * a.y;
|
||||
if (a.x >= 0.0)
|
||||
return vec2(l, h);
|
||||
if (a.y <= 0.0)
|
||||
return vec2(h, l);
|
||||
return vec2(0.0, max(l, h));
|
||||
}
|
||||
|
||||
vec2 cultmath_iv_sqrt(vec2 a) { return vec2(sqrt(max(a.x, 0.0)), sqrt(max(a.y, 0.0))); }
|
||||
|
||||
// Widened by one ulp on each side: exp is not required to be correctly rounded.
|
||||
vec2 cultmath_iv_exp(vec2 a)
|
||||
{
|
||||
float lo = exp(a.x);
|
||||
float hi = exp(a.y);
|
||||
return vec2(lo > 0.0 ? uintBitsToFloat(floatBitsToUint(lo) - 1u) : 0.0, uintBitsToFloat(floatBitsToUint(hi) + 1u));
|
||||
}
|
||||
|
||||
vec2 cultmath_iv_clamp(vec2 a, float minimum, float maximum)
|
||||
{
|
||||
return vec2(cultmath_clamp(a.x, minimum, maximum), cultmath_clamp(a.y, minimum, maximum));
|
||||
}
|
||||
|
||||
vec2 cultmath_iv_saturate(vec2 a) { return vec2(cultmath_saturate(a.x), cultmath_saturate(a.y)); }
|
||||
|
||||
// The endpoints, ordered, widened by one ulp inside [0, 1]: the float32 evaluation of smoothstep is
|
||||
// monotone only to within one ulp.
|
||||
vec2 cultmath_iv_smoothstep(float minimum, float maximum, vec2 a)
|
||||
{
|
||||
float s0 = cultmath_smoothstep(minimum, maximum, a.x);
|
||||
float s1 = cultmath_smoothstep(minimum, maximum, a.y);
|
||||
float lo = min(s0, s1);
|
||||
float hi = max(s0, s1);
|
||||
return vec2(lo > 0.0 ? uintBitsToFloat(floatBitsToUint(lo) - 1u) : 0.0, hi < 1.0 ? uintBitsToFloat(floatBitsToUint(hi) + 1u) : 1.0);
|
||||
}
|
||||
|
||||
// The natural interval extension of x + (y - x) * amount, which is how cultmath_lerp evaluates.
|
||||
vec2 cultmath_iv_lerp(vec2 a, vec2 b, float amount) { return cultmath_iv_add(a, cultmath_iv_scale(cultmath_iv_sub(b, a), amount)); }
|
||||
|
||||
// [n - L r, n + L r] intersected with [-1, 1], n = cultmath_snoise(centre). One snoise, value only.
|
||||
vec2 cultmath_iv_snoise_ball(vec3 centre, float radius)
|
||||
{
|
||||
float n = cultmath_snoise(centre);
|
||||
float e = CULTMATH_SNOISE_LIPSCHITZ * radius;
|
||||
return vec2(max(n - e, -1.0), min(n + e, 1.0));
|
||||
}
|
||||
|
||||
// Octave sum of cultmath_iv_snoise_ball, compounding frequency and amplitude as the value-and-gradient fBm does.
|
||||
vec2 cultmath_iv_fbm_ball(vec3 centre, float radius, int octaves, float lacunarity, float gain)
|
||||
{
|
||||
octaves = clamp(octaves, 0, 16);
|
||||
float amplitude = 1.0;
|
||||
float frequency = 1.0;
|
||||
vec2 sum = vec2(0.0, 0.0);
|
||||
for (int i = 0; i < octaves; i++)
|
||||
{
|
||||
sum = cultmath_iv_add(sum, cultmath_iv_scale(cultmath_iv_snoise_ball(centre * frequency, radius * abs(frequency)), amplitude));
|
||||
frequency *= lacunarity;
|
||||
amplitude *= gain;
|
||||
}
|
||||
|
||||
return sum;
|
||||
}
|
||||
|
||||
// A ball (xyz centre, w radius) enclosing every point of every ray of a screen tile over the depth segment
|
||||
// [z0, z1], warped by at most warp. Camera frame: pinhole at the origin looking down +z, ray (m z, z).
|
||||
// footprintPerDepth = N / (sqrt(2) f) for an N x N tile at focal length f. The radius is widened by
|
||||
// (|c|_1 + radius) 2^-20, which covers the ball's own float32 rounding. Derivation in math.Interval.cs.
|
||||
vec4 cultmath_iv_frustum_ball(vec2 centreSlope, float z0, float z1, float footprintPerDepth, float warp)
|
||||
{
|
||||
float zm = (z0 + z1) * 0.5;
|
||||
float axis = sqrt(centreSlope.x * centreSlope.x + centreSlope.y * centreSlope.y + 1.0);
|
||||
float cx = centreSlope.x * zm;
|
||||
float cy = centreSlope.y * zm;
|
||||
float radius = (z1 - z0) * 0.5 * axis + z1 * footprintPerDepth + warp;
|
||||
radius += (abs(cx) + abs(cy) + abs(zm) + radius) * 9.5367431640625e-7;
|
||||
return vec4(cx, cy, zm, radius);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
float cultmath_value_noise(vec2 position)
|
||||
{
|
||||
vec2 cell = floor(position);
|
||||
vec2 local = cultmath_frac(position);
|
||||
vec2 u = local * local * (vec2(3.0, 3.0) - vec2(2.0, 2.0) * local);
|
||||
|
||||
float a = cultmath_hash(cell);
|
||||
float b = cultmath_hash(cell + vec2(1.0, 0.0));
|
||||
float c = cultmath_hash(cell + vec2(0.0, 1.0));
|
||||
float d = cultmath_hash(cell + vec2(1.0, 1.0));
|
||||
|
||||
return cultmath_lerp(cultmath_lerp(a, b, u.x), cultmath_lerp(c, d, u.x), u.y);
|
||||
}
|
||||
|
||||
float cultmath_value_noise_bicubic(vec2 position)
|
||||
{
|
||||
vec2 cell = floor(position);
|
||||
vec2 local = cultmath_frac(position);
|
||||
|
||||
float y0 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, -1.0)), cultmath_hash(cell + vec2(0.0, -1.0)), cultmath_hash(cell + vec2(1.0, -1.0)), cultmath_hash(cell + vec2(2.0, -1.0)), local.x);
|
||||
float y1 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, 0.0)), cultmath_hash(cell + vec2(0.0, 0.0)), cultmath_hash(cell + vec2(1.0, 0.0)), cultmath_hash(cell + vec2(2.0, 0.0)), local.x);
|
||||
float y2 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, 1.0)), cultmath_hash(cell + vec2(0.0, 1.0)), cultmath_hash(cell + vec2(1.0, 1.0)), cultmath_hash(cell + vec2(2.0, 1.0)), local.x);
|
||||
float y3 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, 2.0)), cultmath_hash(cell + vec2(0.0, 2.0)), cultmath_hash(cell + vec2(1.0, 2.0)), cultmath_hash(cell + vec2(2.0, 2.0)), local.x);
|
||||
|
||||
return cultmath_catmullrom(y0, y1, y2, y3, local.y);
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
@ -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.
|
||||
|
|
@ -111,7 +112,7 @@ float cultmath_snoise(float3 value)
|
|||
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, float4(0.0, 0.0, 0.0, 0.0));
|
||||
float4 sh = -step(h, 0.0);
|
||||
float4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
|
||||
float4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
|
||||
|
||||
|
|
@ -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, float4(0.0, 0.0, 0.0, 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;
|
||||
|
|
@ -316,20 +213,17 @@ float cultmath_damp(float start, float end, float lambda_value, float dt)
|
|||
|
||||
float2 cultmath_damp(float2 start, float2 end, float lambda_value, float dt)
|
||||
{
|
||||
float amount = 1.0 - exp(-lambda_value * dt);
|
||||
return cultmath_lerp(start, end, float2(amount, amount));
|
||||
return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt));
|
||||
}
|
||||
|
||||
float3 cultmath_damp(float3 start, float3 end, float lambda_value, float dt)
|
||||
{
|
||||
float amount = 1.0 - exp(-lambda_value * dt);
|
||||
return cultmath_lerp(start, end, float3(amount, amount, amount));
|
||||
return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt));
|
||||
}
|
||||
|
||||
float4 cultmath_damp(float4 start, float4 end, float lambda_value, float dt)
|
||||
{
|
||||
float amount = 1.0 - exp(-lambda_value * dt);
|
||||
return cultmath_lerp(start, end, float4(amount, amount, amount, amount));
|
||||
return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt));
|
||||
}
|
||||
|
||||
float cultmath_catmullrom(float p0, float p1, float p2, float p3, float t)
|
||||
|
|
@ -392,123 +286,6 @@ float cultmath_hash(float value) { return cultmath_frac(sin(value) * 43758.5453)
|
|||
float cultmath_hash(float2 value) { return cultmath_hash(dot(value, float2(127.1, 311.7))); }
|
||||
float cultmath_hash(float3 value) { return cultmath_hash(dot(value, float3(127.1, 311.7, 74.7))); }
|
||||
|
||||
// PCG hashes (Jarzynski and Olano, JCGT 2020), written over scalar uints so CultMath's int3/int4 carry the bits.
|
||||
uint cultmath_pcg(uint value)
|
||||
{
|
||||
uint state = value * 747796405u + 2891336453u;
|
||||
uint word = ((state >> (int)((state >> 28) + 4u)) ^ 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(float3 value) { return cultmath_pcg3d(int3((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z))); }
|
||||
int3 cultmath_pcg3d(float2 value) { return cultmath_pcg3d(float3(value, 0.0)); }
|
||||
|
||||
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(
|
||||
(float)((uint)hash.x >> 8) * (1.0 / 16777216.0),
|
||||
(float)((uint)hash.y >> 8) * (1.0 / 16777216.0),
|
||||
(float)((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 = (float)((uint)idHash.w >> 8) * (1.0 / 16777216.0);
|
||||
return result;
|
||||
}
|
||||
|
||||
#include "CultMath.Phacelle.hlsl"
|
||||
#include "CultMath.Interval.hlsl"
|
||||
|
||||
float cultmath_value_noise(float2 position)
|
||||
{
|
||||
float2 cell = floor(position);
|
||||
|
|
|
|||
|
|
@ -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,9 +8,7 @@
|
|||
<RootNamespace>CultMath</RootNamespace>
|
||||
<AssemblyName>CultMath</AssemblyName>
|
||||
<PackageId>GameCult.Math</PackageId>
|
||||
<!-- MIT overall; math.Phacelle.cs and CultPhasor.cs (and the two Phacelle HLSL files) are MPL-2.0. -->
|
||||
<PackageLicenseExpression>MIT AND MPL-2.0</PackageLicenseExpression>
|
||||
<Version>0.3.0</Version>
|
||||
<Version>0.2.0</Version>
|
||||
<Description>Clean-room C# math shaped for HLSL-style renderer code.</Description>
|
||||
<Authors>GameCult</Authors>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
|
|
@ -23,9 +21,6 @@
|
|||
<ItemGroup>
|
||||
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
|
||||
<None Include="..\..\shaders\CultMath.hlsl" Pack="true" PackagePath="contentFiles\any\any\shaders\CultMath.hlsl" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="..\..\shaders\CultMath.Phacelle.hlsl" Pack="true" PackagePath="contentFiles\any\any\shaders\CultMath.Phacelle.hlsl" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="..\..\shaders\CultMath.Interval.hlsl" Pack="true" PackagePath="contentFiles\any\any\shaders\CultMath.Interval.hlsl" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="..\..\THIRD-PARTY-NOTICES.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
|
||||
// the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// The result type of math.phacelle (math.Phacelle.cs), Rune Skovbo Johansen's Phacelle noise
|
||||
// (Copyright (c) 2025, MPL-2.0; see THIRD-PARTY-NOTICES.md).
|
||||
|
||||
namespace CultMath;
|
||||
|
||||
/// <summary>
|
||||
/// The value-and-gradient result of <see cref="math.phacelle(float3, float3, float, float)"/>
|
||||
/// (invariant 8's struct return shape). Both fields use the <c>float4(gradient.xyz, value.w)</c>
|
||||
/// layout of a bare value-and-gradient return.
|
||||
/// </summary>
|
||||
public struct CultPhasor
|
||||
{
|
||||
/// <summary>(∇cos, cos): the normalized cosine wave and its gradient.</summary>
|
||||
public float4 cos;
|
||||
|
||||
/// <summary>(∇sin, sin): the normalized sine wave and its gradient.</summary>
|
||||
public float4 sin;
|
||||
|
||||
public CultPhasor(float4 cos, float4 sin)
|
||||
{
|
||||
this.cos = cos;
|
||||
this.sin = sin;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 807ae2c8a758cf31896da12f1e5047bd
|
||||
|
|
@ -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,199 +0,0 @@
|
|||
namespace CultMath;
|
||||
|
||||
public static partial class math
|
||||
{
|
||||
// Interval arithmetic over float2(lo, hi), lo <= hi, both finite (design.md, "Intervals"). Every
|
||||
// iv_* function returns an interval that contains f(x) for every x in its input intervals, where
|
||||
// f(x) is the same function evaluated pointwise in float32, mirrored by cultmath_iv_* in
|
||||
// shaders/CultMath.Interval.hlsl. Each bound is computed with the pointwise function's own float32
|
||||
// operations; because IEEE round-to-nearest is monotone, an op that is monotone on its region
|
||||
// encloses its float32 evaluation with no widening. Where float32 evaluation is not monotone, or
|
||||
// the libm is not required to be, the bound is widened by exactly one ulp, and the doc comment
|
||||
// says so. There is no empty interval: a caller that needs "provably empty" tests hi < cutoff.
|
||||
|
||||
// Lipschitz constant of snoise(float3) in its input: an upper bound on the length of its analytic
|
||||
// gradient. Provenance: NoiseBoundTests.MeasureLipschitz (slow, explicit) takes the largest gradient
|
||||
// length over 1e6 seeded points, refines the 1e4 largest by gradient ascent, and multiplies the
|
||||
// refined maximum by 1.10; NoiseBoundTests.LipschitzConstantPinsSampledGradients pins this value
|
||||
// against a fast re-measure and the ascent from MeasureLipschitz's witness.
|
||||
// Empirical with a margin, not a proof; the enclosure tests are the defence. A change to the
|
||||
// snoise kernel must re-run MeasureLipschitz and re-pin this constant.
|
||||
public const float SNOISE_LIPSCHITZ = 10.099261f;
|
||||
|
||||
/// <summary>The point interval [x, x].</summary>
|
||||
public static float2 iv_point(float value) => new(value, value);
|
||||
|
||||
/// <summary>Encloses x + y for x in a, y in b. Monotone: no widening.</summary>
|
||||
public static float2 iv_add(float2 a, float2 b) => new(a.x + b.x, a.y + b.y);
|
||||
|
||||
/// <summary>Encloses x - y for x in a, y in b. Monotone: no widening.</summary>
|
||||
public static float2 iv_sub(float2 a, float2 b) => new(a.x - b.y, a.y - b.x);
|
||||
|
||||
/// <summary>Encloses -x for x in a. Exact.</summary>
|
||||
public static float2 iv_neg(float2 a) => new(-a.y, -a.x);
|
||||
|
||||
/// <summary>
|
||||
/// Encloses x * y for x in a, y in b: the least and greatest of the four corner products. The
|
||||
/// product is monotone in each factor for a fixed sign of the other, so no widening.
|
||||
/// </summary>
|
||||
public static float2 iv_mul(float2 a, float2 b)
|
||||
{
|
||||
var p0 = a.x * b.x;
|
||||
var p1 = a.x * b.y;
|
||||
var p2 = a.y * b.x;
|
||||
var p3 = a.y * b.y;
|
||||
return new(min(min(p0, p1), min(p2, p3)), max(max(p0, p1), max(p2, p3)));
|
||||
}
|
||||
|
||||
/// <summary>Encloses x * s for x in a. Monotone: no widening.</summary>
|
||||
public static float2 iv_scale(float2 a, float s) => s >= 0.0f ? new(a.x * s, a.y * s) : new(a.y * s, a.x * s);
|
||||
|
||||
/// <summary>Encloses abs(x) for x in a; an interval straddling zero gives [0, max(-lo, hi)]. Exact.</summary>
|
||||
public static float2 iv_abs(float2 a)
|
||||
{
|
||||
if (a.x >= 0.0f)
|
||||
return a;
|
||||
if (a.y <= 0.0f)
|
||||
return new(-a.y, -a.x);
|
||||
return new(0.0f, max(-a.x, a.y));
|
||||
}
|
||||
|
||||
/// <summary>Encloses min(x, y) for x in a, y in b. Monotone: no widening.</summary>
|
||||
public static float2 iv_min(float2 a, float2 b) => new(min(a.x, b.x), min(a.y, b.y));
|
||||
|
||||
/// <summary>Encloses max(x, y) for x in a, y in b. Monotone: no widening.</summary>
|
||||
public static float2 iv_max(float2 a, float2 b) => new(max(a.x, b.x), max(a.y, b.y));
|
||||
|
||||
/// <summary>
|
||||
/// Encloses x * x for x in a; an interval straddling zero gives [0, max(lo * lo, hi * hi)]. Tighter
|
||||
/// than iv_mul(a, a), which cannot know both factors are the same x. Monotone on each side of zero:
|
||||
/// no widening.
|
||||
/// </summary>
|
||||
public static float2 iv_sqr(float2 a)
|
||||
{
|
||||
var l = a.x * a.x;
|
||||
var h = a.y * a.y;
|
||||
if (a.x >= 0.0f)
|
||||
return new(l, h);
|
||||
if (a.y <= 0.0f)
|
||||
return new(h, l);
|
||||
return new(0.0f, max(l, h));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encloses sqrt(x) for x in a with x >= 0 (sqrt's domain): lo is clamped at 0, so [-4, 9] gives [0, 3]
|
||||
/// and an interval wholly below zero gives [0, 0]. sqrt is correctly rounded, hence monotone: no
|
||||
/// widening.
|
||||
/// </summary>
|
||||
public static float2 iv_sqrt(float2 a) => new(sqrt(max(a.x, 0.0f)), sqrt(max(a.y, 0.0f)));
|
||||
|
||||
/// <summary>
|
||||
/// Encloses exp(x) for x in a. Widened by one ulp on each side, because exp is not required to be
|
||||
/// correctly rounded, so a libm need not be monotone. Where exp(hi) overflows to infinity, hi is NaN,
|
||||
/// which a cull test hi < cutoff reads as "not provably below", the safe side.
|
||||
/// </summary>
|
||||
public static float2 iv_exp(float2 a)
|
||||
{
|
||||
var lo = exp(a.x);
|
||||
var hi = exp(a.y);
|
||||
return new(lo > 0.0f ? asfloat(asuint(lo) - 1u) : 0.0f, asfloat(asuint(hi) + 1u));
|
||||
}
|
||||
|
||||
/// <summary>Encloses clamp(x, minimum, maximum) for x in a. Monotone: no widening.</summary>
|
||||
public static float2 iv_clamp(float2 a, float minimum, float maximum) =>
|
||||
new(clamp(a.x, minimum, maximum), clamp(a.y, minimum, maximum));
|
||||
|
||||
/// <summary>Encloses saturate(x) for x in a. Monotone: no widening.</summary>
|
||||
public static float2 iv_saturate(float2 a) => new(saturate(a.x), saturate(a.y));
|
||||
|
||||
/// <summary>
|
||||
/// Encloses smoothstep(minimum, maximum, x) for x in a. smoothstep is monotone in x (increasing when
|
||||
/// minimum < maximum, decreasing when minimum > maximum), so the bound is its value at the two
|
||||
/// endpoints, ordered. Its float32 evaluation t * t * (3 - 2t) is not monotone, by at most one ulp
|
||||
/// (IntervalTests.MeasureMonotonicity), so the bound is widened by one ulp inside [0, 1].
|
||||
/// </summary>
|
||||
public static float2 iv_smoothstep(float minimum, float maximum, float2 a)
|
||||
{
|
||||
var s0 = smoothstep(minimum, maximum, a.x);
|
||||
var s1 = smoothstep(minimum, maximum, a.y);
|
||||
var lo = min(s0, s1);
|
||||
var hi = max(s0, s1);
|
||||
return new(lo > 0.0f ? asfloat(asuint(lo) - 1u) : 0.0f, hi < 1.0f ? asfloat(asuint(hi) + 1u) : 1.0f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encloses lerp(x, y, amount) = x + (y - x) * amount for x in a, y in b, by evaluating that expression
|
||||
/// with iv_sub, iv_scale and iv_add. Each step is monotone, so the result encloses the float32 lerp
|
||||
/// with no widening, for any amount. It is the natural interval extension: x appears twice, so for
|
||||
/// non-point a the width is (width(a) + width(b)) * |amount| + width(a), wider than the true range.
|
||||
/// </summary>
|
||||
public static float2 iv_lerp(float2 a, float2 b, float amount) => iv_add(a, iv_scale(iv_sub(b, a), amount));
|
||||
|
||||
/// <summary>
|
||||
/// Encloses snoise(x) for every x within distance radius of centre: [n - L r, n + L r] intersected
|
||||
/// with [-1, 1], where n = snoise(centre) and L = SNOISE_LIPSCHITZ. radius must be at least 0. One
|
||||
/// snoise evaluation, value only. A domain warp that moves x by at most D is enclosed by passing
|
||||
/// radius + D (NoiseBoundTests.WarpedPointsStayEnclosed).
|
||||
/// </summary>
|
||||
public static float2 iv_snoise_ball(float3 centre, float radius)
|
||||
{
|
||||
var n = snoise(centre);
|
||||
var e = SNOISE_LIPSCHITZ * radius;
|
||||
return new(max(n - e, -1.0f), min(n + e, 1.0f));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encloses the fBm value, sum of a_i * snoise(x * f_i) over the octaves, for every x within distance
|
||||
/// radius of centre: the octave sum of iv_snoise_ball(centre * f_i, radius * |f_i|) scaled by a_i,
|
||||
/// with frequency f_i and amplitude a_i compounded exactly as the value-and-gradient fBm compounds
|
||||
/// them (its .w is what NoiseBoundTests.FbmBallEnclosesPoints encloses). octaves is clamped to [0, 16]
|
||||
/// as there.
|
||||
/// </summary>
|
||||
public static float2 iv_fbm_ball(float3 centre, float radius, int octaves, float lacunarity, float gain)
|
||||
{
|
||||
octaves = clamp(octaves, 0, 16);
|
||||
var amplitude = 1.0f;
|
||||
var frequency = 1.0f;
|
||||
var sum = new float2(0.0f, 0.0f);
|
||||
for (var i = 0; i < octaves; i++)
|
||||
{
|
||||
sum = iv_add(sum, iv_scale(iv_snoise_ball(centre * frequency, radius * abs(frequency)), amplitude));
|
||||
frequency *= lacunarity;
|
||||
amplitude *= gain;
|
||||
}
|
||||
|
||||
return sum;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A ball (xyz centre, w radius) enclosing every point of every ray of a screen tile over the depth
|
||||
/// segment [z0, z1], each point then moved by a warp of length at most warp: the ball to hand
|
||||
/// iv_snoise_ball (scaled by the noise frequency) when one probe serves the whole tile. Camera frame:
|
||||
/// a pinhole at the origin looking down +z, where the ray of slope m is the points (m z, z).
|
||||
/// centreSlope is the tile's central slope m_c; footprintPerDepth is the largest distance of any
|
||||
/// ray's slope from m_c, N / (sqrt(2) f) for an N x N pixel tile at focal length f pixels (the full
|
||||
/// pixel footprint, so sub-pixel jitter is covered). The centre is (m_c z_m, z_m), z_m = (z0 + z1) / 2;
|
||||
/// the radius is ((z1 - z0) / 2) |(m_c, 1)| + z1 footprintPerDepth + warp. Derivation: a point of slope
|
||||
/// m_c + e (|e| <= footprintPerDepth) at depth z is c + (z - z_m)(m_c, 1) + z (e, 0); the first term is
|
||||
/// at most the first radius term, the second at most the second, and the warp adds at most warp.
|
||||
/// The ball also carries its own float32 rounding: the radius is widened by (|c|_1 + radius) 2^-20.
|
||||
/// With u = 2^-24, each centre component carries at most two roundings (z_m, then the product), so the
|
||||
/// float centre is within 2u |c|_1 of the exact one; the radius carries at most seven, so the exact
|
||||
/// radius is at most (1 + 7u) times the float one; the widening rounds once more. 2^-20 = 16u covers
|
||||
/// 8u (|c|_1 + radius) twice over, and fma contraction only removes roundings. So every exact point of
|
||||
/// every ray, at any depth and slope, lies in the returned float ball
|
||||
/// (NoiseBoundTests.TileBallEnclosesEveryRaySegment, degenerate and far segments included). The caller
|
||||
/// rotates and translates the centre into world space (the radius is unchanged) and scales both by its
|
||||
/// noise frequency; the rounding of that transform is the caller's.
|
||||
/// </summary>
|
||||
public static float4 iv_frustum_ball(float2 centreSlope, float z0, float z1, float footprintPerDepth, float warp)
|
||||
{
|
||||
var zm = (z0 + z1) * 0.5f;
|
||||
var axis = sqrt(centreSlope.x * centreSlope.x + centreSlope.y * centreSlope.y + 1.0f);
|
||||
var cx = centreSlope.x * zm;
|
||||
var cy = centreSlope.y * zm;
|
||||
var radius = (z1 - z0) * 0.5f * axis + z1 * footprintPerDepth + warp;
|
||||
radius += (abs(cx) + abs(cy) + abs(zm) + radius) * 9.5367431640625e-7f;
|
||||
return new float4(cx, cy, zm, radius);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 3eb757caae684b388921e28e3c87ba3a
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
|
||||
// the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Phacelle noise. Copyright (c) 2025 Rune Skovbo Johansen (Shadertoy t3dyWl; MPL-2.0), read from the
|
||||
// C# port PhacelleNoise in lpmitchell/AdvancedTerrainErosion, converted to burstable C# by Luke
|
||||
// Mitchell (2026). Modified by GameCult: 3D cells, a caller-supplied stripe wave vector, an exact
|
||||
// gradient and exact pruning. This file and CultPhasor.cs are the MPL-2.0 files of CultMath; the
|
||||
// rest of the package is MIT (see THIRD-PARTY-NOTICES.md).
|
||||
|
||||
namespace CultMath;
|
||||
|
||||
public static partial class math
|
||||
{
|
||||
// Johansen's Phacelle noise (phase + cell), generalised to 3D cells with a caller-supplied
|
||||
// stripe wave vector. Each of the 4x4x4 cells around p carries one jittered feature point
|
||||
// (jitter in [-0.5, 0.5) per axis, from pcg3d over the integer cell coordinate, the same
|
||||
// integer-hashing rule as cellular) and one cosine/sine wave that runs along `side` (the wave
|
||||
// vector: its length is 2*pi times the stripe frequency, and the caller chooses which
|
||||
// perpendicular it is; on a unit sphere that is cross(p_hat, flow)). The waves are blended with
|
||||
// the bell weight max(0, exp(-2 d^2) - 0.01111) (exactly 0 at distance 1.5, the nearest a
|
||||
// feature outside the 4x4x4 block can be in 2D, which is what keeps cell-grid lines out), and
|
||||
// the blended phasor is normalized: its length is stretched to 1 wherever the raw length is at
|
||||
// least 1 - normalization, and scaled by 1 / (1 - normalization) below that. `offset` is the
|
||||
// phase in cycles (1 is a full turn). Value and normalization follow Johansen's
|
||||
// PhacelleNoise as ported in lpmitchell/AdvancedTerrainErosion.
|
||||
//
|
||||
// `normalization` is meant to lie in [0, 1]. At 0 the output is the raw blend (a phasor of
|
||||
// length at most 1); at 1 the floor is 0 and every output is stretched to length 1. Values of
|
||||
// 1 or more saturate at that (the floor is never above the raw length), and a negative value
|
||||
// scales every output down by 1 / (1 - normalization) < 1. The normalized gradient scales as
|
||||
// 1 / |I| where I is the blended phasor (below), so it grows wherever the blend nearly
|
||||
// cancels; a normalization near 1 lets that happen at every point.
|
||||
//
|
||||
// The gradient is exact: it carries the weight derivative (grad w = -4 v exp(-2 |v|^2) where
|
||||
// w > 0) and the normalization, not upstream's convention grad(cos) ~ -sin * side, which
|
||||
// ignores the weight gradient (Asura probe: 16.8 deg off at the median, 97.8 at the 95th
|
||||
// percentile; the exact form is 0.00 and 0.03). With P = sum(w * (cos, sin)) and W = sum(w),
|
||||
// I = P / W has grad I = (grad P - I * grad W) / W; above the threshold the output is I / |I|
|
||||
// with gradient (grad I - out * grad|I|) / |I|, below it grad I / (1 - normalization). The
|
||||
// value is continuous at both seams below; the gradient is not.
|
||||
// * Where |I| crosses the threshold, the gradient jumps between the two branches.
|
||||
// * Where a cell's weight leaves its support (d^2 = ln(1 / 0.01111) / 2 = 2.24995, distance
|
||||
// 1.49998), grad w jumps from -4 * 0.01111 * v to 0: a per-cell jump of length
|
||||
// 0.0444 * |v|, 0.0667 at |v| = 1.5. Measured over random points, the resulting jump in
|
||||
// the output gradient is a median 1.5% of its length and at most about 27%.
|
||||
//
|
||||
// W is the sum of 64 weights and is positive for every input pcg3d produces (the smallest
|
||||
// W found by hill-climbing the hash-driven cell contents is 0.056). It is 0, and the output
|
||||
// NaN, only if all 64 weights vanish, which takes adversarial jitter no hash yields.
|
||||
//
|
||||
// Pruning is exact: per axis, |local - g| - 0.5 (floored at 0) lower-bounds |v| for any jitter,
|
||||
// so a cell whose summed square is at least 2.25 lies outside the support (2.24995 < 2.25) and
|
||||
// has weight exactly 0: it contributes nothing to any sum. Cells whose bound sits just under
|
||||
// 2.25 can still have a nonzero weight, so the cut cannot be lowered. On average the prune
|
||||
// keeps about 45 of the 64 cells (a saving of about 30%); only about 14 have a nonzero weight.
|
||||
// Only the internal overload can switch the prune off; PhacelleTests uses it to pin bit equality.
|
||||
public static CultPhasor phacelle(float3 p, float3 side, float offset, float normalization) =>
|
||||
phacelle(p, side, offset, normalization, true);
|
||||
|
||||
internal static CultPhasor phacelle(float3 p, float3 side, float offset, float normalization, bool prune)
|
||||
{
|
||||
var cell = floor(p);
|
||||
var local = p - cell;
|
||||
var phaseOffset = offset * TAU;
|
||||
var sumW = 0.0f;
|
||||
var sumCos = 0.0f;
|
||||
var sumSin = 0.0f;
|
||||
var gradW = new float3(0.0f, 0.0f, 0.0f);
|
||||
var gradCos = new float3(0.0f, 0.0f, 0.0f);
|
||||
var gradSin = new float3(0.0f, 0.0f, 0.0f);
|
||||
|
||||
for (var dz = -1; dz <= 2; dz++)
|
||||
{
|
||||
var gz = max(0.0f, abs(local.z - (float)dz) - 0.5f);
|
||||
for (var dy = -1; dy <= 2; dy++)
|
||||
{
|
||||
var gy = max(0.0f, abs(local.y - (float)dy) - 0.5f);
|
||||
for (var dx = -1; dx <= 2; dx++)
|
||||
{
|
||||
var gx = max(0.0f, abs(local.x - (float)dx) - 0.5f);
|
||||
if (prune && gx * gx + gy * gy + gz * gz >= 2.25f)
|
||||
continue;
|
||||
|
||||
var gridStep = new float3(dx, dy, dz);
|
||||
var hash = pcg3d(int3(cell + gridStep));
|
||||
var jitter = new float3(cellular_unit(hash.x) - 0.5f, cellular_unit(hash.y) - 0.5f, cellular_unit(hash.z) - 0.5f);
|
||||
var v = local - gridStep - jitter;
|
||||
var falloff = exp(-2.0f * dot(v, v));
|
||||
var w = max(0.0f, falloff - 0.01111f);
|
||||
var dw = w > 0.0f ? (-4.0f * falloff) * v : new float3(0.0f, 0.0f, 0.0f);
|
||||
var phase = dot(v, side) + phaseOffset;
|
||||
var c = cos(phase);
|
||||
var s = sin(phase);
|
||||
|
||||
sumW += w;
|
||||
sumCos += w * c;
|
||||
sumSin += w * s;
|
||||
gradW += dw;
|
||||
gradCos += dw * c - (w * s) * side;
|
||||
gradSin += dw * s + (w * c) * side;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var invW = 1.0f / sumW;
|
||||
var rawCos = sumCos * invW;
|
||||
var rawSin = sumSin * invW;
|
||||
var gradRawCos = (gradCos - rawCos * gradW) * invW;
|
||||
var gradRawSin = (gradSin - rawSin * gradW) * invW;
|
||||
var rawLength = sqrt(rawCos * rawCos + rawSin * rawSin);
|
||||
var floorLength = 1.0f - normalization;
|
||||
|
||||
if (rawLength > floorLength)
|
||||
{
|
||||
var outCos = rawCos / rawLength;
|
||||
var outSin = rawSin / rawLength;
|
||||
var gradLength = (rawCos * gradRawCos + rawSin * gradRawSin) / rawLength;
|
||||
return new CultPhasor(
|
||||
new float4((gradRawCos - outCos * gradLength) / rawLength, outCos),
|
||||
new float4((gradRawSin - outSin * gradLength) / rawLength, outSin));
|
||||
}
|
||||
|
||||
return new CultPhasor(
|
||||
new float4(gradRawCos / floorLength, rawCos / floorLength),
|
||||
new float4(gradRawSin / floorLength, rawSin / floorLength));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 2c9d4e6b1a8f47d3b05e7a3c9d1f8e26
|
||||
|
|
@ -1,10 +1,5 @@
|
|||
using System;
|
||||
|
||||
// Cut 2a-i fix batch 1: cellular_unit's [0,1) mapping bug (F7) is only reachable from cellular's
|
||||
// private call sites; this lets CultMath.Tests probe it directly instead of hunting for a hash
|
||||
// input that happens to land on the bit pattern under test.
|
||||
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("CultMath.Tests")]
|
||||
|
||||
namespace CultMath;
|
||||
|
||||
public static partial class math
|
||||
|
|
@ -110,9 +105,6 @@ public static partial class math
|
|||
public static float2 frac(float2 value) => value - floor(value);
|
||||
public static float3 frac(float3 value) => value - floor(value);
|
||||
public static float4 frac(float4 value) => value - floor(value);
|
||||
// dxc has no double Frc: it demotes to float, as it does for exp and lerp. CultMath's double
|
||||
// overloads keep double precision instead, so frac(double) is x - floor(x) in double.
|
||||
public static double frac(double value) => value - Math.Floor(value);
|
||||
|
||||
// dxc lowers float min/max/clamp to DXIL FMin/FMax (saturate to Saturate, defined as FMin(1, FMax(0, x))).
|
||||
// DXIL.rst: FMin(a, b) is a < b ? a : b, FMax(a, b) is a >= b ? a : b, and a NaN operand returns the other.
|
||||
|
|
@ -138,11 +130,6 @@ public static partial class math
|
|||
public static float2 clamp(float2 value, float2 minimum, float2 maximum) => min(max(value, minimum), maximum);
|
||||
public static float3 clamp(float3 value, float3 minimum, float3 maximum) => min(max(value, minimum), maximum);
|
||||
public static float4 clamp(float4 value, float4 minimum, float4 maximum) => min(max(value, minimum), maximum);
|
||||
// dxc lowers int clamp to IMin(IMax(x, a), b): inverted bounds return b rather than throwing like Math.Clamp.
|
||||
public static int clamp(int value, int minimum, int maximum) => min(max(value, minimum), maximum);
|
||||
public static int2 clamp(int2 value, int2 minimum, int2 maximum) => min(max(value, minimum), maximum);
|
||||
public static int3 clamp(int3 value, int3 minimum, int3 maximum) => min(max(value, minimum), maximum);
|
||||
public static int4 clamp(int4 value, int4 minimum, int4 maximum) => min(max(value, minimum), maximum);
|
||||
|
||||
// DXIL Saturate is FMin(1, FMax(0, x)); that operand order, unlike clamp(x, 0, 1), maps -0 to +0.
|
||||
public static float saturate(float value) => min(1.0f, max(0.0f, value));
|
||||
|
|
@ -218,10 +205,9 @@ public static partial class math
|
|||
public static float distance(float3 left, float3 right) => length(left - right);
|
||||
public static float distance(float4 left, float4 right) => length(left - right);
|
||||
|
||||
// dxc lowers normalize(x) to x * Rsqrt(Dot(x, x)), and DXIL Rsqrt is 1 / sqrt(src): a zero vector is 0 * inf = NaN.
|
||||
public static float2 normalize(float2 value) => value * (1.0f / MathF.Sqrt(dot(value, value)));
|
||||
public static float3 normalize(float3 value) => value * (1.0f / MathF.Sqrt(dot(value, value)));
|
||||
public static float4 normalize(float4 value) => value * (1.0f / MathF.Sqrt(dot(value, value)));
|
||||
public static float2 normalize(float2 value) => value / max(length(value), 1.0e-20f);
|
||||
public static float3 normalize(float3 value) => value / max(length(value), 1.0e-20f);
|
||||
public static float4 normalize(float4 value) => value / max(length(value), 1.0e-20f);
|
||||
public static quaternion normalize(quaternion value)
|
||||
{
|
||||
var length = MathF.Sqrt(value.x * value.x + value.y * value.y + value.z * value.z + value.w * value.w);
|
||||
|
|
@ -387,44 +373,6 @@ public static partial class math
|
|||
public static float hash(float2 value) => hash(dot(value, new float2(127.1f, 311.7f)));
|
||||
public static float hash(float3 value) => hash(dot(value, new float3(127.1f, 311.7f, 74.7f)));
|
||||
|
||||
public static uint asuint(float value) => (uint)BitConverter.SingleToInt32Bits(value);
|
||||
public static float asfloat(uint value) => BitConverter.Int32BitsToSingle((int)value);
|
||||
|
||||
// PCG hashes from Jarzynski and Olano, "Hash Functions for GPU Rendering" (JCGT 9(3), 2020). Integer-only, so
|
||||
// dxc and C# agree bit for bit. pcg3d/pcg4d carry uint bit patterns in int3/int4; float overloads hash IEEE bits.
|
||||
public static uint pcg(uint value)
|
||||
{
|
||||
var state = value * 747796405u + 2891336453u;
|
||||
var word = ((state >> (int)((state >> 28) + 4)) ^ state) * 277803737u;
|
||||
return (word >> 22) ^ word;
|
||||
}
|
||||
|
||||
public static int3 pcg3d(int3 value)
|
||||
{
|
||||
uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u, z = (uint)value.z * 1664525u + 1013904223u;
|
||||
x += y * z; y += z * x; z += x * y;
|
||||
x ^= x >> 16; y ^= y >> 16; z ^= z >> 16;
|
||||
x += y * z; y += z * x; z += x * y;
|
||||
return new int3((int)x, (int)y, (int)z);
|
||||
}
|
||||
|
||||
// The paper hashes 2 -> N inputs through pcg3d with the unused component held constant.
|
||||
public static int3 pcg3d(float2 value) => pcg3d(new float3(value, 0.0f));
|
||||
public static int3 pcg3d(float3 value) => pcg3d(new int3((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z)));
|
||||
|
||||
public static int4 pcg4d(int4 value)
|
||||
{
|
||||
uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u;
|
||||
uint z = (uint)value.z * 1664525u + 1013904223u, w = (uint)value.w * 1664525u + 1013904223u;
|
||||
x += y * w; y += z * x; z += x * y; w += y * z;
|
||||
x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; w ^= w >> 16;
|
||||
x += y * w; y += z * x; z += x * y; w += y * z;
|
||||
return new int4((int)x, (int)y, (int)z, (int)w);
|
||||
}
|
||||
|
||||
public static int4 pcg4d(float4 value) =>
|
||||
pcg4d(new int4((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z), (int)asuint(value.w)));
|
||||
|
||||
public static float log(float value) => MathF.Log(value);
|
||||
public static float2 log(float2 value) => new(log(value.x), log(value.y));
|
||||
public static float3 log(float3 value) => new(log(value.x), log(value.y), log(value.z));
|
||||
|
|
@ -527,81 +475,8 @@ public static partial class math
|
|||
a._m20 * b._m01 + a._m21 * b._m11 + a._m22 * b._m21,
|
||||
a._m20 * b._m02 + a._m21 * b._m12 + a._m22 * b._m22);
|
||||
|
||||
// Abramowitz & Stegun 7.1.26, a single-precision rational/exponential fit with a stated
|
||||
// max absolute error of 1.5e-7. erf is odd, so only x >= 0 is fit and the sign is restored.
|
||||
// Measured against an independent reference: worst absolute error 6.621e-7, inside the
|
||||
// 2e-6 bar this package's tests pin.
|
||||
public static float erf(float value)
|
||||
{
|
||||
const float p = 0.3275911f;
|
||||
const float a1 = 0.254829592f;
|
||||
const float a2 = -0.284496736f;
|
||||
const float a3 = 1.421413741f;
|
||||
const float a4 = -1.453152027f;
|
||||
const float a5 = 1.061405429f;
|
||||
|
||||
var s = sign(value);
|
||||
var x = abs(value);
|
||||
var t = 1.0f / (1.0f + p * x);
|
||||
var poly = ((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t;
|
||||
var result = 1.0f - poly * exp(-x * x);
|
||||
return s * result;
|
||||
}
|
||||
|
||||
// Giles, "Approximating the erfinv function" (GPU Computing Gems, 2010): a single-precision
|
||||
// minimax polynomial in two ranges of w = -ln((1-x)(1+x)), central and tail.
|
||||
// Domain is [-1, 1]. Outside it, including +-infinity, the result is NaN (there is no real
|
||||
// inverse). At the edges the mathematical limit is infinite: erfinv(1) = +infinity and
|
||||
// erfinv(-1) = -infinity. The unguarded polynomial hits log(0) = -infinity at those edges and
|
||||
// the tail branch's leading coefficient is negative, so it delivers the wrong sign; guard the
|
||||
// edges explicitly instead of trusting the fit through the singularity.
|
||||
// Measured against an independent reference: worst absolute error 5.066e-7, and the
|
||||
// erf(erfinv(y)) round trip 6.109e-7 worst case, both inside the 2e-6 bar this package's
|
||||
// tests pin.
|
||||
public static float erfinv(float value)
|
||||
{
|
||||
if (value >= 1.0f) return value > 1.0f ? float.NaN : float.PositiveInfinity;
|
||||
if (value <= -1.0f) return value < -1.0f ? float.NaN : float.NegativeInfinity;
|
||||
|
||||
var w = -log((1.0f - value) * (1.0f + value));
|
||||
float p;
|
||||
if (w < 5.0f)
|
||||
{
|
||||
w -= 2.5f;
|
||||
p = 2.81022636e-08f;
|
||||
p = 3.43273939e-07f + p * w;
|
||||
p = -3.5233877e-06f + p * w;
|
||||
p = -4.39150654e-06f + p * w;
|
||||
p = 0.00021858087f + p * w;
|
||||
p = -0.00125372503f + p * w;
|
||||
p = -0.00417768164f + p * w;
|
||||
p = 0.246640727f + p * w;
|
||||
p = 1.50140941f + p * w;
|
||||
}
|
||||
else
|
||||
{
|
||||
w = sqrt(w) - 3.0f;
|
||||
p = -0.000200214257f;
|
||||
p = 0.000100950558f + p * w;
|
||||
p = 0.00134934322f + p * w;
|
||||
p = -0.00367342844f + p * w;
|
||||
p = 0.00573950773f + p * w;
|
||||
p = -0.0076224613f + p * w;
|
||||
p = 0.00943887047f + p * w;
|
||||
p = 1.00167406f + p * w;
|
||||
p = 2.83297682f + p * w;
|
||||
}
|
||||
|
||||
return p * value;
|
||||
}
|
||||
|
||||
// Ashima Arts / Ian McEwan 3D simplex noise (MIT), mirrored by cultmath_snoise(float3)
|
||||
// in shaders/CultMath.hlsl with the same float32 evaluation order. Follows stegu/webgl-noise
|
||||
// src/noise3D.glsl at 22434e04d7 (kernel radius^2 0.5, scale 105, permute (34x+10)x); see
|
||||
// THIRD-PARTY-NOTICES.md. Continuous across simplex-cell boundaries
|
||||
// (NoiseGradTests.SnoiseAndItsGradientAreContinuousAcrossSimplexCellBoundaries).
|
||||
// SNOISE_LIPSCHITZ (math.Interval.cs) is measured from this kernel: a change here must re-run
|
||||
// NoiseBoundTests.MeasureLipschitz and re-pin it.
|
||||
// in shaders/CultMath.hlsl with the same float32 evaluation order.
|
||||
public static float snoise(float3 value)
|
||||
{
|
||||
const float cx = 1.0f / 6.0f;
|
||||
|
|
@ -648,9 +523,9 @@ public static partial class math
|
|||
p2 *= norm.z;
|
||||
p3 *= norm.w;
|
||||
|
||||
var m = max(0.5f - new float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0f);
|
||||
var m = max(0.6f - new float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0f);
|
||||
m = m * m;
|
||||
return 105.0f * dot(m * m, new float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
|
||||
return 42.0f * dot(m * m, new float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
|
||||
}
|
||||
|
||||
// Ashima Arts / Ian McEwan 2D simplex noise, kept component-explicit so
|
||||
|
|
@ -696,280 +571,8 @@ public static partial class math
|
|||
private static float2 snoise_mod289(float2 value) => value - floor(value * (1.0f / 289.0f)) * 289.0f;
|
||||
private static float3 snoise_mod289(float3 value) => value - floor(value * (1.0f / 289.0f)) * 289.0f;
|
||||
private static float4 snoise_mod289(float4 value) => value - floor(value * (1.0f / 289.0f)) * 289.0f;
|
||||
private static float3 snoise_permute(float3 value) => snoise_mod289(((value * 34.0f) + 10.0f) * value);
|
||||
private static float4 snoise_permute(float4 value) => snoise_mod289(((value * 34.0f) + 10.0f) * value);
|
||||
|
||||
// Analytic gradient of snoise(float3) (invariant 8), following the differentiation in
|
||||
// webgl-noise src/noise3Dgrad.glsl (Ashima Arts / McEwan, MIT), which uses the same kernel
|
||||
// radius^2 0.5, permutation (34x+10)x and scale 105 as snoise(float3) (stegu/webgl-noise
|
||||
// 22434e04d7). Every local up through p0..p3 and x0..x3 below is snoise(float3)'s own derivation
|
||||
// verbatim, so .w is that same field (NoiseGradTests.SnoiseGradValueIsBitEqualToSnoise pins the
|
||||
// two bit-equal over a random sweep). Per corner, m0 = max(0.5 - dot(x,x), 0), so dm0/dx = -2x,
|
||||
// and d(m0^4 * dot(p,x))/dx = 4*m0^3*(-2x)*dot(p,x) + m0^4*p = -8*m0^3*dot(p,x)*x + m0^4*p (p is
|
||||
// that corner's own gradient constant, independent of x); summed over the four corners and
|
||||
// scaled by the same 105 as the value.
|
||||
public static float4 snoise_grad(float3 value)
|
||||
{
|
||||
const float cx = 1.0f / 6.0f;
|
||||
const float cy = 1.0f / 3.0f;
|
||||
var i = floor(value + dot(value, new float3(cy, cy, cy)));
|
||||
var x0 = value - i + dot(i, new float3(cx, cx, cx));
|
||||
var g = step(new float3(x0.y, x0.z, x0.x), x0);
|
||||
var l = 1.0f - g;
|
||||
var lzxy = new float3(l.z, l.x, l.y);
|
||||
var i1 = min(g, lzxy);
|
||||
var i2 = max(g, lzxy);
|
||||
var x1 = x0 - i1 + cx;
|
||||
var x2 = x0 - i2 + cy;
|
||||
var x3 = x0 - 0.5f;
|
||||
|
||||
i = snoise_mod289(i);
|
||||
var p = snoise_permute(snoise_permute(snoise_permute(
|
||||
i.z + new float4(0.0f, i1.z, i2.z, 1.0f)) + i.y + new float4(0.0f, i1.y, i2.y, 1.0f)) + i.x + new float4(0.0f, i1.x, i2.x, 1.0f));
|
||||
|
||||
const float n = 0.142857142857f;
|
||||
var ns = new float3(2.0f * n, 0.5f * n - 1.0f, n);
|
||||
var j = p - 49.0f * floor(p * ns.z * ns.z);
|
||||
var xs = floor(j * ns.z);
|
||||
var ys = floor(j - 7.0f * xs);
|
||||
var x = xs * ns.x + ns.y;
|
||||
var y = ys * ns.x + ns.y;
|
||||
var h = 1.0f - abs(x) - abs(y);
|
||||
|
||||
var b0 = new float4(x.x, x.y, y.x, y.y);
|
||||
var b1 = new float4(x.z, x.w, y.z, y.w);
|
||||
var s0 = floor(b0) * 2.0f + 1.0f;
|
||||
var s1 = floor(b1) * 2.0f + 1.0f;
|
||||
var sh = -step(h, 0.0f);
|
||||
var a0 = new float4(b0.x, b0.z, b0.y, b0.w) + new float4(s0.x, s0.z, s0.y, s0.w) * new float4(sh.x, sh.x, sh.y, sh.y);
|
||||
var a1 = new float4(b1.x, b1.z, b1.y, b1.w) + new float4(s1.x, s1.z, s1.y, s1.w) * new float4(sh.z, sh.z, sh.w, sh.w);
|
||||
|
||||
var p0 = new float3(a0.x, a0.y, h.x);
|
||||
var p1 = new float3(a0.z, a0.w, h.y);
|
||||
var p2 = new float3(a1.x, a1.y, h.z);
|
||||
var p3 = new float3(a1.z, a1.w, h.w);
|
||||
var norm = 1.79284291400159f - 0.85373472095314f * new float4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3));
|
||||
p0 *= norm.x;
|
||||
p1 *= norm.y;
|
||||
p2 *= norm.z;
|
||||
p3 *= norm.w;
|
||||
|
||||
var m0 = max(0.5f - new float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0f);
|
||||
var m2 = m0 * m0;
|
||||
var m3 = m2 * m0;
|
||||
var m4 = m2 * m2;
|
||||
|
||||
var px = new float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3));
|
||||
var value2 = 105.0f * dot(m4, px);
|
||||
var grad = -8.0f * m3.x * x0 * px.x + m4.x * p0
|
||||
+ -8.0f * m3.y * x1 * px.y + m4.y * p1
|
||||
+ -8.0f * m3.z * x2 * px.z + m4.z * p2
|
||||
+ -8.0f * m3.w * x3 * px.w + m4.w * p3;
|
||||
grad *= 105.0f;
|
||||
|
||||
return new float4(grad, value2);
|
||||
}
|
||||
|
||||
// Octave sum of snoise_grad (invariant 8). Each octave samples snoise_grad at p scaled by the
|
||||
// running frequency, so by the chain rule its gradient scales by that same frequency
|
||||
// (d/dp[n(f*p)] = f * grad_n(f*p)); amplitude and frequency both compound per octave (gain,
|
||||
// lacunarity respectively), not applied once for the whole sum, so with 1 octave this is exactly
|
||||
// snoise_grad (NoiseGradTests.FbmGradWithOneOctaveEqualsSnoiseGrad). octaves is clamped to
|
||||
// [0, 16]: HlslSourceCompatibilityTests' bit-parity oracle drives every mirrored int parameter
|
||||
// across the full int32 range, which is the right domain for pcg3d/pcg4d's O(1) hash inputs but
|
||||
// would turn an unclamped octave count into a multi-billion-iteration loop for this mirror.
|
||||
public static float4 fbm_grad(float3 p, int octaves, float lacunarity, float gain)
|
||||
{
|
||||
octaves = clamp(octaves, 0, 16);
|
||||
var amplitude = 1.0f;
|
||||
var frequency = 1.0f;
|
||||
var value = 0.0f;
|
||||
var gradient = new float3(0.0f, 0.0f, 0.0f);
|
||||
for (var i = 0; i < octaves; i++)
|
||||
{
|
||||
var n = snoise_grad(p * frequency);
|
||||
value += amplitude * n.w;
|
||||
gradient += amplitude * frequency * new float3(n.x, n.y, n.z);
|
||||
frequency *= lacunarity;
|
||||
amplitude *= gain;
|
||||
}
|
||||
|
||||
return new float4(gradient, value);
|
||||
}
|
||||
|
||||
// Ridged multifractal noise (Musgrave, Texturing and Modeling, ch. 16; the operator's dune term,
|
||||
// 2026-09-25): each octave folds snoise_grad about zero, value = Sum a_i*(1 - |n_i|), gradient =
|
||||
// -Sum a_i*f_i*sign(n_i)*grad(n_i), the same per-octave frequency scaling as fbm_grad's chain
|
||||
// rule plus the fold's sign flip (d|n|/dp = sign(n)*dn/dp). The crease at n_i = 0 is real and
|
||||
// deliberate, not a bug: NoiseGradTests excludes a band around it, the same way cellular excludes
|
||||
// its F1 = F2 seam. sign follows this codebase's HLSL-matching convention (design.md: sign
|
||||
// returns int, 0 at exactly 0), so the crease itself contributes no gradient rather than an
|
||||
// arbitrary one. Same octave clamp as fbm_grad, for the same bit-parity-oracle reason.
|
||||
public static float4 ridged_grad(float3 p, int octaves, float lacunarity, float gain)
|
||||
{
|
||||
octaves = clamp(octaves, 0, 16);
|
||||
var amplitude = 1.0f;
|
||||
var frequency = 1.0f;
|
||||
var value = 0.0f;
|
||||
var gradient = new float3(0.0f, 0.0f, 0.0f);
|
||||
for (var i = 0; i < octaves; i++)
|
||||
{
|
||||
var n = snoise_grad(p * frequency);
|
||||
var s = (float)sign(n.w);
|
||||
value += amplitude * (1.0f - abs(n.w));
|
||||
gradient += -amplitude * frequency * s * new float3(n.x, n.y, n.z);
|
||||
frequency *= lacunarity;
|
||||
amplitude *= gain;
|
||||
}
|
||||
|
||||
return new float4(gradient, value);
|
||||
}
|
||||
|
||||
// Inigo Quilez, "smooth minimum" (https://iquilezles.org/articles/smin/): the quadratic-polynomial
|
||||
// smin, carried to value-and-gradient form (invariant 8). h is affine in (b.w - a.w) inside the
|
||||
// smoothing band, so differentiating value = lerp(b.w, a.w, h) - k*h*(1-h) with respect to
|
||||
// position, the terms carrying dh/dp cancel exactly (dh/dp * [(a.w-b.w) - k + 2*k*h] and the
|
||||
// bracket is identically zero given how h is built from a.w-b.w). What is left is exactly
|
||||
// lerp(∇b, ∇a, h): the gradient blend is not an approximation, it is the analytic gradient.
|
||||
// Outside the band (|a.w - b.w| >= k), h saturates to 0 or 1 and this is exactly min(a, b) with
|
||||
// that input's own gradient, continuously (h and the correction term both reach the boundary at
|
||||
// the same value from the smooth side, so there is no kink to exclude here the way cellular's
|
||||
// F1 = F2 seam needs one).
|
||||
public static float4 smin_grad(float4 a, float4 b, float k)
|
||||
{
|
||||
var h = saturate(0.5f + 0.5f * (b.w - a.w) / k);
|
||||
var value = lerp(b.w, a.w, h) - k * h * (1.0f - h);
|
||||
var gradient = lerp(new float3(b.x, b.y, b.z), new float3(a.x, a.y, a.z), h);
|
||||
return new float4(gradient, value);
|
||||
}
|
||||
|
||||
// Worley, "A Cellular Texture Basis Function" (SIGGRAPH 1996): F1/F2 and their analytic
|
||||
// gradients, searched over the jittered neighbourhood of feature points. Feature points are
|
||||
// selected with pcg3d over each neighbour's integer cell coordinate (design.md, "Integer
|
||||
// hashing"), never the float32 bit pattern of that coordinate and never the sin-based hash.
|
||||
// Hashing the integer cell coordinate directly is exact and runtime-independent, and it is what
|
||||
// the HLSL mirror does, so C# and shader stay bit-for-bit aligned. What pins this is the oracle
|
||||
// and mirror tests, not a uniformity measurement: reverting to hashing the float32 bit pattern
|
||||
// (or the sin-based hash) is caught by ProductionSearchMatchesTheBruteForceOracleExactly,
|
||||
// EveryMirrorFunctionMatchesCSharpMath, F1NeverExceedsF2, and the other tests that key off the
|
||||
// integer-hash fixtures. The
|
||||
// gradient of a distance field, del|p - c|, is the unit vector (p - c)/|p - c|; that is undefined exactly at
|
||||
// a feature point (F1 = 0 or F2 = 0), so cellular follows the repo's existing degenerate-normal
|
||||
// convention (GameCult.Geometry.CultGeometryIsoSurface.EmitOrientedTriangle: guard the zero-length
|
||||
// case and return the zero vector instead of the NaN a bare normalize would produce there).
|
||||
//
|
||||
// The search radius is 2 cells on every axis (5x5x5), not the classic 1-cell (3x3x3) radius,
|
||||
// because 3x3x3 is not exact: measured 9 of 400,000 points in [-50,50]^3 land on the wrong F2
|
||||
// (worst error 0.088), and past |p| of about 2^23 the float32 jitter can put two distinct cells'
|
||||
// features on the same value, which 3x3x3 has no chance of noticing either way. 5x5x5 is exact,
|
||||
// not merely safer, by the following argument:
|
||||
//
|
||||
// Let u = p - cell be the query's position inside its own cell (u in [0,1)^3 per component), and
|
||||
// let a candidate feature at integer cell offset d = (dx, dy, dz) sit at d + j for some jitter j
|
||||
// in [0,1)^3. Per axis, the displacement from p to that feature is (u_i - j_i) - d_i; since
|
||||
// u_i - j_i is always strictly inside (-1, 1) regardless of the actual u_i and j_i values,
|
||||
// |displacement_i| is always strictly greater than max(0, |d_i| - 1). So every cell with |d_i| >=
|
||||
// 3 on some axis is strictly farther than 2 from p (that axis alone already gives max(0, 3-1) =
|
||||
// 2). Now bound F1 and F2 from above using two real, always-present candidates: the query's own
|
||||
// cell (d = 0), whose distance is always strictly less than sqrt(3) (every axis strictly under
|
||||
// 1); and the near-side neighbour on whichever single axis's u_i sits closest to a cell
|
||||
// boundary. Let m = min(u_i, 1 - u_i) on that chosen axis (m is in [0, 1/2] by construction,
|
||||
// since it is the smaller of the two). Stepping one cell towards that nearer boundary puts the
|
||||
// query within (1 + m) of the far face on the offset axis and within (1 - m) of the near face on
|
||||
// each of the other two axes, so that neighbour's squared distance is at most
|
||||
// (1 + m)^2 + 2*(1 - m)^2 = 3 - 2m + 3m^2, which is <= 3 for every m in [0, 1/2] (3 at m = 0,
|
||||
// dipping to a minimum of 8/3 ~= 2.667 at m = 1/3, then rising back to 2.75 at m = 1/2), and
|
||||
// strictly below 3 because the jitter never reaches exactly 0 or
|
||||
// 1 (u_i - j_i stays strictly inside (-1, 1), so this bound, like the own-cell one, is never
|
||||
// tight). F2 is at most the larger of these two real candidates (adding candidates to a set can
|
||||
// only lower or hold its 2nd-smallest value), so F1 < sqrt(3) and F2 < sqrt(3) always: roughly
|
||||
// 1.732 < 2, margin 2 - sqrt(3) ~= 0.268 (Soul's adversarial check, cut 2a-i: the worst
|
||||
// realizable in-box F2 is exactly sqrt(3), reached only in the unattainable limit at a cell
|
||||
// corner). Every cell outside the 5x5x5 block is farther than 2, hence farther than either F1 or
|
||||
// F2, so the true global F1 and F2 are always inside it. (The matching argument for 3x3x3 only
|
||||
// reaches radius 1, whose excluded cells start at distance > 1, which is less than the 1.732
|
||||
// ceiling — the gap 3x3x3's failures live in.)
|
||||
//
|
||||
// An earlier version of this proof picked the near-side neighbour on a fixed axis rather than
|
||||
// whichever axis sits closest to its boundary, and that version is wrong (Soul, cut 2a-i):
|
||||
// u = (0.5, 0, 0) with the x-neighbour's jittered feature at (-1, 1 - 2^-24, 1 - 2^-24) gives a
|
||||
// distance of about 2.06, outside the radius-2 exclusion the proof needs. Choosing the axis by
|
||||
// proximity to its own boundary is what keeps m <= 1/2 and the bound at sqrt(3); x was the wrong
|
||||
// axis to fix there because u.x = 0.5 is the point on the whole cube farthest from any boundary.
|
||||
//
|
||||
// The loop below still visits every one of the 125 cells in a fixed order, but skips the
|
||||
// pcg3d/jitter/distance work for a cell whose distance cannot possibly beat the current f2: that
|
||||
// per-cell lower bound (the max(0, |d_i| - 1) term above, combined across axes) depends only on
|
||||
// the integer offset, never on p or the hash, so pruning a cell the unpruned loop would also
|
||||
// have rejected changes no output bit (CellularExactSearchTests pins the pruned/unpruned
|
||||
// equivalence and the exactness against a 7x7x7 brute-force oracle).
|
||||
public static CultCellular cellular(float3 p)
|
||||
{
|
||||
var cell = floor(p);
|
||||
var f1 = 1.0e30f;
|
||||
var f2 = 1.0e30f;
|
||||
var c1 = new float3(0.0f, 0.0f, 0.0f);
|
||||
var c2 = new float3(0.0f, 0.0f, 0.0f);
|
||||
var cellId = new float3(0.0f, 0.0f, 0.0f);
|
||||
|
||||
for (var dz = -2; dz <= 2; dz++)
|
||||
{
|
||||
var lz = max(0.0f, abs((float)dz) - 1.0f);
|
||||
for (var dy = -2; dy <= 2; dy++)
|
||||
{
|
||||
var ly = max(0.0f, abs((float)dy) - 1.0f);
|
||||
for (var dx = -2; dx <= 2; dx++)
|
||||
{
|
||||
var lx = max(0.0f, abs((float)dx) - 1.0f);
|
||||
var lowerBound = sqrt(lx * lx + ly * ly + lz * lz);
|
||||
if (lowerBound >= f2)
|
||||
continue;
|
||||
|
||||
var neighbor = cell + new float3(dx, dy, dz);
|
||||
var hash = pcg3d(int3(neighbor));
|
||||
var jitter = new float3(cellular_unit(hash.x), cellular_unit(hash.y), cellular_unit(hash.z));
|
||||
var feature = neighbor + jitter;
|
||||
var d = distance(p, feature);
|
||||
|
||||
if (d < f1)
|
||||
{
|
||||
f2 = f1; c2 = c1;
|
||||
f1 = d; c1 = feature; cellId = neighbor;
|
||||
}
|
||||
else if (d < f2)
|
||||
{
|
||||
f2 = d; c2 = feature;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// F1 = 0 is reachable (p sits exactly on a feature point). F2 = 0 needs two distinct cells'
|
||||
// pcg3d-jittered features to land on the same float32 value in every component: unreachable
|
||||
// at ordinary magnitudes, but confirmed reachable past roughly |p| = 2^23 (design.md,
|
||||
// "Precision domain"), so both share the same degenerate-gradient guard.
|
||||
var grad1 = f1 > 0.0f ? (p - c1) / f1 : new float3(0.0f, 0.0f, 0.0f);
|
||||
var grad2 = f2 > 0.0f ? (p - c2) / f2 : new float3(0.0f, 0.0f, 0.0f);
|
||||
|
||||
// id comes from pcg4d over the winning cell's integer coordinate alone (same int-hashing
|
||||
// rule as the jitter above), never from the pcg3d hash that produced its jitter: sharing a
|
||||
// hash between id and jitter would make id a deterministic function of the jitter it is
|
||||
// supposed to be independent from, correlated across every cell (F4).
|
||||
var idHash = pcg4d(new int4(int3(cellId), 0));
|
||||
|
||||
return new CultCellular(
|
||||
new float4(grad1, f1),
|
||||
new float4(grad2 - grad1, f2 - f1),
|
||||
cellular_unit(idHash.w));
|
||||
}
|
||||
|
||||
// Maps a hash component's uint bit pattern to [0, 1) using the top 24 bits, so the result is
|
||||
// always strictly less than 1: a plain divide-by-2^32 rounds 0xFFFFFF80..0xFFFFFFFF up to
|
||||
// exactly 1.0f in float32, outside the documented range. (uint >> 8) has at most 24 significant
|
||||
// bits, which float32's 24-bit mantissa represents exactly, and the division is by an exact power
|
||||
// of two, so both the shift-and-convert and the divide are the same IEEE-754 round-to-nearest on
|
||||
// dxc and C#, bit-exact on both sides.
|
||||
internal static float cellular_unit(int bits) => ((uint)bits >> 8) * (1.0f / 16777216.0f);
|
||||
private static float3 snoise_permute(float3 value) => snoise_mod289(((value * 34.0f) + 1.0f) * value);
|
||||
private static float4 snoise_permute(float4 value) => snoise_mod289(((value * 34.0f) + 1.0f) * value);
|
||||
|
||||
public static float value_noise(float2 position)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace CultMath.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The HLSL to GLSL ES 3.00 transformation that produces <c>shaders/CultMath.glsl</c>, and one file per
|
||||
/// <see cref="SeparateFiles"/> entry, from <c>shaders/CultMath.hlsl</c> and its includes. The steps below,
|
||||
/// in order, are the complete list documented in docs/design.md (GLSL Target: Source Transformations).
|
||||
/// A dialect gap outside the list is fixed in the HLSL, in the subset both languages share, never by a
|
||||
/// special case here; GlslMirrorTests fails on any HLSL-only token that survives.
|
||||
/// </summary>
|
||||
internal static class GlslLowering
|
||||
{
|
||||
/// <summary>
|
||||
/// Includes under a file-level licence of their own: each is lowered into its own output file instead
|
||||
/// of being inlined, so CultMath.glsl stays MIT. This table is the lowering's only rule keyed by name.
|
||||
/// </summary>
|
||||
internal static readonly IReadOnlyDictionary<string, (string Glsl, string Licence)> SeparateFiles =
|
||||
new Dictionary<string, (string, string)>
|
||||
{
|
||||
// Phacelle is a port of MPL-2.0 code, and MPL-2.0 covers the files that carry it.
|
||||
["CultMath.Phacelle.hlsl"] = ("CultMath.Phacelle.glsl", "MPL-2.0"),
|
||||
};
|
||||
|
||||
private const string Regenerate =
|
||||
"by GlslLowering (packages/cultmath/tests/CultMath.Tests/GlslLowering.cs). Do not edit; regenerate with " +
|
||||
"CULTMATH_WRITE_GLSL=1 dotnet test --filter GlslMirrorTests.";
|
||||
|
||||
/// <summary>
|
||||
/// The lowered files as (name under shaders/, text): CultMath.glsl first, then one per
|
||||
/// <see cref="SeparateFiles"/> entry, which requires CultMath.glsl before it.
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<(string File, string Text)> Lower(string cultMathRoot)
|
||||
{
|
||||
// 1. Each #include is inlined in place by the C# mirror's own resolver, except a SeparateFiles
|
||||
// include, which leaves nothing behind and is lowered into its own file.
|
||||
var inlined = new List<string>();
|
||||
var library = HlslSourceCompatibilityTests.ReadShaderSource(cultMathRoot, name =>
|
||||
{
|
||||
if (SeparateFiles.ContainsKey(name))
|
||||
return false;
|
||||
inlined.Add(name);
|
||||
return true;
|
||||
});
|
||||
|
||||
var outputs = new List<(string File, string Text)>();
|
||||
var pointers = new List<string>();
|
||||
foreach (var (hlsl, (glsl, licence)) in SeparateFiles)
|
||||
{
|
||||
var text = LowerText(
|
||||
File.ReadAllText(Path.Combine(cultMathRoot, "shaders", hlsl)),
|
||||
$"// Generated from shaders/{hlsl} {Regenerate} {licence}; requires CultMath.glsl before it.",
|
||||
glsl);
|
||||
var functions = Regex.Matches(text, @"(?m)^\w+\s+(cultmath_\w+)\s*\(").Select(m => m.Groups[1].Value);
|
||||
pointers.Add($" {string.Join(", ", functions)} is in {glsl}, to be concatenated after this file by consumers that call it.");
|
||||
outputs.Add((glsl, text));
|
||||
}
|
||||
|
||||
var includes = string.Concat(inlined.Select(name => " and " + name));
|
||||
outputs.Insert(0, ("CultMath.glsl", LowerText(
|
||||
library, $"// Generated from shaders/CultMath.hlsl{includes} {Regenerate} MIT.{string.Concat(pointers)}", "CultMath.glsl")));
|
||||
return outputs;
|
||||
}
|
||||
|
||||
private static string LowerText(string hlsl, string provenance, string file)
|
||||
{
|
||||
var source = hlsl.ReplaceLineEndings("\n");
|
||||
|
||||
// 2. Include guards are dropped, and so are the functions taking Texture2D/SamplerState, exactly
|
||||
// as the C# mirror drops them; the host shader samples its own textures.
|
||||
source = Regex.Replace(source, @"(?m)^[ \t]*#(ifndef|define|endif)\b.*$", string.Empty);
|
||||
source = Regex.Replace(source, @"(?ms)^\w+ \w+\([^)]*\b(Texture2D|SamplerState)\b[^)]*\)\s*\{.*?^\}[ \t]*$", string.Empty);
|
||||
|
||||
// 3. Type names: floatN, intN, uintN and boolN become vecN, ivecN, uvecN and bvecN.
|
||||
source = LowerTypeNames(source);
|
||||
|
||||
// 4. File-scope `static const` becomes `const`.
|
||||
source = Regex.Replace(source, @"(?m)^static const ", "const ");
|
||||
|
||||
// 5. C-style casts (float)x, (int)x and (uint)x become constructor calls float(x), int(x) and
|
||||
// uint(x). The operand is found by a balanced scanner, not a regex: (int)((state >> 28) + 4u)
|
||||
// nests parentheses.
|
||||
source = LowerCasts(source);
|
||||
|
||||
// 6. Intrinsic renames: lerp -> mix, frac -> fract, rsqrt -> inversesqrt, asuint ->
|
||||
// floatBitsToUint, asfloat -> uintBitsToFloat, and saturate(x) -> clamp(x, 0.0, 1.0).
|
||||
// Identifier boundaries keep cultmath_lerp and the rest of the cultmath_ names intact.
|
||||
source = Regex.Replace(source, @"\blerp\(", "mix(");
|
||||
source = Regex.Replace(source, @"\bfrac\(", "fract(");
|
||||
source = Regex.Replace(source, @"\brsqrt\(", "inversesqrt(");
|
||||
source = Regex.Replace(source, @"\basuint\(", "floatBitsToUint(");
|
||||
source = Regex.Replace(source, @"\basfloat\(", "uintBitsToFloat(");
|
||||
source = LowerSaturate(source);
|
||||
|
||||
// 7. Float literals pass through: HLSL's unsuffixed 0.5 and 1.0e30 are GLSL literals already.
|
||||
|
||||
// 8. The guard named for the file (CultMath.glsl -> CULTMATH_GLSL), and 9. the provenance line.
|
||||
var guard = file.ToUpperInvariant().Replace('.', '_');
|
||||
return provenance + "\n#ifndef " + guard + "\n#define " + guard + "\n" + source.Trim('\n') + "\n\n#endif\n";
|
||||
}
|
||||
|
||||
/// <summary>Step 3 alone; GlslMirrorTests maps the HLSL signatures with it to compare overloads.</summary>
|
||||
internal static string LowerTypeNames(string source)
|
||||
{
|
||||
source = Regex.Replace(source, @"\bfloat([234])\b", "vec$1");
|
||||
source = Regex.Replace(source, @"\bint([234])\b", "ivec$1");
|
||||
source = Regex.Replace(source, @"\buint([234])\b", "uvec$1");
|
||||
return Regex.Replace(source, @"\bbool([234])\b", "bvec$1");
|
||||
}
|
||||
|
||||
private static readonly Regex Cast = new(@"\((float|int|uint)\)");
|
||||
|
||||
private static string LowerCasts(string source)
|
||||
{
|
||||
for (var match = Cast.Match(source); match.Success; match = Cast.Match(source))
|
||||
{
|
||||
var start = match.Index + match.Length;
|
||||
var end = OperandEnd(source, start);
|
||||
// A parenthesized operand lends its own parentheses to the constructor call.
|
||||
var operand = source[start] == '(' && Balanced(source, start) == end ? source[(start + 1)..(end - 1)] : source[start..end];
|
||||
source = source[..match.Index] + match.Groups[1].Value + "(" + operand + ")" + source[end..];
|
||||
}
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
// The cast operand: a parenthesized group, or an identifier or number followed by any member
|
||||
// accesses, calls and indexing. Anything else is outside the list and throws.
|
||||
private static int OperandEnd(string source, int index)
|
||||
{
|
||||
if (index < source.Length && source[index] == '(')
|
||||
return Balanced(source, index);
|
||||
var token = Regex.Match(source[index..], @"^[A-Za-z_0-9]+");
|
||||
if (!token.Success)
|
||||
throw new InvalidOperationException($"cast operand outside the lowering list at: {source.Substring(index, Math.Min(40, source.Length - index))}");
|
||||
index += token.Length;
|
||||
while (index < source.Length)
|
||||
{
|
||||
if (source[index] == '(' || source[index] == '[')
|
||||
index = Balanced(source, index);
|
||||
else if (source[index] == '.' && index + 1 < source.Length && (char.IsLetter(source[index + 1]) || source[index + 1] == '_'))
|
||||
index += 1 + Regex.Match(source[(index + 1)..], @"^[A-Za-z_0-9]+").Length;
|
||||
else
|
||||
break;
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
// The index one past the bracket that closes the one at `open`.
|
||||
private static int Balanced(string source, int open)
|
||||
{
|
||||
var depth = 0;
|
||||
for (var i = open; i < source.Length; i++)
|
||||
{
|
||||
if (source[i] is '(' or '[')
|
||||
depth++;
|
||||
else if (source[i] is ')' or ']' && --depth == 0)
|
||||
return i + 1;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"unbalanced bracket at {open}");
|
||||
}
|
||||
|
||||
private static string LowerSaturate(string source)
|
||||
{
|
||||
var saturate = new Regex(@"\bsaturate\(");
|
||||
for (var match = saturate.Match(source); match.Success; match = saturate.Match(source))
|
||||
{
|
||||
var open = match.Index + match.Length - 1;
|
||||
var close = Balanced(source, open);
|
||||
var argument = source[(open + 1)..(close - 1)];
|
||||
source = source[..match.Index] + "clamp(" + argument + ", 0.0, 1.0)" + source[close..];
|
||||
}
|
||||
|
||||
return source;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue