Compare commits
617 changed files with 6449 additions and 124665 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
|
||||
|
|
|
|||
52
.github/workflows/cultmesh-portability.yml
vendored
52
.github/workflows/cultmesh-portability.yml
vendored
|
|
@ -95,55 +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.
|
||||
# No workflow ran cultcache-ts's tests, and its test script listed two files by hand while a third sat compiled
|
||||
# and unrun. The wiring check fails when any TypeScript test file is run by no script, and the cache's own suite
|
||||
# runs here beside the packages built on it.
|
||||
- name: Check that every TypeScript test file is run by its package
|
||||
run: node scripts/check-typescript-tests-wired.mjs
|
||||
|
||||
- name: Verify the CultCache document owner with the TypeScript cache
|
||||
run: npm run test --workspace packages/cultcache-ts
|
||||
|
||||
- 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,276 +0,0 @@
|
|||
# CultCache Union Parity: Map
|
||||
|
||||
Campaign `cultlib-gaps`, follow-up `cultlib-gaps:follow_up:union-parity-and-unknown-arms` (from
|
||||
`thing2:follow_up:cultcache-union-parity`), under operator ruling `thing2:ruling:contract-decode-tolerance`
|
||||
("CultCache refuses unknown union arms in every runtime, the C# reference included"). This file holds body facts,
|
||||
the model page and rationale; the cut specs and the question are documents in the mind and cite this file by
|
||||
section and fact number.
|
||||
|
||||
Pinned base: `origin/main` at `aaf0d7f6` (2026-10-10). Imagination pass `imagination-cl-union-parity`, session
|
||||
`self-2026-10-10c-eureka`. Probes live under the session scratchpad, `imagination-cl-union-parity/` (`cs` payload
|
||||
probe, `cs2` store probe, `rs` payload probe, `py` store walkers, `il` decompiled MessagePack-CSharp). None of it is
|
||||
committed. Earlier evidence is not repeated here: Eve `docs/thing2-map.md` P12-P13 (the cross-runtime probe) and
|
||||
`docs/research/cultcache-union-decode-tolerance-prior-art.md` (prior art).
|
||||
|
||||
## Cut order
|
||||
|
||||
1. `union-refusal-cs`: the C# reference refuses an unknown union arm on read and a subtype the union does not name
|
||||
on write. Ruled; nothing blocks it.
|
||||
2. `union-golden`: the recursive-union golden and the arm refusal goldens in `tests/vectors/union-parity/`,
|
||||
written by the C# reference and held by its tests.
|
||||
3. `union-derive-rs` and `union-codec-ts`, in either order or together: cultcache-rs and cultcache-ts gain a
|
||||
union (and, in TS, a slot record) codec that writes the reference shape and decodes the golden byte-identically.
|
||||
`union-codec-py` follows the same golden for cultcache-py.
|
||||
4. `slot-tolerance` (C# reference), then `slot-readonly-parity` (Rust, TypeScript, Python): a record carrying a
|
||||
slot past its reader's declared slots, at any depth, is read-only (ruling
|
||||
`cultlib-gaps:ruling:extra-trailing-slots`; the refinement is settled by section S). Both change a slot rule the
|
||||
cuts in step 3 build, so they wait for 1-3 rather than reopening them.
|
||||
5. `union-parity-release`: changelogs and tags, Breaking where `docs/semver-policy.md` says so.
|
||||
|
||||
Thing2's `cut-contract-rs` waits on step 3 (Rust) only: its first step pins cultcache-rs at the commit that lands
|
||||
`union-derive-rs` and re-runs P12 or the `union-golden` vector both ways.
|
||||
|
||||
Why this order: the C# reference is the wire authority, so its refusal and its golden come first and the other
|
||||
runtimes are held to bytes C# wrote, as `document-variants-c1` was (`docs/runtime-parity-scope.md`). Arms are
|
||||
ruled and slots are not, so the slot rule is its own cut rather than a blocked step inside every runtime's cut.
|
||||
|
||||
## Model page (step 0b)
|
||||
|
||||
| Kind | What names it | Over time | Who decides |
|
||||
|---|---|---|---|
|
||||
| Union arm | An integer key, `[Union(k, typeof(Arm))]` on the abstract base (C#), assigned by the union's author. The wire carries `[k, armSlots]`; the arm's type name never travels. `nil` is a null reference, not an arm. | **Added:** a key never used before. **Retired:** the attribute is removed or commented out and the key is never reused (Aetheria `Behaviors.cs:160,173`; Aetheria-legacy `Behaviors.cs:37-38`). **Renamed type:** free, the key is the identity. **Unknown to a reader** (a newer writer's arm, or a retired arm in an old store): refused, naming the union and the key (ruling). | The union's author assigns keys. The C# reference defines the shape; every runtime keeps parity with it (doctrine, `runtime-parity-scope.md`). |
|
||||
| Arm subtype on write | The value's runtime type must be one the union names. | A subtype the union does not name is today written as `nil` (U4). After cut 1 it is refused. | The union's author, by naming the arm. |
|
||||
| Arm slots, record slots | `[Key(n)]` integers, base-class keys included (an arm is a flat array). Gaps are written `nil`. | **Added:** appended at the next free key. **Retired:** kept as a gap, never reused (Aetheria `ItemInstance.cs:35`, `EntitySerializer.cs` key 5). **Missing in an older store:** the reader's default (C# field initializer; Rust and Python need a declared default). **Extra past the reader's last key, at any depth:** the record is read and is read-only; every write or removal of it is refused, and it is persisted from its original bytes (ruling, S). | The type's author. The slot rule is CultCache's (`src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md`). |
|
||||
| Serde-enum member (Rust) | serde's external tag: the variant **name** (`{"Name": fields}`, a unit variant the bare string). | Persisted already by Rust consumers (section C). They keep their bytes: the union derive is opt-in and serde enums are untouched. | The Rust consumer. Not a cross-runtime shape. |
|
||||
|
||||
## U. Mechanism facts (C# reference, Rust, TS)
|
||||
|
||||
- **U1. The miss branch is `Skip` then `null`, in both union formatters MessagePack-CSharp 3.1.7 can hand out.**
|
||||
`DynamicUnionResolver.BuildDeserialize` (decompiled from `MessagePack.dll` 3.1.7 net9.0 with ilspycmd): after
|
||||
`ReadArrayHeader() == 2` (else `InvalidOperationException "Invalid Union data was detected"`) and `ReadInt32`, a
|
||||
non-zero-start key set is mapped through `keyToJumpMap.TryGetValue`, a miss sets the index to `-1`, the `switch`
|
||||
default calls `reader.Skip()`, and the result local stays `null`. The source generator's `UnionTemplate`
|
||||
(`MessagePackAnalyzer` 3.1.7, `MessagePack.SourceGenerator.dll`) emits the same: `default: reader.Skip(); break;`
|
||||
with `result = null`. Its serializer writes `nil` when `typeToKeyAndJumpMap` has no entry for `value.GetType()`.
|
||||
- **U2. The C# reference decodes an unknown arm to null and does not re-encode to its bytes.** Probe `cs`
|
||||
(payload built by hand with `MessagePackWriter`, decoded with `CultDocumentMessagePackSerialization.OptionsFor`):
|
||||
unknown key 9 in a union field gives `Root=NULL`, in a list `Items=[TextNode,NULL]`; re-encode equal: False in
|
||||
both. The formatter the reference composite hands out for the union is `MessagePack.Formatters.NodeFormatter1`
|
||||
(DynamicUnionResolver's emitted type; the probe assembly carries no generated resolver).
|
||||
- **U3. Missing and extra trailing slots, C# payload level.** Same probe: a payload one slot short decodes with
|
||||
the field initializer (`Added=42`); a payload one slot long decodes with the extra slot skipped. Neither
|
||||
re-encodes to its bytes.
|
||||
- **U4. The reference writes an unnamed subtype as nil.** Same probe: `Root = new RogueNode()` (a subclass the
|
||||
union does not list) serializes to `94 a2 64 32 c0 90 2a`: the root slot is `nil`, and it reads back `Root=NULL`.
|
||||
Silent loss on the write side, the mirror of U2.
|
||||
- **U5. Refusal can live at the CultCache layer; no MessagePack-CSharp fork.** Same probe, `strict` options: a
|
||||
resolver placed ahead of `StandardResolver` returns, for any abstract or interface type with `[Union]`s, a
|
||||
formatter that peeks (`CreatePeekReader`) the `[key, ...]` header, throws
|
||||
`MessagePackSerializationException("union Node has no arm 9")` for a key the union does not list, refuses to
|
||||
serialize an unnamed subtype, and otherwise delegates to the formatter `StandardResolver` would have handed out.
|
||||
Baseline round-trips byte-identical; both unknown-arm cases and U4 are refused; slot behaviour is unchanged.
|
||||
Every C# decode path reaches the composite through `CultDocumentMessagePackSerialization.OptionsFor`
|
||||
(`CultCacheMessagePack.CreateCodec`, `DirectoryMessagePackBackingStore.cs:345`, `CultMesh.cs:2779,3493,3499`,
|
||||
`CultMeshPrimitives.cs:1158,1230,2357`), so one resolver covers the cache, the directory store and CultMesh.
|
||||
Consumer resolvers named by `[CultCacheFormatterResolver]` sit ahead of `CultDocumentResolver` today
|
||||
(`CultDocumentMessagePackSerialization.cs:58-62`); the guard goes first and delegates to the rest of the
|
||||
composite, so a consumer formatter still encodes the arm while the key check still runs.
|
||||
- **U6. A top-level extra slot is dropped on write-back.** Probe `cs2`: a C#-written store, mutated as a newer
|
||||
writer would (catalog member slot 2 added, payload slot 2 `"newer writer data"`), is pulled, the record edited
|
||||
and flushed. The read succeeds, and the written payload is `['n1', 'edited by the older build']`: the newer
|
||||
writer's slot is gone. (The schema id was left unchanged, so the migration report said `Kind=Exact`; with a
|
||||
changed id the documented drift path reports `IgnoredExtraSlots`, `CultCache.cs:840-924`, but writes back the
|
||||
same way.)
|
||||
- **U7. Rust today.** Probe `rs` (`cultcache-rs` at `aaf0d7f6`, payloads built with `rmpv`, decoded with
|
||||
`rmp_serde::from_slice`): a serde enum's unknown variant is refused (`unknown variant 'Exploded'`); the C# arm
|
||||
shape `[0, [...]]` is refused (`expected variant identifier`); a non-nil value in a gap slot is skipped; a missing
|
||||
trailing slot is refused (`missing field 'added'`) unless the field is `#[cultcache(default)]`; an extra trailing
|
||||
slot is skipped (`cultcache-rs-derive/src/lib.rs`, the `IgnoredAny` loop after the slot loop). The derive
|
||||
accepts named structs only (`lib.rs:51-67`) and writes an empty member list into the catalog
|
||||
(`src/lib.rs:2706`).
|
||||
- **U8. TypeScript today.** No typed codec: with no `formatter` a document encodes as a MessagePack **map** of the
|
||||
zod-parsed object and decodes raw (`cult-cache.ts:679-692`; `@msgpack/msgpack` `encode({id:'d',version:7})` is
|
||||
`82 a2 69 64 ...`, a fixmap, where the reference writes an array); positional slots exist only in hand-written
|
||||
formatters (`test/cult-cache.test.ts:1263-1310`). P13 measured that a hand codec matches whichever encoding its
|
||||
author chose.
|
||||
- **U9. Python today.** `define_database_entry_type` (`packages/cultcache-py/src/cultcache_py/documents.py:185-276`)
|
||||
is a slot codec: gaps written `nil`, extra slots ignored, a missing slot defaulted or refused. No union codec.
|
||||
- **U10. The written contracts.** `cultcache-schema-compatibility.md:75-91` classifies "persisted slot is missing
|
||||
locally and can be ignored" as soft-migratable drift, reported as `IgnoredExtraSlots`;
|
||||
`cultcache-persistence-format.md:215-218` says a store reader "never skips a slot it does not understand" for
|
||||
record envelope slots, and every runtime refuses `extra-slot-full-payload.msgpack`
|
||||
(`tests/vectors/document-variants-c0`). `docs/persisted-record-encoding.md` says consumers verify canonicality
|
||||
by re-encoding and comparing bytes. The two contracts disagree about payload slots; the question below is
|
||||
about that disagreement.
|
||||
- **U11. Fixture home.** `tests/vectors/<name>/`: committed bytes every runtime's tests read
|
||||
(`document-variants-c0` by Python `generate.py`, `document-variants-c1` written by C#), consumed by
|
||||
`tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs`, `packages/cultcache-rs/src/lib.rs`,
|
||||
`packages/cultcache-ts/test/cult-cache.test.ts` and `packages/cultcache-py/tests/test_cultcache.py`. The CultNet
|
||||
precedent for a reference-written vector is `CULTNET_WRITE_VECTORS=1` (`docs/cultnet-error-contract-cut.md:170`).
|
||||
|
||||
## S. Slot read-only probe (any depth)
|
||||
|
||||
Imagination pass `imagination-cl-slot-readonly` (session `self-2026-10-10c-eureka`), under ruling
|
||||
`cultlib-gaps:ruling:extra-trailing-slots` ("b, and probe the any-depth refinement"). Probes live under the session
|
||||
scratchpad, `imagination-cl-slot-readonly/` (`cs` C# synthetic, `aeth` C# on Aetheria's game database, `rs`, `py`,
|
||||
`ts`, `il` decompiled MessagePack-CSharp 3.1.7). None is committed. The ruling's test: every runtime's decoder
|
||||
carries a "slot skipped" mark to the record cheaply, on the one decode path, with no second pass and no per-type
|
||||
consumer code.
|
||||
|
||||
- **S1. Where C# skips, and why a resolver sees it.**
|
||||
`DynamicObjectTypeBuilder.BuildDeserializeInternalDeserializeLoopIntKey` emits the slot loop's `switch` default as
|
||||
a direct `MessagePackReader.Skip()` call; the source generator's `FormatterTemplate` writes
|
||||
`default: reader.Skip(); break;`. `MessagePackReader` is a struct, and neither path raises an event or reads an
|
||||
option, so the skip itself cannot be observed without a fork. Both fetch every member formatter from
|
||||
`options.Resolver` (the generated code's `formatterResolver = options.Resolver`), and the union formatters fetch arm
|
||||
formatters the same way (U1). A resolver ahead of the composite is therefore handed every nested object, list
|
||||
element and union arm, and can read each one's array header before delegating.
|
||||
- **S2. The mark arrives in C#.** Probe `cs`: a guard resolver returns, for each concrete int-keyed
|
||||
`[MessagePackObject]` type, a formatter that reads the array count (from `NextCode` for a fixarray, a peek for
|
||||
array16 and array32), compares it with the highest `[Key]` plus one (base keys included, MessagePack's member
|
||||
visibility rule), notes a skip into a `[ThreadStatic]` scope the decode's caller opened, and delegates to the
|
||||
formatter the rest of the composite hands out. An extra slot at depth 0 (record), depth 1 (nested object), depth 2
|
||||
(object in an object), in a union arm and in a list element each arrives as one mark naming the type and both
|
||||
counts; the clean payload gives none. The guard needs `CompositeResolver.Create` around it so formatters are
|
||||
cached (uncached, it was 40 times slower).
|
||||
- **S3. C# cost.** Aetheria `GameData/Aetheria.cc` (12.1 MB; 237 of 238 records decodable with Aetheria.Shared's
|
||||
types; decoded with `OptionsFor` that assembly; best of 9, three rounds): plain 165-209 ms, a second plain composite
|
||||
(A/A) 174-193 ms, guarded 179-196 ms. That is 3.7-8.9% over the best plain run, inside the A/A spread. Worst case,
|
||||
synthetic tiny objects (120,000 records, 23.1 MB, about 24 objects each): plain 618-637 ms with A/A within 4%,
|
||||
guarded 704-705 ms: 11-19%, about 30 ns per decoded object.
|
||||
- **S4. A live case in Aetheria.** The same probe, run with the AetherDb release binaries (built 2026-09-30) against
|
||||
today's `Aetheria.cc`, marks 25 records: `WeaponItemData` whose `BehaviorData` arms (`AutoWeaponData`,
|
||||
`ChargedWeaponData` and others) carry 34 slots where that build declares 21 or 33. The extra slot is
|
||||
`WeaponData.Tracking` (key 33, Aetheria `8cd71ed6`, 2026-10-07): inside a union arm, inside a list. Today that tool
|
||||
reads these records and drops `Tracking` from all 25 at its next landing (S5). Refusing nested extra slots would
|
||||
stop it opening `Aetheria.cc` at all; read-only lets it open the store and refuses writes to those 25.
|
||||
- **S5. A C# flush re-encodes every held record, not only the edited one.** `PushAll` and an unconditional
|
||||
`CommitBatch` write `ToPersistedRecord(entry, SerializePayload)` for every entry (`CultCache.cs:3191-3201`, `3355`,
|
||||
`3384`), under the local descriptor's schema id and catalog entry. So U6's loss reaches every held record with
|
||||
unread slots at the next flush of anything. Rust (holds payload bytes and decodes on `get`, `lib.rs:2280-2293`),
|
||||
Python (holds envelopes and decodes at load, `cache.py:257-285`) and TypeScript (holds payload bytes) carry an
|
||||
untouched record's bytes. In C#, read-only therefore also means persisted from the original record (bytes, schema
|
||||
id, catalog entry), never from the decoded document.
|
||||
- **S6. Rust.** Probe `rs`: the derive's slot loop, with the trailing `IgnoredAny` loop counting into a
|
||||
`thread_local`, marks depth 1, depth 2 and a list element. Depth 0 is the `DatabaseEntry` derive's own trailing
|
||||
loop (`cultcache-rs-derive/src/lib.rs:258`), the same edit. A plain serde struct nested in a record already refuses
|
||||
an extra element (`array had incorrect length, expected 2`), so only derived slot types skip. Cost (120,000
|
||||
records, 21.4 MB): inside the A/A spread (A/A from -3% to +9%; marked against plain from -14% to +13% over six
|
||||
runs). The added code is one branch per object, never taken on a clean payload. Rust decodes on `get`, so whether
|
||||
a held record is writable is answered by decoding its held bytes through the same derive, on the write path only.
|
||||
- **S7. Python and TypeScript.** Probes `py` and `ts`: a slot codec tree passing a context argument (the shape
|
||||
`union-codec-py` and `union-codec-ts` build) marks all five positions. Python (40,000 records, 11.1 MB, msgpack
|
||||
unpack included): +14% against an A/A spread of 8%. TypeScript (120,000 decoded records, codec walk only; no
|
||||
msgpack in the scratch copy): +20% against an A/A spread of 18%. The check is one length comparison per object.
|
||||
- **S8. Kotlin has no library slot codec.** `CultDocumentCodec<T>.decode` is written per type by the consumer
|
||||
(`packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt:31-36`), and the package's own Eve
|
||||
decoders skip extras by hand at 8 sites (`repeat((count - N).coerceAtLeast(0)) { reader.skip() }` in
|
||||
`eve/EveDocuments.kt`). No slot rule, refusal or read-only, can be held by the library there until it owns slot
|
||||
decoding. That is a follow-up, not a per-runtime rule.
|
||||
- **S9. CultMesh decode paths** (`CultMesh.cs:2779,3493,3499`, `CultMeshPrimitives.cs:1158,1230,2357`) go through
|
||||
`OptionsFor`, so through the guard, with no scope open: unchanged. Whether a Mesh replica that re-publishes a
|
||||
decoded document drops slots is a follow-up.
|
||||
- **S10. The refinement is in.** Every runtime with a library slot decoder (C#, Rust, Python, and the TypeScript
|
||||
codec being built) carries the mark to the record on its one decode path, with no second pass and no consumer
|
||||
code. The cost on Aetheria's real store is inside the A/A noise (S3); the worst synthetic case is 11-19% in C#.
|
||||
A nested extra slot makes the record read-only; it is not refused.
|
||||
|
||||
## A. Aetheria (C#, the only consumer persisting `[Union]`s)
|
||||
|
||||
Aetheria consumes the reference through Unity packages pinned by tag (`Packages/manifest.json:55-57`:
|
||||
`caching-unity-v1.4.0`, `cultlib-unity-v1.0.60`), so a CultLib change reaches it only when it bumps the pin.
|
||||
Its only consumer resolver is `MathResolver` (`AssemblyInfo.cs:4`), which supplies no union formatter.
|
||||
|
||||
- **A1. Unions inside persisted documents** (Aetheria `aac2cf91`, `Assets/Scripts/ServerShared`):
|
||||
`BehaviorData` (`Behaviors/Behaviors.cs:147-191`, keys 0-11, 15, 16, 18, 20-24, 28, 31-40; 12 and 26 retired,
|
||||
14, 17, 19, 25, 27, 29, 30 commented out) in `List<BehaviorData> Behaviors` of the item data documents
|
||||
(`ItemData.cs:346,368`); `WeaponData` (`Behaviors/Weapon.cs:15-18`); `EntityPack` (`EntitySerializer.cs:172-173`)
|
||||
in `aetheria.savedzone` contents and its own `Children`; `ItemInstance` and `CraftedItemInstance`
|
||||
(`ItemInstance.cs:18-31`, a union whose arms are also a union) in entity cargo and docking bays
|
||||
(`EntitySerializer.cs:186-187`); `Provenance` (`Provenance.cs:96-98`) in `aetheria.provenanceledger`
|
||||
(`Provenance.cs:11-15`, `Lot.Origin`); `SavedActionBarBinding` (`SavedGame.cs:221-223`) in `aetheria.savedgame`;
|
||||
the input layout rows and keys (`InputLayout.cs:29-50`) in `aetheria.inputlayout`. `Brush`
|
||||
(`Environment.cs:73`) was not traced to a persisted document.
|
||||
- **A2. `GameData/Aetheria.cc` carries no unknown arm.** Walker `py/store_unions.py` over the git-tracked game
|
||||
database (12.7 MB, `cultcache.store.v1`, 238 records, 13 catalog entries): `Behaviors` arm keys
|
||||
{0:3, 1:2, 2:10, 3:5, 4:2, 5:2, 6:3, 8:7, 9:23, 11:2, 16:4, 23:7, 24:5, 31:1, 32:1, 34:1, 35:2, 37:4, 38:10, 39:2,
|
||||
40:1}; none retired or commented out; no `nil` element.
|
||||
- **A3. The run save carries no unknown arm, and does carry missing trailing slots.** Walker `py/run_unions.py`
|
||||
over `GameData/run.cc` (2026-09-30): `EntityPack` arms 0 and 1 (3 each), `ItemInstance` arm 2 (12),
|
||||
`Provenance` arm 0 (64), `SavedActionBarBinding` 9 `nil` (empty bar slots). The `EntityPack` arms carry 21 slots
|
||||
(orbital) and 20 (ship) against today's 22: the `Boss` slot (key 21, added 2026-10-07 in `db4617d8`) is missing,
|
||||
so today's Aetheria reads this save only because a missing trailing slot defaults. `py/gaps.py`: no non-nil
|
||||
retired slot (ItemInstance 2, 9, 10; EntityPack 5). `GameData/stale-2026-09-22/run.cc` is the same shape;
|
||||
`player.cc` holds only `aetheria.playersettings`. `py/slots.py`: every top-level record matches its own store's
|
||||
catalog exactly.
|
||||
- **A4. What changes for Aetheria.** After it adopts a CultLib with cut 1, its current stores load unchanged
|
||||
(A2, A3). A store written later by a build that has added an arm is refused by a build that lacks it, naming the
|
||||
union and key, where today the arm silently becomes `null` (a gear item quietly loses a behaviour, a lot loses
|
||||
its provenance) and the next save writes the loss back. Builds already pinned to 1.0.60 keep the null path; a
|
||||
shipped binary cannot change. Aetheria-legacy does not use CultCache and has no store.
|
||||
- **A5. Other C# stores.** `%LOCALAPPDATA%Low/GameCult/Aetheria/aetheria-unity.cc` (2026-08-08, 1.2 KB) is a
|
||||
`cultcache.store.v2.directory-indexed` header from an older layout, not a union carrier.
|
||||
|
||||
## C. Rust, TypeScript and other consumers
|
||||
|
||||
Eyes audit for this pass (session `self-2026-10-10c-eureka`), `git grep` at each repo's `origin/HEAD`, Rust reach
|
||||
by a lexical script (it can over-count; it does not under-count named fields). Aetheria and CultLib's tests excluded.
|
||||
|
||||
- **C1. Rust: no `DatabaseEntry` record crosses runtimes as a union, and none uses the reference arm shape.**
|
||||
Records that reach serde enums, each keeping its bytes because the derive is opt-in:
|
||||
Huginn `epiphany.pipeline.resolution.v2` (`ResolutionOutcome`, external tag, `eureka-pipeline/src/lib.rs:746`,
|
||||
live on Yggdrasil); Idunn `idunn.deployment_transaction` and `idunn.admitted_generation` (v4 and legacy forms;
|
||||
internally tagged `#[serde(tag = ..., deny_unknown_fields)]` enums in `control_plane.rs`, `deployment_plan.rs`,
|
||||
and two `#[serde(untagged)]` enums kept so older transactions decode, `drivers.rs:259,387`; live at
|
||||
`/var/lib/gamecult/idunn/control.cc`); CodexConnector `gamecult.codex.replay_record.v1` (`CodexReplayState`,
|
||||
`lib.rs:1017`); Epiphany's Atlas, model-request, persona and receipt records (internal, adjacent, external and
|
||||
untagged representations; `.cc` files under `F:\Projects\.epiphany-runtime\` and on Yggdrasil). Unit-only serde
|
||||
enums (a bare string) are widespread in Idunn, Huginn (`Faculty`, the pipeline's unit enums), CodexConnector and
|
||||
Epiphany. No enum reaches any record in Odin, Muninn, Ghostlight, Huginn `mind-body`, or cultnet-rs and
|
||||
cultmesh-rs production records. None of these is read by another runtime as a union today, so none is affected
|
||||
by the arm refusal; the derive changes nothing for them.
|
||||
- **C2. TypeScript: no consumer encodes a tagged union.** The only hand formatters are StreamPixels
|
||||
`apps/service/src/realtime/overlay-state-documents.ts:190,232,277` (positional arrays, no union). Bifrost,
|
||||
Heimdall, Hermodr, Odin scripts, Stonks, Vili, VoidBot (vendored `cultcache-ts`) and weksa have none.
|
||||
- **C3. C# outside Aetheria: StreamPixels' overlay declares unions; its tracked store holds none of them.**
|
||||
`apps/overlay-unity/Assets/Scripts/Assembly-CSharp/` (`8d40541`): `ActivationCondition` (10 arms),
|
||||
`BattleEffect` (2), `StatusEffect` (4), `ItemInstance` (3), `EquippableItemInstance` (2), reachable from
|
||||
`streampixels.status`, `streampixels.action_item`, `streampixels.skill` and gear documents. Walker
|
||||
`py/sp_catalog.py` over `apps/overlay-unity/Data/catalog.cc` (`cultcache.store.v1`, 20 records): only
|
||||
`outfit`, `skin_color`, `hair_style`, `emote` and `hair_color` records, and no `[int, array]` pair at any depth.
|
||||
Its only consumer resolver is `MathResolver` (`Catalog/CatalogAssembly.cs:6`). CultLib's own
|
||||
`GameCult.Networking` `Message` union (`Messages.cs:10`) is a CultNet wire message, decoded with
|
||||
`MessageSerialization.Options` (`MessageSerialization.cs:7`, `DefaultOptions`), outside `OptionsFor`: the arm
|
||||
guard does not reach it (follow-up).
|
||||
- **C4. Python and Kotlin: no union-shaped record.** repixelizer uses `define_database_entry_type` with flat
|
||||
slots; `cultmesh-kotlin` documents are `data class`es with no sealed type.
|
||||
|
||||
So the stores the arm refusal can affect today are Aetheria's (A2-A4, none carrying an unknown arm) and
|
||||
StreamPixels' overlay catalog (C3, carrying no arm). Every other runtime's persisted enums are serde shapes that
|
||||
the opt-in derive leaves alone.
|
||||
|
||||
## Rationale
|
||||
|
||||
- **Why a guard formatter, not a fork or a re-implementation.** U5 shows a guard that only checks the key and the
|
||||
subtype and delegates the encoding is enough. It keeps MessagePack-CSharp's union encoding as the one encoder,
|
||||
costs one small file, and covers every path that already goes through `OptionsFor`. Forking the generated
|
||||
template or vendoring `DynamicUnionResolver` would put a second encoder in CultLib.
|
||||
- **Why the Rust derive is opt-in.** Serde enums already persisted by Rust consumers keep their bytes
|
||||
(`docs/persisted-record-encoding.md`); a changed default would make every such store unreadable or
|
||||
non-canonical. A new derive with explicit arm keys is how a Rust type says "this crosses runtimes".
|
||||
- **Why arms use newtype variants over slot structs.** A C# arm is a subclass whose slots include the base class's
|
||||
keys. A Rust newtype variant over a struct with keyed slots is the same thing, and it needs the slot code the
|
||||
`DatabaseEntry` derive already has, factored so both derives share one slot generator.
|
||||
- **Why the slot rule is a separate, asked cut.** The ruling settles arms and asks for extra trailing slots to be
|
||||
"settled against the same re-encode rule". Two written contracts disagree (U10), and the current behaviour drops
|
||||
data silently (U6), so the choice is the operator's and it should change every runtime at once.
|
||||
- **The recommendation on extra slots was refusal; the ruling chose read-only, and S settles its depth.** An extra trailing slot is the slot-level twin of an unknown
|
||||
arm: data from a newer writer that the reader's types do not describe. The operator ruled refusal for arms
|
||||
knowing it costs an older reader the newer record; the same reasoning gives refusal for slots, and record
|
||||
envelope slots are already refused in every runtime (U10). The alternative with precedent is
|
||||
`variants:ruling:undeclared-read-only` (a record a type can read but does not declare loads read-only): it would
|
||||
let an older reader see a newer record without overwriting it, but only where a catalog shows the extra slot
|
||||
(top level), while most evolution here is nested (A3's `Boss` slot sits inside a union arm).
|
||||
- **Missing trailing slots stay tolerated.** The reader knows a missing slot and its default, so it carries no
|
||||
bytes it does not understand, and Aetheria's live run save depends on it (A3). A store whose guard compares
|
||||
re-encoded bytes (Idunn's) refuses such a record by its own stricter rule, which is that guard's purpose.
|
||||
|
|
@ -1,218 +0,0 @@
|
|||
# CultLib Gaps: Map
|
||||
|
||||
Campaign `cultlib-gaps` (target: `docs/cultlib-gaps-target.md`). This file holds body facts, probes and
|
||||
rationale for the cuts the mind's `cut_spec`s name; the specs cite it by section and fact number.
|
||||
|
||||
Pinned base: `origin/main` at `6bdc23c9` (2026-10-10). Imagination pass `imagination-cl-gaps-cultmath`, session
|
||||
`self-2026-10-10-ag`. Probes live under the session scratchpad, `imagination-cl-gaps-cultmath/` (`probe1` traced
|
||||
copy of the solver and the warm/cold sweep, `suite` a scratch copy of `packages/cultmath` with the candidate fix,
|
||||
`probe2` the sweep against that copy, `probe3` the overload probe). None of it is committed.
|
||||
|
||||
## Cut order
|
||||
|
||||
1. `bls-release-stop`: BoundedLeastSquares stops on a release only when the release is not real.
|
||||
2. `bls-input-field`: a validation helper names the invalid input (follow-up `bls-invalid-input-field`). Blocked on
|
||||
question `cultlib-gaps:question:bls-input-error-shape`.
|
||||
3. `cultmath-release-0-4-1`: release `org.gamecult.cultmath` with cuts 1 and 2. Version follows question
|
||||
`cultlib-gaps:question:bls-fix-release-lane`.
|
||||
4. `cultmath-rotation-algebra`: quaternion algebra, swing-twist and joint clamps (ruling
|
||||
`aetheria-release:ruling:solver-in-gamecult-animation`). Ships in the CultMath release that the first
|
||||
GameCult.Animation release pins; that release is mapped with GameCult.Animation.
|
||||
|
||||
## B. BoundedLeastSquares stopping
|
||||
|
||||
Finding `aetheria-release:finding:cut-thrust-allocator-core.s1.solver-converged-early` (Soul, Medium): a cold start
|
||||
reports `Converged` at a non-optimal point, so Aetheria's thrust allocator (tolerance 1e-12) answers differently
|
||||
warm and cold, by up to .078 throttle.
|
||||
|
||||
### Body facts
|
||||
|
||||
- **B1. One runtime ships the solver.** `BoundedLeastSquares` exists only in
|
||||
`packages/cultmath/src/CultMath/BoundedLeastSquares.cs` (C#, netstandard2.1 and net8.0). The type doc says there is
|
||||
no HLSL mirror; grep over `packages/*` finds no Rust, TypeScript, Python or Kotlin sibling, and no other CultLib
|
||||
project calls it. Its consumer outside CultLib is Aetheria's `ThrustAllocator`.
|
||||
- **B2. The defect reproduces against CultMath at base, outside Aetheria.** `probe1` rebuilds Soul's allocator
|
||||
matrix (Soul's `ProbeAllocator2`, constants H=100, Wy=1000, W=.3, kappa=10, rho=.1, EMax=2) for hull
|
||||
`Synthetic(8, 7005)` and the 8th intent of `System.Random(5)`, (-0.8505, 0.6739, 0.5587): 15 rows, 12 columns.
|
||||
Real `BoundedLeastSquares.Solve` at tolerance 1e-12 from x = 0: `Converged`, 13 iterations, cost 36.0313737,
|
||||
x7 = 1. Re-solving from that answer: 1 iteration, cost 36.0312608, x7 = 0.9222145, which is also the warm answer.
|
||||
The same literals (dumped with `"R"` floats, `probe1 dump`) reproduce it at tolerance 1e-9 and 1e-12, and not at
|
||||
the default 1e-6, where the cold solve stops at iteration 10 inside the tolerance and the re-solve takes 0
|
||||
iterations.
|
||||
- **B2a. The W5 problem as literals** (`probe1 dump`, float `"R"` round trip; m = 15, n = 12, row-major; the
|
||||
regression fixture for `bls-release-stop`). Nonzero entries of A:
|
||||
- row 0: a[0,2] = 100f, a[0,8] = -100f, a[0,9] = 100f
|
||||
- row 1: 13.882518f, 20.86695f, 0, 14.8965845f, 12.38046f, 22.115599f, -13.706654f, 15.85789f, 0, 0, -100f, 100f
|
||||
- row 2: -187.72859f, 239.31168f, 158.87828f, -270.31165f, 235.38467f, -14.704258f, 121.61232f, 244.81305f, 0, 0,
|
||||
0, 0
|
||||
- rows 3-6: a[3+k, 8+k] = 0.3f (k = 0..3); rows 7-14: a[7+i, i] = 0.1f (i = 0..7)
|
||||
- b = {0, 67.385445f, 558.70917f, -3f, -3f, -3f, -3f, 0 x 8}; lo = 0; hi = 1f for columns 0-7, 2f for 8-11.
|
||||
At base, tolerance 1e-12 from x = 0: `Converged`, 13 iterations, x[7] = 1, cost 36.031373704487095; a re-solve
|
||||
from that answer: 1 iteration, x[7] = 0.92221445f, cost 36.031260771352414.
|
||||
- **B3. The stopping rule that fires is the progress rule, after a release** (`BoundedLeastSquares.cs:273-278`). A
|
||||
traced copy of the solver, bit-equal to the real one on this problem, shows the last two passes:
|
||||
- iteration 12: full step lands; column 2 sits at its lower bound with gradient -0.00205 (violation 2.05e-3,
|
||||
tolerance 1.5e-7); the solver releases it and records fMark = f.
|
||||
- iteration 13: the re-solve moves x2 from 0 to 2.05e-7. The cost falls by about 2.1e-10, and the stall band
|
||||
`1e-15 * fScale` is 2.11e-10, so `f >= fMark - band` holds and the solver reports `Converged`. Column 7 is then at
|
||||
its upper bound with gradient +0.00145, ten thousand times the tolerance; releasing it is worth 1.1e-4 of cost.
|
||||
- **B4. Why the progress rule cannot judge a release.** Near a minimiser, a cost difference resolves only about the
|
||||
square root of what a gradient comparison resolves: a real step of size dx against gradient g lowers the cost by
|
||||
about g*dx, while the cost itself is a sum of terms of size fScale. Here g*dx (2e-10) sat exactly at the cost's
|
||||
rounding band. The gradient comparison is far from its own rounding: for column 7, the bound on the double
|
||||
gradient's rounding error, `(n+1) * eps * (|A^T b|_7 + sum_k |(A^T A)_7k x_k|)` (Higham's inner-product bound,
|
||||
gamma_(n+1)), is 9.3e-10, and the violation is 1.56e6 times that.
|
||||
- **B5. What the progress rule is for** (commit 43035bc8, and the tests at `BoundedLeastSquaresTests.cs:774-942`).
|
||||
It stops two kinds of pass that can never reach the tolerance: a free-set re-solve whose residual is rounding on a
|
||||
near-singular free set, and a release whose violation is itself rounding (kktTol at or below the noise, including
|
||||
kktTol = 0 when the box point nearest the origin is stationary). Deleting it outright breaks both: with a noise
|
||||
gate on every test and no progress rule, 8 of 66 BLS tests fail (`wide`, `integer-tie`, `collinear`, `cond` spin to
|
||||
the cap; `NearSingularFreeSetsConfirmOnceAndStop` sees 0 confirmations). Exempting releases from the progress rule
|
||||
with no noise gate fails one: `StalledPassesConvergeInsteadOfSpinningToTheCap("plain")` seed 141, b = 0,
|
||||
kktTol = 0, cycles release 3 (violation 1.7e-18) / stationarity miss / block on 3 until the cap.
|
||||
- **B6. The candidate fix, measured.** In a scratch copy of the package (`suite`, diff `suite/clean.diff`, 44 lines):
|
||||
(a) a bound column is a release candidate only if its violation exceeds both kktTol and the rounding bound of B4;
|
||||
(b) the progress rule judges only passes that release nothing; a pass that releases sets fMark to +Inf, so a
|
||||
release is never measured against a cost recorded before it. Results:
|
||||
- all 66 tests in `BoundedLeastSquaresTests` pass, including `DefaultSolvesAreBitIdenticalToTheirGoldenBits` and
|
||||
every stall and noise-floor test; the same holds with the bound's factor at 1, 16 and 256 in place of n+1;
|
||||
- the W5 problem: cold `Converged` in 14 iterations at the warm answer (x7 = 0.92221, cost 36.03126077);
|
||||
- 400 synthetic hulls x 60 random intents (24,000 calls, warm and cold) at tolerance 1e-12: every call `Converged`,
|
||||
max iterations 45, warm and cold agree within 6e-8 on every call (the original: one call off by .078 in the first
|
||||
2,400). At the default tolerance warm and cold still differ, by more than .1 in 10,273 of 24,000 calls: that is
|
||||
the documented resolution of 1e-6 times the gradient scale, pinned by
|
||||
`AnAllocatorShapedProblemIsStartIndependentAtATightTolerance` (spread >= 0.5), and not this cut's subject.
|
||||
- **B7. Released columns the factor drops.** With the fix, 266 releases in the suite land on a column that
|
||||
`SolveFreeSet` marks dependent (p = 0). The next pass is then a stationarity miss with no release, and the
|
||||
progress rule stops it, as before. No release in the suite or the 24,000 allocator calls produced a step that moves
|
||||
the released column outward. A Lawson-Hanson style rejection guard (re-bind the column, try the next candidate)
|
||||
was prototyped and is not needed by any measured case; it is not in the cut.
|
||||
|
||||
### Prior art, and which one the solver follows
|
||||
|
||||
- The solver is a primal active-set method on the normal equations: solve the free set, step to the first bound
|
||||
crossed, release the most violated bound only after a full step lands. That is the outer/inner loop of
|
||||
Lawson-Hanson NNLS (1974) generalised to two-sided bounds, which is the shape of Stark-Parker BVLS (1995) and of
|
||||
scipy's `lsq_linear(method='bvls')`. It differs in factorisation (Cholesky of `A^T A`, not QR of `A`) and in
|
||||
stopping.
|
||||
- Lawson-Hanson stop on the dual test only (netlib `NNLS`, label 350: `IF (WMAX .le. ZERO)`), and guard roundoff
|
||||
per candidate: a column whose new norm is negligible against rounding (`DIFF(UNORM+...)`) or whose new
|
||||
coefficient is not positive (`ZTEST .gt. ZERO`) is rejected with `W(J)=ZERO` and the next candidate tried. There is
|
||||
no cost-based stop.
|
||||
- scipy's BVLS (`scipy/optimize/_lsq/bvls.py`, main) measures KKT exactly as CultMath's status doc states
|
||||
(`g * on_bound` for bound variables, `|g|` for free ones, max over components) and has two stops: optimality below
|
||||
`tol` (status 1) and a relative cost change below `tol` (status 2), reported as different statuses.
|
||||
- CultMath reported its cost stop as `Converged`, which its status doc defines as "the KKT conditions hold". The fix
|
||||
follows Lawson-Hanson: a release is judged on the gradient against its rounding (the role of their `DIFF` test),
|
||||
and the cost stop is kept only where no gradient test can decide (a free-set residual at rounding level), which is
|
||||
where scipy's status 2 also lives.
|
||||
|
||||
### Rejected alternatives
|
||||
|
||||
- Remove the progress rule and stop on the gradient alone: B5, 8 failures.
|
||||
- Report the cost stop as a new status (scipy's status 2): the W5 answer would still be wrong, just labelled, and a
|
||||
new enum member is a breaking change for callers that switch on the status.
|
||||
- Measure progress from the step (the exact model decrease `t(1-t/2)|L^-1 g|^2`) instead of differencing the cost:
|
||||
exact, but a noise-level gradient still yields a tiny positive decrease, so it needs the same noise threshold, on a
|
||||
quantity further from the KKT test than the gradient itself.
|
||||
|
||||
## I. Naming the invalid input
|
||||
|
||||
Follow-up `cultlib-gaps:follow_up:bls-invalid-input-field` (from Soul finding
|
||||
`cut-bls-kkt-tolerance.s1.invalid-input-names-no-field`): every validation failure returns the same `InvalidInput`.
|
||||
|
||||
- **I1.** Validation today is three sites in `Solve`: tolerance (`:147-148`), bounds (`:149-151`), and finiteness of
|
||||
A and b folded into the `A^T b` build (`:153-161`), which cannot tell A from b.
|
||||
- **I2.** Precedent: LAPACK reports a bad argument as `INFO = -i`, naming the argument and never its value. .NET
|
||||
throws `ArgumentException` with a parameter name, which the solver's no-throw, zero-allocation contract rules out.
|
||||
- Recommended shape (question `bls-input-error-shape`, option `validate-helper`): a public
|
||||
`BoundedLeastSquares.Validate(m, n, a, b, lo, hi, kktRelativeTolerance)` returning a new enum
|
||||
`BoundedLeastSquaresInput { Valid, InvalidTolerance, InvalidBounds, NonFiniteMatrix, NonFiniteTarget }`, the first
|
||||
failure in that order. `Solve` calls it and returns `InvalidInput` for anything but `Valid`, so validation has
|
||||
one owner and the folded finiteness check in the `A^T b` loop is deleted. Additive: `InvalidInput` and every
|
||||
existing result stay.
|
||||
|
||||
## R. Release 0.4.1
|
||||
|
||||
- **R1.** `org.gamecult.cultmath` is at 0.4.0 (`package.json:4`, `CultMath.csproj:13`, tag
|
||||
`cultmath-unity-v0.4.0`). `scripts/check-changelog-semver.mjs:97-101`: in 0.y.z a minor bump is the breaking lane;
|
||||
additions and fixes take the patch lane (0.2.4 added `erf` as a patch).
|
||||
- **R2.** Cut 1 changes results only where the old solver stopped after a release whose violation was above both the
|
||||
caller's tolerance and the gradient's rounding, that is, where it reported `Converged` in breach of its status doc.
|
||||
The default path's golden bits are unchanged (B6). The 0.4.0 precedent (ruling `cultmath-0-4-0-breaking-r2`)
|
||||
declared the snoise tie fix Breaking because two valid functions gave different values; here the old value broke
|
||||
the documented contract. Question `bls-fix-release-lane` asks which lane; the recommendation is `fixed-patch`
|
||||
(0.4.1, `### Fixed`).
|
||||
- The release procedure is the 0.4.0 cut's (`cultlib-gaps:cut_spec:cut-cultmath-release.r1`): Windows-only DLL rebuild
|
||||
on Starfire, tests on Yggdrasil, public-surface diff, Unity import smoke, Soul before the tag.
|
||||
|
||||
## Q. Rotation algebra
|
||||
|
||||
Ruling `aetheria-release:ruling:solver-in-gamecult-animation`: CultMath gains quaternion algebra, swing-twist and
|
||||
joint clamps; the aim-chain solve lives in a new package GameCult.Animation. The interface the consumer needs is the
|
||||
Aetheria map's section "The rig's interface to GameCult.Animation and CultMath" (Aetheria `origin/master`
|
||||
`30d02719`, `docs/aetheria-release-map.md:7559-7601`).
|
||||
|
||||
### Body facts
|
||||
|
||||
- **Q1. What exists.** `quaternion` (`src/CultMath/quaternion.cs`, 95 lines): public float fields x, y, z, w;
|
||||
`identity`; `LookRotation(float3 forward, float3 up)`; implicit conversions to and from `float4`; scalar `==`.
|
||||
`math.normalize(quaternion)` (`math.cs:225-235`) returns identity below length 1e-20. No mul, rotate, conjugate,
|
||||
inverse, axis-angle, slerp, swing-twist or clamp exists. `math` is a partial class (`math.cs`,
|
||||
`math.Interval.cs`, `math.Affine.cs`, `math.Phacelle.cs`); `math.rotate(float2, float)` exists (`math.cs:240`).
|
||||
- **Q2. Serialization.** `GameCult.Caching.MessagePack` serialises `quaternion` as the float array [x, y, z, w]
|
||||
(`CultMathResolver.cs:170-171`) and as an xyzw object in JSON (`CultMathJson.cs:468`). No other runtime
|
||||
(`cultcache-rs`, `-ts`, `-py`) has a quaternion type. The cut adds static functions only: no field, layout or
|
||||
formatter changes, so no wire change and no cross-runtime fixture.
|
||||
- **Q3. Runtimes.** The algebra is C#-only, like `LookRotation`: shaders carry rotations as `float4` (the type's doc),
|
||||
the mirror tests map shader functions to C# and never the reverse (`HlslSourceCompatibilityTests.cs:114-117`), and
|
||||
the native core (`native/cultmath-core`) is a Voronoi tone helper. The Unity bridge already converts `quaternion`
|
||||
(`UnityConversions.cs:16,23`); it needs no change.
|
||||
- **Q4. An overload trap, probed.** `quaternion` converts implicitly from `float4`, and CultMath has no
|
||||
`mul(float4, float4)`. `probe3` declares `mul(quaternion, quaternion)` beside scalar and matrix `mul` overloads and
|
||||
calls it with two `float4`: it compiles and returns the quaternion product. HLSL's `mul(vector, vector)` is the
|
||||
inner product, and Unity.Mathematics 1.3.3 (the copy in Aetheria's PackageCache) defines `mul(float4 a, float4 b)`
|
||||
as such (`matrix.gen.cs:129`), so the same call there is a dot product. The cut therefore adds HLSL's vector
|
||||
`mul(float2|3|4, float2|3|4)` (dot) beside the quaternion `mul`, so a `float4` pair keeps its shader meaning.
|
||||
- **Q5. Unity.Mathematics as reference, not template.** Its signatures for `mul`, `rotate`, `conjugate`, `inverse`,
|
||||
`AxisAngle` and `slerp` (`quaternion.cs:99,493,502,630,651,678`) are the consumer's stated convention ("`mul(a, b)`
|
||||
applies `b` first"). CultMath's design rule is to state semantics in code and not copy Unity.Mathematics
|
||||
(`docs/design.md:465-469`), so the formulas are written from the definitions below.
|
||||
|
||||
### Semantics the cut lands
|
||||
|
||||
Quaternions are Hamilton, stored xyzw, unit for rotation; angles are radians, right-handed about the axis.
|
||||
|
||||
- `math.mul(quaternion a, quaternion b)`: the Hamilton product; `rotate(mul(a, b), v) == rotate(a, rotate(b, v))`.
|
||||
- `math.rotate(quaternion q, float3 v)`: `t = 2 cross(q.xyz, v); v + q.w t + cross(q.xyz, t)`; q assumed unit.
|
||||
- `math.conjugate(q)`: (-x, -y, -z, w). `math.inverse(q)`: `conjugate(q) / dot(q, q)`, no guard (zero in, non-finite
|
||||
out, as HLSL division).
|
||||
- `quaternion.AxisAngle(float3 unitAxis, float radians)`: (axis sin(r/2), cos(r/2)); the axis is not normalised.
|
||||
- `math.slerp(a, b, t)`: shortest arc (negate b when `dot(a, b) < 0`); normalised lerp when the quaternions are
|
||||
nearly parallel; t is not clamped.
|
||||
- `math.swing_twist(q, unitTwistAxis, out swing, out twist)`: twist is q's projection onto the axis, normalised
|
||||
(identity when that projection vanishes, a half-turn swing); `swing = mul(q, conjugate(twist))`, so
|
||||
`q == mul(swing, twist)` (twist applied first).
|
||||
- `math.clamp_twist(twist, unitAxis, min, max)`: angle `2 atan2(dot(twist.xyz, axis), twist.w)` wrapped to (-pi, pi];
|
||||
inside [min, max] the input is returned unchanged, otherwise `AxisAngle(axis, clamp(angle, min, max))`.
|
||||
- `math.clamp_hinge(q, unitAxis, min, max)`: `clamp_twist` of q's twist about the axis; the swing is discarded.
|
||||
- `math.clamp_cone(swing, unitAxis, maxAngle)`: the angle between `rotate(swing, axis)` and `axis`; inside the cone
|
||||
the input is returned unchanged, otherwise the rotation by `maxAngle` about `normalize(cross(axis,
|
||||
rotate(swing, axis)))` (at a half turn, where that cross product vanishes, about the swing's own axis).
|
||||
- Preconditions (`min <= max`, unit axes) are documented, not checked, as everywhere in `math`.
|
||||
|
||||
### What GameCult.Animation will need from this cut (not mapped here)
|
||||
|
||||
- From cut 4: `mul`, `rotate`, `AxisAngle` for `AimChain.Pose` and `EffectorDirection`; `conjugate` or `inverse` for
|
||||
frame changes; `swing_twist` and the clamps if its joints ever carry cone or twist limits (the presenter's per-axis
|
||||
boxes do not).
|
||||
- From cut 1: start-independent `BoundedLeastSquares` at a tight tolerance. `AimChainSolver.Solve` is warm-started
|
||||
from `current`; without cut 1 its answer would depend on the previous frame exactly as the allocator's did.
|
||||
- A CultMath release that carries cut 4, which `org.gamecult.animation`'s `package.json` pins. That release is
|
||||
mapped with GameCult.Animation, so one Soul-verified release pair ships both.
|
||||
|
||||
## Observed, not owned by these cuts
|
||||
|
||||
- At the default tolerance the solver's answer on allocator-shaped problems depends on the start by up to the whole
|
||||
box (B6). That is documented and pinned (`AnAllocatorShapedProblemIsStartIndependentAtATightTolerance`); a
|
||||
consumer that needs one answer passes a tight tolerance, as Aetheria does.
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
# CultLib Gaps: Target
|
||||
|
||||
Date: 2026-10-09. Campaign `cultlib-gaps` in the Eureka mind.
|
||||
|
||||
## Why
|
||||
|
||||
Consumers of CultLib keep finding capabilities a reasonable consumer of a general
|
||||
library would expect and that CultLib lacks. Doctrine says a gap is filled in its
|
||||
owner, never worked around in the consumer. This campaign is the standing home for
|
||||
those gaps, so each one lands in CultLib as a small cut instead of a local helper in
|
||||
whichever project hit it.
|
||||
|
||||
Known gaps at opening (each a follow-up in the mind, owned by the consumer campaign
|
||||
that found it):
|
||||
|
||||
- CultMath `BoundedLeastSquares.Solve` has a fixed KKT tolerance (1e-6); Aetheria's
|
||||
thrust allocator needs a tighter one (`aetheria-release:follow_up:cultmath-bls-kkt-tolerance`).
|
||||
- `cultcache-rs` has no decimal type, its snapshot reads follow symlinks, and its
|
||||
single-file store stages files with the process umask and no mode option
|
||||
(`eureka-body:follow_up:cultcache-rs-decimal-and-nofollow`).
|
||||
|
||||
## Invariants
|
||||
|
||||
- `owner-fills-gap`: a capability a consumer needs from CultLib is added to CultLib, with the consumer adopting it; no consumer-local copy survives the cut that adds it.
|
||||
- `defaults-unchanged`: a new option defaults to today's behaviour; existing callers and stored bytes do not change.
|
||||
- `wire-parity`: a CultCache wire change keeps byte parity across runtimes against the C# reference, with a cross-runtime fixture.
|
||||
- `semver-measured`: a public API change bumps the package version by measured semver and ships as a tagged release consumers can pin.
|
||||
|
||||
## Not in scope
|
||||
|
||||
- Growing CultCache into a general database (memory `cultcache-scope-restraint`).
|
||||
- Any consumer's adoption code beyond the pin bump; that stays in the consumer's campaign.
|
||||
|
|
@ -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,119 +0,0 @@
|
|||
# CultMath tapes: interval-pruned field evaluation on CPU and GPU
|
||||
|
||||
Status: steps 2-5 (tape, pruning, HLSL interpreter, meshing) are **parked**
|
||||
(2026-10-02; ruling tape-target-unparks-when-asura-stable). The affine-forms
|
||||
piece was unparked on 2026-10-03 (ruling affine-forms-unparked) and is on main
|
||||
(156782da), alongside step 1's intervals; its map and as-built record are in
|
||||
`docs/cultmath-interval-ground-cut.md`. This note records the direction for the
|
||||
parked steps so they can be picked up without re-deriving it.
|
||||
|
||||
## 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,75 +0,0 @@
|
|||
# Union and record decode tolerance: prior art and CultCache's stance
|
||||
|
||||
Fork: thing2:question:contract-decode-tolerance. Follow-up: thing2:follow_up:cultcache-union-parity.
|
||||
Eyes pass, 2026-10-10, session self-2026-10-10c-eureka. Facts only; no recommendation.
|
||||
Tags: [fetched] read this session; [local] read in the repo this session; [background] prior knowledge, not refetched; [not found] the fetched page did not say.
|
||||
|
||||
## 1. Established systems
|
||||
|
||||
### Protocol Buffers
|
||||
- Unknown fields: proto3 "preserve[s] unknown fields and include[s] them during parsing and in the serialized output", matching proto2. Lost on JSON serialization and on field-by-field copy into a new message; CopyFrom/MergeFrom avoid the loss. (protobuf.dev/programming-guides/proto3/, "Unknown Fields") [fetched]. The version proto3 started preserving (3.5) [background].
|
||||
- Unknown oneof case: no distinct state. The page warns a NOT_SET result is ambiguous: the oneof may be unset, or set to a field from a newer schema [fetched]. A oneof member the reader lacks is an ordinary unknown field, so it sits in the unknown-field set and is re-emitted [background].
|
||||
- Unknown enum values: "unrecognized enum values will be preserved in the message" (raw int in open-enum languages, accessor in Java) and re-serialized [fetched].
|
||||
|
||||
### Cap'n Proto
|
||||
- Union: a separate tag says which member is set. Adding a member to a union is safe because "existing programs should already know to check the union's tag"; they "may or may not behave reasonably when the tag has a value they don't recognize". Moving an existing field into a new union can show an old reader "a garbage value or throw an exception" (capnproto.org/language.html, "Unions", "Evolving Your Protocol") [fetched].
|
||||
- which() on an unknown discriminant: [not found] in the fetched language and encoding pages. C++ generated which() returns the tag as an enum and the reader is expected to switch with a default [background].
|
||||
- Preservation: [not found] on the fetched pages; structs "can be traversed (e.g., copied) without knowing their type" (encoding.html, Structs) [fetched]. Copy preservation of unknown data is [background].
|
||||
|
||||
### Avro (schema resolution; reader schema and writer schema both required)
|
||||
- Both unions: reader takes its first branch matching the writer's selected branch; "if none match, an error is signalled". Writer union, reader non-union: the selected writer branch must match, else error (Avro spec, Schema Resolution) [fetched; also quoted from the 1.11.1 spec in docs/research/read-only-record-signal-prior-art.md:176-178].
|
||||
- Record, writer has an extra field: "the writer's value for that field is ignored". Reader field missing from the writer: reader's default if it has one; no default is an error [fetched].
|
||||
- Enum, writer symbol absent in reader: reader's declared default if any, else error [fetched].
|
||||
- Preservation: none. Extra fields are dropped by resolution [fetched].
|
||||
|
||||
### FlatBuffers
|
||||
- Union evolution: appending a member is safe ("CodeV1 will not recognize the another_a"); inserting in the middle without an explicit discriminant misreads members; inserting with an explicit discriminant is safe (flatbuffers.dev/evolution/) [fetched].
|
||||
- What an old reader sees for an unknown union type, and re-serialization preservation: [not found] on that page. Generated readers expose a type byte plus a union table pointer; an unknown type value falls outside the generated enum [background].
|
||||
|
||||
### Thrift
|
||||
- Unknown fields and unknown union field ids: [not found] in thrift-rpc.md. Protocol readers skip unrecognized field ids and drop the data; an unknown union id leaves the union empty, and a union with no field set can fail validation [background].
|
||||
|
||||
### MessagePack-CSharp [Union]
|
||||
- README Union section says only "Unique union keys are required"; it states no behaviour for an unknown key and no option for it [fetched, first 100k chars of the README].
|
||||
- Missing keys: members initialize to default (README, Object Serialization) [fetched]. Extra array elements for [Key(int)] and unknown string keys: README silent; it shows a manual formatter that loops and skips extras [fetched].
|
||||
- Generated and dynamic union formatters skip the payload and yield null when the key is absent from the union's key map [background]. The estate's own measurement agrees: unknown arm key decodes to null, no error (question document, "Measured in the C# reference... MessagePack-CSharp 3.1.7") [local, via eureka-state view]. The vendored copy at F:\Projects\CultPong\Assets\Scripts\MessagePack\Resolvers\DynamicUnionResolver.cs does a key-map TryGetValue at :347 (type-map TryGetValue at :206); the miss branch was not read.
|
||||
- No configurable unknown-union behaviour found in the README; whether a newer version adds one [not found].
|
||||
|
||||
### serde
|
||||
- enum-representations page says nothing on unknown variant names, #[serde(other)] or deny_unknown_fields [fetched]. serde_derive de.rs was fetched but the identifier/enum_ modules holding unknown_variant were not in the excerpt [not found].
|
||||
- [background] default: an unknown variant is an error ("unknown variant `X`, expected one of ..."); #[serde(other)] on a unit variant makes the identifier decoder return it for any unknown name (units only, payload discarded); unknown struct fields are ignored unless #[serde(deny_unknown_fields)]; a missing field is an error unless #[serde(default)] or Option.
|
||||
|
||||
### Bincode / postcard
|
||||
- [background] Both are non-self-describing: an enum is a variant index (bincode u32, postcard varint) followed by fields in order; an out-of-range index is a decode error; there are no field tags, so an extra or missing slot is detectable only by running out of or leaving bytes. Evolution is by schema versioning outside the format. Not fetched.
|
||||
|
||||
## 2. Preservation across an older reader, and the failure each answers
|
||||
| System | Unknown data survives re-encode? | Failure answered |
|
||||
|---|---|---|
|
||||
| Protobuf | yes: unknown fields (hence unknown oneof cases) and unknown enum ints, in binary APIs only [fetched] | old intermediary re-serializing a newer message (purpose: background) |
|
||||
| Cap'n Proto | page silent; reader sees a tag it may not know and must handle it [fetched] | in-place access; append-only evolution |
|
||||
| Avro | no: extra field dropped, or error on no branch / no default [fetched] | writer schema travels with data, so resolution is exact and loud |
|
||||
| FlatBuffers | page silent [fetched] | append-only evolution, explicit discriminants |
|
||||
| Thrift | no (skipped) [background] | forward tolerance for old readers |
|
||||
| MessagePack-CSharp | no: arm becomes null (measured), extra slot skipped | not documented |
|
||||
| serde | no; error by default [background] | type safety; leniency is opt-in |
|
||||
| Bincode/postcard | n/a [background] | compactness; no evolution in the format |
|
||||
| Unity SerializeReference, DataContract ExtensionData | yes: bytes retained and recoverable (docs/research/read-only-record-signal-prior-art.md:182-205) [local] | data loss from missing types |
|
||||
|
||||
## 3. CultCache's own stance (F:\Projects\CultLib)
|
||||
- docs/persisted-record-encoding.md:1-19: persisted records are verified by re-encoding what was decoded and comparing bytes; a record that reads but re-encodes differently is refused, on live data at startup or next read. Three Idunn guards cited at :10-14. [local] A tolerated extra trailing slot or a null-collapsed arm would not re-encode to the stored bytes; whether those guards would run on such records was not tested.
|
||||
- No evolution/compatibility doc: `ls docs | grep persist|evol|compat|schema|version` returns only persisted-record-encoding.md. docs/document-variants-cut.md:327-333 covers union-slot reuse at registration ("late union-slot reuse passes registration"; "interface unions are never minted") [local, grep lines only].
|
||||
- Reference union handling: src/GameCult.Caching/CultInspectorModel.cs:768-774 classifies an abstract/interface type by its [UnionAttribute]s ordered by key and refuses a type with none ("declares no [Union] subtypes", :772); also :120. This is the inspector's shape classifier; decode is MessagePack-CSharp's. No other use of UnionAttribute in src/GameCult.Caching. [local]
|
||||
- Trailing-slot tolerance: packages/cultcache-ts/test/cult-cache.test.ts:1176-1195, "CultCache interop reader accepts missing compatible trailing slots and rejects mismatched slots": a legacy-payload store with fewer slots reads with `tags: []`; a mismatched-type payload rejects with /Expected string/u. Missing trailing slots are tested; extra trailing slots are not. [local]
|
||||
- TS decode: packages/cultcache-ts/src/cult-cache.ts:679-692 wraps a formatter as `schema.parse(decode(payload))`, so TS strictness is whatever the zod schema says; no TS union codec (follow-up text). [local]
|
||||
- Rust: packages/cultcache-rs/src/*.rs has no unknown-arm or extra-slot handling found by grep; "unknown" hits are store-header tests (:4889-4890 refuses an unknown header by name; :5009-5019 unknown-header.msgpack, variant-v2.msgpack). Rust enum arms persist as serde external tags (follow-up text). [local]
|
||||
- Consumers persisting unions: Aetheria (F:\Projects\Aetheria) has `[Union]` abstract classes in Assets/Scripts/ServerShared: Provenance.cs:96-98 (keys 0-2), ItemInstance.cs:18-21 (keys 0-3) and :29-31, Environment.cs:73. The assembly registers CultCache documents (AssemblyInfo.cs:3-4; Galaxy.cs, ItemData.cs, AetheriaStores.cs reference CultCache); ItemInstance is held in entity item lists (Entity.cs:2003). An older build reading a store with a newer arm key would take the C# null-arm path, if these types sit inside persisted CultCache documents; that persistence path was not traced. Aetheria-legacy has more unions (Behaviors.cs:29-38, Item.cs:43-63, Contract.cs:10-11); commented-out keys 8, 9 at Behaviors.cs:37-38 show arms retired in place. [local]
|
||||
- Ghostlight and Huginn: not searched for [Union] (the grep matched only Aetheria). Follow-up text: "No live record crosses runtimes as a union today" [local, view]. Same-runtime older-build-reads-newer-store: unverified.
|
||||
- The question document records the C# measurement: extra trailing slot skipped and equal; missing trailing slot leaves member default; unknown arm null, for a Node in a list and for an Outcome; Rust and TS union decoders with explicit keys refuse by construction; Rust extra-slot behaviour unmeasured [local, view].
|
||||
|
||||
## 4. Operator's stated value, and how each option relates (inferred)
|
||||
Verbatim, operator-rapport.md:50: "nobody should have to carry bytes they don't understand ... CultCache's whole promise is that a cache's contents are self-identifying via the upfront catalog." Context: pushing back on storing retired slots as metadata (:49). [local]
|
||||
- Preserve-unknown (protobuf, Unity): the older reader carries bytes the catalog does not describe. Opposite to the stated value.
|
||||
- Drop silently (Avro extra fields, MessagePack slot skipping, Thrift skip): nobody carries the bytes, but the loss is silent and a re-encode no longer equals the stored bytes (persisted-record-encoding.md:4-6).
|
||||
- Null the arm (current C# union behaviour): the unknown arm becomes a value the catalog does not describe either; the record decodes as if valid.
|
||||
- Refuse (Avro no-branch, serde default, Rust/TS explicit-key decoders, store-header refusal): the reader declares it cannot understand the bytes; consistent with self-identifying content because undescribed content is not accepted.
|
||||
- Tolerate trailing slots but refuse unknown arms (fork option 1): the catalog-described slot list governs required members; slots beyond it are skipped, which is the drop-silently pattern for slots and the refuse pattern for arms.
|
||||
|
|
@ -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,151 +0,0 @@
|
|||
# Inverse procedural modelling: describing messy meshes as programs
|
||||
|
||||
Eyes pass, 2026-10-10. Operator: "What if the model had some trash geo and its goal is to describe it parametrically?" Context: Tripo hard-surface hulls; CultMath intervals and affine forms on main, tape/brush grammar parked (docs/cultmath-tape-target.md); the earlier GameCult/VibeGeometry attempt (2026-05). Facts only; tags mark FETCHED, SEARCH (search summary only) and KNOWLEDGE (unverified).
|
||||
|
||||
# Eyes facts: inverse procedural modelling (mesh -> parametric program)
|
||||
Date 2026-10-10. Tags: FETCHED (page/file read this session), SEARCH (search-result digest only), KNOWLEDGE (unverified recall).
|
||||
Search digests come from a small model; SEARCH numbers are not paper-verified.
|
||||
|
||||
## 0. What exists in GameCult
|
||||
- FETCHED F:\Projects\CultLib\docs\cultmath-tape-target.md: steps 2-5 (tape, pruning, HLSL interpreter, meshing) PARKED (2026-10-02,
|
||||
ruling tape-target-unparks-when-asura-stable). Step 1 intervals + affine forms are on main (docs/cultmath-interval-ground-cut.md).
|
||||
The "grammar of nested brushes" is operator intent only (quote 2026-10-02: space station from "a grammar of nested brushes");
|
||||
doc says the station project is "a separate project with its own target". Chosen shape (c): port Fidget ideas into CultLib (Fidget =
|
||||
MPL-2.0 Rust, v0.5.1). Meshing plan: surface nets + Asura tile pass (Newton refinement onto f=0, crease snapping from field gradients);
|
||||
"Thin authored bevels can be shaded by their analytic normals". Tape gradients "reuse CultMath's analytic-gradient primitives".
|
||||
- FETCHED grep CultLib\docs for brush/tape: "brush" hits in cultmath-interval-ground-cut.md are Aetheria noise/gravity-well field brushes
|
||||
(PowerBrush (1-x^2)^16 etc.), not CSG brushes. No CSG brush grammar or tape code exists in CultLib; only the parked doc.
|
||||
- FETCHED (voidbot) separate repo GameCult/GameCult.Geometry.Csg ("CultGeometry CSG", Rust; local F:\Projects\GameCult.Geometry.Csg).
|
||||
Mesh-based convex plane-splitting CSG (not SDF), RealtimeCSG-shaped API: ordered Additive/Subtractive/Intersecting brush streams, boxes,
|
||||
oriented boxes, CylinderZ/DomeCapZ/FloretArm, dirty-frontier, OBJ output, plus domain-tree/selected-cut LOD schemas (src/cult_geometry).
|
||||
Doc text: "agents should revise intent by moving, adding, or renaming brushes, not by poking triangles"; "brush list is the durable
|
||||
design surface. The mesh is a cache."
|
||||
|
||||
## 0b. The earlier "vibe geometry" attempt
|
||||
- FETCHED voidbot gamecult-site GameCult/Projects/VibeGeometry.md: repo GameCult/VibeGeometry, started 2026-05-04, last public push
|
||||
2026-05-07; "agent-authored Blender scene generation: geometry nodes, Python builders, semantic graph contracts, render evidence, and
|
||||
the ongoing war against decorative node spaghetti". Org ledger: "Started as architecture and persistence scaffolding, then dived almost
|
||||
immediately into CSG grammar, parity fixtures, native bridge probing, mesh generation, and performance work."
|
||||
- FETCHED local git F:\Projects\VibeGeometry-cultgeometry-rename (93 commits). Commit 09bff8c (2026-05-29, "Focus VibeGeometry on vg-csg")
|
||||
deleted the scene-generation tree; recoverable at 09bff8c^: docs/research/procedural-doctrine.md (~1000-line "spatial reasoning
|
||||
playbook"), dream-machine-grammar.md, geonodes-translation-corpus.md, ircss-french-houses-* (translations of Blender node graphs into
|
||||
Geometry Script Python), builds/{aetheria-bloom-habitat, lucent-tether-habitat, dream-machine-level, industrial-maintenance-loop}.md,
|
||||
notes/architecture-rationale.md, a "reusable procedural helper library" (aa4d49d). The DSL was Blender Python via Geometry Script
|
||||
(fork F:\Projects\geometry-script; upstream PR carson-katri/geometry-script#69 linked by Metacrat 2026-05-04) emitting geometry-node
|
||||
graphs, plus the Rust brush DSL (LevelDsl: solid_box, cut_box, solid_oriented_box, dome_cap_z, floret_arm).
|
||||
- FETCHED notes/architecture-rationale.md @09bff8c^, section "Core Failure": "The central failure mode is local plausibility without global
|
||||
coherence. An agent can write plausible Blender Python, create a large node graph, and still lose the visual machine ... the script
|
||||
runs, the graph exists, the render has objects in it, and somehow the target has been replaced by decorative procedural fog".
|
||||
"Seductive proxies": valid Python, non-empty renders, high node counts, pretty materials, parameter sliders.
|
||||
procedural-doctrine.md: "Ornament in the wrong frame is not detail. It is a confident lie". Commit titles record frame errors:
|
||||
"Correct Bloom terrace slum orientation", "Correct Lucent dome spin-gravity frame", "Move Lucent ray florets outside dome".
|
||||
- FETCHED VibeGeometry README: "failed experiments are useful without being allowed to hold a steering wheel"; old mission "deleted
|
||||
from the live tree"; live mission narrowed to runtime CSG (domain tree -> LOD CSG -> selected cut -> triangles).
|
||||
- NOT FOUND: no indexed operator or agent statement that models "weren't good enough at spatial reasoning to use the DSL". voidbot
|
||||
search_history (Discord) returned only unrelated hits. The repo docs above are the only failure statements in project words
|
||||
(coherence and frame errors; the phrase "spatial reasoning" appears only in the playbook title and its body).
|
||||
|
||||
## 1. Inverse CSG / shape-program synthesis
|
||||
Most train or test on DeepCAD / ShapeNet / ABC / Infinigen; none retrieved was evaluated on noisy AI-generated hard-surface meshes.
|
||||
- InverseCSG (Du et al., SIGGRAPH Asia 2018, MIT). SEARCH: mesh/point samples -> CSG program; geometric processing (RANSAC-style primitive
|
||||
detection) turns the mixed discrete/continuous problem into a purely discrete one; careful point samples guide a discrete search;
|
||||
divide-and-conquer segmentation; program synthesis (solver) per segment. Claims >100 primitive parts, compact programs, beats prior
|
||||
methods on compactness/runtime. D2CSG authors (SEARCH) criticise it: RANSAC needs tuning, noisy initial primitives, long search.
|
||||
Project page inversecsg.csail.mit.edu lists code; licence not checked.
|
||||
- CSGNet (Sharma 2018): KNOWLEDGE only; supervised, voxel/2D -> CSG sequence.
|
||||
- UCSG-Net (Kania, NeurIPS 2020). SEARCH: unsupervised; predicts box/sphere params; differentiable indicator on SDF; several CSG layers with
|
||||
soft operator choice; structure and params learned jointly by gradient descent; autoencoding scope (2D/3D), no mesh-to-program at test
|
||||
for unseen exotic shapes beyond the trained net.
|
||||
- CAPRI-Net (CVPR 2022). SEARCH: axis-aligned quadric primitives, fixed 3-layer assembly; ABC point-cloud Chamfer 0.09 vs UCSG 1.43; ~5.3 vs
|
||||
12.92 convex shapes. D2CSG (2023), CSG-Stump (2021): SEARCH, unsupervised compact trees. SECAD-Net (CVPR 2023): SEARCH, sketch-extrude,
|
||||
editable; CD 2.94/4.20 (ABC/Fusion) vs UCSG 3.14/4.45.
|
||||
- Point2CAD (Liu et al.): SEARCH only that a repo exists (jofcodeusal/point2cad mirror, Docker toshas/point2cad); method not retrieved.
|
||||
Point2Cyl (CVPR 2022): SEARCH, supervised net to extrusion cylinders. DeepCAD (Wu 2021): KNOWLEDGE, ~178k sketch-extrude sequences.
|
||||
- ShapeAssembly (Jones 2020, TOG): SEARCH; programs of cuboid proxies attached to each other with free parameters; extraction from PartNet.
|
||||
ShapeMOD (2021): SEARCH; discovers macros across a corpus minimising calls and free parameters. Repos/licences not found by search
|
||||
(KNOWLEDGE: github rkjones4/ShapeAssembly exists; unverified).
|
||||
- CAD-Recode (Rukhovich et al., ICCV 2025, arXiv 2412.14042). FETCHED abstract: point cloud -> CadQuery Python (sketch-extrude); point-cloud
|
||||
projector + small pretrained LLM decoder; trained on 1M procedurally generated CAD sequences; eval DeepCAD, Fusion360, CC3D (real scans);
|
||||
output usable by off-the-shelf LLMs for editing and QA. SEARCH (unverified): Qwen 0.5B/1.5B decoders; DeepCAD mean CD 3.43 -> 0.30, IoU
|
||||
77.6 -> 92.0 vs CAD-SIGNet; test time: 10 sampled candidates executed, min-Chamfer kept. Code github.com/filaPro/cad-recode (SEARCH);
|
||||
HF dataset filapro/cad-recode is CC-BY-NC-4.0 (SEARCH); repo licence unchecked. Structure by autoregressive decoding, params as literal
|
||||
tokens, re-ranking by Chamfer. Abstract says nothing on fillets/chamfers.
|
||||
- MeshCoder (Dai et al., NeurIPS 2025, arXiv 2508.14879). FETCHED: 16,384-point cloud -> triplane tokenizer -> Llama-3.2-1B (LoRA) ->
|
||||
Blender Python. API: cube/cylinder/UV-sphere/cone/torus, Translation (sweep 2D section along 3D trajectory), Bridge Loop, Boolean
|
||||
(union/intersect/diff), Array, Fill Grid, Spoon, Fork. Data: ~10M part-code pairs, ~1M object-code pairs, 41 categories (Infinigen
|
||||
Indoor). Table 2 averages CD x1e-2 / IoU%: Shape2Prog 6.00/45.0, PLAD 1.87/67.6, MeshCoder 0.06/86.75. Worst: Fork 58.9 IoU, CeilingLight
|
||||
65.8, Array parts 78.9. Limits: human-made objects only; geometry only; training objects kept only if every part CD < 5e-3. LLM does not
|
||||
edit; edits are manual param changes; GPT-4o used only for QA on the code. Output is part-segmented. Licence not stated in paper.
|
||||
- CADCodeVerify (Alrashedy, ICLR 2025, arXiv 2410.05340). SEARCH: text prompt -> CadQuery (GPT-4); VLM writes/answers validation questions
|
||||
about renders and refines; CADPrompt benchmark (200 objects); GPT-4 point-cloud distance -7.30%, compile rate +5.5% (v2; v1 said 5.0%
|
||||
success). Target is a prompt, not a mesh.
|
||||
- Newer render-loop agents (SEARCH): SEIG "Thinking in Blender" (arXiv 2606.02580): single reference image -> staged Blender code (geometry,
|
||||
materials, composition, lighting), VLM generator + verifier per stage; FETCHED abstract gives no numbers, no code link. LL3M (2508.08228):
|
||||
text -> Blender with critic reading renders. ShapeCraft (2510.17603): renders bbox/part/global views as feedback. SceneCraft (2403.01248):
|
||||
render-critique-revise. Not found by search: "3D-Premise", "LLMto3D", Img2CAD/Text2CAD specifics.
|
||||
|
||||
## 2. Classical primitive fitting as a front end
|
||||
- Efficient RANSAC (Schnabel/Wahl/Klein, CGF 2007): SEARCH/FETCHED page text: planes, spheres, cylinders, cones, tori in unorganised clouds;
|
||||
multi-million-point sets "within less than a minute". CGAL Shape Detection implements it plus Region Growing (Lafarge&Mallet). CGAL is
|
||||
dual GPL/LGPL + commercial; the Shape Detection package's own licence NOT confirmed (digest guessed GPL).
|
||||
- GlobFit (Li/Wu/Chrysanthou/Sharf/Cohen-Or/Mitra, SIGGRAPH 2011): SEARCH; starts from RANSAC primitives, discovers global relations
|
||||
(orientation, placement, equality; symmetry) and enforces them by constrained optimisation; assumes man-made, repeated, globally aligned
|
||||
parts. Code: archived code.google.com/archive/p/globfit, "research use only" wording.
|
||||
- Mitra 2006 symmetry detection, SPFN (2019): KNOWLEDGE/SEARCH only. ParSeNet (2020): SEARCH; B-spline patch decomposition, beats RANSAC
|
||||
and SPFN baselines. No repos/licences retrieved.
|
||||
- What fits supply as data: exact primitive parameters with inlier sets and residuals, segment labels, relation graph (GlobFit).
|
||||
InverseCSG uses the primitives to make the problem discrete. Recorded weakness: noisy primitives, parameter tuning (SEARCH).
|
||||
|
||||
## 3. Differentiable fitting of SDF/CSG programs
|
||||
- UCSG-Net (above): soft operator selection; structure learned jointly.
|
||||
- Fuzzy Boolean differentiable CSG (dgp.toronto.edu/~hsuehtil/pdf/fuzzyBoolean.pdf): SEARCH; gradient descent over boolean and primitive
|
||||
parameters; notes prior work pre-fixes tree structure and operators and optimises only primitive params; replaces min/max with a unified
|
||||
fuzzy operator so the operation type is optimised. Costs listed: analytic SDF per primitive type, per-iteration SDF evaluation, image
|
||||
losses need differentiable sphere tracing.
|
||||
- DiffCSG (arXiv 2409.01421): SEARCH; differentiable rasterisation of mesh CSG, image-space gradients to primitive params; image-guided
|
||||
editing, not mesh fitting.
|
||||
- ResFit, Residual Primitive Fitting with SuperFrusta (arXiv 2512.09201): SEARCH; one analytic SDF primitive with 8 params (dilation, taper,
|
||||
bulge, onion hollowing, profile roundness, axial scaling); alternates global shape analysis and local primitive optimisation; code "to be
|
||||
open-sourced upon acceptance".
|
||||
- Evolutionary mesh -> CSG abstraction (SEARCH; authors not retrieved): fast, coarse. Sigmoid-over-SDF soft occupancy with alternating
|
||||
add-primitive / joint-refine loop (SEARCH, primitive-scene paper).
|
||||
- Not found: a published gradient fit of an SDF program with bevel/round parameters to a messy hard-surface mesh.
|
||||
- Handling of discrete structure across sources: soft selection (UCSG, CSG-Stump, fuzzy Boolean); pre-fixed structure (InverseCSG after
|
||||
RANSAC; CAPRI-Net fixed layers); greedy add/prune (ResFit); neural token decoding (CAD-Recode, MeshCoder).
|
||||
|
||||
## 4. LLM spatial reasoning in 3D code generation
|
||||
- BlenderGym (Gu et al., CVPR 2025, arXiv 2504.01786). FETCHED: 245 start-goal Blender scene pairs, five tasks (placement, lighting,
|
||||
procedural material, blend shape, procedural geometry); the VLM sees start and goal and edits Blender code (target-matching setting).
|
||||
Table 1 best-VLM vs human: geometry Chamfer 1.120 (GPT-4-Turbo) vs 0.334; geometry photometric loss 6.747 vs 1.269; placement CD 8.324 vs
|
||||
1.532; blend shape CD 0.904 vs 0.399. Paper: VLMs "particularly poorly in procedural material and geometry editing"; all open-source
|
||||
models except MiniCPM failed to produce executable code on >75% of geometry instances. Verifier agreement with humans: human 0.79,
|
||||
Claude 3.5 Sonnet 0.66, random 0.5. Scaling a small verifier (InternVL2-8B, k 1 -> 64 over 32 candidates) beat unscaled GPT-4o and
|
||||
Claude 3.5 on blend shape. Low compute: less verification share (0.33); high compute: more (0.62-0.73). Models are 2024-25 era. Code
|
||||
licence not in retrieved text.
|
||||
- BenchCAD (arXiv 2605.10865): SEARCH; 17,900 CadQuery programs, 106 part families; frontier models recover coarse outer geometry but fail
|
||||
faithful parametric programs; sweeps/lofts/twist-extrudes replaced by sketch-extrude; single-literal errors (5.6 vs 5.85) at IoU >= 0.9.
|
||||
BenchCAD-Edit: text instruction sets the upper bound; the image is "at best a clarifier and at worst a distractor" for thinking models.
|
||||
- Text2CAD-Bench (2605.18430): SEARCH; execution failures even at easy level; sweep/loft/shell widely fail; executing != correct.
|
||||
- Text-to-B-Rep paper (2603.11831): SEARCH; spatial misalignment of primitives, omitted fillets, geometry contradicting described layout;
|
||||
authors blame weak precise 3D spatial reasoning.
|
||||
- "Foundation Models for Automatic CAD Generation" (2607.05573): SEARCH, single source; numeric analytic feedback peaked at round 1; VLM
|
||||
critique of renders kept improving in rounds 2-3 for DeepSeek/Qwen; VLM critics struggle on rotationally symmetric geometry.
|
||||
- Multi-view conditioning beat single-view and sketch on Chamfer/F-score in a B-Rep paper (SEARCH, not CadQuery).
|
||||
- Target vs description: BlenderGym gives a target and still shows a large human gap; CAD-Recode and MeshCoder avoid prompting by training
|
||||
point-cloud encoders; no controlled prompted-LLM study of target-vs-description was found.
|
||||
- MindCube spatial study (SEARCH): errors 20% final reasoning step, 13% 3D reconstruction, 6% generated programs.
|
||||
|
||||
## 5. Hard-surface specifics
|
||||
- SDF rounding (Quilez distfunctions, SEARCH): sdRoundBox = box SDF minus r, an iso-offset so the shape grows by r. Polynomial smin with k
|
||||
as blend band; Quilez marks smin as "bound" not exact. Chamfer/fillet operators not retrieved; KNOWLEDGE: hg_sdf (Mercury) has
|
||||
fOpUnionChamfer / fOpUnionRound / fOpUnionStairs (unverified here).
|
||||
- MeshCoder (FETCHED): no fillet/chamfer primitive; booleans, sweeps, bridge-loop, arrays only.
|
||||
- BenchCAD / text-to-B-Rep (SEARCH): omitted fillets/refinement features named as failure; sweeps/lofts degrade to extrudes.
|
||||
- CAD-Recode/DeepCAD data are sketch-extrude (SEARCH/KNOWLEDGE); fillet/chamfer not covered in what was retrieved.
|
||||
- InverseCSG output: boolean of primitives, no blends (KNOWLEDGE). CAPRI-Net quadrics: smooth surface in one primitive (SEARCH). ResFit
|
||||
primitive has a profile-roundness parameter (SEARCH).
|
||||
|
||||
## Gaps
|
||||
- Operator/agent words on why VibeGeometry failed: not indexed (see 0b).
|
||||
- Not read at body level: CAD-Recode fillet handling, InverseCSG experiments, GlobFit relation list, SEIG numbers, ResFit results.
|
||||
- Licences unverified: InverseCSG, ShapeAssembly, CAD-Recode repo, MeshCoder, GlobFit, CGAL Shape Detection, Point2CAD.
|
||||
|
|
@ -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.4.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
358
package-lock.json
generated
358
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",
|
||||
|
|
@ -1202,7 +850,7 @@
|
|||
"version": "0.3.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@gamecult/cultcache-ts": "^0.14.0",
|
||||
"@msgpack/msgpack": "^3.1.3",
|
||||
"cultnet-ts": "^0.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -1218,8 +866,8 @@
|
|||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@gamecult/cultcache-ts": "^0.14.0",
|
||||
"cultnet-ts": "^0.2.0",
|
||||
"koffi": "3.3.0"
|
||||
"@msgpack/msgpack": "^3.1.2",
|
||||
"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
|
|
@ -32,37 +32,6 @@ This is not an ORM in a fake mustache. It is for cases where you want:
|
|||
|
||||
That keeps the polymorphic boundary tight without making callers write boilerplate by candlelight.
|
||||
|
||||
## Reading And Writing Payloads
|
||||
|
||||
A document's bytes travel as a `DocumentPayload`: in a store envelope, a CultNet record and a
|
||||
realtime frame. At run time it is the same `Uint8Array`, so nothing on the wire changes. At compile
|
||||
time it is opaque: it is assignable to and from neither `Uint8Array` nor `ArrayLike<number>`, so no
|
||||
MessagePack decoder accepts it.
|
||||
|
||||
What the type guarantees is that a payload cannot be read or assigned directly: a decoder call, an
|
||||
assignment to `Uint8Array` and `new Uint8Array(payload)` fail to compile. It does not stop what the
|
||||
runtime value allows, because the value is a `Uint8Array`. These routes read it without the owner:
|
||||
`instanceof Uint8Array` or `ArrayBuffer.isView` narrowing, `Object.values`, `Object.assign` onto a
|
||||
`Uint8Array`, a JSON round trip, `Reflect.get`, and a cast. A read made that way sees the raw bytes,
|
||||
which for a codec or formatter document are not the document. No lint or guard closes them. Read
|
||||
through `decodeDocumentPayload`, and when you need the bytes themselves (hashing, signing, a store
|
||||
write) take them with `documentPayloadBytes`, the sanctioned escape.
|
||||
|
||||
- `decodeDocumentPayload(definition, payload)` reads a payload through the definition's formatter,
|
||||
codec or plain map and then its schema. The definition is required.
|
||||
- `encodeDocumentPayload(definition, value)` is the writer: the schema first, then the formatter,
|
||||
codec or plain map.
|
||||
- `documentPayloadFromBytes(bytes)` is where bytes enter (a record you validated, a store read, a
|
||||
writer of pre-encoded or signed bytes) and `documentPayloadBytes(payload)` is where they leave (a
|
||||
store write, a frame encode, hashing or signing). Reach for them only at those edges.
|
||||
|
||||
```ts
|
||||
const note = decodeDocumentPayload(noteDocument, envelope.payload); // parsed Note
|
||||
const payload = encodeDocumentPayload(noteDocument, note); // DocumentPayload
|
||||
```
|
||||
|
||||
Browsers import the same names from `@gamecult/cultcache-ts/document`, which holds no Node built-ins.
|
||||
|
||||
## `.cc` Inspection
|
||||
|
||||
CultCache files may use the `.cc` extension. The bytes are still the canonical
|
||||
|
|
@ -265,54 +234,6 @@ The important boundary is this:
|
|||
|
||||
So callers get typed values, and the persistence layer does not become an open polymorphic sewer.
|
||||
|
||||
## Slot Records and Unions
|
||||
|
||||
`codec` writes the reference runtimes' record shape: a MessagePack array indexed by slot key, nil in a gap, and a
|
||||
union as `[armKey, [armSlots...]]`. A union value is `{ kind: armName, ...armFields }`.
|
||||
|
||||
```ts
|
||||
import { codecs, defineDocumentType } from "@gamecult/cultcache-ts";
|
||||
const { slots, union, list, lazy, string } = codecs;
|
||||
|
||||
const NS = "MyApp.Scene+"; // the C# names of the types, as the reference writes them into a store's schema catalog
|
||||
|
||||
type Node =
|
||||
| { kind: "Box"; id: string; children: (Node | null)[] }
|
||||
| { kind: "Text"; id: string; content: string }
|
||||
| { kind: "Image"; id: string; uri: string; alt: string };
|
||||
|
||||
const node: codecs.Codec<Node | null> = union<Node>("Node", {
|
||||
0: ["Box", slots<{ id: string; children: (Node | null)[] }>({ 0: ["id", string], 1: ["children", list(lazy(() => node))] })],
|
||||
1: ["Text", slots<{ id: string; content: string }>({ 0: ["id", string], 1: ["content", string] })],
|
||||
5: ["Image", slots<{ id: string; uri: string; alt: string }>({ 0: ["id", string], 1: ["uri", string], 3: ["alt", string] })], // slot 2 retired
|
||||
}, `${NS}Node`);
|
||||
|
||||
const sceneDocument = defineDocumentType({
|
||||
type: "scene",
|
||||
schema: sceneSchema,
|
||||
codec: slots<Scene>({ 0: ["id", string], 1: ["root", node], 2: ["title", string, ""] }),
|
||||
name: "id",
|
||||
});
|
||||
```
|
||||
|
||||
- A slot is `[name, codec, default?]`. A gap is written nil and skipped on read; an extra trailing slot is skipped; a
|
||||
slot missing from the payload (or nil, when a default is declared) takes its default, and is refused without one.
|
||||
- A union refuses an undeclared arm key on read, naming the union, the key and the declared keys. Nothing falls back
|
||||
to a default arm. An arm with no slots is `codecs.unit` and is written `[key, []]`.
|
||||
- A refusal names the union, the field and the slot; it never echoes the value it refused.
|
||||
- The leaves are `string`, `boolean`, `bytes`, `int32`, `int64`, `float32` and `float64`, each writing the MessagePack the
|
||||
C# type writes and refusing what C# refuses (a float in an int slot, an `int` outside Int32). `int64` reads a number
|
||||
when the value is a safe integer and a bigint beyond 2^53, and refuses a number beyond 2^53 on write. `list`, `array`
|
||||
and `nullable` wrap a leaf, a union or a record. A `float32` slot rounds an integer once, as the reference's cast does.
|
||||
- Every codec carries the reference's type name, and a store's schema catalog is derived from those names, because the
|
||||
reference refuses a store whose slot names another type. The leaves know theirs; give a union or a record its C# name
|
||||
as the last argument of `union(name, arms, typeName)` or `slots(spec, typeName)`. A codec with a slot it cannot name
|
||||
refuses to derive members: pass `members` instead.
|
||||
- An arm key is an integer within Int32 (negative too), in any MessagePack integer encoding. A float key (even `1.0`) is
|
||||
refused on read, and a union refuses a key outside Int32 when it is defined. A slot key is within 0 to Int32 max.
|
||||
- `codec` supplies the document's formatter and, unless `members` is given, its catalog members. A definition may not
|
||||
carry both `codec` and `formatter`. `schema.parse` still runs on every decoded value.
|
||||
|
||||
## Raw Envelope Fast Path
|
||||
|
||||
For bit-compatible neighbors such as CultNet peers that already share the same
|
||||
|
|
|
|||
|
|
@ -10,24 +10,12 @@
|
|||
"require": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./document": {
|
||||
"types": "./dist/document.d.ts",
|
||||
"require": "./dist/document.js",
|
||||
"default": "./dist/document.js"
|
||||
},
|
||||
"./inspection": {
|
||||
"types": "./dist/cult-cache-inspector.d.ts",
|
||||
"require": "./dist/cult-cache-inspector.js",
|
||||
"default": "./dist/cult-cache-inspector.js"
|
||||
}
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"document": [
|
||||
"dist/document.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
|
@ -35,7 +23,7 @@
|
|||
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json",
|
||||
"prepack": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json",
|
||||
"typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --pretty false",
|
||||
"test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js"
|
||||
"test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --test dist-test/test/cult-cache.test.js"
|
||||
},
|
||||
"keywords": [
|
||||
"messagepack",
|
||||
|
|
@ -57,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,370 +0,0 @@
|
|||
import type { CultCacheSchemaCatalogMember } from "./types";
|
||||
import { WireFloat } from "./wire";
|
||||
|
||||
// A codec turns a value into the shape the reference runtimes write and back. Slot records are positional arrays with
|
||||
// nil gaps; a union value is { kind: armName, ...armFields }, written [armKey, [armSlots...]]. Refusals name the union
|
||||
// or the slot and the key or code, and never echo a decoded value. `typeName` is the reference's name for the type
|
||||
// (CultSchemaTypeNames.FromType): a store's schema catalog carries it per slot, and the reference refuses a store whose
|
||||
// slot names another type. The leaves know theirs; a union or a record the codec cannot name from C# is given it.
|
||||
export interface Codec<TValue> {
|
||||
encode(value: TValue): unknown;
|
||||
decode(raw: unknown): TValue;
|
||||
readonly typeName?: string;
|
||||
// True for a type the reference makes Nullable<T> when the slot may be nil (int, long, float, double, bool).
|
||||
readonly valueType?: boolean;
|
||||
}
|
||||
|
||||
export interface SlotsCodec<TValue extends object> extends Codec<TValue> {
|
||||
// Throws when a slot's codec has no typeName: the catalog cannot be derived without the reference's names.
|
||||
readonly members: readonly CultCacheSchemaCatalogMember[];
|
||||
readonly names: readonly string[];
|
||||
}
|
||||
|
||||
function kindOf(raw: unknown): string {
|
||||
if (raw === null || raw === undefined) return "nil";
|
||||
if (Array.isArray(raw)) return "array";
|
||||
if (raw instanceof WireFloat) return "float";
|
||||
if (typeof raw === "bigint") return "integer";
|
||||
return typeof raw;
|
||||
}
|
||||
|
||||
function refuse(what: string, found: string): never {
|
||||
throw new Error(`expected ${what}, found ${found}`);
|
||||
}
|
||||
|
||||
function expect(raw: unknown, what: string, ok: boolean): asserts ok {
|
||||
if (!ok) refuse(what, kindOf(raw));
|
||||
}
|
||||
|
||||
export const string: Codec<string> = {
|
||||
typeName: "System.String",
|
||||
encode: (value) => (expect(value, "a string", typeof value === "string"), value),
|
||||
decode: (raw) => (expect(raw, "a string", typeof raw === "string"), raw as string),
|
||||
};
|
||||
|
||||
export const boolean: Codec<boolean> = {
|
||||
typeName: "System.Boolean",
|
||||
valueType: true,
|
||||
encode: (value) => (expect(value, "a boolean", typeof value === "boolean"), value),
|
||||
decode: (raw) => (expect(raw, "a boolean", typeof raw === "boolean"), raw as boolean),
|
||||
};
|
||||
|
||||
// The reference's byte[] reads a MessagePack str as its bytes and writes bin (an old-spec writer's output), so a str is
|
||||
// accepted and re-encodes as bin. The wire reader has already decoded the str, so a str that held invalid UTF-8 (it
|
||||
// reads back U+FFFD) is refused rather than turned into other bytes.
|
||||
export const bytes: Codec<Uint8Array> = {
|
||||
typeName: "System.Byte[]",
|
||||
encode: (value) => (expect(value, "bytes", value instanceof Uint8Array), value),
|
||||
decode(raw) {
|
||||
if (typeof raw === "string") {
|
||||
if (raw.includes("<22>")) refuse("bytes", "a string that is not valid UTF-8");
|
||||
return new TextEncoder().encode(raw);
|
||||
}
|
||||
expect(raw, "bytes", raw instanceof Uint8Array);
|
||||
return raw as Uint8Array;
|
||||
},
|
||||
};
|
||||
|
||||
// A C# integer slot: written as the smallest MessagePack integer and read from any integer encoding in range. A float is
|
||||
// refused even when it holds a whole number, as the reference refuses it.
|
||||
function integerCodec(typeName: string, what: string, low: bigint, high: bigint): Codec<number | bigint> {
|
||||
const inRange = (value: number | bigint) => value >= low && value <= high;
|
||||
return {
|
||||
typeName,
|
||||
valueType: true,
|
||||
encode(value) {
|
||||
if (typeof value === "bigint") {
|
||||
if (!inRange(value)) refuse(what, "an integer out of range");
|
||||
return value;
|
||||
}
|
||||
if (typeof value !== "number") refuse(what, kindOf(value));
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
refuse(what, Number.isInteger(value) ? "a number beyond 2^53, which needs a bigint" : "a non-integer number");
|
||||
}
|
||||
if (!inRange(value)) refuse(what, "an integer out of range");
|
||||
return value;
|
||||
},
|
||||
decode(raw) {
|
||||
if (typeof raw !== "number" && typeof raw !== "bigint") refuse(what, kindOf(raw));
|
||||
if (!inRange(raw)) refuse(what, "an integer out of range");
|
||||
return raw;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// C# int. Always a number.
|
||||
export const int32 = integerCodec("System.Int32", "an int32", -(2n ** 31n), 2n ** 31n - 1n) as Codec<number>;
|
||||
// C# long. A number when the value is a safe integer, a bigint beyond 2^53 (so 64-bit values never lose precision).
|
||||
export const int64 = integerCodec("System.Int64", "an int64", -(2n ** 63n), 2n ** 63n - 1n);
|
||||
|
||||
// A 64-bit integer to the nearest float32 in one rounding, ties to even, as the reference's cast does. Number(value)
|
||||
// first would round to a double and then again, and the two roundings can disagree. A bigint reaches here only beyond
|
||||
// 2^53 (the wire reader returns a number for any smaller integer), so at least 30 bits are rounded away.
|
||||
function bigintToFloat32(value: bigint): number {
|
||||
const magnitude = value < 0n ? -value : value;
|
||||
const excess = magnitude.toString(2).length - 24;
|
||||
const kept = magnitude >> BigInt(excess);
|
||||
const rest = magnitude - (kept << BigInt(excess));
|
||||
const half = 1n << BigInt(excess - 1);
|
||||
const rounded = Number(rest > half || (rest === half && (kept & 1n) === 1n) ? kept + 1n : kept) * 2 ** excess;
|
||||
return value < 0n ? -rounded : rounded;
|
||||
}
|
||||
|
||||
function floatCodec(typeName: string, wide: boolean): Codec<number> {
|
||||
const what = wide ? "a float64" : "a float32";
|
||||
return {
|
||||
typeName,
|
||||
valueType: true,
|
||||
encode(value) {
|
||||
expect(value, what, typeof value === "number");
|
||||
return new WireFloat(value, wide);
|
||||
},
|
||||
decode(raw) {
|
||||
if (raw instanceof WireFloat) {
|
||||
// A JS number has no NaN payload, so a signalling float32 NaN cannot be kept; refusing beats changing its bytes.
|
||||
if (raw.signalling && !wide) refuse(what, "a signalling NaN");
|
||||
return wide ? raw.value : Math.fround(raw.value);
|
||||
}
|
||||
if (typeof raw === "number") return wide ? raw : Math.fround(raw);
|
||||
if (typeof raw === "bigint") return wide ? Number(raw) : bigintToFloat32(raw);
|
||||
return refuse(what, kindOf(raw));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// C# float, written as MessagePack float 32.
|
||||
export const float32 = floatCodec("System.Single", false);
|
||||
// C# double, written as MessagePack float 64.
|
||||
export const float64 = floatCodec("System.Double", true);
|
||||
|
||||
function sequence<TItem>(item: Codec<TItem>, nameOf: (itemName: string) => string): Codec<TItem[]> {
|
||||
return {
|
||||
get typeName() { return item.typeName === undefined ? undefined : nameOf(item.typeName); },
|
||||
encode(value) {
|
||||
expect(value, "an array", Array.isArray(value));
|
||||
return value.map((element, index) => at(`element ${index}`, () => item.encode(element)));
|
||||
},
|
||||
decode(raw) {
|
||||
expect(raw, "an array", Array.isArray(raw));
|
||||
return (raw as unknown[]).map((element, index) => at(`element ${index}`, () => item.decode(element)));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// C# List<T>.
|
||||
export function list<TItem>(item: Codec<TItem>): Codec<TItem[]> {
|
||||
return sequence(item, (name) => `System.Collections.Generic.List<${name}>`);
|
||||
}
|
||||
|
||||
// C# T[]. The same wire as list.
|
||||
export function array<TItem>(item: Codec<TItem>): Codec<TItem[]> {
|
||||
return sequence(item, (name) => `${name}[]`);
|
||||
}
|
||||
|
||||
// A slot that may be nil. For a value type the reference's name is Nullable<T>; for a reference type it is unchanged.
|
||||
export function nullable<TValue>(inner: Codec<TValue>): Codec<TValue | null> {
|
||||
return {
|
||||
get typeName() {
|
||||
return inner.typeName !== undefined && inner.valueType ? `System.Nullable<${inner.typeName}>` : inner.typeName;
|
||||
},
|
||||
encode: (value) => (value === null || value === undefined ? null : inner.encode(value)),
|
||||
decode: (raw) => (raw === null ? null : inner.decode(raw)),
|
||||
};
|
||||
}
|
||||
|
||||
export function lazy<TValue>(get: () => Codec<TValue>): Codec<TValue> {
|
||||
let resolved: Codec<TValue> | undefined;
|
||||
const codec = () => (resolved ??= get());
|
||||
return {
|
||||
get typeName() { return codec().typeName; },
|
||||
get valueType() { return codec().valueType; },
|
||||
encode: (value) => codec().encode(value),
|
||||
decode: (raw) => codec().decode(raw),
|
||||
};
|
||||
}
|
||||
|
||||
// Prefixes a refusal with where it happened, so a nested refusal still names its field and its union.
|
||||
function at<TResult>(where: string, run: () => TResult): TResult {
|
||||
try {
|
||||
return run();
|
||||
} catch (error) {
|
||||
throw new Error(`${where}: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
type SlotSpec<TValue, TName extends string = string> =
|
||||
| readonly [name: TName, codec: Codec<TValue>]
|
||||
| readonly [name: TName, codec: Codec<TValue>, defaultValue: TValue | (() => TValue)];
|
||||
|
||||
type SlotsSpec<TValue> = Readonly<Record<number, { [K in keyof TValue & string]: SlotSpec<TValue[K], K> }[keyof TValue & string]>>;
|
||||
|
||||
const INT32_MIN = -(2 ** 31);
|
||||
const INT32_MAX = 2 ** 31 - 1;
|
||||
|
||||
// The largest slot key. A slot record is written as a dense array of key+1 slots (the reference writes the same, nil
|
||||
// in every gap), so a key is a payload length: the cap keeps a definition's typo from allocating without bound.
|
||||
export const MAX_SLOT_KEY = 65535;
|
||||
|
||||
// The declared keys, ascending. The reference's keys are Int32: a union arm may be any (a negative one too), a slot is
|
||||
// 0 to MAX_SLOT_KEY, and a key outside its range is refused at definition rather than allocated for.
|
||||
function slotKeys(spec: Readonly<Record<string, unknown>>, owner: string, low: number, high: number): number[] {
|
||||
return Object.keys(spec)
|
||||
.map((text) => {
|
||||
if (!/^-?(0|[1-9][0-9]*)$/u.test(text) || (low >= 0 && text.startsWith("-"))) {
|
||||
throw new Error(`${owner} declares the key "${text}", which is not ${low < 0 ? "an" : "a non-negative"} integer.`);
|
||||
}
|
||||
const key = BigInt(text);
|
||||
if (key < BigInt(low) || key > BigInt(high)) {
|
||||
throw new Error(`${owner} declares the key ${text}, which is ${high === INT32_MAX ? "outside Int32" : `above the slot key limit ${high}`}.`);
|
||||
}
|
||||
return Number(key);
|
||||
})
|
||||
// Object.keys lists negative keys after the others, in the order they were written: sort them numerically.
|
||||
.sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
function pascalCase(name: string): string {
|
||||
return name
|
||||
.split(/[^A-Za-z0-9]/u)
|
||||
.filter((part) => part.length > 0)
|
||||
.map((part) => part[0].toUpperCase() + part.slice(1))
|
||||
.join("");
|
||||
}
|
||||
|
||||
// A positional record: slot `key` holds field `name`. Gaps are written nil and skipped on read, extra trailing slots
|
||||
// are skipped, and a slot missing from the payload takes its declared default or is refused. A default fills only a
|
||||
// missing trailing slot, never a nil: as in the reference, a nil in a reference slot (string, bytes, record, union,
|
||||
// list) reads as null and writes back as nil, so a record the reference wrote re-encodes byte-identical, and a nil in
|
||||
// a value slot (int, float, bool) is refused unless the slot is nullable.
|
||||
export function slots<TValue extends object>(spec: SlotsSpec<TValue>, typeName?: string): SlotsCodec<TValue> {
|
||||
const keys = slotKeys(spec, "A slot record", 0, MAX_SLOT_KEY);
|
||||
if (keys.length === 0) {
|
||||
throw new Error("A slot record declares no slots.");
|
||||
}
|
||||
const fields = keys.map((key) => {
|
||||
const [name, codec, ...rest] = (spec as unknown as Readonly<Record<number, SlotSpec<unknown>>>)[key];
|
||||
return { key, name, codec, hasDefault: rest.length > 0, defaultValue: rest[0] };
|
||||
});
|
||||
const seen = new Set<string>();
|
||||
for (const field of fields) {
|
||||
if (seen.has(field.name)) {
|
||||
throw new Error(`A slot record declares the field "${field.name}" twice.`);
|
||||
}
|
||||
seen.add(field.name);
|
||||
}
|
||||
const length = keys[keys.length - 1] + 1;
|
||||
const defaultOf = (field: (typeof fields)[number]): unknown =>
|
||||
typeof field.defaultValue === "function" ? (field.defaultValue as () => unknown)() : field.defaultValue;
|
||||
|
||||
return {
|
||||
names: fields.map((field) => field.name),
|
||||
typeName,
|
||||
get members() {
|
||||
return fields.map((field) => {
|
||||
if (field.codec.typeName === undefined) {
|
||||
throw new Error(`${field.name} (slot ${field.key}) has no C# type name for the schema catalog; give its union or record a typeName, or give the document its members.`);
|
||||
}
|
||||
return { slot: field.key, memberName: pascalCase(field.name), typeName: field.codec.typeName };
|
||||
});
|
||||
},
|
||||
encode(value) {
|
||||
expect(value, "a record", typeof value === "object" && value !== null);
|
||||
const out: unknown[] = new Array(length).fill(null);
|
||||
for (const field of fields) {
|
||||
const member = (value as Record<string, unknown>)[field.name];
|
||||
out[field.key] = member === null && !field.codec.valueType
|
||||
? null
|
||||
: at(`${field.name} (slot ${field.key})`, () => field.codec.encode(member));
|
||||
}
|
||||
return out;
|
||||
},
|
||||
decode(raw) {
|
||||
expect(raw, "an array of slots", Array.isArray(raw));
|
||||
const input = raw as unknown[];
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
const present = field.key < input.length;
|
||||
if (!present && !field.hasDefault) {
|
||||
throw new Error(`${field.name} (slot ${field.key}): the slot is missing and declares no default.`);
|
||||
}
|
||||
out[field.name] = !present
|
||||
? defaultOf(field)
|
||||
: input[field.key] === null && !field.codec.valueType
|
||||
? null
|
||||
: at(`${field.name} (slot ${field.key})`, () => field.codec.decode(input[field.key]));
|
||||
}
|
||||
return out as TValue;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// An arm with no slots. It is written [key, []], never [key, nil].
|
||||
export interface UnitArm {
|
||||
readonly unit: true;
|
||||
}
|
||||
export const unit: UnitArm = Object.freeze({ unit: true });
|
||||
|
||||
type ArmSpec<TKind extends string> = readonly [armName: TKind, body: SlotsCodec<any> | UnitArm];
|
||||
|
||||
// A tagged union written [armKey, [armSlots...]]. The reader refuses an undeclared key, naming the union, the key and
|
||||
// the declared keys; there is no fallback arm.
|
||||
export function union<TValue extends { kind: string }>(
|
||||
name: string,
|
||||
arms: Readonly<Record<number, ArmSpec<TValue["kind"]>>>,
|
||||
typeName?: string,
|
||||
): Codec<TValue | null> {
|
||||
const keys = slotKeys(arms, `Union ${name}`, INT32_MIN, INT32_MAX);
|
||||
if (keys.length === 0) {
|
||||
throw new Error(`Union ${name} declares no arms.`);
|
||||
}
|
||||
const byKey = new Map<number, { armName: string; body: SlotsCodec<any> | UnitArm }>();
|
||||
const byName = new Map<string, number>();
|
||||
for (const key of keys) {
|
||||
const [armName, body] = arms[key];
|
||||
if (byName.has(armName)) {
|
||||
throw new Error(`Union ${name} declares the arm "${armName}" twice.`);
|
||||
}
|
||||
if (body !== unit && (body as SlotsCodec<any>).names.includes("kind")) {
|
||||
throw new Error(`Union ${name} arm "${armName}" declares a slot named kind, which is the union's own tag.`);
|
||||
}
|
||||
byKey.set(key, { armName, body });
|
||||
byName.set(armName, key);
|
||||
}
|
||||
const declared = keys.join(", ");
|
||||
|
||||
return {
|
||||
typeName,
|
||||
encode(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
const key = byName.get((value as { kind?: string }).kind as string);
|
||||
if (key === undefined) {
|
||||
throw new Error(`Union ${name}: the value's kind is not one of its arms (${[...byName.keys()].join(", ")}).`);
|
||||
}
|
||||
const { armName, body } = byKey.get(key)!;
|
||||
return [key, body === unit ? [] : at(`arm ${armName}`, () => (body as SlotsCodec<any>).encode(value))];
|
||||
},
|
||||
decode(raw) {
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(raw) || raw.length !== 2 || !Array.isArray(raw[1])) {
|
||||
throw new Error(`Union ${name}: expected [key, slots] and found something else.`);
|
||||
}
|
||||
const key: unknown = raw[0];
|
||||
if (typeof key !== "number" && typeof key !== "bigint") {
|
||||
throw new Error(`Union ${name}: the arm key is not an integer (declared keys: ${declared}).`);
|
||||
}
|
||||
if (key < INT32_MIN || key > INT32_MAX) {
|
||||
throw new Error(`Union ${name}: the arm key is outside Int32 (declared keys: ${declared}).`);
|
||||
}
|
||||
const arm = byKey.get(Number(key));
|
||||
if (arm === undefined) {
|
||||
throw new Error(`Union ${name}: unknown arm key ${key} (declared keys: ${declared}).`);
|
||||
}
|
||||
const fields = arm.body === unit ? {} : at(`arm ${arm.armName}`, () => (arm.body as SlotsCodec<any>).decode(raw[1]));
|
||||
return { kind: arm.armName, ...fields } as unknown as TValue;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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,17 +1,11 @@
|
|||
import {
|
||||
decodeDocumentPayload,
|
||||
documentPayloadBytes,
|
||||
documentPayloadFromBytes,
|
||||
encodeDocumentPayload,
|
||||
resolveDocumentMembers,
|
||||
} from "./document";
|
||||
import { resolveDocumentFormatter } from "./document-formatter";
|
||||
import type { DocumentPayload } from "./document";
|
||||
import { decode, encode } from "@msgpack/msgpack";
|
||||
|
||||
import type {
|
||||
AnyCultCacheDocumentDefinition,
|
||||
CacheBackingStore,
|
||||
CultCacheDocumentAccessor,
|
||||
CultCacheDocumentDefinition,
|
||||
CultCacheDocumentFormatter,
|
||||
CultCacheDocumentRegistry,
|
||||
CultCacheDocumentValue,
|
||||
CultCacheEnvelope,
|
||||
|
|
@ -21,16 +15,10 @@ import type {
|
|||
|
||||
type Accessor = (value: unknown) => string | undefined;
|
||||
|
||||
// A registered definition reads and writes its documents through the owner, on payloads the cache has cloned.
|
||||
type RegisteredFormatter = {
|
||||
encode(value: unknown): DocumentPayload;
|
||||
decode(payload: DocumentPayload): unknown;
|
||||
};
|
||||
|
||||
type RegisteredDefinition = {
|
||||
readonly definition: AnyCultCacheDocumentDefinition;
|
||||
readonly global: boolean;
|
||||
readonly formatter: RegisteredFormatter;
|
||||
readonly formatter: CultCacheDocumentFormatter<unknown>;
|
||||
readonly catalogEntry: CultCacheSchemaCatalogEntry;
|
||||
nameAccessor?: Accessor;
|
||||
indexAccessors: ReadonlyMap<string, Accessor>;
|
||||
|
|
@ -687,13 +675,21 @@ export class CultCache {
|
|||
}
|
||||
}
|
||||
|
||||
#createFormatter(definition: AnyCultCacheDocumentDefinition): RegisteredFormatter {
|
||||
// A definition that declares both a codec and a formatter is refused here, where it is registered.
|
||||
resolveDocumentFormatter(definition);
|
||||
#createFormatter(definition: AnyCultCacheDocumentDefinition): CultCacheDocumentFormatter<unknown> {
|
||||
const userFormatter = definition.formatter ?? {
|
||||
encode: (value: unknown) => encode(value),
|
||||
decode: (payload: Uint8Array) => decode(payload),
|
||||
};
|
||||
|
||||
return {
|
||||
encode: (value: unknown) => this.#cloneBytes(encodeDocumentPayload(definition, value)),
|
||||
decode: (payload: DocumentPayload) => decodeDocumentPayload(definition, this.#cloneBytes(payload)),
|
||||
encode: (value: unknown) => {
|
||||
const parsed = definition.schema.parse(value);
|
||||
return this.#cloneBytes(userFormatter.encode(parsed));
|
||||
},
|
||||
decode: (payload: Uint8Array) => {
|
||||
const decoded = userFormatter.decode(this.#cloneBytes(payload));
|
||||
return definition.schema.parse(decoded);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -703,7 +699,7 @@ export class CultCache {
|
|||
const canonicalSchemaJson = definition.canonicalSchemaJson ?? JSON.stringify({
|
||||
schemaName,
|
||||
schemaVersion,
|
||||
members: [...resolveDocumentMembers(definition)]
|
||||
members: [...(definition.members ?? [])]
|
||||
.sort((left, right) => left.slot - right.slot)
|
||||
.map((member) => ({
|
||||
slot: member.slot,
|
||||
|
|
@ -728,7 +724,7 @@ export class CultCache {
|
|||
contentHash: definition.contentHash ?? schemaId,
|
||||
canonicalSchemaJson,
|
||||
compatibleSchemaIds,
|
||||
members: [...resolveDocumentMembers(definition)]
|
||||
members: [...(definition.members ?? [])]
|
||||
.sort((left, right) => left.slot - right.slot)
|
||||
.map((member) => ({
|
||||
slot: member.slot,
|
||||
|
|
@ -1034,7 +1030,7 @@ export class CultCache {
|
|||
return `${type}::${indexName}`;
|
||||
}
|
||||
|
||||
#cloneBytes(payload: DocumentPayload): DocumentPayload {
|
||||
return documentPayloadFromBytes(new Uint8Array(documentPayloadBytes(payload)));
|
||||
#cloneBytes(payload: Uint8Array): Uint8Array {
|
||||
return new Uint8Array(payload);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
import { decode, encode } from "@msgpack/msgpack";
|
||||
|
||||
import type { SlotsCodec } from "./codec";
|
||||
import type { AnyCultCacheDocumentDefinition, CultCacheDocumentFormatter } from "./types";
|
||||
import { readWire, writeWire } from "./wire";
|
||||
|
||||
// The one place that decides how a document is encoded: its own formatter, or its codec's, or a plain map. A codec and a
|
||||
// formatter are two answers to one question, so a definition that carries both is refused wherever it enters (the
|
||||
// builder resolves through here as well). Package-internal: no entry of the package's exports map names this module, so
|
||||
// a consumer cannot import the formatter and read a payload around the schema.
|
||||
export function resolveDocumentFormatter(definition: AnyCultCacheDocumentDefinition): CultCacheDocumentFormatter<unknown> {
|
||||
if (definition.codec && definition.formatter) {
|
||||
throw new Error(`CultCache document type "${definition.type}" declares both a codec and a formatter.`);
|
||||
}
|
||||
return definition.formatter ?? (definition.codec ? codecFormatter(definition.codec) : plainDocumentFormatter);
|
||||
}
|
||||
|
||||
const plainDocumentFormatter: CultCacheDocumentFormatter<unknown> = {
|
||||
encode: (value: unknown) => encode(value),
|
||||
decode: (payload: Uint8Array) => decode(payload),
|
||||
};
|
||||
|
||||
// The formatter a document gets from a slot codec: the codec's value as one MessagePack payload. Private to this module
|
||||
// for the same reason as the resolver: exported, it would read a payload without the schema.
|
||||
function codecFormatter<TValue extends object>(codec: SlotsCodec<TValue>): CultCacheDocumentFormatter<TValue> {
|
||||
return {
|
||||
encode: (value) => writeWire(codec.encode(value)),
|
||||
decode: (payload) => codec.decode(readWire(payload)),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,10 +1,7 @@
|
|||
import { resolveDocumentFormatter } from "./document-formatter";
|
||||
import type {
|
||||
AnyCultCacheDocumentDefinition,
|
||||
CultCacheSchemaCatalogMember,
|
||||
CultCacheDocumentDefinition,
|
||||
CultCacheDocumentRegistry,
|
||||
CultCacheDocumentValue,
|
||||
CultCacheSchema,
|
||||
} from "./types";
|
||||
|
||||
|
|
@ -69,71 +66,7 @@ export function defineDocumentType<TSchema extends CultCacheSchema>(
|
|||
}
|
||||
}
|
||||
|
||||
resolveDocumentFormatter(definition);
|
||||
return Object.freeze(definition.codec ? { ...definition, members: resolveDocumentMembers(definition) } : { ...definition });
|
||||
}
|
||||
|
||||
/**
|
||||
* The bytes of one document, as they travel in a store envelope, a CultNet record or a realtime frame.
|
||||
*
|
||||
* At run time this is the same `Uint8Array`, so frames encode as they always did. At compile time it is opaque: it is not
|
||||
* assignable to or from `Uint8Array` or `ArrayLike<number>`, so no byte decoder accepts it. The only reader is
|
||||
* `decodeDocumentPayload(definition, payload)`, which needs the definition that says how the bytes are laid out, and the
|
||||
* only writer is `encodeDocumentPayload(definition, value)`. The two escapes are named: `documentPayloadFromBytes` where
|
||||
* bytes enter (a validated record, a store read, tests, a writer of pre-encoded or signed bytes) and
|
||||
* `documentPayloadBytes` where they leave (a store write, a frame encode, hashing or signing).
|
||||
*
|
||||
* The key is a plain string, not a unique symbol, so two installed copies of this package stay compatible.
|
||||
*
|
||||
* What the type guarantees: a payload cannot be read or assigned directly (a decoder call, `const b: Uint8Array = p`,
|
||||
* `new Uint8Array(p)` fail to compile). What it does not: the runtime value is a `Uint8Array`, so these routes read it
|
||||
* without the owner: `instanceof Uint8Array` or `ArrayBuffer.isView` narrowing, `Object.values`, `Object.assign` onto a
|
||||
* `Uint8Array`, a JSON round trip, `Reflect.get`, and a cast. A codec or formatter document read that way is raw bytes,
|
||||
* not the document. The sanctioned escape for bytes is `documentPayloadBytes`; the reader is `decodeDocumentPayload`.
|
||||
*/
|
||||
export interface DocumentPayload {
|
||||
readonly "cultcache.documentPayload": true;
|
||||
readonly byteLength: number;
|
||||
}
|
||||
|
||||
export function documentPayloadFromBytes(bytes: Uint8Array): DocumentPayload {
|
||||
return bytes as unknown as DocumentPayload;
|
||||
}
|
||||
|
||||
export function documentPayloadBytes(payload: DocumentPayload): Uint8Array {
|
||||
return payload as unknown as Uint8Array;
|
||||
}
|
||||
|
||||
// The one reader of a document payload: through the definition's formatter, codec or plain map, then its schema. A
|
||||
// reader holds the definition or it has no business reading the bytes, so there is no definition-less form.
|
||||
export function decodeDocumentPayload<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
payload: DocumentPayload,
|
||||
): CultCacheDocumentValue<TDefinition> {
|
||||
const decoded = resolveDocumentFormatter(definition).decode(documentPayloadBytes(payload));
|
||||
return definition.schema.parse(decoded) as CultCacheDocumentValue<TDefinition>;
|
||||
}
|
||||
|
||||
// The one writer: the definition's schema first, then its formatter, codec or plain map.
|
||||
export function encodeDocumentPayload(definition: AnyCultCacheDocumentDefinition, value: unknown): DocumentPayload {
|
||||
return documentPayloadFromBytes(resolveDocumentFormatter(definition).encode(definition.schema.parse(value)));
|
||||
}
|
||||
|
||||
// The schema members of a document: the ones it declares, else those its codec derives, else none.
|
||||
export function resolveDocumentMembers(definition: AnyCultCacheDocumentDefinition): readonly CultCacheSchemaCatalogMember[] {
|
||||
if (definition.members || !definition.codec) {
|
||||
return definition.members ?? [];
|
||||
}
|
||||
const codec = definition.codec;
|
||||
const indexAlias = (slotName: string) =>
|
||||
!Array.isArray(definition.indexes) && definition.indexes
|
||||
? Object.entries(definition.indexes).find(([, accessor]) => accessor === slotName)?.[0] ?? null
|
||||
: null;
|
||||
return codec.members.map((member, index) => ({
|
||||
...member,
|
||||
isName: (definition.name as unknown) === codec.names[index],
|
||||
indexAlias: indexAlias(codec.names[index]),
|
||||
}));
|
||||
return Object.freeze({ ...definition });
|
||||
}
|
||||
|
||||
export function defineDocumentRegistry<
|
||||
|
|
|
|||
|
|
@ -1,16 +1,6 @@
|
|||
export * as codecs from "./codec";
|
||||
export type { Codec, SlotsCodec } from "./codec";
|
||||
export { CultCache, CultCacheBuilder } from "./cult-cache";
|
||||
export { inspectCultCacheBytes } from "./cult-cache-inspector";
|
||||
export {
|
||||
decodeDocumentPayload,
|
||||
defineDocumentRegistry,
|
||||
defineDocumentType,
|
||||
documentPayloadBytes,
|
||||
documentPayloadFromBytes,
|
||||
encodeDocumentPayload,
|
||||
} from "./document";
|
||||
export type { DocumentPayload } from "./document";
|
||||
export { defineDocumentRegistry, defineDocumentType } from "./document";
|
||||
export { SingleFileMessagePackBackingStore } from "./single-file-messagepack-backing-store";
|
||||
export type {
|
||||
AnyCultCacheDocumentDefinition,
|
||||
|
|
|
|||
|
|
@ -1,11 +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 { documentPayloadBytes, documentPayloadFromBytes } from "./document";
|
||||
import { STORE_FORMAT_VERSION, isV1Snapshot, requireV1RecordSlots } from "./store-format";
|
||||
import type {
|
||||
CacheBackingStore,
|
||||
CultCacheEnvelope,
|
||||
|
|
@ -14,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),
|
||||
|
|
@ -42,60 +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: documentPayloadFromBytes(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: CultCacheEnvelope[] = envelopeArraySchema.parse(normalized).map((entry) => ({
|
||||
...entry,
|
||||
payload: documentPayloadFromBytes(entry.payload),
|
||||
}));
|
||||
|
||||
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> {
|
||||
|
|
@ -121,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);
|
||||
|
|
@ -162,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;
|
||||
|
|
@ -236,7 +221,7 @@ function encodeSnapshot(entries: CultCacheEnvelope[]): unknown[] {
|
|||
entry.key,
|
||||
schemaIdForEnvelope(entry),
|
||||
entry.storedAt,
|
||||
documentPayloadBytes(entry.payload),
|
||||
entry.payload,
|
||||
]);
|
||||
|
||||
return [
|
||||
|
|
@ -300,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;
|
||||
}
|
||||
|
||||
|
|
@ -451,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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,3 @@
|
|||
import type { SlotsCodec } from "./codec";
|
||||
import type { DocumentPayload } from "./document";
|
||||
|
||||
export type CultCacheIndexScalar = string | number | boolean | bigint;
|
||||
|
||||
export interface CultCacheSchema<TValue = unknown> {
|
||||
|
|
@ -63,7 +60,6 @@ export interface CultCacheDocumentDefinition<TSchema extends CultCacheSchema = C
|
|||
| Readonly<Record<string, CultCacheDocumentAccessor<InferCultCacheSchemaValue<TSchema>>>>
|
||||
| readonly CultCacheDocumentIndexDefinition<InferCultCacheSchemaValue<TSchema>>[];
|
||||
formatter?: CultCacheDocumentFormatter<InferCultCacheSchemaValue<TSchema>>;
|
||||
codec?: SlotsCodec<InferCultCacheSchemaValue<TSchema> & object>;
|
||||
}
|
||||
|
||||
export type CultCacheDocumentValue<TDefinition extends CultCacheDocumentDefinition> = InferCultCacheSchemaValue<
|
||||
|
|
@ -81,7 +77,7 @@ export interface CultCacheDocumentRegistry<
|
|||
export interface CultCacheEnvelope {
|
||||
key: string;
|
||||
type: string;
|
||||
payload: DocumentPayload;
|
||||
payload: Uint8Array;
|
||||
storedAt: string;
|
||||
schemaId?: string;
|
||||
catalogEntry?: CultCacheSchemaCatalogEntry;
|
||||
|
|
|
|||
|
|
@ -1,134 +0,0 @@
|
|||
// The slice of MessagePack a codec value uses: nil, booleans, integers, floats, strings, binaries and arrays. A codec
|
||||
// must tell an integer from a float and keep 64-bit integers whole, which a general decoder that hands back JS numbers
|
||||
// cannot; so the codec path owns its reader and writer. An integer decodes to a number, or to a bigint when it is
|
||||
// outside the safe range. A float decodes to a WireFloat, whatever its value.
|
||||
|
||||
export class WireFloat {
|
||||
// signalling: a float32 NaN whose quiet bit is clear. The JS number it becomes has lost that payload.
|
||||
constructor(readonly value: number, readonly wide: boolean, readonly signalling = false) {}
|
||||
}
|
||||
|
||||
const SAFE_MIN = BigInt(Number.MIN_SAFE_INTEGER);
|
||||
const SAFE_MAX = BigInt(Number.MAX_SAFE_INTEGER);
|
||||
const utf8 = new TextEncoder();
|
||||
const utf8Decoder = new TextDecoder();
|
||||
|
||||
export function writeWire(tree: unknown): Uint8Array {
|
||||
const parts: number[] = [];
|
||||
put(tree, parts);
|
||||
return Uint8Array.from(parts);
|
||||
}
|
||||
|
||||
function be(parts: number[], bytes: number, value: bigint): void {
|
||||
for (let shift = BigInt((bytes - 1) * 8); shift >= 0n; shift -= 8n) {
|
||||
parts.push(Number((value >> shift) & 0xffn));
|
||||
}
|
||||
}
|
||||
|
||||
function putInt(value: bigint, parts: number[]): void {
|
||||
if (value >= 0n) {
|
||||
if (value < 128n) parts.push(Number(value));
|
||||
else if (value < 0x100n) { parts.push(0xcc); be(parts, 1, value); }
|
||||
else if (value < 0x10000n) { parts.push(0xcd); be(parts, 2, value); }
|
||||
else if (value < 0x100000000n) { parts.push(0xce); be(parts, 4, value); }
|
||||
else if (value < 0x10000000000000000n) { parts.push(0xcf); be(parts, 8, value); }
|
||||
else throw new Error("cannot write an integer wider than 64 bits.");
|
||||
} else if (value >= -32n) parts.push(0x100 + Number(value));
|
||||
else if (value >= -0x80n) { parts.push(0xd0); be(parts, 1, value & 0xffn); }
|
||||
else if (value >= -0x8000n) { parts.push(0xd1); be(parts, 2, value & 0xffffn); }
|
||||
else if (value >= -0x80000000n) { parts.push(0xd2); be(parts, 4, value & 0xffffffffn); }
|
||||
else if (value >= -0x8000000000000000n) { parts.push(0xd3); be(parts, 8, value & 0xffffffffffffffffn); }
|
||||
else throw new Error("cannot write an integer wider than 64 bits.");
|
||||
}
|
||||
|
||||
function putLength(length: number, parts: number[], fix: number, fixMax: number, codes: readonly [number, number, number]): void {
|
||||
if (length <= fixMax) parts.push(fix | length);
|
||||
else if (codes[0] !== 0 && length < 0x100) { parts.push(codes[0]); be(parts, 1, BigInt(length)); }
|
||||
else if (length < 0x10000) { parts.push(codes[1]); be(parts, 2, BigInt(length)); }
|
||||
else { parts.push(codes[2]); be(parts, 4, BigInt(length)); }
|
||||
}
|
||||
|
||||
function put(node: unknown, parts: number[]): void {
|
||||
if (node === null) parts.push(0xc0);
|
||||
else if (typeof node === "boolean") parts.push(node ? 0xc3 : 0xc2);
|
||||
else if (typeof node === "bigint") putInt(node, parts);
|
||||
else if (typeof node === "number") {
|
||||
if (!Number.isSafeInteger(node)) throw new Error("cannot write a number that is not a safe integer; floats are WireFloat.");
|
||||
putInt(BigInt(node), parts);
|
||||
} else if (node instanceof WireFloat) {
|
||||
const view = new DataView(new ArrayBuffer(8));
|
||||
if (node.wide) { view.setFloat64(0, node.value); parts.push(0xcb); } else { view.setFloat32(0, node.value); parts.push(0xca); }
|
||||
for (let index = 0; index < (node.wide ? 8 : 4); index += 1) parts.push(view.getUint8(index));
|
||||
} else if (typeof node === "string") {
|
||||
const text = utf8.encode(node);
|
||||
putLength(text.length, parts, 0xa0, 31, [0xd9, 0xda, 0xdb]);
|
||||
for (const byte of text) parts.push(byte);
|
||||
} else if (node instanceof Uint8Array) {
|
||||
putLength(node.length, parts, 0, -1, [0xc4, 0xc5, 0xc6]);
|
||||
for (const byte of node) parts.push(byte);
|
||||
} else if (Array.isArray(node)) {
|
||||
putLength(node.length, parts, 0x90, 15, [0, 0xdc, 0xdd]);
|
||||
for (const element of node) put(element, parts);
|
||||
} else {
|
||||
throw new Error("cannot write this kind of value.");
|
||||
}
|
||||
}
|
||||
|
||||
export function readWire(payload: Uint8Array): unknown {
|
||||
const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength);
|
||||
let offset = 0;
|
||||
|
||||
const take = (count: number): number => {
|
||||
if (offset + count > payload.length) throw new Error("the MessagePack payload is truncated.");
|
||||
offset += count;
|
||||
return offset - count;
|
||||
};
|
||||
const integer = (value: bigint): number | bigint => (value >= SAFE_MIN && value <= SAFE_MAX ? Number(value) : value);
|
||||
const string = (length: number): string => utf8Decoder.decode(payload.subarray(take(length), offset));
|
||||
const binary = (length: number): Uint8Array => payload.slice(take(length), offset);
|
||||
const array = (length: number): unknown[] => {
|
||||
const out: unknown[] = [];
|
||||
for (let index = 0; index < length; index += 1) out.push(next());
|
||||
return out;
|
||||
};
|
||||
|
||||
function next(): unknown {
|
||||
const head = payload[take(1)];
|
||||
if (head < 0x80) return head;
|
||||
if (head >= 0xe0) return head - 0x100;
|
||||
if (head >= 0xa0 && head <= 0xbf) return string(head & 0x1f);
|
||||
if (head >= 0x90 && head <= 0x9f) return array(head & 0x0f);
|
||||
switch (head) {
|
||||
case 0xc0: return null;
|
||||
case 0xc2: return false;
|
||||
case 0xc3: return true;
|
||||
case 0xc4: return binary(view.getUint8(take(1)));
|
||||
case 0xc5: return binary(view.getUint16(take(2)));
|
||||
case 0xc6: return binary(view.getUint32(take(4)));
|
||||
case 0xca: {
|
||||
const at = take(4);
|
||||
const bits = view.getUint32(at);
|
||||
return new WireFloat(view.getFloat32(at), false, (bits & 0x7f800000) === 0x7f800000 && (bits & 0x007fffff) !== 0 && (bits & 0x00400000) === 0);
|
||||
}
|
||||
case 0xcb: return new WireFloat(view.getFloat64(take(8)), true);
|
||||
case 0xcc: return view.getUint8(take(1));
|
||||
case 0xcd: return view.getUint16(take(2));
|
||||
case 0xce: return view.getUint32(take(4));
|
||||
case 0xcf: return integer(view.getBigUint64(take(8)));
|
||||
case 0xd0: return view.getInt8(take(1));
|
||||
case 0xd1: return view.getInt16(take(2));
|
||||
case 0xd2: return view.getInt32(take(4));
|
||||
case 0xd3: return integer(view.getBigInt64(take(8)));
|
||||
case 0xd9: return string(view.getUint8(take(1)));
|
||||
case 0xda: return string(view.getUint16(take(2)));
|
||||
case 0xdb: return string(view.getUint32(take(4)));
|
||||
case 0xdc: return array(view.getUint16(take(2)));
|
||||
case 0xdd: return array(view.getUint32(take(4)));
|
||||
default: throw new Error("the MessagePack payload holds a type a codec does not read (map, extension or reserved).");
|
||||
}
|
||||
}
|
||||
|
||||
const tree = next();
|
||||
if (offset !== payload.length) throw new Error("the MessagePack payload has bytes after its value.");
|
||||
return tree;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -13,7 +13,7 @@ import { z } from "zod";
|
|||
|
||||
import { CultCache } from "../src/cult-cache";
|
||||
import { inspectCultCacheBytes } from "../src/cult-cache-inspector";
|
||||
import { defineDocumentRegistry, defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "../src/document";
|
||||
import { defineDocumentRegistry, defineDocumentType } from "../src/document";
|
||||
import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store";
|
||||
import type { CacheBackingStore, CultCacheEnvelope, CultCacheSchema } from "../src/types";
|
||||
|
||||
|
|
@ -202,7 +202,7 @@ test("CultCache fails closed on unknown or illegal persisted polymorphic state",
|
|||
{
|
||||
key: "mystery",
|
||||
type: "forbidden",
|
||||
payload: documentPayloadFromBytes(encode({ theme: "ash" })),
|
||||
payload: encode({ theme: "ash" }),
|
||||
storedAt: new Date().toISOString(),
|
||||
},
|
||||
]))
|
||||
|
|
@ -219,13 +219,13 @@ test("CultCache fails closed on unknown or illegal persisted polymorphic state",
|
|||
{
|
||||
key: "one",
|
||||
type: "settings",
|
||||
payload: documentPayloadFromBytes(encode({ theme: "ash" })),
|
||||
payload: encode({ theme: "ash" }),
|
||||
storedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
key: "two",
|
||||
type: "settings",
|
||||
payload: documentPayloadFromBytes(encode({ theme: "ember" })),
|
||||
payload: encode({ theme: "ember" }),
|
||||
storedAt: new Date().toISOString(),
|
||||
},
|
||||
]))
|
||||
|
|
@ -328,26 +328,6 @@ test("CultCache can ingest raw envelopes without re-encoding the payload", async
|
|||
assert.deepEqual(target.getRequiredEnvelope(noteDocument, "note:hello").payload, envelope.payload);
|
||||
});
|
||||
|
||||
test("the cache keeps its own copy of an envelope's bytes: a caller mutating them afterwards changes nothing", async () => {
|
||||
const noteDocument = defineDocumentType({ type: "note", schema: z.object({ title: z.string(), body: z.string() }) });
|
||||
const origin = CultCache.builder().withDocumentType(noteDocument)
|
||||
.withGenericStore(new SingleFileMessagePackBackingStore(join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "origin.msgpack"))).build();
|
||||
const target = CultCache.builder().withDocumentType(noteDocument)
|
||||
.withGenericStore(new SingleFileMessagePackBackingStore(join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "target.msgpack"))).build();
|
||||
await origin.put(noteDocument, "note:hello", { title: "Hello", body: "world" });
|
||||
|
||||
const envelope = origin.getRequiredEnvelope(noteDocument, "note:hello");
|
||||
const before = Buffer.from(documentPayloadBytes(envelope.payload));
|
||||
await target.putEnvelope(noteDocument, envelope);
|
||||
documentPayloadBytes(envelope.payload).fill(0);
|
||||
|
||||
assert.deepEqual(Buffer.from(documentPayloadBytes(target.getRequiredEnvelope(noteDocument, "note:hello").payload)), before);
|
||||
const read = target.getRequiredEnvelope(noteDocument, "note:hello");
|
||||
documentPayloadBytes(read.payload).fill(0);
|
||||
assert.deepEqual(Buffer.from(documentPayloadBytes(target.getRequiredEnvelope(noteDocument, "note:hello").payload)), before);
|
||||
assert.deepEqual(target.getRequired(noteDocument, "note:hello"), { title: "Hello", body: "world" });
|
||||
});
|
||||
|
||||
test("SingleFileMessagePackBackingStore writes the CultCache v1 snapshot shape", async () => {
|
||||
const namedDocument = defineDocumentType({
|
||||
type: "tests.named_entry",
|
||||
|
|
@ -670,7 +650,7 @@ async function storeState(path: string): Promise<string[]> {
|
|||
return [];
|
||||
}
|
||||
return (await new SingleFileMessagePackBackingStore(path).pullAll())
|
||||
.map((entry) => `${entry.type}:${entry.key}:${Buffer.from(documentPayloadBytes(entry.payload)).toString("hex")}`)
|
||||
.map((entry) => `${entry.type}:${entry.key}:${Buffer.from(entry.payload).toString("hex")}`)
|
||||
.sort();
|
||||
}
|
||||
|
||||
|
|
@ -841,8 +821,8 @@ test("concurrentGlobalPutsCannotBothReachDisk", async () => {
|
|||
const onDisk = await inner.pullAll();
|
||||
assert.deepEqual(onDisk.map((entry) => entry.key), [CultCache.GLOBAL_KEY]);
|
||||
assert.deepEqual(
|
||||
Buffer.from(documentPayloadBytes(onDisk[0].payload)),
|
||||
Buffer.from(documentPayloadBytes(cache.getRequiredEnvelope(settingsDocument, CultCache.GLOBAL_KEY).payload)),
|
||||
Buffer.from(onDisk[0].payload),
|
||||
Buffer.from(cache.getRequiredEnvelope(settingsDocument, CultCache.GLOBAL_KEY).payload),
|
||||
);
|
||||
assert.deepEqual(cache.getRequiredGlobal(settingsDocument), { t: "second" });
|
||||
const reloaded = build(new SingleFileMessagePackBackingStore(storePath));
|
||||
|
|
@ -1062,7 +1042,7 @@ test("pullWithThrowingIndexAccessorLeavesCacheUnchanged", async () => {
|
|||
await new SingleFileMessagePackBackingStore(storePath).push({
|
||||
...potion,
|
||||
key: "bomb",
|
||||
payload: documentPayloadFromBytes(encode({ name: "Bomb", category: "explosive" })),
|
||||
payload: encode({ name: "Bomb", category: "explosive" }),
|
||||
});
|
||||
|
||||
await assert.rejects(cache.pullAllBackingStores(), /category accessor refuses Bomb/u);
|
||||
|
|
@ -1369,7 +1349,7 @@ async function writeTsInteropStore(
|
|||
members: interopNoteDocument.members,
|
||||
},
|
||||
storedAt: new Date().toISOString(),
|
||||
payload: documentPayloadFromBytes(payload),
|
||||
payload,
|
||||
});
|
||||
return note;
|
||||
}
|
||||
|
|
@ -1433,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: documentPayloadFromBytes(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: documentPayloadFromBytes(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: documentPayloadFromBytes(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([...documentPayloadBytes(envelopes[0]!.payload)], [0x92, 0xa5, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x01]);
|
||||
assert.deepEqual([...documentPayloadBytes(envelopes[1]!.payload)], [0x92, 0xa4, 0x62, 0x65, 0x74, 0x61, 0x02]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { encode } from "@msgpack/msgpack";
|
||||
import { z } from "zod";
|
||||
|
||||
import { codecs, decodeDocumentPayload, defineDocumentType, documentPayloadBytes, documentPayloadFromBytes, encodeDocumentPayload } from "../src";
|
||||
|
||||
const payloadOf = (bytes: Uint8Array) => documentPayloadFromBytes(bytes);
|
||||
const note = z.object({ id: z.string(), count: z.number() });
|
||||
const plain = defineDocumentType({ type: "t.plain", schemaId: "t.plain.v0", schema: note, name: "id" });
|
||||
const codec = defineDocumentType({
|
||||
type: "t.codec",
|
||||
schemaId: "t.codec.v0",
|
||||
schema: note,
|
||||
name: "id",
|
||||
codec: codecs.slots<{ id: string; count: number }>({ 0: ["id", codecs.string], 1: ["count", codecs.int32] }, "T.Note"),
|
||||
});
|
||||
const formatted = defineDocumentType({
|
||||
type: "t.formatted",
|
||||
schemaId: "t.formatted.v0",
|
||||
schema: note,
|
||||
name: "id",
|
||||
formatter: { encode: (value) => encode([(value as { id: string }).id]), decode: () => ({ id: "from-formatter", count: 0 }) },
|
||||
});
|
||||
|
||||
test("a payload is read by its definition's formatter, a codec's, or a plain map", () => {
|
||||
assert.deepEqual(decodeDocumentPayload(plain, payloadOf(encode({ id: "a", count: 1 }))), { id: "a", count: 1 });
|
||||
assert.deepEqual(decodeDocumentPayload(codec, payloadOf(encode(["a", 1]))), { id: "a", count: 1 });
|
||||
assert.deepEqual(decodeDocumentPayload(formatted, payloadOf(new Uint8Array([0xc0]))), { id: "from-formatter", count: 0 });
|
||||
});
|
||||
|
||||
test("a definition that declares both a codec and a formatter is refused where its payload is read", () => {
|
||||
const both = { ...codec, formatter: formatted.formatter } as typeof codec;
|
||||
assert.throws(() => decodeDocumentPayload(both, payloadOf(encode(["a", 1]))), (error: Error) =>
|
||||
error.message === 'CultCache document type "t.codec" declares both a codec and a formatter.');
|
||||
});
|
||||
|
||||
test("a payload is written by its definition's codec or formatter, the way it is read", () => {
|
||||
assert.deepEqual(documentPayloadBytes(encodeDocumentPayload(codec, { id: "a", count: 1 })), encode(["a", 1]));
|
||||
assert.deepEqual(documentPayloadBytes(encodeDocumentPayload(plain, { id: "a", count: 1 })), encode({ id: "a", count: 1 }));
|
||||
assert.deepEqual(documentPayloadBytes(encodeDocumentPayload(formatted, { id: "a", count: 1 })), encode(["a"]));
|
||||
});
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { decode, encode } from "@msgpack/msgpack";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
decodeDocumentPayload,
|
||||
defineDocumentType,
|
||||
documentPayloadBytes,
|
||||
documentPayloadFromBytes,
|
||||
encodeDocumentPayload,
|
||||
type AnyCultCacheDocumentDefinition,
|
||||
type CultCacheEnvelope,
|
||||
type DocumentPayload,
|
||||
} from "../src";
|
||||
|
||||
// The rule: a document payload is read only through its definition. The type is the enforcement, so this file is the
|
||||
// proof: tsc compiles it only if every line below that carries @ts-expect-error is an error. A line that starts to
|
||||
// compile makes its @ts-expect-error unused, which is itself an error, so the suite stops until the payload is opaque
|
||||
// again. The function is never called; the lines are not meant to run.
|
||||
export function refusedReaders(envelope: CultCacheEnvelope, definition: AnyCultCacheDocumentDefinition, payload: DocumentPayload): void {
|
||||
// @ts-expect-error a plain msgpack decoder does not accept a document payload
|
||||
decode(envelope.payload);
|
||||
// @ts-expect-error a payload is not assignable to bytes
|
||||
const bytes: Uint8Array = envelope.payload;
|
||||
// @ts-expect-error nor is it a byte source a copy may be taken from
|
||||
new Uint8Array(envelope.payload);
|
||||
// @ts-expect-error a definition's formatter reads bytes, not a payload; the owner unwraps for it
|
||||
definition.formatter!.decode(envelope.payload);
|
||||
// @ts-expect-error the owner needs the definition that says how the bytes are laid out
|
||||
decodeDocumentPayload(undefined, payload);
|
||||
// @ts-expect-error bytes are not a payload until they enter by name
|
||||
const forged: DocumentPayload = new Uint8Array();
|
||||
void [bytes, forged];
|
||||
}
|
||||
|
||||
const note = defineDocumentType({
|
||||
type: "t.note",
|
||||
schemaId: "t.note.v0",
|
||||
schema: z.object({ id: z.string(), count: z.number() }),
|
||||
name: "id",
|
||||
});
|
||||
|
||||
test("a payload is the same bytes at run time, so frames and stores see no change", () => {
|
||||
const bytes = encode({ id: "a", count: 1 });
|
||||
const payload = documentPayloadFromBytes(bytes);
|
||||
assert.equal(documentPayloadBytes(payload), bytes);
|
||||
assert.equal(payload.byteLength, bytes.byteLength);
|
||||
});
|
||||
|
||||
test("the owner writes a payload through the definition's schema and reads it back through the same one", () => {
|
||||
const payload = encodeDocumentPayload(note, { id: "a", count: 1 });
|
||||
assert.deepEqual(decode(documentPayloadBytes(payload)), { id: "a", count: 1 });
|
||||
assert.deepEqual(decodeDocumentPayload(note, payload), { id: "a", count: 1 });
|
||||
// A refusal names the field and never echoes the value it refused.
|
||||
const refusesWithoutEchoing = (action: () => unknown) =>
|
||||
assert.throws(action, (error: Error) => /count/.test(error.message) && !error.message.includes("canary-value"));
|
||||
refusesWithoutEchoing(() => encodeDocumentPayload(note, { id: "a", count: "canary-value" }));
|
||||
refusesWithoutEchoing(() => decodeDocumentPayload(note, documentPayloadFromBytes(encode({ id: "a", count: "canary-value" }))));
|
||||
});
|
||||
|
|
@ -1,789 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { decode, encode } from "@msgpack/msgpack";
|
||||
import { z } from "zod";
|
||||
|
||||
import { codecs } from "../src";
|
||||
import { MAX_SLOT_KEY, type Codec } from "../src/codec";
|
||||
import { CultCache } from "../src/cult-cache";
|
||||
import { readWire, WireFloat, writeWire } from "../src/wire";
|
||||
import { defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "../src/document";
|
||||
import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store";
|
||||
import type { AnyCultCacheDocumentDefinition, CacheBackingStore, CultCacheEnvelope } from "../src/types";
|
||||
|
||||
const { array, boolean, bytes, float32, float64, int32, int64, lazy, list, nullable, slots, string, union, unit } = codecs;
|
||||
|
||||
// The names the C# reference gives the fixture's types (CultSchemaTypeNames.FromType), as union-surface.cc carries them.
|
||||
const CS = "GameCult.Caching.Tests.UnionParityVectorTests+";
|
||||
|
||||
// The golden is tests/vectors/union-parity, written by the C# reference. Everything below that is bytes or an expected
|
||||
// outcome is read from those committed files; only the value the payload must decode to is spelled here.
|
||||
function findVectors(from: string): string {
|
||||
for (let dir = from; ; dir = resolve(dir, "..")) {
|
||||
if (existsSync(join(dir, "tests", "vectors", "union-parity"))) return join(dir, "tests", "vectors", "union-parity");
|
||||
if (resolve(dir, "..") === dir) throw new Error("tests/vectors/union-parity not found above the test.");
|
||||
}
|
||||
}
|
||||
const vectors = findVectors(__dirname);
|
||||
const read = async (name: string) => new Uint8Array(await readFile(join(vectors, name)));
|
||||
|
||||
// The string the C# writer put inside every refusal payload, to prove a refusal never echoes the payload.
|
||||
const CANARY = "CANARY-union-golden-4c2a";
|
||||
|
||||
type Node =
|
||||
| { kind: "Box"; id: string; children: (Node | null)[] }
|
||||
| { kind: "Text"; id: string; content: string }
|
||||
| { kind: "Use"; id: string; composition: Composition; slots: (Node | null)[] }
|
||||
| { kind: "Image"; id: string; uri: string; alt: string };
|
||||
interface Composition { name: string; body: Node | null }
|
||||
type Outcome = { kind: "Pending"; message: string } | { kind: "Completed"; sourceVersion: number } | { kind: "Denied" };
|
||||
type Item = { kind: "Gem"; name: string } | { kind: "Tool"; name: string; uses: number } | { kind: "Part"; name: string; count: number };
|
||||
type CraftedItem = { kind: "Tool"; name: string; uses: number } | { kind: "Part"; name: string; count: number };
|
||||
interface Surface {
|
||||
id: string;
|
||||
version: number;
|
||||
root: Node | null;
|
||||
outcomes: (Outcome | null)[];
|
||||
part: Item | null;
|
||||
crafted: CraftedItem | null;
|
||||
missing: Node | null;
|
||||
}
|
||||
|
||||
const composition = slots<Composition>({ 0: ["name", string], 1: ["body", lazy(() => node)] }, `${CS}Composition`);
|
||||
const node: Codec<Node | null> = union<Node>("Node", {
|
||||
0: ["Box", slots<{ id: string; children: (Node | null)[] }>({ 0: ["id", string], 1: ["children", list(lazy(() => node))] })],
|
||||
1: ["Text", slots<{ id: string; content: string }>({ 0: ["id", string], 1: ["content", string] })],
|
||||
2: ["Use", slots<{ id: string; composition: Composition; slots: (Node | null)[] }>({
|
||||
0: ["id", string], 1: ["composition", composition], 2: ["slots", list(lazy(() => node))],
|
||||
})],
|
||||
5: ["Image", slots<{ id: string; uri: string; alt: string }>({ 0: ["id", string], 1: ["uri", string], 3: ["alt", string] })],
|
||||
}, `${CS}Node`);
|
||||
const outcome = union<Outcome>("Outcome", {
|
||||
0: ["Pending", slots<{ message: string }>({ 0: ["message", string] })],
|
||||
1: ["Completed", slots<{ sourceVersion: number }>({ 0: ["sourceVersion", int32] })],
|
||||
2: ["Denied", unit],
|
||||
}, `${CS}Outcome`);
|
||||
const toolArm = slots<{ name: string; uses: number }>({ 0: ["name", string], 1: ["uses", int32] });
|
||||
const partArm = slots<{ name: string; count: number }>({ 0: ["name", string], 1: ["count", int32] });
|
||||
const item = union<Item>("Item", {
|
||||
0: ["Gem", slots<{ name: string }>({ 0: ["name", string] })],
|
||||
1: ["Tool", toolArm],
|
||||
2: ["Part", partArm],
|
||||
}, `${CS}Item`);
|
||||
const craftedItem = union<CraftedItem>("CraftedItem", { 0: ["Tool", toolArm], 1: ["Part", partArm] }, `${CS}CraftedItem`);
|
||||
const surfaceCodec = slots<Surface>({
|
||||
0: ["id", string],
|
||||
1: ["version", int32],
|
||||
2: ["root", node],
|
||||
3: ["outcomes", list(outcome)],
|
||||
4: ["part", item],
|
||||
5: ["crafted", craftedItem],
|
||||
6: ["missing", node, null],
|
||||
}, `${CS}UnionSurface`);
|
||||
|
||||
const nodeSchema: z.ZodType<Node | null> = z.lazy(() =>
|
||||
z.union([
|
||||
z.null(),
|
||||
z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("Box"), id: z.string(), children: z.array(nodeSchema) }),
|
||||
z.object({ kind: z.literal("Text"), id: z.string(), content: z.string() }),
|
||||
z.object({
|
||||
kind: z.literal("Use"),
|
||||
id: z.string(),
|
||||
composition: z.object({ name: z.string(), body: nodeSchema }),
|
||||
slots: z.array(nodeSchema),
|
||||
}),
|
||||
z.object({ kind: z.literal("Image"), id: z.string(), uri: z.string(), alt: z.string() }),
|
||||
]),
|
||||
]));
|
||||
const surfaceSchema = z.object({
|
||||
id: z.string(),
|
||||
version: z.number(),
|
||||
root: nodeSchema,
|
||||
outcomes: z.array(z.union([
|
||||
z.null(),
|
||||
z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("Pending"), message: z.string() }),
|
||||
z.object({ kind: z.literal("Completed"), sourceVersion: z.number() }),
|
||||
z.object({ kind: z.literal("Denied") }),
|
||||
]),
|
||||
])),
|
||||
part: z.union([z.null(), z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("Gem"), name: z.string() }),
|
||||
z.object({ kind: z.literal("Tool"), name: z.string(), uses: z.number() }),
|
||||
z.object({ kind: z.literal("Part"), name: z.string(), count: z.number() }),
|
||||
])]),
|
||||
crafted: z.union([z.null(), z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("Tool"), name: z.string(), uses: z.number() }),
|
||||
z.object({ kind: z.literal("Part"), name: z.string(), count: z.number() }),
|
||||
])]),
|
||||
missing: nodeSchema,
|
||||
}) as unknown as z.ZodType<Surface>;
|
||||
|
||||
function surfaceDocument(schemaId?: string) {
|
||||
return defineDocumentType({
|
||||
type: "tests.union_surface",
|
||||
schemaId: schemaId ?? "tests.union_surface.v1",
|
||||
schemaName: "tests.union_surface",
|
||||
schemaVersion: "tests.union_surface.v1",
|
||||
schema: surfaceSchema,
|
||||
codec: surfaceCodec,
|
||||
name: "id",
|
||||
});
|
||||
}
|
||||
|
||||
const golden: Surface = {
|
||||
id: "surface",
|
||||
version: 3,
|
||||
root: {
|
||||
kind: "Box",
|
||||
id: "root",
|
||||
children: [
|
||||
{ kind: "Text", id: "t1", content: "hello" },
|
||||
{
|
||||
kind: "Use",
|
||||
id: "u1",
|
||||
composition: {
|
||||
name: "comp",
|
||||
body: {
|
||||
kind: "Box",
|
||||
id: "inner",
|
||||
children: [
|
||||
{ kind: "Text", id: "deep", content: "x" },
|
||||
{ kind: "Image", id: "img", uri: "uri://a", alt: "alt" },
|
||||
],
|
||||
},
|
||||
},
|
||||
slots: [{ kind: "Text", id: "s1", content: "slot" }, null, { kind: "Box", id: "b2", children: [] }],
|
||||
},
|
||||
{ kind: "Image", id: "i2", uri: "uri://b", alt: "" },
|
||||
],
|
||||
},
|
||||
outcomes: [{ kind: "Pending", message: "wait" }, { kind: "Completed", sourceVersion: 7 }, { kind: "Denied" }],
|
||||
part: { kind: "Tool", name: "hammer", uses: 12 },
|
||||
crafted: { kind: "Part", name: "gear", count: 4 },
|
||||
missing: null,
|
||||
};
|
||||
|
||||
const hex = (data: Uint8Array) => Buffer.from(data).toString("hex");
|
||||
|
||||
async function outcomeEntries() {
|
||||
const entries = decode(await read("outcomes.msgpack")) as [string, string, string, number | null, string][];
|
||||
return entries.map(([file, outcome, unionName, key, detail]) => ({ file, outcome, unionName, key, detail }));
|
||||
}
|
||||
|
||||
test("the union surface vector decodes to the constructed value and re-encodes byte-identical", async () => {
|
||||
const committed = await read("union-surface.msgpack");
|
||||
const decoded = surfaceCodec.decode(readWire(committed));
|
||||
assert.deepEqual(decoded, golden);
|
||||
assert.equal(hex(writeWire(surfaceCodec.encode(decoded))), hex(committed));
|
||||
assert.equal(hex(writeWire(surfaceCodec.encode(golden))), hex(committed));
|
||||
});
|
||||
|
||||
test("a unit arm is written [key, []] and its payload must be an array", () => {
|
||||
assert.deepEqual(outcome.encode({ kind: "Denied" }), [2, []]);
|
||||
assert.throws(() => outcome.decode([2, null]), /Union Outcome/u);
|
||||
});
|
||||
|
||||
test("union-surface.cc reads through SingleFileMessagePackBackingStore and writes the same payload", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "union-parity-"));
|
||||
const file = join(dir, "union-surface.cc");
|
||||
await writeFile(file, await read("union-surface.cc"));
|
||||
const [envelope] = await new SingleFileMessagePackBackingStore(file).pullAll();
|
||||
assert.equal(hex(documentPayloadBytes(envelope.payload)), hex(await read("union-surface.msgpack")));
|
||||
|
||||
const document = surfaceDocument(envelope.schemaId);
|
||||
const cache = CultCache.builder().withDocumentType(document).withGenericStore(new SingleFileMessagePackBackingStore(file)).build();
|
||||
await cache.pullAllBackingStores();
|
||||
assert.deepEqual(cache.get(document, envelope.key), golden);
|
||||
|
||||
const written = join(dir, "written.cc");
|
||||
const fresh = CultCache.builder().withDocumentType(document).withGenericStore(new SingleFileMessagePackBackingStore(written)).build();
|
||||
await fresh.put(document, envelope.key, golden);
|
||||
const [rewritten] = await new SingleFileMessagePackBackingStore(written).pullAll();
|
||||
assert.equal(hex(documentPayloadBytes(rewritten.payload)), hex(documentPayloadBytes(envelope.payload)));
|
||||
|
||||
// The reference refuses a store whose schema catalog names another type for a slot, so the catalog TS writes must be
|
||||
// the one C# wrote: same canonical schema text, same member names, slots and types.
|
||||
assert.equal(rewritten.catalogEntry!.canonicalSchemaJson, envelope.catalogEntry!.canonicalSchemaJson);
|
||||
const shape = (members: readonly { slot: number; memberName: string; typeName: string; isName?: boolean }[]) =>
|
||||
members.map((member) => [member.slot, member.memberName, member.typeName, member.isName === true]);
|
||||
assert.deepEqual(shape(rewritten.catalogEntry!.members!), shape(envelope.catalogEntry!.members!));
|
||||
assert.equal(rewritten.catalogEntry!.members!.length, 7);
|
||||
});
|
||||
|
||||
test("every outcome vector is listed and holds: refusals name the union and key, never the payload", async () => {
|
||||
const entries = await outcomeEntries();
|
||||
assert.equal(entries.length, 9);
|
||||
const document = surfaceDocument();
|
||||
let refused = 0;
|
||||
const carrying: string[] = [];
|
||||
let readOnly = 0;
|
||||
|
||||
for (const { file, outcome: expected, unionName, key, detail } of entries) {
|
||||
const payload = await read(file);
|
||||
if (expected === "refused") {
|
||||
refused += 1;
|
||||
if (Buffer.from(payload).includes(CANARY)) carrying.push(file);
|
||||
assert.throws(
|
||||
() => surfaceCodec.decode(readWire(payload)),
|
||||
(error: Error) => {
|
||||
assert.ok(!error.message.includes(CANARY), `${file}: the refusal echoed the payload`);
|
||||
if (detail === "arity") {
|
||||
return error.message.includes(`Union ${unionName}`) && error.message.includes("expected [key, slots]");
|
||||
}
|
||||
return error.message.includes(`Union ${unionName}:`) && error.message.includes(`key ${key} `);
|
||||
},
|
||||
file,
|
||||
);
|
||||
const refusingCache = CultCache.builder().withDocumentType(document).withGenericStore(memoryStore([envelopeOf(payload)])).build();
|
||||
await assert.rejects(() => refusingCache.pullAllBackingStores(), new RegExp(`Union ${unionName}`, "u"), file);
|
||||
} else {
|
||||
readOnly += 1;
|
||||
assert.equal(expected, "read-only");
|
||||
assert.deepEqual(surfaceCodec.decode(readWire(payload)), golden, `${file}: the extra slot is read past`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual([refused, readOnly], [5, 4]);
|
||||
assert.deepEqual(carrying, ["unknown-arm-field.msgpack", "unknown-arm-list.msgpack", "unknown-arm-nested.msgpack"], "the canary is in the payloads whose arm is unknown");
|
||||
});
|
||||
|
||||
test("a refusal lists the declared keys, so a key only the outer union declares is unknown to the inner", async () => {
|
||||
const entry = (await outcomeEntries()).find((candidate) => candidate.file === "inner-union-outer-key.msgpack")!;
|
||||
const payload = readWire(await read(entry.file)) as unknown[];
|
||||
assert.throws(
|
||||
() => surfaceCodec.decode(payload),
|
||||
/Union CraftedItem: unknown arm key 2 \(declared keys: 0, 1\)\./u,
|
||||
);
|
||||
});
|
||||
|
||||
test("an arm that is not [key, slots] is refused naming the union, and a non-integer key is not echoed", () => {
|
||||
for (const bad of [[1], [1, null], [1, [], []], "x", 7, {}]) {
|
||||
assert.throws(() => node.decode(bad), /^Error: Union Node: expected \[key, slots\]/u);
|
||||
}
|
||||
assert.throws(
|
||||
() => node.decode([CANARY, []]),
|
||||
(error: Error) => error.message.includes("Union Node") && error.message.includes("not an integer") && !error.message.includes(CANARY),
|
||||
);
|
||||
});
|
||||
|
||||
test("an arm key that is not a declared key is refused, with no fallback", () => {
|
||||
assert.throws(() => node.decode([4, []]), /Union Node: unknown arm key 4 \(declared keys: 0, 1, 2, 5\)/u);
|
||||
assert.throws(() => outcome.decode([3, []]), /Union Outcome: unknown arm key 3/u);
|
||||
});
|
||||
|
||||
test("a union encodes by kind and refuses a kind it does not declare without echoing it", () => {
|
||||
assert.deepEqual(node.encode({ kind: "Text", id: "a", content: "b" }), [1, ["a", "b"]]);
|
||||
assert.equal(node.encode(null), null);
|
||||
assert.throws(
|
||||
() => node.encode({ kind: CANARY, id: "a" } as unknown as Node),
|
||||
(error: Error) => error.message.includes("Union Node") && !error.message.includes(CANARY),
|
||||
);
|
||||
});
|
||||
|
||||
test("slots: a gap writes nil, a missing slot reads its default, one without a default is refused naming the slot", () => {
|
||||
const record = slots<{ a: string; c: string; d: number }>({ 0: ["a", string], 2: ["c", string], 4: ["d", int32, 9] });
|
||||
assert.deepEqual(record.encode({ a: "x", c: "y", d: 1 }), ["x", null, "y", null, 1]);
|
||||
assert.deepEqual(record.decode(["x", null, "y"]), { a: "x", c: "y", d: 9 });
|
||||
assert.deepEqual(record.decode(["x", 5, "y", null]), { a: "x", c: "y", d: 9 });
|
||||
assert.throws(() => record.decode(["x", 5, "y", null, null]), { message: "d (slot 4): expected an int32, found nil" });
|
||||
assert.throws(() => record.decode(["x"]), /c \(slot 2\): the slot is missing and declares no default/u);
|
||||
assert.deepEqual(record.decode(["x", null, "y", null, 1, "extra", "slots"]), { a: "x", c: "y", d: 1 });
|
||||
assert.throws(() => record.decode({ a: "x" }), /expected an array of slots/u);
|
||||
});
|
||||
|
||||
test("slots: a default may be a function and fills only a missing slot, never a nil", () => {
|
||||
const record = slots<{ tags: string[] }>({ 0: ["tags", list(string), () => ["fresh"]] });
|
||||
assert.deepEqual(record.decode([]), { tags: ["fresh"] });
|
||||
assert.deepEqual(record.decode([null]), { tags: null });
|
||||
const counted = slots<{ n: number }>({ 0: ["n", int32, 4] });
|
||||
assert.deepEqual(counted.decode([]), { n: 4 });
|
||||
assert.throws(() => counted.decode([null]), { message: "n (slot 0): expected an int32, found nil" });
|
||||
});
|
||||
|
||||
test("a slot refusal names the field and slot and never the value", () => {
|
||||
const record = slots<{ name: string }>({ 3: ["name", string] });
|
||||
assert.throws(
|
||||
() => record.decode([null, null, null, { secret: CANARY }]),
|
||||
(error: Error) => error.message.startsWith("name (slot 3):") && !error.message.includes(CANARY),
|
||||
);
|
||||
});
|
||||
|
||||
test("leaf codecs refuse the wrong shape on both sides, saying what was expected and what kind was found", () => {
|
||||
const table: [string, Codec<unknown>, string, unknown, string][] = [
|
||||
["string", string, "a string", 5, "number"],
|
||||
["int32", int32, "an int32", "5", "string"],
|
||||
["int32", int32, "an int32", Number.POSITIVE_INFINITY, "a non-integer number"],
|
||||
["int32", int32, "an int32", Number.NaN, "a non-integer number"],
|
||||
["int32", int32, "an int32", 1.5, "a non-integer number"],
|
||||
["int32", int32, "an int32", 2 ** 31, "an integer out of range"],
|
||||
["int64", int64, "an int64", 2 ** 53, "a number beyond 2^53, which needs a bigint"],
|
||||
["int64", int64, "an int64", 2n ** 63n, "an integer out of range"],
|
||||
["float64", float64, "a float64", "1.5", "string"],
|
||||
["float32", float32, "a float32", null, "nil"],
|
||||
["boolean", boolean, "a boolean", "true", "string"],
|
||||
["bytes", bytes, "bytes", [1, 2], "array"],
|
||||
["string", string, "a string", null, "nil"],
|
||||
["list", list(string), "an array", "abc", "string"],
|
||||
];
|
||||
for (const [label, codec, what, bad, found] of table) {
|
||||
for (const side of ["encode", "decode"] as const) {
|
||||
// Decode sees the wire's shapes: a JS float or a number beyond 2^53 never arrives from a payload.
|
||||
if (side === "decode" && (typeof bad === "number" && (!Number.isInteger(bad) || Math.abs(bad) >= 2 ** 53))) continue;
|
||||
assert.throws(() => codec[side](bad), { message: `expected ${what}, found ${found}` }, `${label} ${side}`);
|
||||
}
|
||||
}
|
||||
for (const [codec, good] of [[string, "s"], [int32, 1], [int64, 5], [float64, 1.5], [float32, 1.5], [boolean, false], [bytes, new Uint8Array([7])], [list(int32), [1, 2]]] as [Codec<unknown>, unknown][]) {
|
||||
assert.deepEqual(codec.decode(codec.encode(good)), good);
|
||||
}
|
||||
});
|
||||
|
||||
test("a list element refusal names the element, and nullable writes nil for null and undefined", () => {
|
||||
const numbers = list(int32);
|
||||
assert.throws(() => numbers.encode([1, "x" as unknown as number]), /^Error: element 1: expected an int32, found string$/u);
|
||||
assert.throws(() => numbers.decode([1, 2, null]), /^Error: element 2: expected an int32, found nil$/u);
|
||||
const maybe = nullable(int32);
|
||||
assert.equal(maybe.encode(null), null);
|
||||
assert.equal(maybe.encode(undefined as unknown as null), null);
|
||||
assert.equal(maybe.encode(0), 0);
|
||||
assert.equal(maybe.decode(null), null);
|
||||
assert.equal(maybe.decode(0), 0);
|
||||
assert.throws(() => maybe.decode("x"), /expected an int32/u);
|
||||
});
|
||||
|
||||
test("a record refuses what is not a record and names the field and slot of a bad value", () => {
|
||||
const record = slots<{ name: string }>({ 3: ["name", string] });
|
||||
assert.throws(() => record.encode(null as unknown as { name: string }), /^Error: expected a record, found nil$/u);
|
||||
assert.throws(() => record.encode("x" as unknown as { name: string }), /^Error: expected a record, found string$/u);
|
||||
assert.throws(() => record.encode({ name: 5 as unknown as string }), /^Error: name \(slot 3\): expected a string, found number$/u);
|
||||
});
|
||||
|
||||
test("a union names itself, its arm and the field when an arm's slots are refused", () => {
|
||||
assert.throws(() => node.encode({ kind: "Text", id: "a", content: 5 as unknown as string }), /^Error: arm Text: content \(slot 1\): expected a string, found number$/u);
|
||||
assert.throws(() => node.decode([1, ["a", 5]]), /^Error: arm Text: content \(slot 1\): expected a string, found number$/u);
|
||||
assert.equal(node.encode(undefined as unknown as null), null);
|
||||
assert.throws(
|
||||
() => node.encode({ kind: "Nope" } as unknown as Node),
|
||||
/^Error: Union Node: the value's kind is not one of its arms \(Box, Text, Use, Image\)\.$/u,
|
||||
);
|
||||
});
|
||||
|
||||
test("slot names become catalog member names in Pascal case", () => {
|
||||
const record = slots<{ _hidden: string; two_words: string; Mixed9: string }>({ 0: ["_hidden", string], 1: ["two_words", string], 2: ["Mixed9", string] });
|
||||
assert.deepEqual(record.members.map((member) => member.memberName), ["Hidden", "TwoWords", "Mixed9"]);
|
||||
});
|
||||
|
||||
test("definitions are refused at definition time", () => {
|
||||
assert.throws(() => union<{ kind: "A" }>("U", { 0: ["A", slots<{ kind: string }>({ 0: ["kind", string] })] }), /Union U arm "A" declares a slot named kind/u);
|
||||
assert.throws(() => union<{ kind: "A" | "B" }>("U", { 0: ["A", unit], 1: ["A" as "B", unit] }), /declares the arm "A" twice/u);
|
||||
assert.throws(() => union<{ kind: "A" }>("U", { x: ["A", unit] } as never), /^Error: Union U declares the key "x", which is not an integer\.$/u);
|
||||
assert.throws(() => union<{ kind: "A" }>("U", { "01": ["A", unit] } as never), /key "01"/u);
|
||||
assert.throws(() => union<{ kind: "A" }>("U", { "1.5": ["A", unit] } as never), /key "1\.5"/u);
|
||||
assert.throws(() => union<{ kind: "A" }>("U", {} as never), /^Error: Union U declares no arms\.$/u);
|
||||
assert.throws(() => slots<{ a: string; b: string }>({ 0: ["a", string], 1: ["a" as "b", string] }), /declares the field "a" twice/u);
|
||||
assert.throws(() => slots<{ a: string }>({}), /declares no slots/u);
|
||||
assert.throws(() => slots<{ a: string }>({ [-1]: ["a", string] }), /^Error: A slot record declares the key "-1"/u);
|
||||
assert.throws(
|
||||
() => defineDocumentType({
|
||||
type: "t",
|
||||
schema: surfaceSchema,
|
||||
codec: surfaceCodec,
|
||||
formatter: { encode: () => new Uint8Array(), decode: () => golden },
|
||||
}),
|
||||
/declares both a codec and a formatter/u,
|
||||
);
|
||||
});
|
||||
|
||||
test("a codec supplies the document's catalog members unless it is given its own", () => {
|
||||
const document = defineDocumentType({
|
||||
type: "t",
|
||||
schema: z.object({ a: z.string(), the_b: z.string() }),
|
||||
codec: slots<{ a: string; the_b: string }>({ 0: ["a", string], 3: ["the_b", string] }),
|
||||
name: "a",
|
||||
indexes: { byB: "the_b" },
|
||||
});
|
||||
assert.deepEqual(document.members, [
|
||||
{ slot: 0, memberName: "A", typeName: "System.String", isName: true, indexAlias: null },
|
||||
{ slot: 3, memberName: "TheB", typeName: "System.String", isName: false, indexAlias: "byB" },
|
||||
]);
|
||||
const given = [{ slot: 9, memberName: "Mine", typeName: "System.String" }];
|
||||
const own = defineDocumentType({ type: "t", schema: z.object({ a: z.string() }), codec: slots<{ a: string }>({ 0: ["a", string] }), members: given });
|
||||
assert.deepEqual(own.members, given);
|
||||
});
|
||||
|
||||
test("schema.parse still runs on a decoded value, and a document without a codec still writes a map", async () => {
|
||||
const strict = defineDocumentType({
|
||||
type: "t",
|
||||
schema: z.object({ a: z.string().min(3) }),
|
||||
codec: slots<{ a: string }>({ 0: ["a", string] }),
|
||||
});
|
||||
const store = memoryStore([{ key: "k", type: "t", payload: documentPayloadFromBytes(encode(["xy"])), storedAt: new Date().toISOString() }]);
|
||||
await assert.rejects(() => CultCache.builder().withDocumentType(strict).withGenericStore(store).build().pullAllBackingStores());
|
||||
|
||||
const plain = defineDocumentType({ type: "p", schema: z.object({ id: z.string() }) });
|
||||
const holder = memoryStore([]);
|
||||
await CultCache.builder().withDocumentType(plain).withGenericStore(holder).build().put(plain, "k", { id: "d" });
|
||||
assert.deepEqual(decode(documentPayloadBytes(holder.entries[0].payload)), { id: "d" });
|
||||
});
|
||||
|
||||
test("bytes and nullable round-trip through a slot record", () => {
|
||||
const record = slots<{ blob: Uint8Array; maybe: string | null }>({ 0: ["blob", bytes], 1: ["maybe", nullable(string)] });
|
||||
const wire = encode(record.encode({ blob: new Uint8Array([1, 2]), maybe: null }));
|
||||
assert.deepEqual(record.decode(decode(wire)), { blob: new Uint8Array([1, 2]), maybe: null });
|
||||
assert.throws(() => record.decode([new Uint8Array(), 4]), /maybe \(slot 1\): expected a string/u);
|
||||
});
|
||||
|
||||
const fromHex = (text: string) => new Uint8Array(Buffer.from(text, "hex"));
|
||||
|
||||
// Arm keys are integers within Int32 in the reference: a float key is refused even when it holds a whole number, and a
|
||||
// key outside Int32 is refused whether it is declared or read. Every payload below is [key, [7]] or [key, ["wait"]].
|
||||
test("an arm key is an integer in any MessagePack integer encoding, and a float-typed key is refused", () => {
|
||||
const completed = { kind: "Completed", sourceVersion: 7 };
|
||||
for (const key of ["01", "cc01", "cd0001", "ce00000001", "cf0000000000000001", "d001", "d10001", "d200000001", "d30000000000000001"]) {
|
||||
assert.deepEqual(outcome.decode(readWire(fromHex(`92${key}9107`))), completed, key);
|
||||
}
|
||||
for (const key of ["ca3f800000", "cb3ff0000000000000"]) {
|
||||
assert.throws(() => outcome.decode(readWire(fromHex(`92${key}9107`))), { message: "Union Outcome: the arm key is not an integer (declared keys: 0, 1, 2)." }, key);
|
||||
}
|
||||
assert.throws(() => outcome.decode(readWire(fromHex("92cb800000000000000091a477616974"))), /^Error: Union Outcome: the arm key is not an integer/u, "-0.0 is a float, not key 0");
|
||||
for (const key of ["ce80000000", "cf8000000000000000", "d30000000080000000"]) {
|
||||
assert.throws(() => outcome.decode(readWire(fromHex(`92${key}9107`))), { message: "Union Outcome: the arm key is outside Int32 (declared keys: 0, 1, 2)." }, key);
|
||||
}
|
||||
assert.throws(() => outcome.decode(readWire(fromHex("92d2800000009107"))), { message: "Union Outcome: unknown arm key -2147483648 (declared keys: 0, 1, 2)." });
|
||||
assert.throws(() => outcome.decode(readWire(fromHex("92ff9107"))), { message: "Union Outcome: unknown arm key -1 (declared keys: 0, 1, 2)." });
|
||||
});
|
||||
|
||||
test("a union refuses a declared arm key outside Int32", () => {
|
||||
assert.doesNotThrow(() => union<{ kind: "A" }>("U", { 2147483647: ["A", unit] }));
|
||||
assert.throws(() => union<{ kind: "A" }>("U", { 2147483648: ["A", unit] }), { message: "Union U declares the key 2147483648, which is outside Int32." });
|
||||
assert.throws(() => union<{ kind: "A" }>("U", { 3000000000: ["A", unit] }), /outside Int32/u);
|
||||
});
|
||||
|
||||
test("the wire reader keeps 64-bit integers whole, tells a float from an integer, and refuses what is not a value", () => {
|
||||
assert.equal(readWire(fromHex("cf0020000000000001")), 2n ** 53n + 1n);
|
||||
assert.equal(readWire(fromHex("cf001fffffffffffff")), 2 ** 53 - 1);
|
||||
assert.equal(readWire(fromHex("d38000000000000000")), -(2n ** 63n));
|
||||
assert.equal(readWire(fromHex("cfffffffffffffffff")), 2n ** 64n - 1n);
|
||||
assert.ok(readWire(fromHex("cb3ff0000000000000")) instanceof WireFloat);
|
||||
assert.ok(readWire(fromHex("ca3f800000")) instanceof WireFloat);
|
||||
assert.equal(readWire(fromHex("01")), 1);
|
||||
assert.throws(() => readWire(fromHex("92a161")), /truncated/u, "an array that promises more than the payload holds");
|
||||
assert.throws(() => readWire(fromHex("dd")), /truncated/u);
|
||||
assert.throws(() => readWire(fromHex("ddffffffff")), /truncated/u, "a huge array header reserves nothing");
|
||||
assert.throws(() => readWire(fromHex("a5616263")), /truncated/u);
|
||||
assert.throws(() => readWire(fromHex("0102")), /bytes after its value/u);
|
||||
assert.throws(() => readWire(fromHex("81a161a162")), /map, extension or reserved/u);
|
||||
assert.throws(() => readWire(fromHex("c1")), /map, extension or reserved/u);
|
||||
assert.throws(() => readWire(fromHex("d4010a")), /map, extension or reserved/u);
|
||||
});
|
||||
|
||||
test("the wire writer writes the smallest integer, keeps a float's width, and round-trips what it reads", () => {
|
||||
const tree = [0, 127, 128, 255, 256, 65535, 65536, 4294967295, 4294967296, -1, -32, -33, -128, -129, -32768, -32769, -2147483648, -2147483649,
|
||||
2n ** 53n, 2n ** 64n - 1n, -(2n ** 63n), new WireFloat(1.5, false), new WireFloat(1.5, true), "", "a".repeat(31), "a".repeat(32), "a".repeat(256),
|
||||
new Uint8Array(0), new Uint8Array(300), [], null, true, false, "héllo"];
|
||||
assert.deepEqual(readWire(writeWire(tree)), tree);
|
||||
assert.equal(hex(writeWire(5)), "05");
|
||||
assert.equal(hex(writeWire(128)), "cc80");
|
||||
assert.equal(hex(writeWire(-33)), "d0df");
|
||||
assert.equal(hex(writeWire(2n ** 53n + 2n)), "cf0020000000000002");
|
||||
assert.equal(hex(writeWire(new WireFloat(3, true))), "cb4008000000000000");
|
||||
assert.equal(hex(writeWire(new WireFloat(1.5, false))), "ca3fc00000");
|
||||
assert.equal(hex(writeWire(new Array(16).fill(null))), `dc0010${"c0".repeat(16)}`);
|
||||
assert.throws(() => writeWire(1.5), /not a safe integer/u);
|
||||
assert.throws(() => writeWire(2 ** 53), /not a safe integer/u);
|
||||
assert.throws(() => writeWire(2n ** 64n), /wider than 64 bits/u);
|
||||
assert.throws(() => writeWire({}), /this kind of value/u);
|
||||
});
|
||||
|
||||
test("a codec and a formatter are refused together wherever the definition enters", () => {
|
||||
const both = { type: "t", schema: surfaceSchema, codec: surfaceCodec, formatter: { encode: () => new Uint8Array(), decode: () => golden } };
|
||||
assert.throws(() => defineDocumentType(both), /declares both a codec and a formatter/u);
|
||||
assert.throws(() => CultCache.builder().withDocumentType(both), /declares both a codec and a formatter/u);
|
||||
assert.throws(() => CultCache.builder().withRegistry([both]), /declares both a codec and a formatter/u);
|
||||
});
|
||||
|
||||
test("a definition that skips defineDocumentType still writes its codec's bytes and its derived catalog", async () => {
|
||||
const raw = { type: "tests.union_surface", schemaId: "tests.union_surface.v1", schema: surfaceSchema, codec: surfaceCodec, name: "id" } as unknown as AnyCultCacheDocumentDefinition;
|
||||
const holder = memoryStore([]);
|
||||
const cache = CultCache.builder().withDocumentType(raw).withGenericStore(holder).build();
|
||||
await cache.put(raw as never, "surface", golden as never);
|
||||
assert.equal(hex(documentPayloadBytes(holder.entries[0].payload)), hex(await read("union-surface.msgpack")));
|
||||
const [snapshot] = cache.snapshot();
|
||||
assert.deepEqual(snapshot.catalogEntry!.members!.map((member) => member.typeName).slice(0, 2), ["System.String", "System.Int32"]);
|
||||
});
|
||||
|
||||
test("every leaf and wrapper carries the reference's type name, and a catalog is not derived from a codec that cannot name a slot", () => {
|
||||
assert.deepEqual(
|
||||
[string, boolean, bytes, int32, int64, float32, float64].map((codec) => codec.typeName),
|
||||
["System.String", "System.Boolean", "System.Byte[]", "System.Int32", "System.Int64", "System.Single", "System.Double"],
|
||||
);
|
||||
assert.equal(list(int64).typeName, "System.Collections.Generic.List<System.Int64>");
|
||||
assert.equal(array(string).typeName, "System.String[]");
|
||||
assert.equal(nullable(int32).typeName, "System.Nullable<System.Int32>");
|
||||
assert.equal(nullable(string).typeName, "System.String");
|
||||
assert.equal(nullable(list(string)).typeName, "System.Collections.Generic.List<System.String>");
|
||||
assert.equal(list(union<{ kind: "A" }>("U", { 0: ["A", unit] })).typeName, undefined);
|
||||
const unnamed = slots<{ id: string; what: { kind: "A" } | null }>({ 0: ["id", string], 1: ["what", union<{ kind: "A" }>("U", { 0: ["A", unit] })] });
|
||||
assert.throws(() => unnamed.members, /^Error: what \(slot 1\) has no C# type name for the schema catalog/u);
|
||||
assert.throws(() => defineDocumentType({ type: "t", schema: z.any(), codec: unnamed as never }), /what \(slot 1\) has no C# type name/u);
|
||||
const given = defineDocumentType({ type: "t", schema: z.any(), codec: unnamed as never, members: [{ slot: 0, memberName: "Id", typeName: "System.String" }] });
|
||||
assert.equal(given.members!.length, 1);
|
||||
});
|
||||
|
||||
// tests/vectors/numeric-leaves is written by the C# reference (NumericLeafVectorTests): what it reads and refuses for each
|
||||
// numeric leaf, and the bytes it writes.
|
||||
const numericVectors = join(vectors, "..", "numeric-leaves");
|
||||
const numericLeaves: Record<string, Codec<unknown>> = { int32, int64, float32, float64 };
|
||||
const doubleBitsHex = (value: number) => {
|
||||
const view = new DataView(new ArrayBuffer(8));
|
||||
view.setFloat64(0, value);
|
||||
return Buffer.from(view.buffer).toString("hex");
|
||||
};
|
||||
const int64BitsHex = (value: number | bigint) => BigInt.asUintN(64, BigInt(value)).toString(16).padStart(16, "0");
|
||||
const isNaNBits = (bitsHex: string) => {
|
||||
const bits = BigInt(`0x${bitsHex}`);
|
||||
return ((bits >> 52n) & 0x7ffn) === 0x7ffn && (bits & ((1n << 52n) - 1n)) !== 0n;
|
||||
};
|
||||
|
||||
const isSignallingFloat32 = (payload: Uint8Array) => {
|
||||
// payload is a one-slot record [float32]: 91 ca b0 b1 b2 b3
|
||||
if (payload.length !== 6 || payload[1] !== 0xca) return false;
|
||||
const bits = new DataView(payload.buffer, payload.byteOffset).getUint32(2);
|
||||
return (bits & 0x7f800000) === 0x7f800000 && (bits & 0x007fffff) !== 0 && (bits & 0x00400000) === 0;
|
||||
};
|
||||
|
||||
test("numeric leaves read and refuse exactly what the C# reference does", async () => {
|
||||
const rows = readWire(new Uint8Array(await readFile(join(numericVectors, "reads.msgpack")))) as [string, Uint8Array, boolean, Uint8Array][];
|
||||
assert.equal(rows.length, 160);
|
||||
let accepted = 0;
|
||||
let refused = 0;
|
||||
for (const [leaf, payload, wasAccepted, bits] of rows) {
|
||||
const label = `${leaf} ${hex(payload)}`;
|
||||
const record = slots<{ v: unknown }>({ 0: ["v", numericLeaves[leaf]] });
|
||||
if (wasAccepted && leaf === "float32" && isSignallingFloat32(payload)) {
|
||||
// The one reading that differs from the reference: it reads a float32 signalling NaN, this runtime refuses it
|
||||
// (float32-nan.msgpack shows the reference keeps its bits, which a JS number cannot).
|
||||
assert.throws(() => record.decode(readWire(payload)), (error: Error) => !error.message.includes(hex(payload).slice(2)), label);
|
||||
refused += 1;
|
||||
} else if (wasAccepted) {
|
||||
const read = record.decode(readWire(payload)).v as number | bigint;
|
||||
const wanted = hex(bits);
|
||||
if (leaf.startsWith("float")) {
|
||||
if (isNaNBits(wanted)) assert.ok(Number.isNaN(read), label);
|
||||
else assert.equal(doubleBitsHex(read as number), wanted, label);
|
||||
} else {
|
||||
assert.equal(int64BitsHex(read), wanted, label);
|
||||
}
|
||||
accepted += 1;
|
||||
} else {
|
||||
assert.throws(() => record.decode(readWire(payload)), (error: Error) => !error.message.includes(hex(payload).slice(2)), label);
|
||||
refused += 1;
|
||||
}
|
||||
}
|
||||
assert.ok(accepted > 40 && refused > 40, `accepted ${accepted}, refused ${refused}`);
|
||||
assert.equal(accepted + refused, rows.length);
|
||||
});
|
||||
|
||||
// float32-nan.msgpack: [payload, bytes the reference writes after reading it] for a float32 slot holding an infinity or a NaN.
|
||||
// The reference keeps every payload bit; a JS number cannot keep a signalling NaN's, so that read is refused.
|
||||
test("a float32 NaN round-trips with its bits, and a signalling one is refused on read", async () => {
|
||||
const rows = readWire(new Uint8Array(await readFile(join(numericVectors, "float32-nan.msgpack")))) as [Uint8Array, Uint8Array][];
|
||||
assert.equal(rows.length, 6);
|
||||
const record = slots<{ v: number }>({ 0: ["v", float32] });
|
||||
let refused = 0;
|
||||
for (const [payload, rewritten] of rows) {
|
||||
if (isSignallingFloat32(payload)) {
|
||||
assert.equal(hex(rewritten), hex(payload), "the reference keeps the signalling bits");
|
||||
assert.throws(() => record.decode(readWire(payload)), { message: "v (slot 0): expected a float32, found a signalling NaN" }, hex(payload));
|
||||
refused += 1;
|
||||
} else {
|
||||
assert.equal(hex(writeWire(record.encode(record.decode(readWire(payload))))), hex(rewritten), hex(payload));
|
||||
}
|
||||
}
|
||||
assert.equal(refused, 3);
|
||||
});
|
||||
|
||||
test("only a NaN with its quiet bit clear is signalling: a finite float32 with a clear bit 22 reads, and a float64 slot reads any float32 NaN", () => {
|
||||
const f32 = slots<{ v: number }>({ 0: ["v", float32] });
|
||||
const f64 = slots<{ v: number }>({ 0: ["v", float64] });
|
||||
assert.equal(f32.decode(readWire(fromHex("91ca3f800001"))).v, Math.fround(1.0000001192092896));
|
||||
assert.equal(f32.decode(readWire(fromHex("91ca00000001"))).v, Math.fround(1e-45));
|
||||
assert.equal(f32.decode(readWire(fromHex("91ca7f800000"))).v, Infinity);
|
||||
assert.ok(Number.isNaN(f32.decode(readWire(fromHex("91ca7fc00001"))).v));
|
||||
assert.ok(Number.isNaN(f64.decode(readWire(fromHex("91ca7f800001"))).v));
|
||||
});
|
||||
|
||||
test("numeric leaves write the bytes the C# reference writes, and 64-bit values lose nothing", async () => {
|
||||
const rows = readWire(new Uint8Array(await readFile(join(numericVectors, "writes.msgpack")))) as [string, string, Uint8Array][];
|
||||
assert.equal(rows.length, 46);
|
||||
for (const [leaf, text, expected] of rows) {
|
||||
const value = leaf.startsWith("float") ? new DataView(Uint8Array.from(Buffer.from(text, "hex")).buffer).getFloat64(0) : leaf === "int32" ? Number(text) : BigInt(text);
|
||||
const record = slots<{ v: unknown }>({ 0: ["v", numericLeaves[leaf]] });
|
||||
assert.equal(hex(writeWire(record.encode({ v: value }))), hex(expected), `${leaf} ${text}`);
|
||||
}
|
||||
const wide = slots<{ v: number | bigint }>({ 0: ["v", int64] });
|
||||
for (const value of [2n ** 53n + 1n, 2n ** 63n - 1n, -(2n ** 63n), -(2n ** 53n) - 1n]) {
|
||||
assert.equal(wide.decode(readWire(writeWire(wide.encode({ v: value })))).v, value);
|
||||
}
|
||||
assert.equal(wide.decode(readWire(writeWire(wide.encode({ v: 2n })))).v, 2, "a bigint that fits comes back a number");
|
||||
});
|
||||
|
||||
test("the wire writer's width boundaries: where a length or an integer changes its header", () => {
|
||||
assert.equal(hex(writeWire(0)), "00");
|
||||
assert.equal(hex(writeWire("a".repeat(31))).slice(0, 2), "bf");
|
||||
assert.equal(hex(writeWire("a".repeat(32))).slice(0, 4), "d920");
|
||||
assert.equal(hex(writeWire("a".repeat(255))).slice(0, 4), "d9ff");
|
||||
assert.equal(hex(writeWire("a".repeat(256))).slice(0, 6), "da0100");
|
||||
assert.equal(hex(writeWire("a".repeat(65535))).slice(0, 6), "daffff");
|
||||
assert.equal(hex(writeWire("a".repeat(65536))).slice(0, 10), "db00010000");
|
||||
assert.equal(hex(writeWire(new Uint8Array(1))), "c40100");
|
||||
assert.equal(hex(writeWire(new Uint8Array(256))).slice(0, 6), "c50100");
|
||||
assert.equal(hex(writeWire(new Uint8Array(65536))).slice(0, 10), "c600010000");
|
||||
assert.equal(hex(writeWire(new Array(15).fill(null))), `9f${"c0".repeat(15)}`);
|
||||
assert.equal(hex(writeWire(new Array(65535).fill(null))).slice(0, 6), "dcffff");
|
||||
assert.equal(hex(writeWire(new Array(65536).fill(null))).slice(0, 10), "dd00010000");
|
||||
assert.throws(() => writeWire(-(2n ** 63n) - 1n), { message: "cannot write an integer wider than 64 bits." });
|
||||
assert.throws(() => writeWire(undefined), /this kind of value/u);
|
||||
assert.equal(hex(writeWire(-(2n ** 63n))), "d38000000000000000");
|
||||
});
|
||||
|
||||
test("the wire reader's boundaries: the largest fixarray, the 32-bit binary and string, the safe integer edge", () => {
|
||||
assert.deepEqual(readWire(fromHex(`9f${"c0".repeat(15)}`)), new Array(15).fill(null));
|
||||
assert.deepEqual(readWire(fromHex("c600000001aa")), new Uint8Array([0xaa]));
|
||||
assert.equal(readWire(fromHex("db0000000161")), "a");
|
||||
assert.equal(readWire(fromHex("d3ffe0000000000001")), -(2 ** 53 - 1));
|
||||
assert.equal(readWire(fromHex("d3ffe0000000000000")), -(2n ** 53n));
|
||||
assert.equal(readWire(fromHex("cf0020000000000000")), 2n ** 53n);
|
||||
});
|
||||
|
||||
test("a refusal says what was found: nil, a float, an integer that is not the slot's, a plain kind", () => {
|
||||
assert.throws(() => int32.encode(undefined as unknown as number), { message: "expected an int32, found nil" });
|
||||
assert.throws(() => string.decode(new WireFloat(1, true)), { message: "expected a string, found float" });
|
||||
assert.throws(() => string.decode(5n), { message: "expected a string, found integer" });
|
||||
assert.throws(() => int32.decode(new WireFloat(7, true)), { message: "expected an int32, found float" });
|
||||
assert.throws(() => float64.decode("1"), { message: "expected a float64, found string" });
|
||||
assert.throws(() => int64.encode(1.5), { message: "expected an int64, found a non-integer number" });
|
||||
});
|
||||
|
||||
test("nullable names a value type Nullable<T> and anything else as itself", () => {
|
||||
assert.equal(nullable(boolean).typeName, "System.Nullable<System.Boolean>");
|
||||
assert.equal(nullable(int64).typeName, "System.Nullable<System.Int64>");
|
||||
assert.equal(nullable(float32).typeName, "System.Nullable<System.Single>");
|
||||
assert.equal(nullable(float64).typeName, "System.Nullable<System.Double>");
|
||||
assert.equal(nullable(bytes).typeName, "System.Byte[]");
|
||||
const unnamedValueType: Codec<number> = { valueType: true, encode: (value) => value, decode: (raw) => raw as number };
|
||||
assert.equal(nullable(unnamedValueType).typeName, undefined);
|
||||
assert.equal(lazy(() => int32).valueType, true);
|
||||
});
|
||||
|
||||
test("the Int32 edges of an arm key: the largest is an arm, one past either end is outside", () => {
|
||||
const edge = union<{ kind: "A" }>("U", { 2147483647: ["A", unit] });
|
||||
assert.deepEqual(edge.decode(readWire(fromHex("92ce7fffffff90"))), { kind: "A" });
|
||||
assert.deepEqual(edge.encode({ kind: "A" }), [2147483647, []]);
|
||||
assert.throws(() => edge.decode(readWire(fromHex("92ce8000000090"))), { message: "Union U: the arm key is outside Int32 (declared keys: 2147483647)." });
|
||||
assert.throws(() => edge.decode(readWire(fromHex("92d3ffffffff7fffffff90"))), { message: "Union U: the arm key is outside Int32 (declared keys: 2147483647)." });
|
||||
assert.throws(() => edge.decode(readWire(fromHex("92d3ffffffff8000000090"))), { message: "Union U: unknown arm key -2147483648 (declared keys: 2147483647)." });
|
||||
});
|
||||
|
||||
function envelopeOf(payload: Uint8Array): CultCacheEnvelope {
|
||||
return { key: "surface", type: "tests.union_surface", payload: documentPayloadFromBytes(payload), storedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
function memoryStore(entries: CultCacheEnvelope[]): CacheBackingStore & { entries: CultCacheEnvelope[] } {
|
||||
return {
|
||||
entries,
|
||||
async pullAll() { return entries.map((entry) => ({ ...entry })); },
|
||||
async push(entry) { entries.push(entry); },
|
||||
async delete() {},
|
||||
};
|
||||
}
|
||||
|
||||
test("arm keys span Int32 and are listed ascending; slot keys are non-negative Int32", () => {
|
||||
const keys = union<{ kind: "High" | "Zero" | "Neg" | "Low" }>("U", {
|
||||
2147483647: ["High", unit],
|
||||
0: ["Zero", unit],
|
||||
[-1]: ["Neg", unit],
|
||||
[-2147483648]: ["Low", unit],
|
||||
});
|
||||
for (const [kind, key] of [["High", 2147483647], ["Zero", 0], ["Neg", -1], ["Low", -2147483648]] as const) {
|
||||
const written = keys.encode({ kind });
|
||||
assert.deepEqual(written, [key, []]);
|
||||
assert.deepEqual(keys.decode(readWire(writeWire(written))), { kind });
|
||||
}
|
||||
assert.throws(() => keys.decode(readWire(fromHex("920590"))), { message: "Union U: unknown arm key 5 (declared keys: -2147483648, -1, 0, 2147483647)." });
|
||||
assert.throws(() => union<{ kind: "A" }>("U", { [-2147483649]: ["A", unit] }), { message: "Union U declares the key -2147483649, which is outside Int32." });
|
||||
assert.throws(() => union<{ kind: "A" }>("U", { 99999999999999999999: ["A", unit] } as never), { message: "Union U declares the key 100000000000000000000, which is outside Int32." });
|
||||
assert.throws(() => slots<{ a: string }>({ 2147483647: ["a", string] }), { message: "A slot record declares the key 2147483647, which is above the slot key limit 65535." });
|
||||
assert.throws(() => slots<{ a: string }>({ 2147483648: ["a", string] }), { message: "A slot record declares the key 2147483648, which is above the slot key limit 65535." });
|
||||
assert.throws(() => slots<{ a: string }>({ [-1]: ["a", string] }), { message: 'A slot record declares the key "-1", which is not a non-negative integer.' });
|
||||
});
|
||||
|
||||
// A slot record is a dense array of key+1 slots, as the reference writes it, so the key is capped: the largest accepted
|
||||
// key writes a 65536-slot payload, one more is refused at definition.
|
||||
test("a slot key is capped at 65535: the cap is the largest slot record, one past it is refused at definition", () => {
|
||||
assert.equal(MAX_SLOT_KEY, 65535);
|
||||
const edge = slots<{ a: string }>({ 65535: ["a", string] });
|
||||
const written = edge.encode({ a: "x" }) as unknown[];
|
||||
assert.equal(written.length, 65536);
|
||||
assert.equal(written[65535], "x");
|
||||
assert.equal(written.filter((slot) => slot === null).length, 65535);
|
||||
assert.deepEqual(edge.decode(readWire(writeWire(written))), { a: "x" });
|
||||
assert.throws(() => slots<{ a: string }>({ 65536: ["a", string] }), { message: "A slot record declares the key 65536, which is above the slot key limit 65535." });
|
||||
});
|
||||
|
||||
test("the mixed-record vector is the bytes the reference writes for a record of every leaf kind", async () => {
|
||||
const mixed = slots<{ id: string; i: number; l: number | bigint; f: number; d: number }>({
|
||||
0: ["id", string], 1: ["i", int32], 2: ["l", int64], 3: ["f", float32], 4: ["d", float64],
|
||||
});
|
||||
const committed = new Uint8Array(await readFile(join(vectors, "..", "numeric-leaves", "mixed-record.msgpack")));
|
||||
assert.equal(hex(writeWire(mixed.encode({ id: "n1", i: 7, l: 8, f: 1.5, d: 2.5 }))), hex(committed));
|
||||
assert.deepEqual(mixed.decode(readWire(committed)), { id: "n1", i: 7, l: 8, f: 1.5, d: 2.5 });
|
||||
});
|
||||
|
||||
// tests/vectors/slot-nil is written by the C# reference (SlotNilVectorTests): what it reads and rewrites for a nil, a str in a
|
||||
// byte[] slot, and a record short of its last slots. A runtime accepts what it accepts and rewrites the same bytes.
|
||||
test("slots read and rewrite exactly what the C# reference does for nil, a str in bytes, and short records", async () => {
|
||||
const inner = slots<{ x: string }>({ 0: ["x", string, ""] });
|
||||
const probe = slots<{ s: string; i: number; b: Uint8Array; r: { x: string }; l: string[] }>({
|
||||
0: ["s", string, ""],
|
||||
1: ["i", int32, 0],
|
||||
2: ["b", bytes, () => new Uint8Array()],
|
||||
3: ["r", inner, null as unknown as { x: string }],
|
||||
4: ["l", list(string), () => []],
|
||||
});
|
||||
const rows = readWire(new Uint8Array(await readFile(join(vectors, "..", "slot-nil", "reads.msgpack")))) as [string, Uint8Array, boolean, Uint8Array][];
|
||||
assert.equal(rows.length, 15);
|
||||
let accepted = 0;
|
||||
for (const [name, payload, wasAccepted, rewritten] of rows) {
|
||||
if (wasAccepted) {
|
||||
assert.equal(hex(writeWire(probe.encode(probe.decode(readWire(payload))))), hex(rewritten), name);
|
||||
accepted += 1;
|
||||
} else {
|
||||
assert.throws(() => probe.decode(readWire(payload)), (error: Error) => !error.message.includes(hex(payload).slice(2)), name);
|
||||
}
|
||||
}
|
||||
assert.equal(accepted, 13);
|
||||
});
|
||||
|
||||
test("a nil reads as null and writes as nil in every reference slot, and a value slot refuses it", () => {
|
||||
const record = slots<{ s: string; b: Uint8Array; i: number }>({ 0: ["s", string], 1: ["b", bytes], 2: ["i", int32, 5] });
|
||||
const read = record.decode([null, null, 3]);
|
||||
assert.deepEqual(read, { s: null, b: null, i: 3 });
|
||||
assert.deepEqual(record.encode(read), [null, null, 3]);
|
||||
assert.throws(() => record.encode({ s: "a", b: new Uint8Array(), i: null as unknown as number }), { message: "i (slot 2): expected an int32, found nil" });
|
||||
assert.throws(() => record.decode(["a", new Uint8Array(), null]), { message: "i (slot 2): expected an int32, found nil" });
|
||||
});
|
||||
|
||||
test("a bytes slot reads a MessagePack str as its bytes and writes bin, and refuses a str that was not valid UTF-8", () => {
|
||||
const record = slots<{ b: Uint8Array }>({ 0: ["b", bytes] });
|
||||
const read = record.decode(readWire(fromHex("91a3616263")));
|
||||
assert.equal(hex(read.b), "616263");
|
||||
assert.equal(hex(writeWire(record.encode(read))), "91c403616263");
|
||||
assert.throws(() => record.decode(readWire(fromHex("91a2c328"))), { message: "b (slot 0): expected bytes, found a string that is not valid UTF-8" });
|
||||
assert.throws(() => record.decode([5]), { message: "b (slot 0): expected bytes, found number" });
|
||||
});
|
||||
|
|
@ -26,44 +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` and `shaders/CultMath.Affine.hlsl` from the same
|
||||
directory, so ship all four.
|
||||
- interval arithmetic for culling empty space: `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). The arithmetic functions return an interval built to
|
||||
contain the function's value over their operand intervals, so a raymarch
|
||||
can skip a segment whose `hi` is below its cutoff; `cultmath_iv_*` in HLSL.
|
||||
The noise functions rest on empirical constants with a margin, not a proof:
|
||||
`iv_snoise_ball(float3)` on `SNOISE_LIPSCHITZ` (`iv_fbm_ball` through it),
|
||||
`iv_snoise_ball(float2)` on `SNOISE2_LIPSCHITZ`, and `af_snoise` on
|
||||
`SNOISE_HESSIAN` and `SNOISE_LIPSCHITZ` (`af_fbm` through it); the enclosure
|
||||
tests sample them. `iv_frustum_ball` and `af_frustum_ball` read no noise
|
||||
constant: the ball is geometry plus a rounding widening, and the noise call
|
||||
it feeds carries the constant.
|
||||
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.
|
||||
|
|
@ -90,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));
|
||||
|
|
@ -129,12 +98,6 @@ The repository root is not a Unity package. Build the tracked package with
|
|||
assembly and rejects stale binaries, missing metadata, unexpected assemblies,
|
||||
or leaked C# source.
|
||||
|
||||
Before tagging `cultmath-unity-v*`, run the import and dispatch smoke on a Windows machine with a GPU,
|
||||
alone: `packages/cultmath/scripts/unity-smoke/run-unity-smoke.ps1 -UnityExe <path to Unity.exe>`. It
|
||||
builds a scratch project outside the repository that references the package by a `file:` path,
|
||||
dispatches a compute shader that includes `CultMath.Interval.hlsl` and `CultMath.Affine.hlsl`, requires
|
||||
a deliberately broken control shader to be rejected, and deletes the project afterwards.
|
||||
|
||||
## Shader Tooling
|
||||
|
||||
CultMath keeps portable DXC outside git under `packages/cultmath/.tools/`:
|
||||
|
|
|
|||
|
|
@ -6,40 +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`). Modified from upstream:
|
||||
the 3D corner tie order follows Gustavson's `simplexnoise1234`, because the
|
||||
upstream `step` compares are cyclic where the offset's components are equal.
|
||||
|
||||
`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,347 +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.
|
||||
|
||||
Every `af_*` function is an ulp-bounded family, and `af_snoise`, `af_fbm` and `af_frustum_ball` also carry a two-sided `check`
|
||||
of their own, applied the same way: the device's own `snoise` or fBm at 75 sampled points of the case's set must lie in the device's
|
||||
form with no tolerance (a device whose gradient error exceeds the allowance fails), its width must not pass the Lipschitz form's
|
||||
allowance, and the device's `af_frustum_ball` radius must enclose the sampled ray points and stay within its rounding widening.
|
||||
The reference is the device's own noise, which is right for culling soundness but would pass a device whose noise is wrong
|
||||
everywhere, so two appended families (`af_snoise` and `af_fbm`, 256 cases each) add the centre leg: the fixture's own `x0`
|
||||
(C#'s value at the centre) must lie in the device form's `[x0 - e, x0 + e]`, for every `af_snoise` and `af_fbm` family. They
|
||||
also draw reach `|axis| + radius` from 1e-6 to 1e-3, the short slices near a camera, where the float32 allowance is most of `e`:
|
||||
the main families' reach is at least about 1e-3, where the second-order remainder is far larger and a device that drops the
|
||||
allowance still passes. `GoldenFixtureMatchesCSharp` requires each of the two to hold cases that fail on C#'s own results once
|
||||
the allowance is removed.
|
||||
|
||||
`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. Ties in the offset `x0` resolve in Gustavson's
|
||||
`simplexnoise1234` order (x over y, y over z, x over z), a total order, so the
|
||||
four corners are always a simplex. Upstream's `step` compares are cyclic: where
|
||||
the three components are equal, which is every input whose differences `y - x`
|
||||
and `z - x` are whole numbers (every integer point, the origin and every
|
||||
`(t, t, t)` among them), the corners repeat the origin vertex and the value
|
||||
jumps by about 0.77 near the origin and up to 1.56 at magnitudes of 64 and
|
||||
above. `SnoiseTieScopeTests` pins the set against the 0.3.0 values. `NoiseGradTests.SnoiseIsContinuousOnTheSimplexDiagonal` pins the order.
|
||||
- `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.
|
||||
|
|
@ -472,368 +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
|
||||
(7.2400064), and multiplies by 1.10: `L = 7.9640074`. 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 or choice tracking. Correlation
|
||||
between the operands of one expression is what "Affine forms" below adds.
|
||||
How loose the ball bounds 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.16x, and 1.11x with the warp. Most cells are fog, and
|
||||
the pre-pass probes each one.
|
||||
- (c) The deep well (ruling `wells-scale-order-of-magnitude`): the zone bowl
|
||||
plus one well at its centre, `PowerPulse` exponent 2, scale 2220, depth 600.
|
||||
The band `G = 60` is where only the noise decides density (`s` within
|
||||
`F -+ 1.5 A` with the fade at 1); depth and half-depth radius are each
|
||||
`10 G`. The camera's `xz` is uniform within 300 of the centre, at
|
||||
`y = F - h(xz) / 2`, so it looks across the bowl from inside it. Both rows run
|
||||
on the same seeds. With no warp, the centred interval takes 3.20x (oracle
|
||||
ceiling 5.24x, efficiency 0.611, cull fraction 0.988, probe overhead 0.940)
|
||||
and the affine bound 3.57x (efficiency 0.682, cull 0.993, overhead 0.905). With
|
||||
the warp `D = 60`: 3.12x (ceiling 5.31x, efficiency 0.588, cull 0.987, overhead
|
||||
0.929) against 3.46x (efficiency 0.651, cull 0.993, overhead 0.880), so affine
|
||||
is 1.12x and 1.11x cheaper. The range mask (the oracle cells plus every cell
|
||||
where any of 5 x 5 slopes x 9 depths has density above zero) gives a range
|
||||
ceiling of 0.859 (0.848 with the warp), the cost a bound that proves exactly
|
||||
the range-empty cells would reach against the oracle's; the efficiencies
|
||||
against it are 0.794 and 0.712 (warp 0), 0.768 and 0.694 (warp `D`).
|
||||
- (c-band) The shallow wells, four mass-derived wells in the zone bowl with
|
||||
the camera gazing across, whose geometry is band-dominated, printed only.
|
||||
Centred interval 3.01x (efficiency 0.414, cull 0.960, overhead 0.958), affine
|
||||
3.95x (0.544, 0.980, 0.901); with the warp 2.75x (0.383, 0.954, 0.944) against
|
||||
3.54x (0.494, 0.976, 0.865).
|
||||
- (d) r1's uniform slab, which has no envelope: 1.38x with the warp 0.05, and
|
||||
1.67x 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.08, with identical
|
||||
transmittance. The footprint-aware march takes 13.50: 2.69x fewer. `snoise`
|
||||
per pixel goes from 32.98 to 26.70, and combined cost from 40.25 to 29.54 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.59x (`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) on the deep well (rulings `affine-wells-contract-cheaper-than-interval`
|
||||
and `wells-overhead-plus-floor`): the affine bound's combined cost ratio is
|
||||
above the centred interval's at warp 0 and at warp `D`; and, hard, the
|
||||
pre-pass proves at least half of the oracle-empty cells empty under both
|
||||
bounds, so a pre-pass that skips nothing fails. The probe overhead, the
|
||||
efficiencies against the oracle and the range ceiling, the ceilings and the
|
||||
cull fraction are printed, not asserted; (c-band) is printed only;
|
||||
- (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) affine 3.57x and 3.46x against
|
||||
the centred interval's 3.20x and 3.12x, with a cull fraction of 0.987 at the
|
||||
lowest, (e) 2.69x fewer steps and a depth error 1.42x the fixed-step march's.
|
||||
The fields, the grids, the 0.2 envelope weight and the 2.0 gradient weight are
|
||||
not tuned toward the contracts. An affine probe is one `snoise_grad`, counted at
|
||||
2.0 `snoise` evaluations (an estimate, printed beside the count).
|
||||
|
||||
Consumers: the gamecult.org ground shader (through the GLSL lowering) and
|
||||
Aetheria's nebula raymarch at its CultMath pin bump.
|
||||
|
||||
## Affine forms
|
||||
|
||||
`math.Affine.cs` carries reduced affine forms with one shared symbol: a
|
||||
`float3(x0, a, e)` is the set `x0 + a eps + e delta` with `eps` and `delta` in
|
||||
`[-1, 1]` and `e >= 0` (Messine's AF1, as Gamito and Maddock use it for gradient
|
||||
noise, in one register as an interval is a `float2`). `eps` is shared by every
|
||||
form over one region (for a screen-tile slice, the depth parameter, `z = z_m +
|
||||
h eps`), so `af_sub(x, x)` has `a = 0`; `delta` is private to the form, and
|
||||
every term that cannot be tracked is condensed into `e`. A slice is a
|
||||
`float4(centre, radius)` with a `float3` axis. No op takes a compound type, a
|
||||
reference parameter or a swizzle write, so each mirrors into the HLSL common
|
||||
subset.
|
||||
|
||||
The ops: `af_point`, `af_symbol`, `af_from_iv`, `af_range` (the bridge back to
|
||||
`iv_*`), `af_add`, `af_sub`, `af_neg`, `af_scale`, `af_add_iv`, `af_mul` (the
|
||||
product `|a_x a_y|` and the cross-error terms condensed into `e`, so there is
|
||||
no `af_sqr`), `af_snoise` and `af_fbm`, and the slice pair `af_frustum_axis` and
|
||||
`af_frustum_ball`.
|
||||
|
||||
Rounding. Every op returns a form whose set at each `eps` contains the float32
|
||||
value of its function at every point of its operands' sets at that `eps`. Each
|
||||
op folds its own float32 rounding into `e` as `S 2^-20`, `S` the sum of the
|
||||
magnitudes its roundings are relative to (named in the op's comment): at most
|
||||
`8u S` is needed with `u = 2^-24`, and the factor of two over that is the
|
||||
allowance for a device whose `+` and `*` are faithful rather than correctly
|
||||
rounded. `AffineTests.EveryOpEnclosesItsPoints` evaluates the form's set in
|
||||
double and the function in float32 with no tolerance. Products and `af_from_iv` also
|
||||
add `2^-126`, the most an underflow to a subnormal can lose (`TinyOperandsEncloseTheirPoints`).
|
||||
`af_from_iv` halves its endpoints before it sums and differences them, and `af_range`
|
||||
scales its terms before it sums them, so endpoints near the float maximum do not overflow
|
||||
in the middle of the op; a form that overflows anyway gives `af_range` infinite endpoints,
|
||||
never NaN (`FromIntervalAndRangeHoldAtTheExtremes`). `af_range` finds a NaN endpoint by its bit pattern, because
|
||||
`lo == lo` is a compare dxc marks `fast` and deletes under its default flags. `AbsorptionsArePinned` and
|
||||
`SnoiseFormCarriesItsDocumentedWidth` pin each absorption and `af_snoise`'s float32 allowance both
|
||||
ways: the enclosure tests alone cannot see them on a CPU, where float32 is exact.
|
||||
|
||||
`af_snoise(centre, axis, radius)` encloses `snoise` over `centre + axis eps + w`,
|
||||
`|w| <= radius`, from one `snoise_grad`. It returns the narrower of the
|
||||
Lipschitz form `(n, 0, L R)` and the centred form `(n, g . axis, |g| radius +
|
||||
M R^2 / 2)` (`R = |axis| + radius`, `L = SNOISE_LIPSCHITZ`, `M =
|
||||
SNOISE_HESSIAN`), so its total width is never more than the Lipschitz form's.
|
||||
The centred form is Taylor's theorem, which is why it beats the ball when the
|
||||
radius is small and the axis carries the variation. `af_fbm` compounds octaves
|
||||
as `iv_fbm_ball` does. The float32 evaluation allowance is derived in
|
||||
`af_snoise`'s comment.
|
||||
|
||||
`SNOISE_HESSIAN = 56.050385` is 1.10 times the refined maximum of the spectral
|
||||
norm of the Hessian of `snoise(float3)`, measured by
|
||||
`NoiseBoundTests.MeasureHessian` over 1e6 seeded points (central differences of
|
||||
`snoise_grad`) and refined by ascent from the 1e4 largest: sampled 50.1366,
|
||||
refined 50.9549, witness (-245.19885, -53.01323, -91.47363).
|
||||
`HessianConstantPinsSampledCurvature` fails on a 1% move.
|
||||
`SNOISE2_LIPSCHITZ = 8.117002` is the same for the gradient of `snoise(float2)`
|
||||
(`MeasureLipschitz2`: sampled 7.2575, refined 7.3791, witness (-67.12454,
|
||||
-129.53296), `|snoise2|` at most 0.99964), and it backs `iv_snoise_ball(float2,
|
||||
float)`. Both are empirical with a margin, not proofs. A random ball cannot see a
|
||||
curvature constant 20% low, so `SNOISE_HESSIAN` is defended by
|
||||
`HessianConstantPinsSampledCurvature` and `SnoiseFormEnclosesTheCurvatureWitness`
|
||||
(`af_snoise` at the point where the curvature is realised, along 4,000 directions: a
|
||||
constant of 40 misses there). The margin over the function itself: a scan of the whole
|
||||
289^3 hash period found a realised maximum of 50.58, and bounding each corner's gradient
|
||||
independently over every hash configuration gives 52.22, so 56.05 sits 7% above the
|
||||
configuration bound. A change to the kernels must re-run the measurements.
|
||||
|
||||
The slice. `af_frustum_ball(centreSlope, z0, z1, footprintPerDepth,
|
||||
warpVariation)` returns the centre `(m_c z_m, z_m)` and a radius `z1
|
||||
footprintPerDepth + warpVariation`, with `af_frustum_axis` the axis `(m_c, 1) h`:
|
||||
every ray point at depth `z` is `centre + axis eps(z) + w`. Its derivation
|
||||
and the clamp residual are in the comment. The caller rotates, translates and
|
||||
scales the slice into the noise's frame, and owns the rounding of that.
|
||||
|
||||
The centred warp. A flow-warped sample reads `snoise` at `p + flow(p) shift`. The
|
||||
caller does not enlarge the ball by the whole displacement `|flow| shift`; it
|
||||
moves the centre by the flow at the centre, `c + flow(c) shift`, and widens the
|
||||
radius by the flow's variation across the ball, `r + |shift| rho` with `rho =
|
||||
FlowAmplitude (SNOISE2_LIPSCHITZ FlowFrequency r + a) sqrt 2` (the clamp to the unit
|
||||
disc is nonexpansive), where `a = 2^-14 + L2 2^-20 (|q|_1 + r FlowFrequency)` is the
|
||||
float32 allowance of the flow's two `snoise(float2)` reads (the point `q` plus its offset rounds at the
|
||||
magnitude of `q`, and a float32 flow at two points of a ball differs by more than the exact
|
||||
function can). CultMath owns neither the flow nor the envelope: the warp's
|
||||
centre and variation are the caller's, and a consumer adds them to the slice's
|
||||
`warpVariation` or to the form's radius. With two phases that cross-fade, each
|
||||
phase takes its own centre; one ball serves when the phases' centres coincide
|
||||
(warp 0). `NoiseBoundTests.CentredWarpStaysEnclosed` checks 2,000 balls, radius 1e-3 to
|
||||
500, and fails when `rho` is dropped or halved; `AffineWarpStaysEnclosed` checks
|
||||
3,000 tile slices of `FogField.Slice`, which returns the final `af_snoise` arguments
|
||||
(each phase's moved centre and grown radius, the detail's unwarped set, the shared axis),
|
||||
so the affine bound wires nothing itself: the reach over which the flow is varied is
|
||||
`|axis| + radius`, each phase carries its own growth, and the gate reads each argument's
|
||||
reach. It fails when the reach is the ball's radius or the larger of the two, a phase's
|
||||
growth is dropped or taken from the other phase, the axis or the detail radius is scaled,
|
||||
or `rho` is halved. `AffineCompositionTightensItsRanges` sees the shared-symbol
|
||||
composition itself: the bound is exactly the intersection of the affine arithmetic's piece
|
||||
and the interval composition over the forms' ranges, and the affine piece is the narrower
|
||||
of the two in at least 35% of the open slices (the piece alone is wider in total).
|
||||
|
||||
The deep well is the representative Aetheria case: the camera inside a sun's
|
||||
bowl, looking across. `DeepWellIsTenBandsDeepAndWide` computes the band `G` from
|
||||
`FogField`'s own floor offset, amplitude and noise range (60) and requires the
|
||||
depth (633 at the centre against the rim) and the half-depth radius to be at
|
||||
least `10 G`, every drawn camera sitting inside the well below the rim; a well of
|
||||
depth 150 fails. The shallow wells stay as the printed shallow-well, band-dominated row (c-band).
|
||||
|
||||
The test field's affine bound takes depth as `af_symbol(z_m, h)`, builds `s`
|
||||
affine in it with the height interval, the footprint and the slope-depth
|
||||
product in `e` through `af_add_iv`, takes the noise through `af_frustum_*` and
|
||||
`af_snoise` per phase and for the detail octave, fades by `af_from_iv` and
|
||||
`af_mul`, reads the range with `af_range`, and intersects with the centred
|
||||
interval's bound (both sound). `FogField.Bound` makes no direct `snoise` call.
|
||||
The cull-map render (`DeepWellCullMap`, explicit) writes a PNG when
|
||||
`CULTMATH_WRITE_CULLMAP` names a directory: one deep-well camera at warp `D`,
|
||||
960 x 540 in 8 x 8 tiles, with panels for dense cells per ray under the centred
|
||||
interval, under the affine bound and under the oracle mask (one logarithmic
|
||||
scale) and the transmittance. Its run: 4.23, 3.60 and 2.02 cells per ray on
|
||||
average, 44 the most, mean transmittance 0.278.
|
||||
|
||||
## 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,13 +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.Affine.hlsl",
|
||||
"Shaders\CultMath.Affine.hlsl.meta"
|
||||
"Shaders\CultMath.hlsl.meta"
|
||||
)
|
||||
foreach ($relativePath in $requiredTemplateFiles) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $templateRoot $relativePath) -PathType Leaf)) {
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
#pragma kernel CSMain
|
||||
#include "Packages/org.gamecult.cultmath/Shaders/CultMath.hlsl"
|
||||
#include "Packages/org.gamecult.cultmath/Shaders/CultMath.Interval.hlsl"
|
||||
#include "Packages/org.gamecult.cultmath/Shaders/CultMath.Affine.hlsl"
|
||||
RWStructuredBuffer<float4> Out;
|
||||
[numthreads(1,1,1)] void CSMain(uint3 id : SV_DispatchThreadID)
|
||||
{
|
||||
float2 r = cultmath_iv_add_missing(float2(1, 2), float2(3, 5));
|
||||
float2 a = cultmath_af_range(cultmath_af_add(cultmath_af_point(3), cultmath_af_point(5)));
|
||||
Out[0] = float4(r, a);
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"name": "CultMath.Smoke.Tests",
|
||||
"rootNamespace": "",
|
||||
"references": [ "UnityEngine.TestRunner", "UnityEditor.TestRunner" ],
|
||||
"includePlatforms": [ "Editor" ],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": true,
|
||||
"precompiledReferences": [ "nunit.framework.dll", "CultMath.dll" ],
|
||||
"autoReferenced": false,
|
||||
"defineConstraints": [ "UNITY_INCLUDE_TESTS" ],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
#pragma kernel CSMain
|
||||
#include "Packages/org.gamecult.cultmath/Shaders/CultMath.hlsl"
|
||||
#include "Packages/org.gamecult.cultmath/Shaders/CultMath.Interval.hlsl"
|
||||
#include "Packages/org.gamecult.cultmath/Shaders/CultMath.Affine.hlsl"
|
||||
RWStructuredBuffer<float4> Out;
|
||||
[numthreads(1,1,1)] void CSMain(uint3 id : SV_DispatchThreadID)
|
||||
{
|
||||
float2 r = cultmath_iv_add(float2(1, 2), float2(3, 5));
|
||||
float2 a = cultmath_af_range(cultmath_af_add(cultmath_af_point(3), cultmath_af_point(5)));
|
||||
Out[0] = float4(r, a);
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
using System;
|
||||
using CultMath;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
public class SmokeTests
|
||||
{
|
||||
// Compiles by FindKernel and dispatches on the device; false when the shader does not compile or the output is wrong.
|
||||
static bool CompilesAndRuns(string path)
|
||||
{
|
||||
var cs = AssetDatabase.LoadAssetAtPath<ComputeShader>(path);
|
||||
Assert.IsNotNull(cs, path);
|
||||
int k;
|
||||
try { k = cs.FindKernel("CSMain"); } catch (Exception e) { Debug.Log("SMOKE " + path + " FindKernel threw: " + e.Message); return false; }
|
||||
if (!cs.HasKernel("CSMain") || !cs.IsSupported(k)) { Debug.Log("SMOKE " + path + " kernel unusable"); return false; }
|
||||
var buf = new ComputeBuffer(1, 16);
|
||||
try
|
||||
{
|
||||
cs.SetBuffer(k, "Out", buf);
|
||||
cs.Dispatch(k, 1, 1, 1);
|
||||
var o = new Vector4[1]; buf.GetData(o);
|
||||
Debug.Log($"SMOKE {path} out=({o[0].x},{o[0].y},{o[0].z},{o[0].w}) gpu={SystemInfo.graphicsDeviceType}");
|
||||
return o[0].x == 4f && o[0].y == 7f && o[0].z <= 8f && o[0].w >= 8f && o[0].w - o[0].z < 1e-3f;
|
||||
}
|
||||
finally { buf.Release(); }
|
||||
}
|
||||
|
||||
[Test] public void CSharpIntervalAndAffineWork()
|
||||
{
|
||||
var r = math.iv_add(new float2(1, 2), new float2(3, 5));
|
||||
Assert.AreEqual(4f, r.x); Assert.AreEqual(7f, r.y);
|
||||
Assert.AreEqual(0f, math.snoise(new float3(0, 0, 0)));
|
||||
}
|
||||
|
||||
[Test] public void RealShaderCompilesAndDispatches() { Assert.IsTrue(CompilesAndRuns("Assets/Shaders/Good.compute")); }
|
||||
|
||||
[Test] public void BrokenShaderIsDetected()
|
||||
{
|
||||
LogAssert.ignoreFailingMessages = true;
|
||||
Assert.IsFalse(CompilesAndRuns("Assets/Shaders/Broken.compute"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
<#
|
||||
.SYNOPSIS
|
||||
The release smoke for the tracked org.gamecult.cultmath Unity package: imports it into a
|
||||
scratch project, compiles and dispatches a compute shader that includes the package's
|
||||
CultMath.Interval.hlsl and CultMath.Affine.hlsl on a real graphics device, and proves a
|
||||
deliberately broken shader is rejected.
|
||||
|
||||
.DESCRIPTION
|
||||
Run it on a Windows machine with a GPU, alone, before tagging cultmath-unity-v*. The scratch
|
||||
project is built outside the repository under -WorkRoot, references the package by a file: path,
|
||||
and is deleted afterwards (-Keep leaves it). Exit 0 only when Unity exited 0, this run wrote the
|
||||
results file (earlier ones are deleted before launch), and all three tests of SmokeTests.cs
|
||||
passed: the C# calls, the real shader dispatched with the right output, and the broken control
|
||||
shader detected.
|
||||
|
||||
Never add -nographics. Without a device nothing compiles, and
|
||||
ShaderUtil.GetComputeShaderMessageCount reports 0 messages for a compute shader that calls an
|
||||
undeclared function until FindKernel or Dispatch compiles it, so a message-count check passes a
|
||||
broken shader. SmokeTests.cs compiles by FindKernel, dispatches, and reads the output back.
|
||||
|
||||
For a long run start it detached and poll the log, for example:
|
||||
Start-Process powershell -WindowStyle Hidden -ArgumentList '-File', '<this script>', '-UnityExe', '<Unity.exe>', '-WorkRoot', '<dir>'
|
||||
The script writes unity.pid and unity.log under the scratch root's logs folder (kept after the run).
|
||||
|
||||
.PARAMETER UnityExe
|
||||
Path to Unity.exe of the editor to test with (the cut that released 0.4.0 used 6000.3.24f1).
|
||||
.PARAMETER WorkRoot
|
||||
Directory outside the repository for the scratch project and logs. Default: a new folder under TEMP.
|
||||
.PARAMETER Keep
|
||||
Keep the scratch project instead of deleting it.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $UnityExe,
|
||||
[string] $WorkRoot = (Join-Path $env:TEMP ("cultmath-unity-smoke-" + [guid]::NewGuid().ToString('N').Substring(0, 8))),
|
||||
[switch] $Keep
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if (-not (Test-Path -LiteralPath $UnityExe -PathType Leaf)) { throw "UnityExe not found: $UnityExe" }
|
||||
|
||||
$smoke = $PSScriptRoot
|
||||
$package = (Resolve-Path (Join-Path $smoke '..\..\unity\org.gamecult.cultmath')).Path
|
||||
$repoRoot = (git -C $smoke rev-parse --show-toplevel).Trim()
|
||||
$work = [IO.Path]::GetFullPath($WorkRoot)
|
||||
if ($work.StartsWith([IO.Path]::GetFullPath($repoRoot), [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "WorkRoot must be outside the repository: $work"
|
||||
}
|
||||
$project = Join-Path $work 'project'
|
||||
$logs = Join-Path $work 'logs'
|
||||
New-Item -ItemType Directory -Force -Path $logs | Out-Null
|
||||
foreach ($d in 'Packages', 'Assets\Shaders', 'Assets\Tests') { New-Item -ItemType Directory -Force -Path (Join-Path $project $d) | Out-Null }
|
||||
|
||||
Copy-Item -LiteralPath (Join-Path $smoke 'Good.compute'), (Join-Path $smoke 'Broken.compute') -Destination (Join-Path $project 'Assets\Shaders')
|
||||
Copy-Item -LiteralPath (Join-Path $smoke 'SmokeTests.cs'), (Join-Path $smoke 'CultMath.Smoke.Tests.asmdef') -Destination (Join-Path $project 'Assets\Tests')
|
||||
|
||||
$packageUri = 'file:' + $package.Replace([string][char]92, '/')
|
||||
$manifest = [ordered]@{
|
||||
dependencies = [ordered]@{
|
||||
'com.unity.test-framework' = '1.6.0'
|
||||
'org.gamecult.cultmath' = $packageUri
|
||||
}
|
||||
}
|
||||
[IO.File]::WriteAllText((Join-Path $project 'Packages\manifest.json'), ($manifest | ConvertTo-Json -Depth 4), (New-Object Text.UTF8Encoding($false)))
|
||||
|
||||
$log = Join-Path $logs 'unity.log'
|
||||
$results = Join-Path $logs 'results.xml'
|
||||
# A kept logs folder may hold an earlier run's evidence: nothing from a previous run may decide this one.
|
||||
foreach ($stale in $log, $results, (Join-Path $logs 'unity.pid')) { Remove-Item -LiteralPath $stale -Force -ErrorAction SilentlyContinue }
|
||||
if (Test-Path -LiteralPath $results) { throw "could not clear the previous results file: $results" }
|
||||
$unityArgs = @('-batchmode', '-projectPath', $project, '-runTests', '-testPlatform', 'EditMode',
|
||||
'-testResults', $results, '-logFile', $log)
|
||||
$launched = Get-Date
|
||||
$p = Start-Process -PassThru -FilePath $UnityExe -ArgumentList $unityArgs -WindowStyle Hidden
|
||||
$p.Id | Out-File (Join-Path $logs 'unity.pid') -Encoding ascii
|
||||
"unity pid=$($p.Id) log=$log"
|
||||
$p.WaitForExit()
|
||||
"unity exit=$($p.ExitCode)"
|
||||
|
||||
# Pass needs all of: Unity exited 0; a results file written by this run; the real shader test passed and
|
||||
# the broken control test passed (it asserts that the broken shader is rejected).
|
||||
$ok = $false
|
||||
if ($p.ExitCode -ne 0) { "unity exited non-zero: $($p.ExitCode)" }
|
||||
elseif (-not (Test-Path -LiteralPath $results)) { 'no results file from this run' }
|
||||
elseif ((Get-Item -LiteralPath $results).LastWriteTime -lt $launched) { 'results file predates this run' }
|
||||
else {
|
||||
$cases = @(([xml](Get-Content -LiteralPath $results -Raw)).SelectNodes('//test-case'))
|
||||
$cases | ForEach-Object { "{0} {1}" -f $_.result, $_.name }
|
||||
$want = 'CSharpIntervalAndAffineWork', 'RealShaderCompilesAndDispatches', 'BrokenShaderIsDetected'
|
||||
$passed = @($cases | Where-Object { $_.result -eq 'Passed' } | ForEach-Object { ($_.name -split '\.')[-1] })
|
||||
$ok = (@($want | Where-Object { $passed -contains $_ }).Count -eq $want.Count)
|
||||
}
|
||||
Get-Content -LiteralPath $log -ErrorAction SilentlyContinue | Select-String -CaseSensitive -Pattern '^SMOKE ' | ForEach-Object { $_.Line }
|
||||
|
||||
if (-not $Keep) { Remove-Item -LiteralPath $project -Recurse -Force }
|
||||
if ($ok) { 'SMOKE PASSED'; exit 0 } else { 'SMOKE FAILED'; exit 1 }
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
#ifndef CULTMATH_AFFINE_HLSL
|
||||
#define CULTMATH_AFFINE_HLSL
|
||||
|
||||
// Reduced affine forms with one shared symbol over float3(x0, a, e): the set { x0 + a eps + e delta :
|
||||
// delta in [-1, 1] } at the region's shared eps in [-1, 1], e >= 0. Every cultmath_af_* function mirrors
|
||||
// math.Affine.cs bit for bit; see that file and docs/design.md ("Affine forms") for each op's enclosure
|
||||
// and the float32 rounding allowance it folds into e. Included by CultMath.hlsl after
|
||||
// CultMath.Interval.hlsl, whose CULTMATH_SNOISE_LIPSCHITZ it shares, and after its own
|
||||
// definition of cultmath_snoise_grad.
|
||||
|
||||
// An upper bound on the spectral norm of the Hessian of cultmath_snoise(float3); provenance in
|
||||
// math.Affine.cs (SNOISE_HESSIAN).
|
||||
static const float CULTMATH_SNOISE_HESSIAN = 56.050385;
|
||||
|
||||
float3 cultmath_af_point(float value) { return float3(value, 0.0, 0.0); }
|
||||
float3 cultmath_af_symbol(float x0, float a) { return float3(x0, a, 0.0); }
|
||||
|
||||
float3 cultmath_af_from_iv(float2 interval)
|
||||
{
|
||||
float x0 = interval.x * 0.5 + interval.y * 0.5;
|
||||
float e = interval.y * 0.5 - interval.x * 0.5;
|
||||
return float3(x0, 0.0, e + (abs(x0) + e) * 9.5367431640625e-7 + 1.17549435e-38);
|
||||
}
|
||||
|
||||
// An endpoint that would be NaN is the infinity on its side. NaN is found by its bit pattern: dxc marks
|
||||
// compares fast, so a test that needs NaN to make `lo == lo` false is deleted under its default flags.
|
||||
float2 cultmath_af_range(float3 x)
|
||||
{
|
||||
float r = abs(x.y) + x.z;
|
||||
float w = abs(x.x) * 9.5367431640625e-7 + r * 9.5367431640625e-7;
|
||||
float lo = x.x - r - w;
|
||||
float hi = x.x + r + w;
|
||||
return float2((asuint(lo) & 0x7fffffffu) > 0x7f800000u ? asfloat(0xff800000u) : lo,
|
||||
(asuint(hi) & 0x7fffffffu) > 0x7f800000u ? asfloat(0x7f800000u) : hi);
|
||||
}
|
||||
|
||||
float3 cultmath_af_add(float3 x, float3 y)
|
||||
{
|
||||
float x0 = x.x + y.x;
|
||||
float a = x.y + y.y;
|
||||
float e = x.z + y.z;
|
||||
return float3(x0, a, e + (abs(x0) + abs(a) + e) * 9.5367431640625e-7);
|
||||
}
|
||||
|
||||
float3 cultmath_af_sub(float3 x, float3 y)
|
||||
{
|
||||
float x0 = x.x - y.x;
|
||||
float a = x.y - y.y;
|
||||
float e = x.z + y.z;
|
||||
return float3(x0, a, e + (abs(x0) + abs(a) + e) * 9.5367431640625e-7);
|
||||
}
|
||||
|
||||
float3 cultmath_af_neg(float3 x) { return float3(-x.x, -x.y, x.z); }
|
||||
|
||||
float3 cultmath_af_scale(float3 x, float s)
|
||||
{
|
||||
float x0 = x.x * s;
|
||||
float a = x.y * s;
|
||||
float e = x.z * abs(s);
|
||||
return float3(x0, a, e + (abs(x0) + abs(a) + e) * 9.5367431640625e-7 + 1.17549435e-38);
|
||||
}
|
||||
|
||||
float3 cultmath_af_add_iv(float3 x, float2 interval) { return cultmath_af_add(x, cultmath_af_from_iv(interval)); }
|
||||
|
||||
float3 cultmath_af_mul(float3 x, float3 y)
|
||||
{
|
||||
float x0 = x.x * y.x;
|
||||
float p = x.x * y.y;
|
||||
float q = y.x * x.y;
|
||||
float a = p + q;
|
||||
float e = abs(x.x) * y.z + abs(y.x) * x.z + (abs(x.y) + x.z) * (abs(y.y) + y.z);
|
||||
return float3(x0, a, e + (abs(x0) + abs(p) + abs(q) + e) * 9.5367431640625e-7 + 1.17549435e-38);
|
||||
}
|
||||
|
||||
// The narrower of the Lipschitz form and the centred (Taylor) form over every p = centre + axis eps + w,
|
||||
// |w| <= radius, from one cultmath_snoise_grad at the centre; the allowance for the float32 evaluation is
|
||||
// in math.Affine.cs.
|
||||
float3 cultmath_af_snoise(float3 centre, float3 axis, float radius)
|
||||
{
|
||||
float4 g = cultmath_snoise_grad(centre);
|
||||
float3 gradient = float3(g.x, g.y, g.z);
|
||||
float reach = length(axis) + radius;
|
||||
float lipschitz = CULTMATH_SNOISE_LIPSCHITZ * reach;
|
||||
float a = dot(gradient, axis);
|
||||
float remainder = length(gradient) * radius + 0.5 * CULTMATH_SNOISE_HESSIAN * reach * reach;
|
||||
float allowance = 6.103515625e-5 + 2.44140625e-4 * reach
|
||||
+ CULTMATH_SNOISE_LIPSCHITZ * 9.5367431640625e-7 * (abs(centre.x) + abs(centre.y) + abs(centre.z) + reach);
|
||||
bool centred = abs(a) + remainder < lipschitz;
|
||||
float slope = centred ? a : 0.0;
|
||||
float e = (centred ? remainder : lipschitz) + allowance;
|
||||
return float3(g.w, slope, e + (abs(g.w) + abs(slope) + e) * 9.5367431640625e-7);
|
||||
}
|
||||
|
||||
// Octave sum of cultmath_af_snoise, compounding frequency and amplitude as the value-and-gradient fBm does.
|
||||
float3 cultmath_af_fbm(float3 centre, float3 axis, float radius, int octaves, float lacunarity, float gain)
|
||||
{
|
||||
octaves = clamp(octaves, 0, 16);
|
||||
float amplitude = 1.0;
|
||||
float frequency = 1.0;
|
||||
float3 sum = float3(0.0, 0.0, 0.0);
|
||||
for (int i = 0; i < octaves; i++)
|
||||
{
|
||||
sum = cultmath_af_add(sum, cultmath_af_scale(cultmath_af_snoise(centre * frequency, axis * frequency, radius * abs(frequency)), amplitude));
|
||||
frequency *= lacunarity;
|
||||
amplitude *= gain;
|
||||
}
|
||||
|
||||
return sum;
|
||||
}
|
||||
|
||||
// The axis of a screen tile's slice over the depth segment [z0, z1]; camera frame of
|
||||
// cultmath_iv_frustum_ball.
|
||||
float3 cultmath_af_frustum_axis(float2 centreSlope, float z0, float z1)
|
||||
{
|
||||
float h = (z1 - z0) * 0.5;
|
||||
return float3(centreSlope.x * h, centreSlope.y * h, h);
|
||||
}
|
||||
|
||||
// The affine slice of a screen tile over [z0, z1]: centre xyz, radius w. Derivation in math.Affine.cs.
|
||||
float4 cultmath_af_frustum_ball(float2 centreSlope, float z0, float z1, float footprintPerDepth, float warpVariation)
|
||||
{
|
||||
float zm = (z0 + z1) * 0.5;
|
||||
float h = (z1 - z0) * 0.5;
|
||||
float cx = centreSlope.x * zm;
|
||||
float cy = centreSlope.y * zm;
|
||||
float spread = (abs(centreSlope.x) + abs(centreSlope.y) + 1.0) * h;
|
||||
float radius = z1 * footprintPerDepth + warpVariation;
|
||||
radius += (abs(cx) + abs(cy) + abs(zm) + spread + radius) * 9.5367431640625e-7;
|
||||
return float4(cx, cy, zm, radius);
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 26f28fce38e14eed8b9b5dbd758e4ffc
|
||||
ShaderIncludeImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -1,133 +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 = 7.9640074;
|
||||
|
||||
// Lipschitz constant of cultmath_snoise(float2); provenance in math.Interval.cs (SNOISE2_LIPSCHITZ).
|
||||
static const float CULTMATH_SNOISE2_LIPSCHITZ = 8.117002;
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
// The 2D overload: [n - L2 r, n + L2 r] intersected with [-1, 1], n = cultmath_snoise(centre). One snoise, value only.
|
||||
float2 cultmath_iv_snoise_ball(float2 centre, float radius)
|
||||
{
|
||||
float n = cultmath_snoise(centre);
|
||||
float e = CULTMATH_SNOISE2_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:
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue