Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Meta Vens
cc71f17b7b Say what the cell-mass test proves, and what it does not
Soul: a constant erf passes it. It is one necessary property, not correctness.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 21:43:52 +02:00
Meta Vens
f3aae704e9 Put msquic.dll where the QUIC native test build's loader needs it (Windows)
Running QUIC Cut 3's Windows leg exposed the same class of defect Linux
had at 5533c34: CULTMESH_QUIC_BUILD_TESTS has never had its own packaging
step on Windows, and build-quic-native.ps1's copy of msquic.dll only
lands in the final packaged output directory, never beside the test
binary in the CMake build tree.

Unlike the Linux failure, this one did not fail loudly. gamecult_mesh_quic_native.dll
imports msquic.dll by name, and Windows DLL search falls through to PATH
when the importing binary's own directory doesn't have it. This
workstation has an unrelated msquic.dll on PATH from the Windows
Performance Toolkit (529,480 bytes, unversioned) that is not the pinned
Microsoft.Native.Quic.MsQuic.OpenSSL 2.5.9 build (4,181,856 bytes). Every
asserts-build scenario ran clean against that wrong binary before this
fix, with no error to say so.

Mirrors the Linux POST_BUILD fix: CMake already receives MSQUIC_ROOT, so
the test target now copies msquic.dll from MSQUIC_ROOT/bin/x64 beside
itself instead of leaving the loader to guess.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:43:21 +02:00
Meta Vens
6e8362b7dc List erf/erfinv in the README's math vocabulary
They shipped without ever being added to the public surface list.
Scalar-only is noted explicitly: every other scalar in math.cs has
float2/3/4 overloads, erf/erfinv do not, and that stays a separate
scope decision since the only consumer (Aetheria fire control) is
scalar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 21:30:07 +02:00
Meta Vens
4cdc02b200 Give erf/erfinv real accuracy, domain and monotonicity tests
The round-trip test (ErfinvInvertsErf) was the only defense of
erfinv's accuracy, and it is blind by construction: it can only prove
erf and erfinv agree with each other, not that either is correct. A
hand mutation of a single tail coefficient degraded erfinv's worst
error 300-400x (5.0e-7 to 2.0e-4) and every committed test still
passed. Added ErfinvMatchesReferenceValues, pinned against reference
values from bisecting the platform's double-precision erf (Python's
math.erf, glibc-backed) rather than this package's own erf, out to
|y| = 0.9999, at the same 2e-6 absolute bar erf's own reference test
carries. Expected values are computed for the actual float32-rounded
input, not the decimal literal, because erfinv's derivative diverges
near +-1 and that rounding alone moves the true answer by more than
the error bar if ignored.

Verified against three hand mutations, each restored byte-exactly
afterward:
- erfinv tail leading coefficient 2.83297682f -> 2.83317682f: killed
  (4 assertions failed).
- erfinv tail second coefficient 1.00167406f -> 1.00187406f: killed
  (4 assertions failed).
- erf's `p` coefficient 0.3275911f -> 0.3275930f: survives. Soul
  measured this one 2.3x worse but still under the 2e-6 bar the spec
  pins; this is not a fixture gap, it is the mutant staying inside the
  accuracy envelope the tests are supposed to allow. Recorded here
  rather than tightening the bar past what the spec asked for.

Added ErfinvAtDomainEdgesAndBeyond, pinning the domain and edge
behavior from the matching source comment: erfinv(1) = +infinity,
erfinv(-1) = -infinity, and NaN outside [-1, 1] including +-infinity
and NaN input.

Deleted ErfinvIsMonotonicallyIncreasing: it asserted strict
monotonicity, which erfinv does not have. Scanning every representable
float32 in [0, 0.999999] finds 9662 backward steps, first at
y = 0.00022214651, worst drop 5.960464e-8 (one ULP at that scale) --
invisible only because the old fixture stepped by 0.001. erf has the
same shape: 100 backward steps over [0, 4.5], worst drop 1.192093e-7.
Replaced it with two tests: ErfinvIsMonotonicWithinFloatingPointTolerance,
pinning monotonicity to within 1e-6 (comfortably above the measured
noise floor), and ErfCellMassesStayNonNegativeAcrossRealisticGeometries,
the consumer-level property that actually matters for fire control:
differences of adjacent erf values (cell probability mass) stay
non-negative across 16/64/256/1024/4096 cells over +-4 sigma, plus a
tight 1 sigma spread and a very tight 0.25 sigma spread.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 21:30:07 +02:00
Meta Vens
fc9ce970b8 Fix erfinv's sign inversion at the domain edges
math.erfinv(1.0f) returned -infinity and erfinv(-1.0f) returned
+infinity: at value = +-1, w = -log((1-value)(1+value)) = -log(0) =
+infinity, the tail branch's leading coefficient is negative, and
`return p * value` delivered the inverted infinity. erf saturates to
exactly 1.0f at 5.543 sigma, so any interval edge beyond that reaches
this bug in ordinary use.

Guard both edges explicitly before the polynomial runs: erfinv(1) is
now +infinity, erfinv(-1) is -infinity, and anything strictly outside
[-1, 1] (including +-infinity) is NaN, which is the correct answer and
was already the fallthrough behavior. Documented the domain and the
edge behavior in a comment, matching the house style `sign` already
uses at math.cs:84. The approximation itself is unchanged.

Also recorded the true measured accuracy beside erf and erfinv: worst
absolute error 6.621e-7 and 5.066e-7 respectively, both inside the
2e-6 bar this package's tests pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 21:30:07 +02:00
Meta Vens
5533c34783 Make the QUIC native test build put libmsquic.so.2 where the loader needs it
The README's Linux test build has never worked: both the bridge and the
test executable carry INSTALL_RPATH "$ORIGIN" and expect libmsquic.so.2
beside them, but only scripts/build-quic-native.sh's packaged output
directory ever got that copy. A test build produced a binary that died
at once with "cannot open shared object file". Fix it where the
knowledge already lives instead of adding a README workaround: CMake
already receives MSQUIC_LIB_DIR, so the test target now copies
libmsquic.so.2 beside itself in a POST_BUILD step.

While verifying the README's own worked example end to end, found a
second, independent break in the same docker block: MSQUIC_INCLUDE_DIR
and MSQUIC_LIB_DIR were passed as paths relative to the repository
root, but CMake resolves a relative cache-variable path against the
CMakeLists.txt that consumes it, not the invocation directory, so the
compile failed on a missing msquic.h before it ever reached the
loader. Made both paths absolute with $(pwd) and documented why.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:15:45 +02:00
Meta Vens
1bf581d68b Record the QUIC Linux leg green, and a README path that never worked
All ten scenarios pass under asserts and all four required under release, on
Yggdrasil, in the committed debian:13 image. Worst overshoot anywhere was 4 ms
against a 400 ms tolerance. F1 is closed on Linux: the build that ships now
runs the timeout rule.

Running the README's own worked example is what found the second thing. It
cannot work: the test binary carries an $ORIGIN rpath and nothing puts
libmsquic beside it for a test build. That example was written in the same
commit that deleted the harness, so the working path was removed and the
broken one published together.

Two things stay open. Windows still owes its release configuration for the
same reason Linux did. And F5's stress wants sixteen CPU burners on the
platform where burners are now forbidden, which is the operator's call rather
than mine to route around.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 21:12:05 +02:00
Meta Vens
cbff486b1e Record where Soul's selection probes live, and that losing them is acceptable
The pass ran against 52082ec. Its nine hand probes, including the Rust
both-hops probe that proved S-3 on a matched pair, sit unpushed on soul-probe
in a scratch clone. Probes are throwaway by doctrine, so the pointer says both
where they are and that the findings recorded in this map are the durable
copy. Fix batch 4's tests have to reproduce the behaviour independently
anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 21:00:23 +02:00
Meta Vens
c5c4c073b4 Correct the record: QUIC batch 8 landed before the retirement
My previous commit rewrote the batch-8 rulings as if the batch were still
owed. It was not: it landed at 5646ff7 and the harness went at 796433f, both
on hands/cultmath-erf. The map was behind the code, not the other way round.

What that changes: the release configuration and the seven mutation entries
landed inside the harness and left with it two commits later, so the release
build is owed again as a plain build. The scenario runner and every seam
survived untouched and now run as direct arguments to the binary.

Cut 3's only outstanding work is the Linux leg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:55:37 +02:00
Meta Vens
7889515993 Rewrite QUIC batch 8 under the harness retirement
Four of the six rulings for the eighth fix batch named harness entries. The
operator retired the hand-written mutation harness the same day, with no
fallback, so those clauses are dead text. What each ruling protected is not:
the release configuration, the seam wrapping the whole duration expression,
pollhammer against a runtime holding live objects, the zero and negative
timeout bounds, and closerace surviving starvation all stand as behaviour.

The bridge is C++ and no ecosystem mutation tool is wired here, so it gets no
committed mutation evidence at all. Its rules are pinned by scenarios at the
layer that decides them; a mutant is something Soul tries by hand and throws
away.

F5's 16 burners now run on Yggdrasil. Those burners are what froze the
operator's workstation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:54:00 +02:00
Meta Vens
c244003339 Record the Stryker triage: a dozen live survivors on this cut's own rules
The triage landed on the fifth attempt. Most of the 85 survivors are message
strings; about a dozen are real, and they sit on the rules the cut exists to
enforce: the alias-numeric refusal degrades to "only if every schema agrees",
a half-blank cites.target stops being refused, the index-alias role naming is
untested at all four sites, the row tiebreak and EdgesFor's ordering are
pinned by nothing, and ComputeDigest's hop branches have no coverage at all.
The equivalent mutants are named so nobody chases them twice.

R-AA's triage half is done; what it now owes is a behavioural test per live
survivor.

Soul also withdrew its own claim that C#'s cursor digest does not sort its
lists. It does, at CultNetSelectionEvaluator.cs:637.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:52:20 +02:00
Meta Vens
a9bb08f9db Record Soul's corrections: the job was not wedged, and the Stryker triage is owed
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:49:08 +02:00
Meta Vens
618b7b562b Record the whole-cut Soul pass: the hop is keyed wrong and the runtimes disagree
Cut 1 may not merge. Row identity becomes (schemaId, recordKey), the hop
behaves identically in both runtimes, the error message obeys its own schema,
and cultnet-rs becomes mutable by cargo-mutants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:44:40 +02:00
Meta Vens
b688cb2091 Cut 12.0: test erf and erfinv behaviour
Covers the pinned accuracy bar against tabulated erf values (0, +-0.5,
+-1, +-2, +-3, within 2e-6), odd symmetry, the +-1 limits, erfinv's
round trip through erf over y in [-0.999, 0.999], and erfinv monotonicity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:42:57 +02:00
Meta Vens
87b95aefa5 Cut 12.0: add erf and erfinv to CultMath
Fire control's Gaussian scatter needs erf for a cell's probability mass
and erfinv to draw the lateral offset, both called per shot. Abramowitz-
Stegun 7.1.26 for erf (stated max abs error 1.5e-7), Giles 2010 (GPU
Computing Gems) for erfinv. Neither is an HLSL intrinsic, so there is no
shader-parity obligation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:42:57 +02:00
Meta Vens
1bcc19298f Record C# pass B, and correct Self's cursor-parity premise
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:23:38 +02:00
Meta Vens
f1aa105472 Record R-N in both runtimes, and the MessagePack key-order trap
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:02:49 +02:00
Meta Vens
806c05a700 Record fix batch 3's landed pieces and what is still pending
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 19:37:35 +02:00
Meta Vens
796433f2d4 Delete the mutation harness; keep the QUIC scenarios runnable directly
Better to have nothing than a harness that punishes refactoring
(operator ruling, 2026-09-22). Remove scripts/mutate-cultmesh.mjs
(1451 lines) and its Node dependency from the Linux QUIC dev image.

The behavioural coverage stays: native/GameCult.Mesh.Quic.Native's
scenario runner and its seams (waitseam, pollhammer, holdtimeout,
zerotimeout, closerace, and the rest) are untouched. The README and
the dev Dockerfile now document building with
-DCULTMESH_QUIC_BUILD_TESTS=ON -DCULTMESH_QUIC_DEBUG_ASSERTS=ON and
running each scenario as a direct argument to the built binary,
instead of through the harness.

docs/cultnet-selection-cut.md and docs/typescript-quic-realtime-cut.md
still name the harness; they are Self's campaign history and stay as
written.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:41:28 +02:00
Meta Vens
d0480a7d83 Record the operator's acceptance: Q-BP2 A, the title rule, R-D, the Q-J door
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 13:55:04 +02:00
Meta Vens
5646ff7351 Land the QUIC Cut 3 eighth fix batch: release config, whole-duration seam, pollhammer live objects, zero-timeout bound, closerace starvation fix
Self's ruling of 2026-09-22 named six findings against Soul's eighth pass.
This lands the code side of all six:

- The wait-record seam (CULTMESH_QUIC_DEBUG_RECORD_WAIT) now wraps the whole
  duration handed to `wait_for`, not the raw `timeout_ms` before it is
  converted to milliseconds, so arithmetic applied after that conversion can
  no longer live outside what the seam records. Its "unset" sentinel moves to
  a separate `debug_wait_recorded` flag so a recorded -1 could never be
  confused with nothing recorded.
- `pollhammer` also runs against a runtime holding a live connection to an
  unroutable address (RFC 5737 TEST-NET-1), a best-effort stream on it, and a
  real (never-connected) listener, closing the gap where the wait's predicate
  was only ever exercised on an empty runtime.
- A new `zerotimeout` scenario pins that `timeout_ms <= 0` returns within
  50 ms, probing 0, -1 and INT32_MIN.
- `closerace`'s settle no longer sleeps a guessed duration: where the
  development seam exists it waits for `debug_peak_calls` to reach the poller
  count (reset before any poller thread exists, so nothing can zero a count a
  poller already raised) before a short fixed margin closes the last gap
  between being counted and being inside the condition wait.
- `kGenerousLateToleranceMs` narrows from 800 ms to 400 ms so the wall-clock
  scenarios can catch arithmetic applied outside the seam's own argument,
  which `waitseam`'s equality check structurally cannot see.

scripts/mutate-cultmesh.mjs gains a plain `release` configuration on both
platforms (CULTMESH_QUIC_DEBUG_ASSERTS=OFF, CMAKE_BUILD_TYPE=Release) running
`polltimeout`, `pollbusy`, `pollhammer` and `zerotimeout`, and seven new
mutation entries: R1 (the release macro divides the duration by 10), W1-W3
(600 ms added, a 500 ms floor, and a 1000/909 scale, each applied outside the
seam's own argument), P1 (the idle predicate also ends on a live connection),
and Z1-Z2 (a 500 ms sleep and a 300 ms spin on a non-positive timeout). Two
stale comments are corrected: the scenario list above `assertScenarios` now
names `pollhammer` among the clock-sensitive entries, and a historical "1300"
probe value that no longer matches the test file is removed. The header's
section 7 intro now says the seam serves both the quiesce and the wait
recording, not quiesce alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 12:27:56 +02:00
Meta Vens
a1309c638c Record the Rust alias port: row-for-row with the reference
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:21:08 +02:00
Meta Vens
8ef170b847 Record the early Soul pass's new findings and rule fix batch 3
Refusals become wire messages, cursors are keyed, one id reaches the
authorizer, and v0 lowering preserves v0's pre-cut answers exactly,
correcting the earlier ruling that lowered an empty list to no filter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:06:11 +02:00
Meta Vens
9b88cb4e5c Record Soul's eighth pass: the release build never runs the rule it ships
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 11:43:50 +02:00
Meta Vens
722859550d Record the shared fixture and rule the alias defect: the C# reference's rule is the rule
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 11:34:57 +02:00
Meta Vens
e3dbb640f7 Correct the f32 3e20 example: the Rust Hands was right
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 10:27:57 +02:00
Meta Vens
0ab91d0298 Record the C# half of the selection fix batch, and what it left undone
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 09:42:17 +02:00
Meta Vens
451ecc2d5e Record the whole-cut Soul pass: Cut 1 does not merge, and rule the fix batch
The C# reference never serves v1, cited returns the wrong edges in both
runtimes, and the runtimes disagree on floats, schema aliases and ordering.
Floats render as their exact decimal expansion, flagged for the operator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 08:12:05 +02:00
Meta Vens
a704a77ec3 Correct the orphan-rule claim, and add two pre-merge items for Huginn
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 07:49:05 +02:00
Meta Vens
070fac062d Record the seventh fix batch: the wait is observed where it is decided
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 07:04:56 +02:00
Meta Vens
0a49f869f2 Record selection commit 4: parity vectors, and the hop they leave out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 06:57:24 +02:00
Meta Vens
d9629a8e26 Record the selection commit 2 fix batch and Rust commit 3
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 06:17:54 +02:00
Meta Vens
b8fad13867 Rewire the QUIC harness's native entries onto the wait seam
cultmesh_quic_native.cpp's wait_for callsite now reads
std::chrono::milliseconds(CULTMESH_QUIC_DEBUG_RECORD_WAIT(timeout_ms)) instead
of std::chrono::milliseconds(timeout_ms), so every existing entry anchored on
the old spelling is repointed at the new one, with the loosening's derivation
placed inside the macro's argument so the seam's record and the wait's own
duration stay the same evaluation.

Soul's seventh pass named eight derivations that were each the identity at
every wall-clock probe the sixth pass's tolerances could afford: a floor at 50
with no early check, a 16 ms and a 40 ms quantum, a wait that doubles past a
runtime's twelfth poll, a clamp at 7275, an offset of 25, a floor of 40, and a
6% scale. All eight are new entries here, killed by waitseam's exact mismatch
rather than by timing.

The seam itself gets a revert (it never records, so waitseam reads -1
forever) and a loosening (the record moves to an unconditional statement
ahead of the branch, reading the host's raw argument on every call instead of
only what the bridge hands its wait — indistinguishable at every value that
reaches a real wait, and caught only at timeout_ms <= 0, where the bridge's
own guard never waits and the honest seam must stay untouched).

S1's two predicate survivors get names and entries: K1 gives up after 64
wakes, K2 ends the wait on any wake once runtime->error is non-empty. Both
are new pollhammer targets; the existing "no predicate" revert is S1's
"returns on any wake" and needed no new entry, only its anchor fixed. S5 gets
a second hold-guard entry at the stated 1500 ms ceiling, alongside the
existing 1000 ms one.

assertScenarios gains waitseam and pollhammer.

Every added/changed entry was hand-verified against a manual win32-x64 build
before this commit: waitseam killed the seam's revert, its loosening, and a
representative +25 ms offset by exact mismatch; holdtimeout killed the S5
1500 ms guard by a 1.4 s overshoot; pollhammer killed K1, K2 and the
no-predicate revert within tens of milliseconds. Each mutation was applied by
hand, run, and hand-reverted with a diff against the working tree before the
next.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 05:37:05 +02:00
Meta Vens
66830e92be Record the wait, not its shadow: a dev-only seam for cultmesh_quic_next_event
Self's ruling of 2026-09-22 called it after seven Soul passes: a wall-clock
probe against cultmesh_quic_next_event's timeout can never tell the host's
argument from a clamp, floor, round, scale or later-poll fault that happens to
be the identity at the probe's own value, because there is always another
function that matches. Move the observation to where the rule is decided.

Add cultmesh_quic_debug_last_wait_ms / _reset_last_wait_ms, built exactly like
the existing hold seam: gated behind CULTMESH_QUIC_DEBUG_ASSERTS, absent from a
shipped build. CULTMESH_QUIC_DEBUG_RECORD_WAIT wraps the literal timeout_ms
handed to the condition wait as a pass-through, so what is recorded and what is
actually waited on are the same evaluation and cannot diverge except by
changing what the macro wraps.

Two scenarios:
- waitseam asserts the recorded wait equals the host's own argument, exactly,
  over 0, 1, 15, 16, 39, 40, 999, 1000, 1001, 7300 and INT32_MAX (the header
  states no narrower maximum), and over 40 polls on one runtime past the point
  every prior scenario stopped asking. timeout_ms <= 0 never reaches the wait
  at all, so its probe checks the honest converse — the seam stays untouched —
  which is what catches a seam that records the raw argument on every call
  instead of only the value it actually hands its wait.
- pollhammer runs one thread hammering every gate-touching entry point that
  cannot itself queue an event, in a yield loop, against another thread's idle
  poll, once on a clean runtime and once after a refused listener_open has
  left runtime->error non-empty. The idle poll must still stay for its
  timeout.

polltimeout, pollbusy and holdtimeout stay as generous-margin proof that the
recorded wait is really waited, not as the precision instrument they used to
have to be: the fastest-of-ten mechanism, the 15 ms probe's tight tolerance,
and the stated-limits table they rested on are gone, because waitseam now
kills every clamp, floor, round, scale, offset and later-poll mutant by
equality regardless of timing. The Windows priority raise these three
scenarios carried is gone with them: a flat 800 ms late tolerance already
covers the worst overshoot the old table recorded (127 ms at normal priority
under load), so raising the process above the load bought nothing the new
margin does not already have. holdtimeout's long probe moves to 1600 ms, above
the stated 1500 ms ceiling on the hold guard, with the hold kept armed to
3000 ms so a guard that parks a naturally-timed-out call anyway overshoots by
well more than the margin.

Verified locally on win32-x64: waitseam and pollhammer pass against the
unmutated bridge; a seam-loosening mutant (recording the raw argument outside
the wait's own branch) fails waitseam's 0 ms probe; an inline +25 ms offset on
the wait's argument fails waitseam by exact mismatch; the S5 hold-guard mutant
(timeout_ms > 1500) fails holdtimeout by 1.4 s overshoot; K1 (++wakes > 64), K2
(!error.empty()) and a no-predicate revert all fail pollhammer's idle poll
within tens of milliseconds. All were hand-reverted and diffed clean against
the working tree before this commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 05:33:19 +02:00
Meta Vens
bc9fbb4fe3 Record Soul's seventh pass, and move the wait's observation to where it is decided
Seven passes on one rule, each killing the last Soul's functions and each
followed by another that is identity at every probe. Wall-clock observation
from outside cannot converge on an arithmetic rule. A dev-only seam records
the computed wait, and equality kills the whole family.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 05:10:25 +02:00
Meta Vens
1fbb28ae90 Record Soul on selection commit 2 and rule the fix batch
The reconcile tests lost what they proved, the shard snapshot still loops on
its own, and the public options type silently drops every term v0 cannot
carry. Empty lists are refused at the one door, Mesh refuses what it cannot
send, and the dead identity code is deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 05:02:05 +02:00
Meta Vens
0402ab88bc Record the Mesh collapse and correct two claims in its report
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 03:55:43 +02:00
Meta Vens
b7c641a681 Specify Q-J's canonical decimal, and sweep the float64 text the ruling left live
The operator ruled decimal strings on 2026-09-17 and the map recorded it in
section 16, but section 2 and D8 still said float64 and nothing specified the
canonical form or the comparison. Commit 1 built what section 2 said. The Rust
Hands stopped on the conflict before writing a line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 03:17:18 +02:00
Meta Vens
3d86bfacec Record selection commits 0-1, the fourth engine, and the ledger miss
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 02:35:34 +02:00
Meta Vens
907f216456 Record the sixth fix batch, accept the Windows priority raise, dispatch Soul 7
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 02:21:57 +02:00
Meta Vens
ff72f16ed6 Give the hold's guard a mutant that reads the timeout, and reach it
Soul's sixth pass (N4): `(woken || timeout_ms > 1000) && HELD` survived both
targets. The two existing hold entries only rearrange the guard's syntax,
and `holdtimeout`'s single 150 ms poll sits below any threshold such a guard
would carry.

`holdtimeout` now runs two idle polls under the armed hold, 150 ms and
1300 ms, and holds for 2200 ms. The new entry is that guard. Compiled in on
both targets, the 1300 ms poll is parked until the release and returns at
about 2200 ms, and every other scenario stays green. The harness kills it
on both targets.

With the two polls running together, the first poll's exit also wakes the
second. That gives the asserts build a second multi-threaded witness for the
predicate: the N1 revert and read-what-woke mutants die here as well as on
`pollbusy`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 02:01:41 +02:00
Meta Vens
4ffa4c9628 Pin the wait's predicate with a host that has a second thread
Soul's sixth pass (N1): every CallScope destructor wakes every waiter, so
without the predicate a poll leaves whenever another host thread makes any
call. Every scenario had a single-threaded host, and dropping the predicate
survived the whole matrix on both targets.

`pollbusy` is a public-API scenario and needs no development seam. A second
host thread calls `cultmesh_quic_last_error` every 20 ms for twice the
poll's timeout while an idle `poll(1000)` waits. It checks the timing first.
It then checks the other thread's call count, so a poll that passed without
ever being woken reads as the fixture failing.

Three harness entries:
- revert: no predicate, `woken` taken from `cv_status`;
- loosening: the wait reads what woke it and does not wait again;
- loosening: a hand-written loop that restarts the whole timeout on every
  wake. Its lateness is a function of the input.

Each was compiled in and run through every scenario on both targets:
- revert and read-what-woke: `pollbusy` returns at 0 ms;
- re-arm: `pollbusy` returns at about 3000 ms.
The earlier scenarios all stay green under all three. The harness kills all
three on both targets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 02:01:27 +02:00
Meta Vens
37967e8b9b Probe the host's timeout at 15, 200 and 7300 ms, and stress the tolerance
Soul's sixth pass (N2): with probes at 150, 200 and 5000 ms, `min(t,5000)`,
`min(t,4990)`, `t*11/10` and `t+70` survived both targets, `max(t,220)`
survived linux-x64, and `max(t,250)` and `t+80` died by 22 and 2 ms. "5000 is
above any plausible ceiling" was wrong: 5000 is a round ceiling.

- A 15 ms probe, asked ten times with the fastest checked against 30 ms. It
  is the only place a floor or an added constant shows. Scheduler delay only
  adds, so noise has to delay all ten attempts to fail the bridge.
- The long probe is 7300 ms, which is not a round number.
- A single poll gets 60 ms plus a sixteenth of the timeout.
- Each timed scenario prints its worst overshoot per probe.
- Three harness entries: a round clamp `min(t,5000)`, a floor
  `max(t,100)` and an offset `t+100`.

Stressed with two busy threads per logical CPU (16 on 8), five rounds of
every timed scenario on both targets. At normal priority, Windows put a
single poll up to 127 ms late and the fastest of ten 15 ms attempts up to
87 ms late, and a 45 ms tolerance failed the unmutated bridge in four rounds
of five. The timed scenarios therefore raise their own process to
HIGH_PRIORITY_CLASS on win32-x64, which needs no privilege. With that, the
worst single-poll overshoot was 15 ms on win32-x64 and 41 ms on linux-x64,
at normal priority there. The fastest 15 ms attempt was 0 ms late on both.

The limit in source is measured, not asserted. Each boundary mutant was
compiled in and run on both targets:
- `t+25` survived and `t+40` died;
- `max(t,40)` survived and `max(t,50)` died, on linux-x64 by 5 ms;
- `t*106/100` survived and `t*108/100` died;
- `min(t,7285)` survived and `min(t,7275)` died.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:59:37 +02:00
Meta Vens
b3d9cf7821 Tell an existing Windows clone how to get its build script back to LF
`*.sh text eol=lf` reaches new clones only. A clone checked out before the
rule keeps scripts/build-quic-native.sh with carriage returns after pulling,
because the blob did not change: git neither rewrites the file nor reports it
modified, and the container still dies on the interpreter `bash\r`.

The README now gives the repair where a newcomer meets the container path,
and .gitattributes points to it. Verified on clones made at 367c9bc and moved
to c25de5a, from Git Bash and from Windows PowerShell 5.1: 109 CR bytes
before, none after, status clean. The line uses `;` so it runs in both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:03:51 +02:00
Meta Vens
c25de5a895 Record Soul's sixth pass: the wait's predicate is pinned by nothing
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 00:50:29 +02:00
Meta Vens
7cd5a2b63f Record the fifth fix batch landing and dispatch Soul's sixth pass
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 00:23:24 +02:00
Meta Vens
fa327a0627 Name the CultMesh MCP bridge as a priority campaign
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 05:37:00 +02:00
Meta Vens
089859deb4 Write and enforce the semver policy, per operator ruling on 1.0.60
1.0.60 removed public API (CultInspectorAssetPath) under a patch bump; its
own changelog says "Breaking" and the version number lied. Operator ruling:
semver is policy, strictly, for every published package family.

docs/semver-policy.md states what counts as breaking per surface (managed
API, Unity inspector attributes, CultCache wire format/store compatibility,
CultNet protocol, native bridge exports), the 0.y.z pre-1.0 convention, tag
naming, and required changelog shape. Records the 1.0.60 scar as history;
does not renumber it.

scripts/check-changelog-semver.mjs is the mechanical check: refuses a
changelog "### Breaking" section under too small a bump, and refuses a
version that skips, reverses, or repeats the previous published tag. Skips
re-litigating a version that is already tagged (rebuilding immutable
history is not a release action). Wired into publish-packages.yml for the
four npm/PyPI packages, and into build-unity-package.ps1 (cultlib),
packages/cultmath/scripts/build-unity-package.ps1 (cultmath), and a new
scripts/verify-caching-unity-release.ps1 (caching.unity has no build script
of its own to hook into).

18 node:test cases cover the required table (breaking+major/minor/patch,
non-breaking, 0.y.z, skip, downgrade) plus CLI-level git-tag resolution.
Hand-mutated each rule (bump classification, pre-1.0 lane requirement,
breaking-heading detection, breaking early-return, already-published skip)
and confirmed each kill, then restored byte-exact. No mutation harness is
committed; CultLib has none today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:03:22 +02:00
Meta Vens
45c2f4006f Say the OpenSSL bridge ships with this release, and pack the changelog
Soul S1: the 1.0.60 entry claimed the OpenSSL MsQuic bridge had shipped
since 1.0.59; git says both blobs first appear in this release. S2: the
package build staged package.json and README only, so the produced package
and the tracked template differed by the changelog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 20:40:12 +02:00
Meta Vens
ef81c96094 Renormalize CultAssetGuidKey.cs to CRLF and rebuild dependent plugin DLLs
CultAssetGuidKey.cs (and the test file added in 0b88706... no, 6c7cfa7) had a
literal LF working-tree checkout under core.autocrlf=true / text=auto, so a
build run from a stale bypassed-checkout copy of this worktree embedded a
source checksum a fresh clone's CRLF checkout would never reproduce. The
tracked git object was already normalized; only the working-tree bytes in
this worktree were wrong. Restored the CRLF checkout (git add reports no
diff against the index for CultAssetGuidKey.cs itself), then rebuilt with
scripts/build-unity-package.ps1 -UpdateTemplate. GameCult.Caching(.pdb) and
every managed plugin that depends on it (GameCult.Caching.MessagePack,
GameCult.Mesh, GameCult.Mesh.Quic.Native, GameCult.Networking,
GameCult.Networking.WebSockets) picked up new MVIDs/PDB checksums as a
result; the native gamecult_mesh_quic_native.dll/msquic.dll are untouched
since the native build did not run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 20:12:52 +02:00
Meta Vens
9847a96070 Track the new CHANGELOG.md.meta files (force-added like other tracked .meta)
*.meta is gitignored repo-wide; every tracked Unity .meta file, these two
included, is force-added on purpose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 20:11:48 +02:00
Meta Vens
0b8870677e Add CHANGELOG.md to both Unity packages (UPM convention)
CultLib 1.0.60: CultInspectorAssetPath removed for CultInspectorAssetGuid +
CultAssetGuidKey (breaking for inspector annotations), and the native QUIC
bridge's Schannel to OpenSSL MsQuic 2.5.9 static-link change shipping since
1.0.59. Caching Studio 1.4.0: the GUID picker, sub-asset keys, built-in
refusal, and the stale-value warning/Clear button. build-unity-package.ps1
only stages files it names explicitly, so it neither drops nor overwrites
these; no script change needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 20:11:33 +02:00
Meta Vens
6c7cfa7a94 Studio: refuse built-ins by asset identity, not GUID digits; add Clear for stale keys
TrimEnd('0','f') missed Library/unity default resources built-ins whose GUID
has no all-zero or all-f suffix (e.g. 0000000000000000e000000000000000, the
LegacyRuntime font / Cube mesh in 6000.3.24f1). Ask AssetDatabase instead: a
real project asset is always a main or sub-asset under Assets/ or Packages/;
built-ins are neither. Also add a Clear button beside the stale-value warning
so a non-resolving stored key can be cleared without picking a substitute
asset first (drawing still never rewrites a stored value on its own).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 20:09:53 +02:00
Meta Vens
0c9cb57bea Correct stale Schannel/40,448-byte claims, ship OpenSSL's license notice
Soul flagged (F3) two docs still describing the pre-release native QUIC
bridge: unity/org.gamecult.cultlib/README.md said Schannel, and
native/GameCult.Mesh.Quic.Native/README.md still described the older
40,448-byte committed plugin as current. The shipped bridge (since the
1.0.60/1.4.0 release, commit 0155ba8) is the OpenSSL MsQuic 2.5.9 build,
302,080 bytes, static CRT, no CRT import. Both docs now describe that build
and name what it replaced.

The OpenSSL build's NuGet package carries an Apache-2.0 notice for OpenSSL
itself (Microsoft.Native.Quic.MsQuic.OpenSSL's THIRD-PARTY-NOTICES) that the
committed Third Party Notices folder never carried - the Schannel build had
no such dependency to notice. build-quic-native.ps1 now copies it out
alongside MSQUIC-LICENSE.txt, build-unity-package.ps1 stages it into the
package output and the tracked template, and it is now committed as
OPENSSL-NOTICE.txt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 19:49:14 +02:00
Meta Vens
1e398e0116 Studio: read and write sub-asset Addressables keys, warn on stale values
Ruling S (2026-09-17): a stored [CultInspectorAssetGuid] value is the
Addressables key form itself, the bare GUID for a main asset or
"guid[subAssetName]" for a picked sub-asset (one sprite of a sheet, say) -
the same shape AssetReference keeps and Addressables.LoadAssetAsync<T>
accepts.

CultAssetGuidKey (engine-free, GameCult.Caching) pins the parse/format rule
with unit tests (bare guid, guid[name], names with brackets/spaces,
malformed input), so headless checks can share it without touching Unity.

The Studio drawer's AssetGuid branch now: resolves either key form on read;
refuses to store a built-in resource's GUID (it has no Addressables entry)
and logs why instead of silently writing nothing; shows a warning next to a
stored value that fails to parse or resolve instead of drawing it as an
ordinary empty field, and never rewrites that stale value on its own.

Rebuilt GameCult.Caching/GameCult.Caching.MessagePack plugin DLLs
(scripts/build-unity-package.ps1 -UpdateTemplate) since the new type ships
in GameCult.Caching. Compiled CultCacheStudioDrawers.cs and
CultCacheStudioWindow.cs against Unity 6000.3.24f1's editor reference set
plus these plugins in a scratch csproj (removed after): 0 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 19:48:45 +02:00
Meta Vens
0155ba8cca Prepare 1.0.60/1.4.0 release: rebuild plugin DLLs, bump package.json
Removing the public asset path attribute is breaking. Runs
scripts/build-unity-package.ps1 -UpdateTemplate to refresh the tracked
plugin DLLs/pdbs in unity/org.gamecult.cultlib/Runtime/Plugins, and bumps
unity/org.gamecult.cultlib/package.json to 1.0.60 and
src/GameCult.Unity/Assets/Caching/package.json to 1.4.0 (dependency bumped
to cultlib 1.0.60). Tags are the next, separate step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 18:46:07 +02:00
Meta Vens
95a3c32250 Replace the asset path attribute with a stable GUID identity
CultInspectorAssetPathAttribute stored an engine asset path, which breaks
when the asset moves. CultInspectorAssetGuidAttribute stores the asset's
stable identity (in Unity, the .meta GUID) instead. The Studio drawer now
resolves the stored GUID to a path only to load the preview object, and
writes back AssetPathToGUID on a change. No path form survives for the
next field to pick up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 18:43:03 +02:00
Meta Vens
244154e2f5 Say what the restore digest is, since it is not an identity
The last report quoted the restored sha256 as though two runs agreeing on it
meant they had run the same source. They had not: `text=auto` gives a Windows
checkout carriage returns, so the win32-x64 run restored to b163cab7 and the
linux-x64 run to e3c6528a from the same commit. The digest is a restore check —
the file this run started with is the file it ended with — and it is said where
it is printed, so the next report does not borrow it for something it cannot do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 03:11:05 +02:00
Meta Vens
b985f07887 Say what the single-waiter wake actually rests on, and what is still unreached
The note beside the close's wake claimed the narrowing to `notify_one` was
equivalent, and gave the weaker half of the reason. The equivalence needs two
premises together: `closing` is set permanently under the gate before the notify
and appears in every waiter's predicate, so after a close begins no poller can
block at all and the set of waiters is fixed; and each exiting call's own scope
destructor takes the gate, decrements and wakes everyone, so one wake starts a
chain that reaches all of them. Neither carries it alone. Both are now written
where the claim is made, so it can be checked rather than believed.

Narrowing both wakes also survives, and that is not equivalent by any argument:
a wake consumed by the closer, waiting on the same variable for the count to
reach zero, leaves the chain stopped and the rest of the pollers sitting out
their timeouts. It passes on wait-queue ordering — the pollers are queued before
the closer, so a FIFO head is never the closer until it is the only waiter left.

Recorded as unreached, with the reason, and deliberately not as unreachable:
that claim has been wrong twice in this campaign. A scenario cannot force it
while the first premise holds, since the only way to put the closer ahead of a
poller in that queue is a poller that blocks after the close began. Reaching it
would need a seam that chooses which waiter a notify goes to. The standard does
not promise FIFO, so what survives here is the implementation's doing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:47:34 +02:00
Meta Vens
5ab4c627d7 Put a call in both states so the seam's own guard is pinned
The last pass's only non-comment source change was held by nothing. Reverting the
hold's guard so it fires unconditionally survived all five scenarios, because no
scenario ever had a call in both states at once: the timeout scenario never arms
the hold, and the hold scenarios never let a timeout expire.

`holdtimeout` is the missing state. The hold is armed, the runtime is idle, and
one poll asks for 150 ms while the hold stays armed for 800. A call the wait woke
is the seam's to park; a call whose own timeout expired is on its way out, and
parking it would let `holdclose` report the bridge keeping a host call inside the
library when what kept it was the fixture. The measurement is which of those two
numbers the poll comes back at.

Two entries, and the loosening is the one worth reading: the same two conditions
in the other order. The hold blocks, so the order is the rule — asked first, it
parks the call and then decides whether it should have. Both die on
`holdtimeout` at about 800 ms and on nothing else; `holdclose` and `latecall`
stay green under both, which is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:46:49 +02:00
Meta Vens
c682619fbd Close the timeout against waits derived from the argument, not only constants
The two bands ruled out a constant and ruled out nothing else. Any mapping that
is the identity at 200 and 900 passed for free, and the late tolerance was a flat
300 ms, so anything within that of the truth passed at every value forever.
Three ordinary ones survived all five scenarios on both targets: clamping the
wait to at most a second, adding 150 ms to it, and scaling it by six fifths.

The clamp is the one that matters. Its failure is verbatim the bug the last pass
claimed to fix — a host asking for five seconds gets one, forever, with nothing
reporting it — and "cap the wait so a shutdown gets noticed" is the most ordinary
spelling that line will ever be given.

So the long probe goes to 5000, above any ceiling a bridge would plausibly clamp
to, which kills the clamp and the scale on the early check and the late one. And
the late tolerance becomes a fraction of what was asked for — 60 ms plus an
eighth — which is what kills the offset: 150 ms is 75% late at the 200 ms probe
and there is no flat gap left to hide in. Measured overshoot on either target is
about 10 ms, against 85 ms allowed at the short probe and 685 at the long.

The table gets the clamp as an entry of its own, because the whole lesson here is
that a table of constants says nothing about a wait computed from the argument,
and a table of constants would go green again the moment these numbers drifted
back. Its honest limit is stated where the tolerance is defined: a scaling
smaller than an eighth survives, and nothing here claims otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:45:57 +02:00
Meta Vens
c177533b4f Offer the container line to both shells, and name the short-path requirement
The container recipe carried a PowerShell backtick continuation in a snippet
whose next paragraph invites a POSIX shell to run it, where a backtick is not a
continuation. A continuation has to pick a shell, so the line does not have one.
The Dockerfile's own copy of the recipe still mounted one machine's checkout by
absolute path; it mounts whatever checkout is running it, like the other two.

And the requirement a newcomer meets by tripping over it: on win32-x64 the
harness needs the checkout somewhere short. MSBuild's file tracker gives out on
long paths, so a run from a deep temporary directory fails the build for every
mutant, the no-op control included. That is loud — the harness stops on a red
control and prints what the child said — but what the child says is MSBuild, not
the path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:44:47 +02:00
Meta Vens
ca6c0f5852 Keep shell scripts LF so a Windows clone can run the Linux path
`* text=auto` and nothing else, so a Windows clone writes every shell script with
carriage returns. The documented Linux path is a container that runs one of them,
and it dies in under a second with a complaint about an interpreter named
`bash\r`. Nothing in that failure names line endings, and the same clone builds
and tests fine everywhere else, so it reads as a broken image.

`scripts/build-quic-native.sh` is the only shell script in the repository and the
only file on those paths that an interpreter reads by shebang; the mutation
runner is Node and handles carriage returns itself, and the Dockerfile builds
either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:43:37 +02:00
Meta Vens
367c9bc2b8 Record the pass that beat the bands with a clamp
No constant sits in both bands, which is true and is not the rule. A wait that
caps itself at a second passes every scenario, and capping a wait so shutdown
gets noticed is the most ordinary way that line will ever be written. An offset
and a scale survive beside it.

A fresh Windows clone also cannot run the documented Linux path at all, because
nothing tells git to leave shell scripts alone and the container dies on a
carriage return in the interpreter line. The mount fix works; this sat behind
it.

The equivalence holds, but on two premises nobody had written down, and
narrowing both wakes survives while being equivalent to nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 02:36:37 +02:00
Meta Vens
1845301f91 Record the batch that made the timeout observable
The blind spot was not where the hold sat. No scenario ever let a timeout
finish a poll, because both ended their waits with a close, so the duration
governed nothing anyone could see. A scenario that polls an idle runtime and
measures elapsed against what it asked kills all three constants, and it asks
twice at values whose bands do not overlap, because one value cannot tell a
bridge honouring the host from one waiting on that same number of its own.

The probe that reached the two-phase poll through a closed port is committed,
the wake finally has the entry the table had been claiming, and a surviving
mutant is argued equivalent and handed to Soul rather than swapped for an
easier one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 02:12:12 +02:00
Meta Vens
a937a0b07e Let the bridge's own timeout, refusal and two-phase poll be seen
Three rules the bridge has always promised, none of which anything committed
could see.

The timeout was the structural one. Both scenarios end their waits with a close,
so the duration the bridge waits on governed nothing either of them observes: a
hard-coded 2000 ms survived the whole Linux matrix and Windows, and a host asking
for five seconds would have got two forever with nothing reporting it. The fix is
not another entry at another constant. A timeout's only observable is how long
the call takes, so `polltimeout` lets an idle runtime's poll be ended by its own
timeout and measures it, twice at different values, because no single constant
sits in both bands. The seam's hold moves behind that too: it parks only a call
the wait woke, so a poll whose own timeout expired is on its way out rather than
held there by the fixture and counted as the bridge's doing.

The refusal during a close (the header's section 4) had no scenario at all. A
call admitted after the closer has begun increments the in-flight count behind a
wait that has already read it. `latecall` holds the close open with the seam and
makes a call into it.

And the two-phase poll was written off as needing a listener, a credential and a
connection. It needs a closed loopback port: the failure always carries a reason,
which is an 87-byte payload, in about a millisecond. Moving the pop above the
copy crashes on both targets.

18 killed, 0 survived, 0 skipped on linux-x64; 16 killed, 0 survived on
win32-x64, where the two ThreadSanitizer entries are honestly skipped. Both runs
restored the source to the same sha256, b163cab7d48ebc93.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:09:50 +02:00
Meta Vens
25d7de26b3 Point the container recipe at whatever checkout is running it
The mount was this machine's own path, so the one documented way to build and
check the Linux bridge needed editing before it would run anywhere else. It is
the repository root now, named as the shell names it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:09:12 +02:00
Meta Vens
122c2172d9 Rule the reference check onto read now and write later
A reference whose target contradicts its declaration is noticed on read in this
cut and refused by name. Writing the same check into the put path is intended
rather than optional: the declaration already names the target and the writer
already holds the record, so it is cheap, and leaving it to consumers means
every tool that writes records reimplements it and a tool that forgets looks
exactly like one that did not until something refuses.

The consumer story rests on less than it sounds. There is no Studio repository
and no project by that name; the tooling that enforces this today is one
console project in one game. That makes now the cheapest moment to move the
rule to its owner.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 01:44:09 +02:00
Meta Vens
2df124e269 Name what the generator needs before it can exist
A generator runs against published schemas, and the published schema entry is
flat: the cache walks the base types to collect members and then throws the
hierarchy away. Fed that, a generator emits one flat class per leaf with the
inherited members copied into each, which is the opposite of building the
hierarchy up for you.

So the catalog carrying each leaf's base chain is a prerequisite of that
campaign rather than a nicety for naming families, and families fall out of it
once it exists. The campaign itself waits until this cut settles the shape it
would be generating against. No generator exists in the repository today.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 01:38:24 +02:00
Meta Vens
34df099e8c Rule the cross product, and park families for the right reason
A caller needing exact pairs asks once per schema. The limit is stated rather
than answered with a third addressing list.

Naming a family is parked, but not because there is nothing to name. In one
process a family is an assignability check; over the wire there is no type
system, and nothing publishes the hierarchy where a caller can see it. The fix
is a catalog entry naming each leaf's base chain, which is small work that
moves the catalog's shape, so it belongs in a cut already touching it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 01:30:45 +02:00
Meta Vens
6e4b9e7eb4 Rule numbers onto decimal strings
Floats are fragile, and every value in this vocabulary is already a string, so
decimal strings keep the wire to one value encoding rather than adding a
second. The recommendation this reverses had that backwards.

What it costs is a comparison rule, and that is where the danger sits: compared
as text, ten is less than nine. The cut owes a canonical decimal form and a
numeric comparison, both pinned, and it no longer owes anyone a sentence about
where exactness runs out.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 01:23:44 +02:00
Meta Vens
fa6c000e7f Record the pass where the verdict splits
A stranger can now rerun both targets from the committed container and the
README, which was the batch's point and holds. The rules are another matter.
The scenario holds its callers after the wait whose duration is the rule, so
a hard-coded two seconds walks through the whole matrix while the committed
entry only ever died because one constant was smaller than another.

The gap recorded as unreachable is reachable in fifteen lines against a closed
port, and the mutant crashes a plain optimised build on contact. Second gap
falsified today. The next batch fixes the cause once instead of adding another
entry at another constant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 01:19:02 +02:00
Meta Vens
47618428a3 Close three questions the map still lists as open
The operator ruled ordinals, the two deletions and the leaf titles earlier
today, in the other campaign's map. This one still carried them as open, and
a stale live question is exactly what a later agent reads as a decision still
to make.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 01:13:41 +02:00
Meta Vens
ee91062cee Finish the selection map, and price it honestly
The cache already declares and persists what type each member is, so numbers
cost no migration. What they do cost is a public way to read a declared value
at all, because the evaluator today cannot reach one from outside the cache,
which is why the existing snapshot loop reflects. That is a hundred and fifty
lines, not sixty, and the ledger moves the wrong way to say so.

Three claims inherited from the earlier pass were wrong and were cut rather
than designed around. A fifth selector engine turned up in Rust. And the
cache's index maps keep one key per value, so an evaluator that reached for
them would answer a query about a whole family with a single row.

Watching a selection is mapped as its own cut, where nine overloads that are
a query language in disguise collapse into one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 01:13:08 +02:00
Meta Vens
211b5e320f Point the Linux recipe at the container that exists
The map carried a one-line invocation that installed no JavaScript runtime,
so it could not run the mutation runner, and said nothing about disabling
randomisation or relaxing seccomp. That is how the Linux path came to be red
for everyone except the agent that had found the flags by hand. The recipe now
lives with the image and the bridge README, and the map points at them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 00:48:50 +02:00
Meta Vens
440709829d Commit the container the linux-x64 entries are honest in
The image the bridge is built and mutated in existed nowhere in the repository,
and the recipe that stood in for it installed a C++ toolchain and no JavaScript
runtime — so the container could not run the harness that checks the bridge it
had just built. It is a file now, pinned to the same debian:13 digest as the
release workflow, carrying ninja, Node, setarch and MsQuic's four runtime
dependencies.

The harness configures with `-G Ninja` on linux-x64, which is what
scripts/build-quic-native.sh uses. CMake's Linux default is Makefiles, the image
carries ninja rather than make, and the default configured nothing at all: the
first run in the committed container died in cmake. It said so, because the
control now prints what the child said, which is the whole point of that change.

The seccomp requirement is stated where someone hitting it will read it — the
bridge's README, beside the recipe — rather than being knowledge the runner
assumes its caller already has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:37:16 +02:00
Meta Vens
c2c07605d8 Map the selection cut for CultNet's database organ
Three selector engines evaluate the same two allowlists today, two of them
carrying identical copies of the same reflection, and projection is a delegate
no production code ever constructs. The map replaces all of it with one
selection a caller can state: any-of over the aliases the cache already
declares, a projection it asks for, an order, a cursor, and one hop along a
declared reference.

The audit is reported as measured rather than as hoped. About 326 of 21,720
lines collapse in CultMesh, and the repository grows by about 370, because the
Rust runtime gains a capability it never had.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 00:36:44 +02:00
Meta Vens
12c8bfcc7d Let the prohibition on a late call stand on its own
Section 4 forbade any host call that begins after `cultmesh_quic_runtime_close`
starts, and promised in the same sentence that such a call is refused with -1;
section 6 repeated the promise for `cultmesh_quic_next_event`. The refusal lives
behind the runtime's own gate, so once the close has returned there is nothing
left to read it: the case the sentence describes is exactly the case where the
promise cannot hold.

The prohibition stands alone. The refusal is stated for what it is — a call that
races the start of a close, while the close is still running, is refused and
touches nothing — and no longer as a guarantee covering the call the section
forbids.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:25:59 +02:00
Meta Vens
e4e09a086c Enter the rules the quiesce rests on, and stop exiting green on skips
Four entries the native target did not have. Two for the rule that a blocking
crossing is a counted host call: the revert deletes the call scope from
`cultmesh_quic_next_event`, and the loosening keeps the scope and its refusal but
drops it at the door, before the call blocks. Two for the rule that a positive
timeout blocks until an event or the close: the revert inverts the comparison,
and the loosening waits a token millisecond. All four were green before this,
because nothing checked that the pollers were inside the library at all.

The bounded-wait loosening goes from 1 ms to 50 ms. A 1 ms bound only ever lost
a race — the margin was a few hundred lock handoffs — and the same loosening at
50 ms survived on both targets. `holdclose` holds its pollers for 500 ms, so any
bound shorter than the hold now expires with calls still inside. A bound longer
than the hold is not distinguished, which is this entry's honest limit.

A run that skips entries covered those rules with nothing, and exiting 0 let a
win32-x64 run — where every ThreadSanitizer rule is skipped — be read as a green
gate. It exits 2 and says so, with a killed/survived/skipped line.

A red control arrived as a bare colour with the child's output thrown away,
which is how a configuration that could not start at all stayed invisible. The
control now prints what the child said. On linux-x64 the sanitizer
configurations are re-executed through `setarch -R`, because ThreadSanitizer
dies before `main` on the kernel's address-space randomisation; both that and
the seccomp denial behind `personality(ADDR_NO_RANDOMIZE)` stop the run with the
cause and the required invocation instead of being reported as kills.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:24:21 +02:00
Meta Vens
62e53df447 Hold host calls inside the bridge so the quiesce is tested, not assumed
The closerace scenario pinned the order of the count and the notify but never
that a host call is counted at all. Deleting the call scope from
`cultmesh_quic_next_event` outright leaves every configuration green — the
pollers finish before the teardown reaches them — and so does inverting the
comparison that makes a positive timeout block, which empties the library before
the close even begins. A scenario that only sleeps and hopes cannot tell any of
that apart from the real thing.

So the bridge says it itself, in development builds only (section 7 of the
header, behind CULTMESH_QUIC_DEBUG_ASSERTS, absent from every shipped binary):
the peak number of host calls counted inside the library, the number counted
when the close began its wait, and a hold that parks a call inside the library
until the scenario releases it. A held call returns the 0 an empty queue would
have and touches the runtime no further, so a bridge that frees the runtime
under it is reported by the scenario rather than crashing inside it.

`holdclose` drives the quiesce through that seam: the calls are counted, they
were still counted when the close began, and the close did not return while they
were held. `closerace` keeps hunting the race and now also refuses to pass when
a poller left before the close began, which is a scenario that raced nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:23:04 +02:00
Meta Vens
51fb449b56 Record the second fix batch and the harness that was never there
The lifetime fix holds under a thread sanitizer and the assertion kills the
deleted wait on both targets. The harness defending all of it does not hold.
Every native kill this cut had reported came from scratchpads that no longer
exist, the committed Linux path dies before its scenario starts and hides the
reason, and the scenario stays green when calls are not counted at all or
when nothing ever parks.

Two rules are defended by nothing committed. They are recorded as undefended
rather than given a number that would look better.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 00:02:05 +02:00
Meta Vens
180917d75b Mutate the native bridge, and name the target each entry is honest on
The mutation suite covered only the TypeScript rules. The bridge's two runtime
lifetime rules now have entries of their own, each with a revert that puts back
the wrong spelling and a loosening that keeps the shape and gives away the
guarantee: a notify moved outside the gate, a notify outside it that fires only
on the last call out, the quiesce wait deleted, and the quiesce wait made
bounded. Their killer is the scenario runner, built through CMake rather than
npm, so the runner now takes a check per target instead of assuming a workspace,
and a target without a dist/ keeps the verified restore without the sentinel.

Every native entry names the platforms it is honest on, and a run elsewhere
reports it as skipped rather than killed. There is no ThreadSanitizer for MSVC,
so the notify rule cannot be seen on win32-x64 at all. Soul's Cut 3 pass found
this the hard way, reporting two mutations killed from a win32-x64 release run
where both in fact survive.

Naming a target on the command line runs only that one, which is what lets the
native entries run inside the Debian 13 container where the TypeScript workspaces
have no installed dependencies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 23:23:50 +02:00
Meta Vens
f4186e7427 Run the runtime-lifetime scenarios from the repository
The bridge's close path had no committed test. Whether a close is safe against
host threads already inside the library is the one thing about it a host cannot
establish by reading, and every check of it so far lived in a session scratchpad
and is gone.

The scenario runner under tests/ drives that path and nothing else: it parks
pollers in cultmesh_quic_next_event, closes the runtime under them, and asserts
that the close is what woke them. It consumes the bridge through the public
header, as a host does. CULTMESH_QUIC_BUILD_TESTS builds it, off by default,
because a delivery build has no business producing an executable.

CULTMESH_QUIC_DEBUG_ASSERTS, also off by default, makes the bridge state the
quiesce invariant itself rather than leaving it to whatever a sanitizer happens
to catch. Deleting the quiesce wait is otherwise falsifiable only by luck:
AddressSanitizer saw it 4 times in 48 attempts with pollers blocked mid-copy of
large frames, and never on release. With the assertion and pollers blocked it
fails on the first close.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 23:23:30 +02:00
Meta Vens
19d8d5c948 Say that the committed Unity plugin is the older build
The README claimed a rebuild reproduces the committed Unity plugin DLL. It does
not. That plugin is 40,448 bytes, predates both the static CRT and the move off
Schannel, and still imports VCRUNTIME140.dll and MSVCP140.dll; the current source
builds to 302,080 bytes with no CRT import.

What /Brepro buys is that a build reproduces itself, and the README now says
that, along with the fact that the committed plugin stays the older build until
the Unity package build replaces it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 23:22:59 +02:00
Meta Vens
53d59296d4 Say what the return code makes portable
Section 3 and cultmesh_quic_last_status both told a host that the portable answer
is the return code. What the mapping makes portable is the sign. The magnitude is
not: the same MsQuic failure carries different numbers on different platforms, a
TLS error being -12032 on win32-x64 and -1126 on linux-x64, and the 16-bit mask
gives QUIC_STATUS_USER_CANCELED and QUIC_STATUS_FILE_NOT_FOUND the same -1002 on
Windows.

The header now says the sign is what a host may branch on, that the magnitude is
diagnostic and platform-specific, and that switching on a particular code
identifies nothing reliably.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 23:21:50 +02:00
Meta Vens
dc9e12cd38 Print a Windows status without sign-extending it
Every error sentence widened its QUIC_STATUS straight to uint64_t. On Windows
that status is a signed HRESULT, so the sentence carried 0xFFFFFFFF80072B18 where
the status is 0x80072B18. The status is 32 bits on both platforms and now goes
through uint32_t on its way in.

Diagnostics only: the v1 managed connector matches on the wording of these
sentences, not on the hex.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 23:20:55 +02:00
Meta Vens
a4753f4e9f Notify the closer while still holding the gate
A call scope used to drop the lock after decrementing the in-flight count and
notify afterwards. That left a window: the closer's predicate is already true
once the count reaches zero, so a scope preempted between the unlock and the
notify could come back to find the runtime torn down and signal a condition
variable that no longer exists. ThreadSanitizer reports it as a broadcast racing
the destroy.

The decrement and the notify now happen under one lock guard. The closer cannot
return from its wait until the scope releases the gate, so the condition
variable is alive for as long as anything can signal it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 23:18:26 +02:00
Meta Vens
27ae3cb292 Record Soul's second pass, and rule the surviving mutation killable
The cut does not close. The lifetime model was right in shape and wrong by
one line: the last call notifies after it has released the lock, so the
runtime can be destroyed in between and the wake lands on freed memory. A
sanitizer found it on the unmutated bridge.

The mutation that survived by construction is ruled killable rather than
accepted. Under a sanitizer with pollers blocked mid-copy it fails four times
in forty-eight, so a dev-only assertion makes it fail every time instead. Two
further mutations are honest only on the sanitizer target and the table must
say so, because a mutation whose kill depends on the target is not killed
until it runs there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 22:58:15 +02:00
Meta Vens
e1ce4915b6 Record the Cut 3 fix batch
The bridge keeps an object alive across the call that names it, closes
each handle exactly once, and quiesces before it frees. The contract is a
header hosts and tests are checked against. Return codes are negative on
every platform, one completion per frame, and every connection's end is
observable. One mutation survived by construction and is handed to Soul
to rule on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 22:24:51 +02:00
Meta Vens
c729dff2ee Pin the Windows MsQuic to the OpenSSL build, and both of its digests
Q9 ruled A. The Schannel MsQuic refuses QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12
with QUIC_STATUS_NOT_SUPPORTED, and PKCS12 is the only credential type a
provider has on both platforms, so a Windows provider could not open a
listener against the runtime this repo was shipping. The same bridge
binary passes all twenty smoke checks and the v1 gate against the OpenSSL
build, which is what Soul verified and what this now downloads.

Two digests, not one. The zip is pinned by FEE9A664 and 19,179,817 bytes;
the msquic.dll inside it by C17C6581 and 4,181,856 bytes. The earlier
report and the cut map's first record put the zip's digest on the DLL,
which is a pin that checks the archive and then ships a file nobody looked
at. Sizes ride along with both, because a digest that fails tells you less
than a digest and a length that fail together.

The CRT is linked statically. A Node or Unity host without the Visual C++
redistributable cannot load a DLL that imports vcruntime140.dll, and
Windows reports that as "the specified module could not be found" while
naming the module that was found. Confirmed on the rebuild: msquic.dll,
CRYPT32, ntdll, KERNEL32, and no CRT import at all. The DLL goes from
83,456 to 302,080 bytes, which is the price of the host not needing
anything installed.

The Unity plugin's committed msquic.dll is still the Schannel one and this
does not touch it; the next build-unity-package.ps1 run is what replaces
it, and that is not in this batch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:04:27 +02:00
Meta Vens
8c418961c4 Close the three ABI gaps the bridge left open
Event 7 was not one per frame. The bridge writes the stream's kind byte
itself, that send completed as an anonymous event 7, and a host counting
completions saw six for five frames with nothing to tell them apart. The
send request now says which send it was; the kind byte's completion is the
bridge's own business and is dropped. A send MsQuic released without
putting on the wire reports it: event 7 carries a send result in `code`,
completed or canceled, so an aborted send is accounted for rather than
silently missing. Measured: 32 sends queued under an abort, 32 event 7, 28
canceled and 4 completed.

The initiator of a shutdown learned nothing. MsQuic gives it only
shutdown-complete, and the bridge swallowed that, so a host could end its
own connection and never see the id end. Event 4 now has one emitter —
shutdown-complete — and the two initiated-by callbacks record why rather
than announcing it. Each connection id gets exactly one event 4, whoever
ended it, with the code its side passed, and it is emitted after the id
has stopped resolving, so a host told a connection is gone cannot still
resolve it on the next line.

Return codes inverted by platform. QUIC_STATUS is a negative HRESULT on
Windows and a positive errno on POSIX, so a Linux host checking for a
negative code saw every failure as success, against a README that promised
negative. A MsQuic refusal is now -(1000 + (status & 0xffff)) on both, and
cultmesh_quic_last_status hands over the raw bits for diagnostics. On
win32-x64 a garbage PKCS12 returns -12032 with last_status 0x80072B18.
The v1 exports move with it; the managed connector tests the value against
zero and formats it, so the contract it was shipped with holds.

Smaller ones, from the same pass: AppendTrace is v1-only and bounded,
because a long-lived v2 connection grew that string once per MsQuic event
for nothing to read; a listener configuration failure takes the connection
out of the map instead of leaving it naming a handle MsQuic is about to
close; and next_event refuses a null payload with a non-zero capacity
rather than treating the lie as a discard.

Soul's C consumer now compiles against the header and asserts its own
struct and its eleven bound prototypes against it, and the koffi smoke
checks its declarations and the thirteen exports the same way: 20 of 20,
seq clean, cancel clean, the v1 gate 3 passed 1 skipped as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:00:06 +02:00
Meta Vens
47aa7b668b Keep bridge objects alive across the call that names them
FindStream, FindConnection and FindListener took the lock, dropped it, and
handed back a raw pointer. MsQuic's worker freed the object on
shutdown-complete and the host thread dereferenced it on its next send.
One release run in three crashed on each target under an ordinary
peer-driven stream teardown; ASan caught it three times out of three, with
the free inside DestroyStream on a worker and the read inside
stream_send_frame on the host thread. A null check cannot fix that: the
pointer is not null, it is dead.

The maps hold shared_ptr now and a lookup returns a reference the caller
owns for the length of its call. The worker's destroy takes the object out
of its map and drops the map's reference only. The MsQuic handle follows
the object: the destructor closes it exactly once, under the exchange
guard, so the handle is alive for as long as any holder is. A stream holds
its connection, which orders every stream handle's close before its
connection's without a rule anywhere saying so.

runtime_close quiesces. It marks the runtime closing, wakes every blocked
poller, and waits for every in-flight call to return before it closes a
handle or frees a byte; a call that arrives after that is refused with -1.
Waking a poller without waiting for it was how close returned a poller
into freed memory while looking like it supported the pattern.

Two things fall out of the model rather than being fixed on top of it: a
listener that fails partway through opening leaves no map entry and no
handle, because every failure path just drops the local reference, and a
stream is in the map before StreamStart, because the shutdown-complete
that StreamStart can reach on a worker has to find it.

Cut with it: Listener::stopped, V1Client::connection (an unread raw
pointer into the same hazard), and FindListener.

Event 7 is still one too many and the shutdown initiator still learns
nothing; that is the next commit. The rest of Soul's C consumer passes on
win32-x64, including race at 400 streams, which crashed before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:50:48 +02:00
Meta Vens
9a01321a8a Give the QUIC bridge a host-facing header
Soul wrote a C consumer of this ABI from the spec text, because there was
nothing to include. The struct layout, the exports and the conventions
lived in the .cpp and in prose, which is three places for one contract and
no way to notice when they part.

include/cultmesh_quic_native.h is now the contract: the 64-byte event with
every field offset asserted, the exports with their signatures, the id and
return-code conventions, the object lifetime rules, and the threading
rules. The .cpp includes it and keeps none of its own copy. The README
points at it rather than restating it.

The lifetime and return-code sections describe where the bridge is going,
not where it is; the two commits after this one make them true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:43:01 +02:00
Meta Vens
7badce1e4e Record Soul's Cut 3 pass
The bridge's lifetimes are wrong: a lookup hands the host a pointer the
worker can free before the next call. Release crashes on both platforms
under an ordinary stream teardown. Three ABI gaps ride with it, and the
return-code contract inverts between Windows and POSIX. One digest in
this map was the zip's, not the DLL's. All in a fix batch before Cut 4.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 21:34:24 +02:00
Meta Vens
27446e7761 Rule Q9: pin the OpenSSL MsQuic on Windows
One credential type on both platforms, the provider role works, the ABI
is unchanged. The tree pays four megabytes more and the Unity plugin's
copy diverges until its rebuild. The map's claim that PKCS12 was one type
for both providers is withdrawn.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 21:29:57 +02:00
Meta Vens
aa22b6141a Record the Cut 2 fixes and Cut 3's landing, and raise Q9
The bridge is a runtime-neutral two-role event queue, built and smoked
on both platforms with the Linux artifact named as development-loop only.
Two hangs the old shape never exercised are pinned. One fork stops the
Windows provider: the pinned Schannel flavour does not implement the
PKCS12 credential the spec assumed was one type everywhere, while the
same bridge passes beside the OpenSSL flavour. Recommended: pin OpenSSL
on Windows and pay the tree size.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 21:07:55 +02:00
Meta Vens
23dc0939e0 Give the bridge a Linux build the deploy host's own distribution can produce
There is no Linux MsQuic binary in any NuGet package, and the v2.5.9 GitHub
release carries only test bundles, so the Windows script's approach does not
transfer. What does exist is Microsoft's Debian 13 pool, which is the same
distribution Yggdrasil runs, and the msquic source tree at the matching tag for
the POSIX headers the NuGet omits.

`scripts/build-quic-native.sh` fetches the pinned `libmsquic_2.5.9_amd64.deb`
and the three headers `msquic.h`, `msquic_posix.h` and `quic_sal_stub.h`, and
verifies all four by SHA-256 the same way `build-quic-native.ps1:14` verifies
the NuGet. A digest that stops matching is a changed dependency, not a warning.

Two details the deb forced. It carries three library files and no licence text,
so the MIT licence is copied from the one this repo already committed for the
Windows plugin — the same text for both platforms. And it ships only
`libmsquic.so.2.5.9`, with no `libmsquic.so` for `-lmsquic` to resolve against,
so the script makes that link in the extracted tree rather than in the image.

The Linux CMake branch links `-fvisibility=hidden`, so only the twelve v2
exports leave the `.so` and the Windows-only v1 five are absent by construction
rather than by discipline, and sets `RPATH $ORIGIN` so the `libmsquic.so.2`
shipped beside the bridge resolves without being installed system-wide.

`build-quic-native.ps1`'s default output directory becomes
`artifacts/quic-native/win32-x64` so the two platforms' outputs sit beside each
other instead of on top of each other. Both existing callers —
`build-unity-package.ps1:80-82` and the Native.Tests csproj `:20-22` — pass an
explicit `-OutputDirectory`, so neither is affected.

Built and smoked in the workstation's Docker `debian:13` engine pinned by
digest: g++ 14.2.0, glibc 2.41, `libgamecult_mesh_quic_native.so` 85,456 bytes
with `NEEDED libmsquic.so.2` and `RUNPATH $ORIGIN`, and the same 15-check v2 FFI
smoke that runs on Windows passes under Node 24 on that image. That is a Linux
build on a Linux host and nothing more: Q6 ruled the release artifact comes from
the Actions job, and this one is the development loop.

A Linux host still needs MsQuic's own runtime dependencies present — libssl3t64,
libnuma1, libxdp1, libnl-route-3-200 — which is now written down, because the
first smoke run failed on a missing libxdp.so.1 and the reason was not obvious
from the error.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 21:04:14 +02:00
Meta Vens
6a091ef65c Make the MsQuic bridge a two-role event queue instead of a Unity client
The bridge was one platform, one role and one host: Windows by construction
(`windows.h`, `__declspec`, `CryptHashCertificate2`), client-only, and shaped
around the managed connector that polls it every 2 ms. Nothing about MsQuic
required that; the shape came from having exactly one caller.

It now carries a C ABI v2 of twelve exports over a single per-runtime event
queue: open a runtime, open listeners and connections on it, open streams and
send frames, and take typed events out of one blocking wait. The event struct is
64 bytes described by layout rather than by a shared header, so a host declares
it in its own FFI and no header travels with the ABI. `next_event` keeps the v1
two-phase convention — return 2, leave the event at the head, report the size
the caller needs — because that is the one part of v1 that was already
host-agnostic.

The rule that shapes everything else is that nothing calls out. MsQuic's worker
threads only push onto the queue and wake that wait, so no host runtime is
entered from a thread it does not own, and the bridge needs to know nothing
about what is on the other side. That is what makes one binary serve both a
Node process through FFI and a Unity player through P/Invoke.

Trust moved out. A v2 client connection defers validation, emits the DER as
event 3 and answers MsQuic only when the host calls
`connection_certificate_complete`; if the host never answers, MsQuic's handshake
timeout closes the connection and event 4 says so. The C code no longer has an
opinion about certificates — except in the v1 shim, where it must.

The five v1 exports are unchanged in signature and behaviour and are now
`#ifdef _WIN32`, matching `CanConnect`'s own Windows gate. A v1 client is a
private v2 runtime holding one connection, and the pin comparison is the single
thing it still decides in C, because that is the contract the shipped Unity
package was built against. `windows.h`, `wincrypt.h` and `CryptHashCertificate2`
appear nowhere outside that block.

Two things the rewrite had to get right that the old shape never exercised.
The v1 pin must be attached before `ConnectionStart`, not after it returns: the
handshake runs on an MsQuic worker thread and reaches the certificate event
immediately on loopback, so a pin applied afterwards loses the race and the
connection silently takes the host path nobody is listening on. And teardown
must close the connection handles itself — `RegistrationClose` blocks until the
app has closed every child handle, and a silent shutdown delivers no shutdown
events at all, so leaning on the callbacks to do it deadlocks the caller
forever. Both were found by the tests hanging, and both now have a mutation that
reproduces them.

Verified: the four v1 tests in tests/GameCult.Mesh.Quic.Native.Tests pass
unchanged (3 passed, 1 skipped for want of an external endpoint), including the
wrong-pin refusal and the departed-client broadcast test. A 15-check FFI smoke
drives the whole v2 path — listener, deferred certificate, both sides connected,
latest-only stream, one frame with FIN, identical bytes out — and passes on
Windows x64 and on Linux x64. Ten source mutations, ten killed, two green
no-op controls.

One open fork, reported separately and not decided here: the pinned Windows
MsQuic is the Schannel build, which refuses
QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12 with QUIC_STATUS_NOT_SUPPORTED, so
`listener_open` cannot open a provider against it. The map assumed PKCS12 was
"one credential type for Schannel and OpenSSL"; the header declares it but the
Schannel provider does not implement it. The identical bridge binary passes the
full provider smoke against OpenSSL MsQuic 2.5.9, so this is a question about
which flavour Windows pins, not about the bridge.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 21:03:01 +02:00
Meta Vens
7a0749b8e2 Say what the eleven stricter endpoint spellings actually are
Both comments around `STRICTER_THAN_CSHARP` called the divergences host
repairs. Most are not. Of the eleven, three carry a backslash and two have no
authority for `System.Uri` to accept; `refusedByCSharpUriShape` refuses those
five on the raw string, before there is a host to repair. Four are genuine host
repairs `URL` would make and C# would not — two trailing dots, two fullwidth
hosts — and the last two throw in `URL` before any rule of ours runs.

The same slip sits in `isProtectedEndpoint`'s own doc comment, over a different
eleven: it says `URL` repairs eleven spellings `System.Uri` refuses, when only
the four carrying a tab or CR/LF are rewritten and the other seven are admitted
as written.

Wording only; no rule changes. It matters because the next reader deciding
whether the pre-check earns its keep will reach for the reason the comment
gives, and "host repairs" points at the wrong half of the list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 20:21:42 +02:00
Meta Vens
6ed0ff75af Run both halves of the realtime frame parity in CI
The codec's byte parity rests on two vector files written from opposite sides:
`realtime-frame-vectors.json` by the C# reference and decoded by TypeScript,
and `realtime-frame-vectors.ts-written.json` by the TypeScript codec and
decoded by C#. Neither half ran in CI. The TypeScript suite ran nowhere —
cultmesh-ts was not in any job — and the C# test ran nowhere either, because
`cultnet-interop.yml` is windows-latest and main-only and does not filter for
it. A codec that agreed only with itself would have gone green on every push,
which is the exact failure the two-file scheme exists to prevent.

Both now run in the `reactive-and-resource-lifetime` job beside the authority
steps, on windows-latest and ubuntu-latest, for main, codex/** and pull
requests. That job was already the one place the TypeScript side of CultMesh
gets exercised on Linux at all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 20:21:27 +02:00
Meta Vens
9a4c9396b4 Stop the realtime decoder handing back a view of its own frame
Two mutants the cut-2 pass recorded as equivalent were not. Both notes were
reasoning, and both were wrong in the same way: they argued from the shape of
the code instead of trying the input that breaks it.

The decoder's length refusal reads `payloadLength < 0 || expected !== length`.
The note said the first clause could never fire on its own, because a negative
payload length makes the sum smaller than the frame. It does not: the sum also
carries the three identity lengths, and a negative payload cancels against
them. A 37-byte frame declaring `channelLength=65535, payloadLength=-65535`
sums to exactly 37, matches its own byte length, and without the clause decodes
to three blank identities and an empty payload where `CultMeshRealtimeWireProtocol`
refuses it. Two smaller spellings do the same, `ch=1, pl=-1` over 37 bytes and
`ch=2, pl=-1` over 38. No test covered that shape; one does now, and the
mutation is listed and killed rather than excused.

The payload copy was worse, because the fix and the bug looked identical. The
decoder used `bytes.slice(...)`, which copies a `Uint8Array` — and the existing
test passes one, so it was green — but Node's `Buffer` overrides `slice` with
an alias of `subarray`. A `Buffer` is what a socket read hands a Node caller,
which is exactly how the QUIC plane in cut 4 will reach this function, so every
decoded payload would have been a live view of a frame buffer the transport is
free to reuse.

`Uint8Array.prototype.slice.call(bytes, ...)` was the obvious repair and does
copy, but `%TypedArray%.prototype.slice` takes its result constructor from
`@@species`, and `Buffer`'s is `FastBuffer`: the payload then comes back a
`Buffer` from a `Buffer` frame and a `Uint8Array` from a `Uint8Array` one, so
the same frame compares equal or unequal under `deepStrictEqual` depending on
how it arrived. `new Uint8Array(bytes.subarray(...))` copies and is one type on
both. Three mutations now guard the line — return a view, return a `Buffer`
view, return a species-typed copy — and the first two die only on their own
input type.

Mutation runner: 75 mutations, 75 killed, 0 survivors, three green controls.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 20:20:16 +02:00
Meta Vens
65f2b666ef Run the TypeScript workspaces by the name cultcache-ts actually has
`npm run build:ts` and `test:ts` have been dead since 8cb3b72, not 8dd5a45 as
first recorded. That commit renamed the package to @gamecult/cultcache-ts and
left `run-typescript-workspaces.mjs` asking npm for a workspace called
`cultcache-ts`, which no longer exists; npm refused at the first entry in the
list, so neither script reached any workspace at all. npm resolves a workspace
by manifest name, not directory, so the list moves whenever a package is scoped
— said out loud in the source now, since nothing else will say it.

The mutation runner is renamed `scripts/mutate-cultmesh.mjs`. It stopped being
about the route verifier alone when cut 2 gave it a third target, the realtime
frame codec, and a file named for one of its three targets invites the reader to
assume the other two are somebody else's problem.

`docs/typescript-quic-realtime-cut.md:46-47` names the old path; the map is
Self's and is updated separately.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 20:19:49 +02:00
Meta Vens
343dffc1de Record Soul's Cut 2 pass and correct one attribution
Byte parity held both ways under Soul's own frames. Three small things
did not: a mutant declared equivalent was not, the payload copy is a view
for a Node buffer, and the frame parity has no CI executor. The breakage
of the aggregate scripts dates from the scoped rename, not the version
bump this map first named.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 19:58:50 +02:00
Meta Vens
fef767d34a Record Cut 2's landing
The frame codec exists with parity proven both ways from its first
commit, and its test found a real divergence before any consumer could:
the JavaScript decoder deletes a leading byte-order mark the .NET decoder
keeps. Two housekeeping rulings taken: the aggregate TypeScript scripts
have been broken by a rename for two days and get their one-word fix, and
the mutation runner is renamed for what it now covers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 19:24:11 +02:00
Meta Vens
ae29910b30 Mutate the realtime frame codec, one mutation per rule
A third target in the committed runner, `realtime`, over
`packages/cultmesh-ts/src/realtime-wire.ts`, killed by that package's own
suite. It gets the machinery the other two already have: a no-op control
through the same write path, the sidecar and its verified restore, and the
post-restore rebuild that proves `dist/` no longer carries a mutant, with
`0x31545343` as the sentinel its last mutation removes.

Thirty-seven mutations, one per rule the codec owns: each header field's
offset, width and endianness; the field order the reference fixed; the magic,
the stamped header size and wire version; the delivery ordinal; UTF-8
identities and byte-counting length prefixes; the identity and payload
ceilings; the ALPN, the two QUIC error codes and the two stream kinds; the
four encode refusals from the transport contract; each decode refusal and the
order they fire in; the byte-order mark; and the two aliasing rules, that
decode reads only the viewed bytes of an offset view and that the decoded
payload does not alias the frame. A mutation that changes the encoding
symmetrically still dies, because the vector bytes were written on the other
side.

All seventy-two mutations across the three targets are killed and all three
controls are green.

One mutant is not listed and says why in the table, as the P1363 length guard
already did: removing `payloadLength < 0` from the decoder's length refusal is
equivalent. The clause mirrors the reference but cannot fire alone, because
reaching it means the frame is already 37 bytes and a negative length can then
never make the sum match. It survived when tried.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 19:20:57 +02:00
Meta Vens
6cbc73fb17 Encode a CultMesh realtime frame in TypeScript
One codec, `packages/cultmesh-ts/src/realtime-wire.ts`, rule for rule what
`CultMeshRealtimeWireProtocol` does: the 37-byte little-endian fixed header
with magic 0x31545343, the delivery byte as the C# enum ordinal, the i64
epoch and sequence, three u16 identity lengths, the i32 payload length, the
stamped header size and wire version, then UTF-8 identities and the payload.
It touches no socket; QUIC stream framing stays transport-owned. The blank
reading in `Validate` is `IsNullOrWhiteSpace`, whose whitespace set is not
`String.prototype.trim`'s, so it borrows `isNullOrWhiteSpaceCSharp` from
`cultnet-ts` rather than growing a second reading of the same rule.

Byte parity is proven in both directions and in two processes, which is what
the authority vectors cost us to learn. `contracts/cultmesh/realtime-frame-vectors.json`
is written by the new C# test under CULTMESH_WRITE_VECTORS=1 and decoded and
re-encoded here; `realtime-frame-vectors.ts-written.json` is written by
`scripts/write-cultmesh-realtime-frame-vectors.mjs` with this codec, over
different identities and epochs, and decoded by the same C# test with the
reference implementation alone. Both files share one field shape, so each
side reads both with one reader. The two cases that would put megabytes into
a committed contract file, identities at exactly 65,535 UTF-8 bytes and a
64 MiB payload, are recorded as a build rule plus the digest of the whole
encoding; both sides build them the same way.

Three things the C# reference gets from its type system and TypeScript does
not, each refused rather than left to surface as a stray runtime error: a
`bigint` outside the i64 range, a delivery string the union cannot spell, and
a leading U+FEFF, which `TextDecoder` deletes by default and
`Encoding.UTF8.GetString` keeps. The last one is a real divergence the test
caught; the decoder now sets `ignoreBOM`. U+FEFF is not whitespace to .NET,
so such an identity passes the transport contract and reaches the wire.

There was no C# test for this codec before; this is also its first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 19:20:41 +02:00
Meta Vens
fe2555da63 Repair only the file states the mutation runner itself wrote
A SIGKILL leaves the sidecar behind, and the next run wrote it over whatever
the file then held. That is right after a kill and destructive after one: a fix
written to the source between the kill and the next run was reverted to bytes
from before the kill, with a line claiming a repair, and the run then reported
mutation results for code nobody had written.

The mutation table is the record of what a live run can leave behind: the
original (which is the sidecar, and also what the no-op control writes) or the
original with exactly one of that target's mutations applied. Repair now
recognises those and nothing else. A file matching neither stops the run with
its own digest and the sidecar's and changes nothing.

Reproduced both halves against the shared target. Killed mid-mutation, then
appended a line to the source: the rerun exits 1, prints both sha256s, and the
hand-written line is still there afterwards. With the mutant put back and the
sidecar unchanged, the rerun repairs as before and now names the mutation the
dead run was inside.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 18:35:44 +02:00
Meta Vens
6c933d034b Spell the transcript separator as an escape, not a raw byte
The unit separator that joins protocol ids sat in the source as a literal
U+001F: once in `cultmesh-authority.ts` and twice in its test. Every editor,
every diff and every review renders it as nothing, so `join("")` reads as a
join on the empty string and the transcript looks wrong to anyone checking it
against the C# reference, which spells the same separator as an escape.
The bytes were right, which is exactly why nothing caught it.

The three become escapes. The bytes do not move: the C#-written and the
TypeScript-written route vectors both still verify through the shared module,
and the sorted-transcript test still builds the same expected bytes.

A new test reads both files and refuses any byte below 0x20 that is not LF or
CR, so the class recurs loudly rather than silently. It earned its keep on the
first run by catching a literal U+001F in the comment introducing it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 18:32:07 +02:00
Meta Vens
c64a2da81d Refuse the URL-repaired spellings in the protection gate too
`isLoopbackEndpoint` hands its endpoint to `refusedByCSharpUriShape` before
`URL` sees it; `isProtectedEndpoint` did not, and `URL` repairs eleven
spellings `System.Uri` refuses outright. Every one of them answered protected
here and unprotected in the C# reference, which is the dangerous direction:
the channel-protection gate opened on a route `IsProtected` would have closed.
`wss:x`, `wss:host`, `wss:host:8443`, `wss:/host` and `wss:///host` have no
authority for `System.Uri`; `wss://host\path` and `wss://host:8443\` carry a
backslash; `wss://ho\tst/m`, `wss://host\t/m` and `quic://ho\tst` a tab, and
`wss://host\r\n/m` a CR/LF, all of which `URL` deletes.

Soul's 71 raw-shape spellings go in as a table with the answer the C#
`IsProtected` gave for each on 2026-09-16, and the test asserts the invariant
rather than equality: TypeScript may never call an endpoint protected that C#
refuses. Eleven of the 71 now differ the safe way round, C# protected and this
not, and they are pinned by name rather than excluded, so neither a twelfth
joining them nor one of them flipping passes quietly. Two throw in `URL` before
any rule of ours runs (an IPv6 zone id, a non-breaking-space host); the other
nine are host repairs `refusedByCSharpUriShape` refuses on purpose, because the
same repair would make `isLoopbackEndpoint` lie. None is dialable as a browser
CultMesh route.

The mutation that drops the pre-check from `isProtectedEndpoint` alone, leaving
`isLoopbackEndpoint`'s in place, is killed by that test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 18:29:13 +02:00
Meta Vens
565ce79a4a Close Cut 1 and start Cut 2
Three Soul passes and three fix batches leave one verifier whose rules
match the C# reference as executed, parity pinned both ways and run in
CI on both operating systems. Three residuals ride with Cut 2. Two
statements this map made about its own batch were overstated and are
corrected: the NUL byte never caused a rewrite, and CI was not green
throughout.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 18:21:52 +02:00
Meta Vens
74fee16003 Record the Cut 1 edge-alignment batch
TypeScript now cleans a route the way the C# constructors do, reads
whitespace with C#'s alphabet, refuses the endpoint spellings the URL
parser would otherwise repair, and names the two signature refusals as
C# names them. Every probe reruns with zero reachable disagreements. Two
scars kept for the postmortem: a sidecar that reverted fixes it was meant
to protect, and a NUL byte that made git read source as binary.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:51:25 +02:00
Meta Vens
0dfef0e5eb Run the TypeScript authority tests in CI, on both operating systems
Nothing ran `npm run test --workspace packages/cultnet-ts` in CI. The shared
route verifier's tests, which are the whole TypeScript side of the CultMesh
authority contract, ran only on a developer's machine, and the browser client's
tests ran nowhere. `cultnet-interop.yml` is windows-latest and triggers on main
only, so even that would not have covered Linux or a pull request.

Both now run in `cultmesh-portability.yml`'s `reactive-and-resource-lifetime`
job, beside the step that verifies the same vectors with the C# reference. That
job already matrixes windows-latest and ubuntu-latest, already triggers on
main, codex/** and pull requests, and already installs the TypeScript workspace
dependencies, so the steps cost two npm runs and no new setup.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:40:39 +02:00
Meta Vens
dfe582edbc Kill the browser's local reading of "unsigned", and mutate the browser too
Soul replaced the shared `isUnsignedCertificate` call in the browser's
session-acceptance check with a local empty-string comparison and every one of
the 18 browser tests still passed, because every test used an empty signature.
A whitespace signature is where the two readings part: the shared route
verifier accepts such a route as unsigned on a loopback local-development
endpoint, and an inline comparison then demands a provider proof for the
session the verifier just let through. The test now opens that session with
three spaces, U+0085 and a no-break-space/tab/newline mix as well as the empty
string, and asserts U+FEFF the other way, since it is whitespace to JavaScript
and a signature to C#.

The mutation runner grew a second target. `shared` is the one verifier, killed
by the cultnet-ts tests; `browser` is `packages/cultmesh-browser/src/index.ts`,
killed by the browser tests. A browser mutation cannot be killed by cultnet-ts
tests, so the killer is the target's own workspace, and each target carries its
own sidecar, control, sentinel and post-restore dist assertion.

Two repairs this pass earned:

- The sidecar is now removed inside the `finally`, right after the verified
  restore. A throw mid-run left it behind holding the pre-run bytes, and the
  next run "repaired" the file from them, silently reverting everything fixed
  in between. That happened here, twice, before it was noticed.
- The NUL in the loopback pre-check's strip regex had been written as a literal
  byte rather than an escape. It read the same, but git classified the file as
  binary and stopped normalising its line endings, so the whole file diffed as
  rewritten. Escaped, along with the ASCII host range beside it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:39:50 +02:00
Meta Vens
803ee12a7a Refuse the endpoint spellings WHATWG URL repairs and System.Uri does not
`isLoopbackEndpoint` handed the raw endpoint to `URL` and read the host it got
back. `URL` is a repairing parser and `System.Uri` is not, so seven spellings
were loopback here and remote in C#: a trailing dot on the host, which `URL`
drops; `ws:127.0.0.1`, `ws:/127.0.0.1` and `ws:///127.0.0.1`, whose authority
`URL` infers; a backslash path separator, which `URL` reads as a separator;
fullwidth digits and letters in the host, which `URL` folds to ASCII; and a tab
in the host, which `URL` deletes. Each of those is a local-development bypass:
a route the C# consumer would have demanded a certificate for opened unsigned
here.

`refusedByCSharpUriShape` refuses exactly those, on the raw string, before
`URL` sees it. Leading and trailing C0 controls and spaces around the whole
endpoint are stripped first, because both sides accept them. The spellings
Hands probed earlier and C# accepts -- `127.1`, hex, decimal,
`[::FFFF:7F00:1]`, userinfo, `localhost:8443` -- are untouched.

The doc comment claimed equivalence with `System.Uri.IsLoopback`. It is not
equivalent and now says so: two divergences remain, both unreachable from a
browser CultMesh route, and both named. An IPv6 zone id throws in `URL` and is
a host-local interface name no browser socket can dial; `file:` and `mailto:`
have no `URL` host and are refused by `openSocket`, which takes `ws:` and
`wss:` only.

The test is Soul's endpoint list with the C# answer as the expectation: 82 of
the 88 it probed, the six unreachable ones named in the doc comment instead.

Mutations: the trailing-dot refusal dropped, the scheme-and-authority shape
inverted, the backslash refusal dropped, and the non-ASCII host refusal
dropped -- each killed by the table.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:22:06 +02:00
Meta Vens
c4c2330805 Clean the route fields before the transcript, as the C# constructors do
The C# reference signs a transcript over a *constructed* route, and the
constructors clean on the way in: `CultMeshAuthorityRoute.Clean` trims each
protocol id, drops the blank ones, keeps one of each ordinally and sorts
ordinally; `RequireNonEmpty` trims the runtime id and endpoint; `Require`
trims the provider key id and coordinates; `CultMeshRouteCertificate` trims
the Odin key id; a blank generation becomes the runtime id and endpoint
joined by "@". TypeScript cleaned none of it and transcribed the caller's
string, so a route C# signed and accepted was refused here the moment any
field carried padding or a duplicate.

Soul's tamper loop is the evidence: C# accepted a duplicated protocol id, a
padded generation and a padded Odin key id against the committed vector's own
signature, and TypeScript refused all three. The test now reruns those, plus a
padded protocol id, a blank one, a reversed list, a padded endpoint and a
padded provider key id, against the C#-written vector's signature. A padded
Odin key id must also find its root, so the trust map keys on the trimmed id
as the C# dictionary does.

`verseId` is left alone on purpose: it is not a route field, and
`CanonicalRoute` passes the caller's string into `Canonical` untrimmed. The
test pins that too.

`protocolId ?? "cultmesh.documents.v1"` is gone. A C# route with no protocol
ids is `Array.Empty<string>()` and transcribes as the empty string; reading
the handshake's `protocolId` as a one-element list wrote bytes the reference
never writes. `canonicalSession` matches its C# counterpart the other way:
it cleans nothing on the request, because the handshake message is what the
wire carried, and only the endpoint is cleaned, because the one C# passes is
`Route.Endpoint`.

Mutations: the dedupe removed, the blank-id filter removed, the generation
trim removed, the provider key id trim removed, and the Odin key id trim
removed from the lookup -- each killed by its test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:14:35 +02:00
Meta Vens
e67e695a63 Read whitespace and the signature refusals as C# does
Two edge alignments that have to land together, because the test for the
first asserts the messages of the second.

`String.prototype.trim` is not `char.IsWhiteSpace`. JavaScript counts U+FEFF,
which .NET does not; .NET counts U+0085, which JavaScript does not. Every
`trim()` in the shared verifier stood in for a C# `IsNullOrWhiteSpace` or
`Trim()`, so both were wrong at those two code points: a U+0085 signature was
an unsigned route to C# and a bad signature to TypeScript, and a U+FEFF
signature the other way round. `isCSharpWhiteSpace`, `trimCSharp` and
`isNullOrWhiteSpaceCSharp` are that set, and `isUnsignedCertificate` and the
signature trim now use it. The test pins all 34 code points Soul probed on
both sides against the answers .NET 10 gave on 2026-09-16.

The C# `Verify` also has three signature refusals, not one: a signature that
is not base64, a decoded length that is not 64, and a signature that decodes
but does not verify. TypeScript collapsed the first two into the third and
lost the distinction the reference draws. Split, with each message pinned by
its exact C# string. `verifyP256` keeps its own checks: it is the silent
`VerifySession` path, which returns false for all three.

Mutations: the whitespace-set site reverted to `String.prototype.trim`,
U+0085 dropped from the set, and the two new messages merged back into
"invalid" -- each killed by its test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:09:48 +02:00
Meta Vens
b7bb483989 Record Soul's second Cut 1 pass
The cut closes on its invariants. What is left is the gap between the C#
rules as written and as executed: the route is cleaned before the
transcript on one side and transcribed raw on the other, whitespace and
loopback are decided by different alphabets at the edges, and the browser
guard is enforced by name only. All in a final Hands batch before Cut 4
makes a TypeScript signer real.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:02:03 +02:00
Meta Vens
04087c6ab6 Record the browser session alignment and widen the name grep
The browser's local-development short-circuit reads "unsigned" through the
shared predicate, so the two TypeScript sides cannot disagree with each
other or with C#. The negative grep now names the two functions the fix
batch added. The generated contracts file's line-ending churn is recorded
where unassigned findings live.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:45:00 +02:00
Meta Vens
a801e0a441 Share one reading of an unsigned certificate between verifier and browser
The browser's local-development session-proof short-circuit tested
`!route.certificate`, while the shared verifier already treated an empty or
whitespace signature as unsigned, matching C# `Verify` (`:120`). A loopback
route carrying a blank-signature certificate therefore passed the verifier
and was then refused for lacking a provider key, where C# returns
`IsLocalDevelopment` and skips the session proof.

`isUnsignedCertificate` in `cultnet-ts/authority` is now the one owner of
that reading; the verifier and the browser both call it. A browser test pins
the local-development loopback open without a provider proof, the signed
loopback certificate still demanding it, and the remote counterpart refused
in both trust modes. The mutation runner gains two predicate mutations
(whitespace treated as signed; only a missing certificate treated as
unsigned), both killed by the existing whitespace-signature test in
cultnet-ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:43:09 +02:00
Meta Vens
e3f56c183f Record the Cut 1 fix batch
Both parity directions are now pinned in the repo and run in CI, the
public verifier copies its bytes, and the four edge rules match the C#
reference, with the loopback set probed rather than assumed. One scar: the
runner's multi-line anchors had never matched on a CRLF checkout, so two
earlier kill claims rested on an LF-on-disk state. One browser-side
divergence remains and is in Hands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:37:04 +02:00
Meta Vens
0e4f479ac5 Make the mutation runner leave no mutant behind
Every test run inside the runner rebuilds `packages/cultnet-ts/dist/`
from the mutant under test, so after the final restore of the source the
compiled module still carried the last mutant. The runner now rebuilds
the package after the restore and asserts `dist/cultmesh-authority.js`
contains a sentinel the last listed mutation removes, checked against the
list itself so a reordering cannot make the assertion vacuous.

The original bytes are also written to `<target>.mutation-original`
before the first write and removed after the verified restore. A run
killed mid-mutation is repaired from that sidecar at the next run's
start, instead of by whoever notices git status.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:34:31 +02:00
Meta Vens
cf1203a607 Run the C# authority vector checks in CI
`CultMeshAuthorityProofTests` verifies both vector files with the C#
reference, but no workflow ran it. The portability workflow's
reactive-and-resource-lifetime job is the one that already builds and
runs `GameCult.Mesh.Tests` on every push to main and codex branches and
on every pull request, on Windows and Ubuntu, so it gains one step:
`dotnet test --filter FullyQualifiedName~AuthorityProof`. The interop
workflow was not chosen: it runs on Windows only, on main pushes only,
and runs no Mesh tests. It already covers the TypeScript direction
through `npm test --workspace packages/cultnet-ts`.

CULTMESH_WRITE_VECTORS stays unset in CI; the committed files are the
contract.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:28:29 +02:00
Meta Vens
1590145a81 Pin the TypeScript-signed direction of the authority parity check
`contracts/cultmesh/authority-route-vectors.json` proves C# bytes verify
in TypeScript. Nothing in the repo proved the reverse; Soul's probe did it
once in a scratch directory. It is now a committed check in the same shape,
mirrored:

- `scripts/sign-cultmesh-authority-vectors.mjs` builds the route and
  session transcripts with the shared module, signs them with fixed
  TypeScript-owned test keys over awkward inputs (a quic scheme with a
  query, three unsorted protocol ids, non-ASCII generation text, priority
  13), self-verifies, and writes
  `contracts/cultmesh/authority-route-vectors.ts-signed.json`.
- `CultMeshAuthorityProofTests.AuthorityRouteVectorsSignedByTypeScriptVerifyHere`
  reads that file with keys taken from the file, asserts the protocol ids
  are unsorted, that `Validate` and `VerifySessionProof` accept, and that
  a priority-tampered route and a replayed nonce are refused.
- The TypeScript vector test runs over both files, so a transcript change
  on the TypeScript side fails here before C# could trust a stale file.

Checked negatively: one base64 character changed in the committed
signature fails the C# test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:27:06 +02:00
Meta Vens
c2ffc32ff6 Make the TypeScript route verifier the C# rules exactly
verifyP256 handed WebCrypto `view.slice().buffer`. For a Node Buffer,
`slice` is `subarray`, so `.buffer` is the shared pool: a pooled Buffer or
a subarray of a larger allocation verified false while the same bytes as a
plain Uint8Array verified true. Copy exactly the viewed bytes.

Trust rules that had drifted from `CultMeshAuthorityProof.cs`:

- A certificate whose signature is empty or whitespace is an unsigned
  route (C# `Verify` line 120): local-development loopback accepts it,
  everything else refuses with the unsigned message.
- Loopback is `System.Uri.IsLoopback`: any 127.0.0.0/8 host, `::1`, the
  IPv4-mapped `::ffff:127.0.0.1` (only `.1`), and the host names
  `localhost` and `loopback`. WHATWG URL normalises `127.1`, `0x7f000001`
  and `2130706433` the way System.Uri does. Probed on the C# side through
  the public policy before the rule was written; the test pins that set.
- The root lookup runs before the validity window, so an expired route
  under an unknown root is refused as untrusted on both sides.
- A policy listing one Odin key id twice is refused at first use, the
  closest a plain object gets to the C# constructor throwing.

One test per rule, one mutation per rule in the runner's list. A fixture
whose protocol ids are unsorted and whose expected transcript is the
sorted one means removing the sort is now killed.

The runner now matches anchors in the file's own line ending. On an
autocrlf checkout the two multi-line anchors it already had ("route
transcript field order", "session transcript binds the nonce") matched
nothing, so the run threw at those entries instead of testing them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:24:41 +02:00
Meta Vens
c00bed8575 Record Soul's Cut 1 pass
The verifier is sound in both directions, the second one by probe. What
can go wrong silently is the verification story: a mutant left compiled
in an ignored directory, a C# vector check with no CI owner, a public
function that reads a pooled buffer wrong, and four edge rules that drift
from the reference. One map claim about error texts is withdrawn.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:19:46 +02:00
Meta Vens
737e13286b Record Cut 1's landing
One route verifier in cultnet-ts, the browser copy deleted, a C#-written
vector file, and a mutation runner with a control. Soul is on it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:01:46 +02:00
Meta Vens
062a5c6678 Add a mutation check for the shared route verifier
One mutation per rule the cut map names, applied to the source bytes and
restored from the original bytes with a digest check; a no-op control
runs first through the same write path. A survivor is a rule no test
pins and fails the script.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 15:59:09 +02:00
Meta Vens
66286b9dfb Delete the browser copy of the route verifier
cultmesh-browser now verifies routes and provider session proofs through
cultnet-ts/authority. Its own verifyAuthorityRoute, canonicalRoute,
canonicalSession, canonicalFields, verifyP256, isLoopbackEndpoint and the
two base64 helpers are gone; the six route and trust types are re-exported
under their old Browser names as aliases of the shared types. randomNonce
stays: it is client behaviour, not verification. All 17 browser tests pass
unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 15:59:09 +02:00
Meta Vens
e234a64ae9 Add the shared CultMesh route verifier to cultnet-ts
Cut 1 of the TypeScript QUIC realtime plane: one TypeScript
implementation of Odin root lookup, the validity window, the canonical
route and session transcripts, P-256/P1363 verification, loopback
detection and the protected-scheme rule, published as
`cultnet-ts/authority`. Browser-safe by construction: WebCrypto,
TextEncoder, atob/btoa, URL, DataView; no node: import, and a test that
says so.

The protected-scheme rule is the C# rule (wss, https, any scheme
containing "quic") rather than wss-only, so the module is not wrong for
QUIC routes; the browser client still refuses non-ws(s) endpoints itself.

The transcript bytes are pinned by contracts/cultmesh/authority-route-
vectors.json, written by the C# reference under CULTMESH_WRITE_VECTORS=1
with fixed test keys and otherwise re-verified by both runtimes. The
vector carries a route certificate and a provider session proof.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 15:59:09 +02:00
Meta Vens
1c12136872 Rule Q8: the native binaries are committed
The deploy tarball is a git archive, so committed files are the only ones
that reach Yggdrasil without a second mechanism, and the Windows pair is
already committed for Unity. Four binaries, about eight megabytes, with a
manifest and digests beside them. No operator question is open on this map.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 15:57:33 +02:00
Meta Vens
4a2fdafa61 Remap Cut 6 under C: binaries in the tree, a release workflow, no registry
The deploy tarball is a git archive of two package directories, so only
committed files reach Yggdrasil and cultmesh-ts does not ride at all
today. Under C the binding resolves binaries from an environment variable
or the package's own native directory, a workflow builds the Linux artifact
in a debian:13 container and attaches it to a release with a manifest and
digests, and the operator smoke ships its own archive. Registry publication
is parked with its seam named. One question remains: whether the four
binaries are committed or fetched at deploy time.

The corrected npm premise is recorded in the operator's words: reachable,
publication deferred by choice.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 15:28:31 +02:00
Meta Vens
8d8ad568aa Re-rule Q5 to C: no registry, because npmjs is unreachable here
Ruling A assumed a publish job whose token is minted by logging in to
npmjs.com, and the operator's subnet is blocked there. That is also why the
existing cultcache-ts publish job has never run. Consumers keep file: paths;
the binaries ship in the CultLib tree the deploy path carries; the Actions
build attaches the Linux artifact to a release. Cut 6 and the lines of Cuts
4 and 7 that assumed a registry are flagged for remapping.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 15:12:08 +02:00
Meta Vens
3c29fc934d Record the Q5, Q6 and Q7 rulings in the QUIC cut map
All three recommendations were taken: unscoped cultnet-ts and cultmesh-ts
with only the native platform packages scoped; the Linux artifact built by
GitHub Actions in a debian:13 container and smoked on Yggdrasil; the lookup
source port plus a static source now, with the Node Odin rendezvous parked.

The status header now says no operator question is open and cut 1 has not
started, so a reader of the header alone knows where the campaign stands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 13:35:11 +02:00
Meta Vens
bf9f6f7874 Map the TypeScript QUIC realtime plane migration
Seven cuts against main at f2cd2eb4, with the largest liability priced first
rather than discovered later. The native binding was the open question: a probe
loaded the committed msquic.dll through koffi under Node 24, drove a handshake
on MsQuic's own threads and closed cleanly, with no addon build and no toolchain
at load time. It is one cut, not the campaign.

The consumer audit found no package at zero external consumers and every
external consumer on a file: path rather than the registry, which is why
registry naming is an operator question rather than an assumption.

Two corrections against the target are recorded in the map rather than absorbed
silently: a cited transport filename that does not exist under that name, and a
registry path the target assumes for cultmesh-ts that has never existed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:16:56 +02:00
Meta Vens
f2cd2eb464 Add the TypeScript QUIC realtime plane migration target
Eureka target for giving cultmesh-ts a QUIC realtime provider and consumer,
byte-compatible with GameCult.Mesh.Quic and the Unity native MsQuic connector.
First consumer: the StreamPixels service publishing to its Unity overlay.

Operator rulings 2026-09-16: overlay character, subathon window and scene
intents travel as QUIC state frames and the costume catalog as an HTTPS
content artifact; shared TypeScript route verification moves to a
browser-safe module; Windows x64 and Linux x64 only; datagrams out of scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 20:02:33 +02:00
Meta Vens
a0813c6eed Release CultLib Unity 1.0.59 and the Studio 1.3.1
Corrects the 1.0.58/1.3.0 release. Rebuilds the tracked plugins with
scripts/build-unity-package.ps1 -UpdateTemplate so they carry "" as the
unset CultRecordRef wire form in every position (nil still reads as unset)
and the edit-start Record snapshot. The native QUIC connector changes once
to its /Brepro form; later rebuilds reproduce it byte for byte. The Studio
package moves to 1.3.1 and depends on cultlib 1.0.59.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 21:35:34 +02:00
Meta Vens
2053ab740d Build the native QUIC connector reproducibly with /Brepro
b85a828 changed x86_64/gamecult_mesh_quic_native.dll with no native
source change. The two copies differ in six bytes: the PE header
TimeDateStamp and the debug directory timestamp. The MSVC link stamps the
build time, so every -UpdateTemplate run rewrote the committed DLL.

/Brepro on compile and link replaces the timestamps with content hashes.
Two clean builds from an emptied build root now produce identical bytes,
and the DLL embeds no build path. The committed DLL changes once to the
reproducible form; after that the release byte check covers it like the
managed plugins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 21:34:23 +02:00
Meta Vens
b03118db2c Space the Studio drawer's ReplaceKey and FreshKey arguments
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 21:34:23 +02:00
Meta Vens
29bec57021 Decode an edit's Record from the bytes its Document came from
BeginEdit cloned Document eagerly but Record lazily from record.Document,
so a cache change in place before Record's first read made the two copies
come from different states. BeginEdit now serializes the document once and
deserializes Document from those bytes; Record deserializes the same bytes
on first read. Record is the state the edit began from, and costs one
deserialize only when a drawer reads it, with no second serialize.

Each commit still spends the edit, so a slider drag begins an edit per
frame; that serialize was already paid for Document, and Record adds a
deserialize only in frames where a drawer reads it.

The new test mutates the cached document between BeginEdit and the first
Record read; it fails on the lazy clone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 21:34:23 +02:00
Meta Vens
a92381e589 Write an unset CultRecordRef as "" in every position
452f928 made nil the unset wire form, including dictionary keys: a
Dictionary<CultRecordRef<H>, float> holding the empty key encoded it as C0.
Python msgpack refuses nil map keys under strict_map_key, its default, so
cultcache-py cannot decode such a record. Nil cannot be the one canonical
form; "" can, and every runtime reads it.

The writer always writes "". The reader still takes nil as unset, because
452f928/b85a828 stores and Aetheria's Cut 9 import wrote nil; such a record
converts to "" on its next save and is byte-stable after that. The
store-composition contract names "" for value and key position, requires
readers to accept nil, and tells other runtimes to write "".

The tests pin payload bytes, so each claim is consequential: an unset ref
writes "", reloads through a fresh cache and resaves byte-identical; a
nil-written legacy payload reads as unset and resaves as ""; a ref-keyed
dictionary with an empty key writes "", reads back unset and resaves
byte-identical. The nil writer fails every byte assertion, a reader taking
nil as a set key fails the legacy pair, and a reader taking "" as a set key
fails the read-back assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 21:34:23 +02:00
Meta Vens
b85a82872e Release CultLib Unity 1.0.58 and the Studio 1.3.0
Rebuilds the tracked plugins with scripts/build-unity-package.ps1
-UpdateTemplate so they carry the unset-ref wire fix and
CultInspectorEdit.Record. The Studio package moves to 1.3.0 for
CultInspector.Record and depends on cultlib 1.0.58.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 21:18:01 +02:00
Meta Vens
90e7dbee27 Give inspector drawers the record they sit in, read-only
A drawer saw only its value; Aetheria's schematic-shape drawer needs sibling
members of the owning document (texture path, aspect, hull tints).

The model owns the seam: CultInspectorEdit.Record is a second copy of the
stored document as the edit began, cloned on first read and kept for the
edit. The Studio lowers it as CultInspector.Record, so a runtime CultUI
panel gets the same seam from the same edit. The Draw signature is
unchanged, so existing drawers keep compiling.

Why not hand drawers the edit copy the Studio already draws: that copy is
what Commit upserts, so a write through it would be saved without passing
through the returned value, GUI.changed, or read-only metadata. The record
copy is read by nothing, so writing to it saves nothing and changes neither
the edit nor the cache. It does not show edits other drawers made earlier in
the same frame.

The Studio's separate document-type field collapses into the held edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 21:01:22 +02:00
Meta Vens
452f928bb7 Write an unset CultRecordRef as nil and read it back unset
The formatter wrote default(CultRecordRef<T>) as nil but read nil back as
"", so re-upserting a loaded record changed its bytes (0xC0 became 0xA0).

One in-memory unset value: CultRecordKey.Value is never null, so
default(CultRecordRef<T>) equals a ref built from "". One wire form: an
empty key writes nil, and nil or a legacy "" reads back as default.
Load then save is byte-stable; a store holding "" converges to nil on its
next save.

Rust, TypeScript and Python have no reference type: a reference member is a
string in the consumer's own payload, so there is no sibling codec to align.
The store-composition contract now names nil as the one unset wire form.

The new test loads a record with an unset ref through a fresh cache,
re-upserts and flushes, and requires identical payload bytes; it fails on
the old formatter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 21:01:07 +02:00
Meta Vens
0f2c1f04ef Add PCG integer hashes to CultMath and release 0.2.3
CultMath's hash is the shader-style frac(sin(x) * 43758.5453) and
returns float. Unity.Mathematics' hash returned a well-mixed uint, and
seeding from the float hash collapses: a 100x100 position grid gave 1625
distinct values in Aetheria's zone generator.

- math.pcg(uint), pcg3d(int3/float2/float3), and pcg4d(int4/float4)
  follow Jarzynski and Olano, "Hash Functions for GPU Rendering"
  (JCGT 9(3), 2020). pcg3d and pcg4d are the paper's code from page 32.
  pcg is O'Neill's RXS-M-XS 32/32 output over one LCG step, with the
  constants checked against pcg-c's pcg_variants.h.
- CultMath has no uint vectors, so pcg3d and pcg4d carry uint bit
  patterns in int3/int4. The float overloads hash IEEE-754 bits via the
  HLSL intrinsic asuint(float), which is new. pcg3d(float2) holds z at
  0, the paper's route for 2 -> N inputs.
- The HLSL mirror gains the same functions, written over scalar uints
  so the text compiles both in dxc 1.9.0.5402 (lib_6_3, HV 2021, clean)
  and in C#. HlslSourceCompatibilityTests now compares int and uint
  arguments and results by value; a wrong shift in the mirror's pcg4d
  gives 208 mismatches.
- Known-answer vectors come from an independent Python transcription.
  pcg3d separates a 100x100 half-unit grid (the full int3). One
  component alone has one birthday collision there, as a 32-bit value
  can. The same grid on the integer lattice has none.
- design.md states that the hashes are integer-exact in HLSL and on the
  CPU: dxc wraps uint multiply and add modulo 2^32, as C# unchecked does.
- Bump to 0.2.3 and rebuild the tracked Unity DLL and pdb.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:19:53 +02:00
Meta Vens
111b918ec0 Make CultMath normalize follow dxc and release 0.2.2
dxc 1.9.0.5402 lowers float2/3/4 normalize(x) to x * Rsqrt(Dot(x, x)),
and DXIL.rst defines Rsqrt as 1 / sqrt(src). A zero vector normalizes
to NaN and a NaN component poisons every component. CultMath divided
by max(length, 1e-20), which returned 0 for a zero vector and a huge
finite component beside a NaN one.

- math.normalize(float2/3/4) is now value * (1 / sqrt(dot(value, value))).
- The HLSL mirror drops cultmath_normalize and its epsilon constant: it
  now equals the normalize intrinsic, and nothing consumed it.
- HlslSemanticsTests pins zero and NaN normalize, the rsqrt bit pattern,
  and zero length/distance. design.md states the rule and notes that
  DXIL.rst's Rsqrt special-value table is a copy of Round's.
- GameCult.Geometry's iso-surface gave degenerate triangles (an inside
  vertex on the isovalue) a zero normal through the old epsilon. It now
  guards them explicitly, with a test.
- quaternion normalize keeps its identity fallback: quaternion is not
  an HLSL type.
- Bump to 0.2.2 and rebuild the tracked Unity DLL and pdb.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:04:04 +02:00
Meta Vens
f0605367e0 Release CultMath 0.2.1
Bump the package and assembly version and rebuild the tracked Unity
DLL and pdb from a deterministic build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 16:16:32 +02:00
Meta Vens
7821e06d55 Add HLSL vector conversions, int clamp, and double frac to CultMath
Aetheria Cut 8a needs int2(float2), clamp(int, int, int), and
frac(double). Fill them in CultMath as dxc compiles them:

- generate-swizzles.ps1 emits same-size conversion constructors and
  math functions between float, int, and bool vectors. dxc lowers
  float to int as fptosi (toward zero; NaN and out-of-range undefined),
  numeric to bool as != 0 (fcmp une, NaN is true), bool to numeric as
  0 or 1. floatN(int) splat keeps floatN(0) from going ambiguous
  against floatN(intN).
- Int and int-vector clamp are min(max(x, a), b), matching dxc's
  IMin(IMax(x, a), b); inverted bounds return b.
- frac(double) keeps double precision. dxc demotes double frac to
  float, as it does exp and lerp; CultMath's existing double overloads
  already diverge that way, and design.md now says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 16:16:32 +02:00
Meta Vens
ae194d5eb2 Release CultLib Unity 1.0.57 with deterministic plugin assemblies
Some checks failed
Publish packages / cultcache-py to PyPI (push) Has been cancelled
Publish packages / cultmesh-py to PyPI (push) Has been cancelled
Publish packages / cultnet-py to PyPI (push) Has been cancelled
Publish packages / @gamecult/cultcache-ts to npm (push) Has been cancelled
Bumps org.gamecult.cultlib to 1.0.57 and rebuilds its managed plugins from
main, so the Unity package carries the store-routing CultCache (one home
store per type, AddBackingStore(CacheBackingStore, Type[]), atomic commit)
instead of the Sep 3 assemblies. Install docs point at the new tag.

The build script now publishes without Source Link or the source revision,
with CI path mapping and cleared Release intermediates. The previous DLLs
named the commit and the worktree they were built in, so committed plugins
could never match a fresh build of the commit that holds them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 15:18:42 +02:00
Meta Vens
546c919d4a Merge the CultCache store-routing cuts into main
Brings Cuts 1-6 of the Aetheria CultCache migration (one home store per
type, fixed routes, atomic commit with compare-exchange, parked SoA) and
CultMath 0.2.0 onto main, which had moved on with the CultNet contract
consolidation. The two lines touch disjoint files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 15:16:29 +02:00
Meta Vens
61549aa90b Make cultnet-ts a self-contained package again; revert fa00f3f's rootDir widen
REC-032 Soul finding: dist/contracts.js required "../../../contracts/cultnet/...",
escaping the cultnet-ts package tree entirely. That breaks every installed
consumer: the two-runtime sample script (packs and installs cultnet-ts into a
clean consumer), test-typescript-package-closure.mjs, and Heimdall's pnpm copy
of cultnet-ts (honours `files`, would 404 on its next vendor/CultLib bump).
Only Stonks (file: symlink into the live checkout) happened to still work.

Fix: keep CultLib/contracts/cultnet as the only tracked source, but have
tools/generate-swarm-contracts.mjs wipe and re-copy it into a package-local,
gitignored packages/cultnet-ts/contracts/cultnet/ every time it runs (build,
typecheck, test, test:interop, prepack, the two-runtime script, and Heimdral's
`npm run build` all already invoke it). src/contracts.ts and the swarm-contracts
manifest now import that package-local copy ("../contracts/cultnet/...") instead
of reaching three directories up. package.json gets back `files: ["dist",
"contracts"]` and `prepack` so a packed tarball carries its own schema copy.

Also revert fa00f3f's tsconfig.test.json rootDir widen ("." -> "../..") and the
matching test-script/dist-test path changes and __dirname-relative recomputation
in cultnet-interop.test.ts and runtime-presence-health.test.ts: that widening
was working around dist-test not receiving a copy of the (now out-of-package)
contracts, but nothing in this repo actually needs the wider rootDir once the
schemas are package-local again, and the widened paths never matched what CI's
interop lanes and package.json's own dist-test paths expect --
packages/cultnet-ts/dist-test/test/interop/cultnet-interop.test.js, not
.../dist-test/packages/cultnet-ts/test/interop/.... PR #3 CI run 34766892424 on
7d3a3dc failed the first interop lane on exactly this missing-file defect.
cultnet-interop.test.ts's interopSchemaPath already resolved correctly once
cultNetTsRoot was reverted (it was never touched by fa00f3f) and continues to
point at the canonical CultLib/contracts/cultnet/interop copy, which is fine
since it is only read by the spawned non-TS peer processes, not required() by
this package. The cultmesh-ts shard-catalog $id lookup fix from fa00f3f
(cultnet.shard_catalog_request.v0 -> the CultLib contracts URL id) is unrelated
to the rootDir defect and is left as is.

REC-032, Soul redirect on branch claude/rec-032-cultnet-contracts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 18:03:29 +02:00
Meta Vens
7d3a3dc42c Let the CultNet contract drift guard pass on a clean tree
git grep exits 1 when it finds nothing, and the GitHub pwsh step wrapper
ends the step with that $LASTEXITCODE, so the guard failed on exactly the
clean tree it should accept. End the step with an explicit exit 0 after the
checks; a real drift still throws first.

REC-032.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:52:15 +02:00
Meta Vens
59aca27b67 Add CI drift guard for the CultNet contracts consolidation
REC-032 commit 2: fail cultnet-interop.yml before it installs Node
dependencies if either negative check from commit 1 finds anything:
a *.schema.json file living outside contracts/cultnet/, or a lingering
reference to the retired https://github.com/GameCult/cultnet-ts/contracts
(or .../integration/contracts) namespace anywhere but .voidbot/birth
snapshots. The old-namespace grep uses git grep -I so the deferred
Unity plugin DLL (D4, not rebuilt in this pass) doesn't trip a
permanent false failure.

Verified locally: staging a stray contracts/stray.schema.json and a
stray-id.ts referencing the old namespace makes the guard's logic
report failure for both; removing them makes it pass again against the
real tree.

REC-032.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 17:45:53 +02:00
Meta Vens
fa00f3ffa2 Fix cultnet-ts test build for out-of-package contract location
REC-032 commit 1 follow-up, found during required verification: moving
the CultNet schemas out of packages/cultnet-ts/contracts into
CultLib/contracts/cultnet broke npm test for cultnet-ts. tsconfig.test.json
compiled with rootDir "." (to cover both src/ and test/ as siblings), and
TypeScript's resolveJsonModule copy-into-output behavior only mirrors
files that fall within the declared rootDir. The old contracts/ folder
was a sibling of src/ (inside rootDir), so it got copied into
dist-test/contracts and the "../contracts/x" import resolved. The new
contracts/cultnet is two directories above the package, outside any
possible rootDir short of the repo root, so nothing got copied and the
"../../../contracts/cultnet/x" import 404'd at runtime under dist-test
(the production "dist" build was unaffected, since dist/ and src/ sit at
the same depth under the package).

Fix: widen tsconfig.test.json's rootDir to the CultLib repo root so
resolveJsonModule can find and copy contracts/cultnet into dist-test,
update the "test"/"test:interop" npm scripts for the resulting deeper
dist-test/packages/cultnet-ts/... output path, and correct the two
__dirname-relative computations in test/interop/cultnet-interop.test.ts
and test/runtime-presence-health.test.ts that assumed the old shallower
depth.

Also fixes packages/cultmesh-ts/test/cultmesh.test.ts, which still
looked up the shard-catalog builtins by their old bare
cultnet.shard_catalog_{request,response}.v0 schemaId; CultNetSchemaCatalog
keys by $id, which D1 changed to the CultLib contracts URL, so the
lookup returned undefined.

REC-032, decision D1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 17:43:55 +02:00
Meta Vens
fcc49cffa0 Consolidate CultNet schema contracts into CultLib/contracts/cultnet
REC-032 commit 1: move the canonical 33 TS + witness-artifact-bundle
schema files (34 total) into runtime-neutral CultLib/contracts/cultnet,
move the interop-note schema (and delete the zero-consumer discovery-*
schemas per D3, tagged parked/cultnet-discovery-contracts) into
contracts/cultnet/interop, and delete the 27 C# and 24 Rust duplicate
copies after confirming byte-for-byte equivalence (only
cultnet.snapshot-request differs: TS carries shardId/shardEpoch, a
known drift already resolved in favor of the TS shape).

Rewrite every $id from https://github.com/GameCult/cultnet-ts/contracts/
and .../cultnet-ts/integration/contracts/ to
https://github.com/GameCult/CultLib/contracts/cultnet/(interop/); give
the two shard-catalog schemas URL ids per D1, replacing the bare
cultnet.shard_catalog_*.v0 identifiers; leave ghostlight.agent-state's
Ghostlight-owned $id untouched per D5.

Repoint every runtime's load path and id literal at the new location:
C# csproj EmbeddedResource + LogicalName, Rust include_str! paths,
TS contracts.ts imports + swarm-contracts manifest/tooling + package.json
files/prepack, and the id/path literals in the C#, Rust, Python and
Kotlin interop tests and peers. Python and Kotlin still build their own
schema JSON in-process (D2); only their base constants, id literals,
and (for Kotlin) contentHash literals were swapped. Every Kotlin
contentHash was recomputed from the final canonical files using the
runtime's own stable-stringify + SHA-256 function; all matched the
planner's precomputed values except cultnet.schema-catalog-response,
where the planner's proposed value did not match recomputation and the
freshly computed value was used instead.

REC-032, decisions D1/D2/D3/D5.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 17:27:41 +02:00
200 changed files with 14091 additions and 3754 deletions

13
.gitattributes vendored
View file

@ -1,2 +1,15 @@
# 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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,45 @@
{
"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=="
}
}

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.session-accepted.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultmesh.session-accepted.schema.json",
"title": "CultMesh Session Accepted",
"type": "object",
"required": ["schemaVersion", "messageId", "accepted", "verseId", "authorityRuntimeId", "protocolId", "routeGeneration", "clientNonce", "providerKeyId", "providerSignature"],

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.session-open.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultmesh.session-open.schema.json",
"title": "CultMesh Session Open",
"type": "object",
"required": ["schemaVersion", "messageId", "sourceRuntimeId", "verseId", "authorityRuntimeId", "protocolId", "routeGeneration", "clientNonce"],

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.verse-catalog-request.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultmesh.verse-catalog-request.schema.json",
"title": "CultMesh Verse Catalog Request",
"type": "object",
"required": ["schemaVersion", "messageId"],

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.verse-catalog-response.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultmesh.verse-catalog-response.schema.json",
"title": "CultMesh Verse Catalog Response",
"type": "object",
"required": ["schemaVersion", "messageId", "verses"],

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.database-change-raw.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.database-change-raw.schema.json",
"title": "CultNet Raw Database Change Message",
"type": "object",
"required": ["schemaVersion", "messageId", "subscriptionId", "changeKind"],

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.database-subscribe.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.database-subscribe.schema.json",
"title": "CultNet Database Subscribe Message",
"type": "object",
"required": ["schemaVersion", "messageId", "subscriptionId", "includeSnapshot"],

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.database-unsubscribe.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.database-unsubscribe.schema.json",
"title": "CultNet Database Unsubscribe Message",
"type": "object",
"required": ["schemaVersion", "messageId", "subscriptionId"],

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-delete.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.document-delete.schema.json",
"title": "CultNet Document Delete Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-mutation-contract.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.document-mutation-contract.schema.json",
"title": "CultNet Document Mutation Contract",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put-raw.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.document-put-raw.schema.json",
"title": "CultNet Raw Document Put Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.document-put.schema.json",
"title": "CultNet Document Put Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-record.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.document-record.schema.json",
"title": "CultNet Document Record",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.error.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.error.schema.json",
"title": "CultNet Error Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.hello.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.hello.schema.json",
"title": "CultNet Hello Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.login-success.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.login-success.schema.json",
"title": "CultNet Login Success Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.login.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.login.schema.json",
"title": "CultNet Login Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.operation-request.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.operation-request.schema.json",
"title": "CultNet Operation Request",
"type": "object",
"required": ["schemaVersion", "messageId", "serviceId", "operation", "payloadSchema", "payload"],

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.operation-response.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.operation-response.schema.json",
"title": "CultNet Operation Response",
"type": "object",
"required": ["schemaVersion", "messageId", "serviceId", "operation", "status", "payloadSchema", "payload"],

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.raw-document-record.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.raw-document-record.schema.json",
"title": "CultNet Raw Document Record",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.register.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.register.schema.json",
"title": "CultNet Register Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.sample-change-name.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.sample-change-name.schema.json",
"title": "CultNet Sample Change Name Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.sample-chat.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.sample-chat.schema.json",
"title": "CultNet Sample Chat Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.schema-catalog-request.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.schema-catalog-request.schema.json",
"title": "CultNet Schema Catalog Request Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.schema-catalog-response.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.schema-catalog-response.schema.json",
"title": "CultNet Schema Catalog Response Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.schema-descriptor.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.schema-descriptor.schema.json",
"title": "CultNet Schema Descriptor",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "cultnet.shard_catalog_request.v0",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.shard-catalog-request.schema.json",
"type": "object",
"required": ["schemaVersion", "messageId"],
"additionalProperties": false,

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "cultnet.shard_catalog_response.v0",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.shard-catalog-response.schema.json",
"type": "object",
"required": ["schemaVersion", "messageId", "shards"],
"additionalProperties": false,

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.snapshot-request.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.snapshot-request.schema.json",
"title": "CultNet Snapshot Request Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.snapshot-response-raw.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.snapshot-response-raw.schema.json",
"title": "CultNet Raw Snapshot Response Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.snapshot-response.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.snapshot-response.schema.json",
"title": "CultNet Snapshot Response Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.transport-profile.schema.json",
"title": "CultNet Transport Profile",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.verify.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.verify.schema.json",
"title": "CultNet Verify Message",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.witness-artifact-bundle.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.witness-artifact-bundle.schema.json",
"title": "CultNet Witness Artifact Bundle",
"type": "object",
"required": [

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-note.schema.json",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-note.schema.json",
"title": "CultNet Interop Note",
"type": "object",
"required": [

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,8 @@
# 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.
@ -19,5 +22,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.46
https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.57
```

213
docs/semver-policy.md Normal file
View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,20 +1,118 @@
cmake_minimum_required(VERSION 3.24)
project(gamecult_mesh_quic_native LANGUAGES CXX)
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")
# 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")
endif()
add_library(gamecult_mesh_quic_native SHARED cultmesh_quic_native.cpp)
target_compile_features(gamecult_mesh_quic_native PRIVATE cxx_std_20)
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)
# 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()
set_target_properties(gamecult_mesh_quic_native PROPERTIES
OUTPUT_NAME "gamecult_mesh_quic_native"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
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()

View file

@ -1,10 +1,168 @@
# GameCult.Mesh.Quic.Native
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`.
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.
The build consumes Microsoft's pinned
`Microsoft.Native.Quic.MsQuic.Schannel` package. It does not contain
Aetheria-specific code.
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.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,371 @@
/* 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

View file

@ -26,13 +26,13 @@ shader semantics.
acceleration and semi-implicit Euler integration
- `math` intrinsics: `radians`, `degrees`, `abs`, `floor`, `ceil`, `frac`,
`min`, `max`, `clamp`, `saturate`, `lerp`, `step`, `smoothstep`, `dot`,
`cross`, `length`, `distance`, `normalize`, `reflect`, `csum`, exponential
`cross`, `length`, `distance`, `normalize`, `reflect`, `csum`, `erf`,
`erfinv` (scalar only), 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, including CultMath's
safe `normalize` contract.
exposes `cultmath_*` functions for shared semantics HLSL does not provide.
- `Voronoi.SampleTones`, a C# batch surface that calls the Rust
`cultmath-core` native kernel when `cultmath_core` is available and falls back
to the managed parity path otherwise.
@ -59,7 +59,7 @@ float grain = math.value_noise_bicubic(math.float2(12.0f, 3.5f));
```hlsl
#include "CultMath.hlsl"
float3 normal = cultmath_normalize(float3(0.25, 1.0, -0.1));
float3 normal = normalize(float3(0.25, 1.0, -0.1));
float rim = cultmath_saturate(1.0 - dot(normal, float3(0.0, 0.0, -1.0)));
float glow = cultmath_smoothstep(0.2, 1.0, rim);
float grain = cultmath_value_noise_bicubic(float2(12.0, 3.5));

View file

@ -52,7 +52,26 @@ 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.
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.
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
@ -64,9 +83,7 @@ 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; 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
NaN input. Callers that must detect bad data should check `isnan` before calling these
functions.
Deferred until a consumer needs them: `float4x4`, `transpose`, `determinant`,
@ -103,9 +120,20 @@ 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.
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.
`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.
Where HLSL is silent, CultMath keeps its own decisions and does not defer to
Unity.Mathematics: `normalize` is safe (divides by `max(length, 1e-20)`),
`hash` returns float, and `Random` is CultMath's own xorshift32. Engine-shaped
Unity.Mathematics: `hash` returns float, and `Random` is CultMath's own xorshift32. Engine-shaped
helpers that HLSL lacks (`float2x2.Rotate`, `float3x3.Euler`, `quaternion`,
`snoise`) enter only when a consumer needs them, with their semantics stated in
code.

View file

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

View file

@ -11,7 +11,6 @@
static const float CULTMATH_PI = 3.14159265358979323846;
static const float CULTMATH_TAU = 6.28318530717958647692;
static const float CULTMATH_HALF_PI = 1.57079632679489661923;
static const float CULTMATH_NORMALIZE_EPSILON = 1.0e-20;
float cultmath_radians(float degrees_value) { return degrees_value * (CULTMATH_PI / 180.0); }
float cultmath_degrees(float radians_value) { return radians_value * (180.0 / CULTMATH_PI); }
@ -171,10 +170,6 @@ float cultmath_distance(float2 left, float2 right) { return length(left - right)
float cultmath_distance(float3 left, float3 right) { return length(left - right); }
float cultmath_distance(float4 left, float4 right) { return length(left - right); }
float2 cultmath_normalize(float2 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); }
float3 cultmath_normalize(float3 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); }
float4 cultmath_normalize(float4 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); }
float2 cultmath_reflect(float2 incident, float2 normal_value)
{
return incident - 2.0 * dot(normal_value, incident) * normal_value;
@ -286,6 +281,38 @@ float cultmath_hash(float value) { return cultmath_frac(sin(value) * 43758.5453)
float cultmath_hash(float2 value) { return cultmath_hash(dot(value, float2(127.1, 311.7))); }
float cultmath_hash(float3 value) { return cultmath_hash(dot(value, float3(127.1, 311.7, 74.7))); }
// PCG hashes (Jarzynski and Olano, JCGT 2020), written over scalar uints so CultMath's int3/int4 carry the bits.
uint cultmath_pcg(uint value)
{
uint state = value * 747796405u + 2891336453u;
uint word = ((state >> (int)((state >> 28) + 4)) ^ state) * 277803737u;
return (word >> 22) ^ word;
}
int3 cultmath_pcg3d(int3 value)
{
uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u, z = (uint)value.z * 1664525u + 1013904223u;
x += y * z; y += z * x; z += x * y;
x ^= x >> 16; y ^= y >> 16; z ^= z >> 16;
x += y * z; y += z * x; z += x * y;
return int3((int)x, (int)y, (int)z);
}
int3 cultmath_pcg3d(float2 value) { return cultmath_pcg3d(float3(value, 0.0)); }
int3 cultmath_pcg3d(float3 value) { return cultmath_pcg3d(int3((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z))); }
int4 cultmath_pcg4d(int4 value)
{
uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u;
uint z = (uint)value.z * 1664525u + 1013904223u, w = (uint)value.w * 1664525u + 1013904223u;
x += y * w; y += z * x; z += x * y; w += y * z;
x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; w ^= w >> 16;
x += y * w; y += z * x; z += x * y; w += y * z;
return int4((int)x, (int)y, (int)z, (int)w);
}
int4 cultmath_pcg4d(float4 value) { return cultmath_pcg4d(int4((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z), (int)asuint(value.w))); }
float cultmath_value_noise(float2 position)
{
float2 cell = floor(position);

View file

@ -8,7 +8,7 @@
<RootNamespace>CultMath</RootNamespace>
<AssemblyName>CultMath</AssemblyName>
<PackageId>GameCult.Math</PackageId>
<Version>0.2.0</Version>
<Version>0.2.3</Version>
<Description>Clean-room C# math shaped for HLSL-style renderer code.</Description>
<Authors>GameCult</Authors>
<PackageReadmeFile>README.md</PackageReadmeFile>

View file

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

View file

@ -105,6 +105,9 @@ public static partial class math
public static float2 frac(float2 value) => value - floor(value);
public static float3 frac(float3 value) => value - floor(value);
public static float4 frac(float4 value) => value - floor(value);
// dxc has no double Frc: it demotes to float, as it does for exp and lerp. CultMath's double
// overloads keep double precision instead, so frac(double) is x - floor(x) in double.
public static double frac(double value) => value - Math.Floor(value);
// dxc lowers float min/max/clamp to DXIL FMin/FMax (saturate to Saturate, defined as FMin(1, FMax(0, x))).
// DXIL.rst: FMin(a, b) is a < b ? a : b, FMax(a, b) is a >= b ? a : b, and a NaN operand returns the other.
@ -130,6 +133,11 @@ public static partial class math
public static float2 clamp(float2 value, float2 minimum, float2 maximum) => min(max(value, minimum), maximum);
public static float3 clamp(float3 value, float3 minimum, float3 maximum) => min(max(value, minimum), maximum);
public static float4 clamp(float4 value, float4 minimum, float4 maximum) => min(max(value, minimum), maximum);
// dxc lowers int clamp to IMin(IMax(x, a), b): inverted bounds return b rather than throwing like Math.Clamp.
public static int clamp(int value, int minimum, int maximum) => min(max(value, minimum), maximum);
public static int2 clamp(int2 value, int2 minimum, int2 maximum) => min(max(value, minimum), maximum);
public static int3 clamp(int3 value, int3 minimum, int3 maximum) => min(max(value, minimum), maximum);
public static int4 clamp(int4 value, int4 minimum, int4 maximum) => min(max(value, minimum), maximum);
// DXIL Saturate is FMin(1, FMax(0, x)); that operand order, unlike clamp(x, 0, 1), maps -0 to +0.
public static float saturate(float value) => min(1.0f, max(0.0f, value));
@ -205,9 +213,10 @@ public static partial class math
public static float distance(float3 left, float3 right) => length(left - right);
public static float distance(float4 left, float4 right) => length(left - right);
public static float2 normalize(float2 value) => value / max(length(value), 1.0e-20f);
public static float3 normalize(float3 value) => value / max(length(value), 1.0e-20f);
public static float4 normalize(float4 value) => value / max(length(value), 1.0e-20f);
// dxc lowers normalize(x) to x * Rsqrt(Dot(x, x)), and DXIL Rsqrt is 1 / sqrt(src): a zero vector is 0 * inf = NaN.
public static float2 normalize(float2 value) => value * (1.0f / MathF.Sqrt(dot(value, value)));
public static float3 normalize(float3 value) => value * (1.0f / MathF.Sqrt(dot(value, value)));
public static float4 normalize(float4 value) => value * (1.0f / MathF.Sqrt(dot(value, value)));
public static quaternion normalize(quaternion value)
{
var length = MathF.Sqrt(value.x * value.x + value.y * value.y + value.z * value.z + value.w * value.w);
@ -373,6 +382,43 @@ public static partial class math
public static float hash(float2 value) => hash(dot(value, new float2(127.1f, 311.7f)));
public static float hash(float3 value) => hash(dot(value, new float3(127.1f, 311.7f, 74.7f)));
public static uint asuint(float value) => (uint)BitConverter.SingleToInt32Bits(value);
// PCG hashes from Jarzynski and Olano, "Hash Functions for GPU Rendering" (JCGT 9(3), 2020). Integer-only, so
// dxc and C# agree bit for bit. pcg3d/pcg4d carry uint bit patterns in int3/int4; float overloads hash IEEE bits.
public static uint pcg(uint value)
{
var state = value * 747796405u + 2891336453u;
var word = ((state >> (int)((state >> 28) + 4)) ^ state) * 277803737u;
return (word >> 22) ^ word;
}
public static int3 pcg3d(int3 value)
{
uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u, z = (uint)value.z * 1664525u + 1013904223u;
x += y * z; y += z * x; z += x * y;
x ^= x >> 16; y ^= y >> 16; z ^= z >> 16;
x += y * z; y += z * x; z += x * y;
return new int3((int)x, (int)y, (int)z);
}
// The paper hashes 2 -> N inputs through pcg3d with the unused component held constant.
public static int3 pcg3d(float2 value) => pcg3d(new float3(value, 0.0f));
public static int3 pcg3d(float3 value) => pcg3d(new int3((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z)));
public static int4 pcg4d(int4 value)
{
uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u;
uint z = (uint)value.z * 1664525u + 1013904223u, w = (uint)value.w * 1664525u + 1013904223u;
x += y * w; y += z * x; z += x * y; w += y * z;
x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; w ^= w >> 16;
x += y * w; y += z * x; z += x * y; w += y * z;
return new int4((int)x, (int)y, (int)z, (int)w);
}
public static int4 pcg4d(float4 value) =>
pcg4d(new int4((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z), (int)asuint(value.w)));
public static float log(float value) => MathF.Log(value);
public static float2 log(float2 value) => new(log(value.x), log(value.y));
public static float3 log(float3 value) => new(log(value.x), log(value.y), log(value.z));
@ -475,6 +521,74 @@ public static partial class math
a._m20 * b._m01 + a._m21 * b._m11 + a._m22 * b._m21,
a._m20 * b._m02 + a._m21 * b._m12 + a._m22 * b._m22);
// Abramowitz & Stegun 7.1.26, a single-precision rational/exponential fit with a stated
// max absolute error of 1.5e-7. erf is odd, so only x >= 0 is fit and the sign is restored.
// Measured against an independent reference: worst absolute error 6.621e-7, inside the
// 2e-6 bar this package's tests pin.
public static float erf(float value)
{
const float p = 0.3275911f;
const float a1 = 0.254829592f;
const float a2 = -0.284496736f;
const float a3 = 1.421413741f;
const float a4 = -1.453152027f;
const float a5 = 1.061405429f;
var s = sign(value);
var x = abs(value);
var t = 1.0f / (1.0f + p * x);
var poly = ((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t;
var result = 1.0f - poly * exp(-x * x);
return s * result;
}
// Giles, "Approximating the erfinv function" (GPU Computing Gems, 2010): a single-precision
// minimax polynomial in two ranges of w = -ln((1-x)(1+x)), central and tail.
// Domain is [-1, 1]. Outside it, including +-infinity, the result is NaN (there is no real
// inverse). At the edges the mathematical limit is infinite: erfinv(1) = +infinity and
// erfinv(-1) = -infinity. The unguarded polynomial hits log(0) = -infinity at those edges and
// the tail branch's leading coefficient is negative, so it delivers the wrong sign; guard the
// edges explicitly instead of trusting the fit through the singularity.
// Measured against an independent reference: worst absolute error 5.066e-7, and the
// erf(erfinv(y)) round trip 6.109e-7 worst case, both inside the 2e-6 bar this package's
// tests pin.
public static float erfinv(float value)
{
if (value >= 1.0f) return value > 1.0f ? float.NaN : float.PositiveInfinity;
if (value <= -1.0f) return value < -1.0f ? float.NaN : float.NegativeInfinity;
var w = -log((1.0f - value) * (1.0f + value));
float p;
if (w < 5.0f)
{
w -= 2.5f;
p = 2.81022636e-08f;
p = 3.43273939e-07f + p * w;
p = -3.5233877e-06f + p * w;
p = -4.39150654e-06f + p * w;
p = 0.00021858087f + p * w;
p = -0.00125372503f + p * w;
p = -0.00417768164f + p * w;
p = 0.246640727f + p * w;
p = 1.50140941f + p * w;
}
else
{
w = sqrt(w) - 3.0f;
p = -0.000200214257f;
p = 0.000100950558f + p * w;
p = 0.00134934322f + p * w;
p = -0.00367342844f + p * w;
p = 0.00573950773f + p * w;
p = -0.0076224613f + p * w;
p = 0.00943887047f + p * w;
p = 1.00167406f + p * w;
p = 2.83297682f + p * w;
}
return p * value;
}
// Ashima Arts / Ian McEwan 3D simplex noise (MIT), mirrored by cultmath_snoise(float3)
// in shaders/CultMath.hlsl with the same float32 evaluation order.
public static float snoise(float3 value)

View file

@ -31,7 +31,6 @@ public sealed class HlslMirrorTests
"cultmath_smootherstep",
"cultmath_lengthsq",
"cultmath_distance",
"cultmath_normalize",
"cultmath_reflect",
"cultmath_rotate",
"cultmath_csum",
@ -41,6 +40,9 @@ public sealed class HlslMirrorTests
"cultmath_quadratic_bezier",
"cultmath_cubic_bezier",
"cultmath_hash",
"cultmath_pcg(uint",
"cultmath_pcg3d(int3",
"cultmath_pcg4d(int4",
"cultmath_value_noise",
"cultmath_snoise(float2",
"cultmath_snoise(float3",
@ -54,8 +56,8 @@ public sealed class HlslMirrorTests
Assert.Contains(symbol, include);
}
Assert.Contains("CULTMATH_NORMALIZE_EPSILON", include);
Assert.Contains("max(length(value), CULTMATH_NORMALIZE_EPSILON)", include);
// HLSL's normalize intrinsic is the contract; a mirror copy would only restate it.
Assert.DoesNotContain("cultmath_normalize", include);
}
private static string GetCultMathRoot()

View file

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

View file

@ -78,26 +78,33 @@ public sealed class HlslSourceCompatibilityTests
foreach (var input in cases)
{
var slot = 0;
var args = types.Select(t => t == typeof(float) ? (object)input(slot++)
: Activator.CreateInstance(t, Enumerable.Range(0, Components(t)).Select(_ => (object)input(slot++)).ToArray())!).ToArray();
var expected = Floats(counterpart.Invoke(null, args)!);
var actual = Floats(mirror.Invoke(shader, args)!);
if (expected.Length != actual.Length || expected.Zip(actual).Any(p => !(float.IsNaN(p.First) && float.IsNaN(p.Second))
&& BitConverter.SingleToInt32Bits(p.First) != BitConverter.SingleToInt32Bits(p.Second)))
var args = types.Select(t => Scalars.Contains(t) ? Scalar(t, input(slot++))
: Activator.CreateInstance(t, Components(t).Select(f => Scalar(f.FieldType, input(slot++))).ToArray())!).ToArray();
var expected = Values(counterpart.Invoke(null, args)!);
var actual = Values(mirror.Invoke(shader, args)!);
if (expected.Length != actual.Length || expected.Zip(actual).Any(p => p.First is float a && p.Second is float b
? !(float.IsNaN(a) && float.IsNaN(b)) && BitConverter.SingleToInt32Bits(a) != BitConverter.SingleToInt32Bits(b)
: !p.First.Equals(p.Second)))
{
mismatches.Add($"{mirror.Name}({string.Join(", ", args.Select(a => string.Join(" ", Floats(a))))}): C# {string.Join(" ", expected)}, mirror {string.Join(" ", actual)}");
mismatches.Add($"{mirror.Name}({string.Join(", ", args.Select(a => string.Join(" ", Values(a))))}): C# {string.Join(" ", expected)}, mirror {string.Join(" ", actual)}");
}
}
}
Assert.True(mismatches.Count == 0, $"{mismatches.Count} mismatches:{Environment.NewLine}{string.Join(Environment.NewLine, mismatches.Take(12))}");
Assert.Equal(26, compared.Count);
Assert.Equal(28, compared.Count);
}
private static int Components(Type t) => t.GetFields().Count(f => f.FieldType == typeof(float) && !f.IsStatic);
// Integer arguments take the bit pattern of the float input, so specials become 0, 0x80000000, NaN bits, ...
private static readonly Type[] Scalars = { typeof(float), typeof(int), typeof(uint) };
private static float[] Floats(object value) => value is float f ? new[] { f }
: value.GetType().GetFields().Where(field => field.FieldType == typeof(float) && !field.IsStatic).Select(field => (float)field.GetValue(value)!).ToArray();
private static object Scalar(Type t, float f) => t == typeof(float) ? f
: t == typeof(int) ? BitConverter.SingleToInt32Bits(f) : (object)(uint)BitConverter.SingleToInt32Bits(f);
private static FieldInfo[] Components(Type t) => t.GetFields().Where(f => !f.IsStatic && Scalars.Contains(f.FieldType)).ToArray();
private static object[] Values(object value) => Scalars.Contains(value.GetType()) ? new[] { value }
: Components(value.GetType()).Select(field => field.GetValue(value)!).ToArray();
/// <summary>The documented HLSL-to-C# transformations, and nothing else.</summary>
internal static string TransformHlslToCSharp(string hlsl)

View file

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

View file

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

View file

@ -11,7 +11,6 @@
static const float CULTMATH_PI = 3.14159265358979323846;
static const float CULTMATH_TAU = 6.28318530717958647692;
static const float CULTMATH_HALF_PI = 1.57079632679489661923;
static const float CULTMATH_NORMALIZE_EPSILON = 1.0e-20;
float cultmath_radians(float degrees_value) { return degrees_value * (CULTMATH_PI / 180.0); }
float cultmath_degrees(float radians_value) { return radians_value * (180.0 / CULTMATH_PI); }
@ -171,10 +170,6 @@ float cultmath_distance(float2 left, float2 right) { return length(left - right)
float cultmath_distance(float3 left, float3 right) { return length(left - right); }
float cultmath_distance(float4 left, float4 right) { return length(left - right); }
float2 cultmath_normalize(float2 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); }
float3 cultmath_normalize(float3 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); }
float4 cultmath_normalize(float4 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); }
float2 cultmath_reflect(float2 incident, float2 normal_value)
{
return incident - 2.0 * dot(normal_value, incident) * normal_value;
@ -286,6 +281,38 @@ float cultmath_hash(float value) { return cultmath_frac(sin(value) * 43758.5453)
float cultmath_hash(float2 value) { return cultmath_hash(dot(value, float2(127.1, 311.7))); }
float cultmath_hash(float3 value) { return cultmath_hash(dot(value, float3(127.1, 311.7, 74.7))); }
// PCG hashes (Jarzynski and Olano, JCGT 2020), written over scalar uints so CultMath's int3/int4 carry the bits.
uint cultmath_pcg(uint value)
{
uint state = value * 747796405u + 2891336453u;
uint word = ((state >> (int)((state >> 28) + 4)) ^ state) * 277803737u;
return (word >> 22) ^ word;
}
int3 cultmath_pcg3d(int3 value)
{
uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u, z = (uint)value.z * 1664525u + 1013904223u;
x += y * z; y += z * x; z += x * y;
x ^= x >> 16; y ^= y >> 16; z ^= z >> 16;
x += y * z; y += z * x; z += x * y;
return int3((int)x, (int)y, (int)z);
}
int3 cultmath_pcg3d(float2 value) { return cultmath_pcg3d(float3(value, 0.0)); }
int3 cultmath_pcg3d(float3 value) { return cultmath_pcg3d(int3((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z))); }
int4 cultmath_pcg4d(int4 value)
{
uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u;
uint z = (uint)value.z * 1664525u + 1013904223u, w = (uint)value.w * 1664525u + 1013904223u;
x += y * w; y += z * x; z += x * y; w += y * z;
x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; w ^= w >> 16;
x += y * w; y += z * x; z += x * y; w += y * z;
return int4((int)x, (int)y, (int)z, (int)w);
}
int4 cultmath_pcg4d(float4 value) { return cultmath_pcg4d(int4((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z), (int)asuint(value.w))); }
float cultmath_value_noise(float2 position)
{
float2 cell = floor(position);

View file

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

View file

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

View file

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

View file

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

View file

@ -1759,11 +1759,11 @@ class CultMeshTests(unittest.TestCase):
)
self.assertEqual(
builtins.list()[0].schema_id,
"https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json",
"https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.transport-profile.schema.json",
)
self.assertIsNone(builtins.list()[0].schema_json)
peer_exchange_schema_id = (
"https://github.com/GameCult/cultnet-ts/contracts/cultmesh.peer-exchange-request.schema.json"
"https://github.com/GameCult/CultLib/contracts/cultnet/cultmesh.peer-exchange-request.schema.json"
)
filtered_builtin = CultMesh.create_builtin_schema_catalog(
include_schema_json=True,
@ -4033,7 +4033,7 @@ class CultMeshTests(unittest.TestCase):
self.assertIn("cultmesh.peer_exchange_response.v0", wire_descriptors)
self.assertIn("cultnet.error.v0", {descriptor.schema_version for descriptor in synced_wire_descriptors})
self.assertIn("cultnet.document_put_raw.v0", {descriptor.schema_version for descriptor in synced_wire_descriptors})
self.assertIsNotNone(synced_schema_catalog.get("https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put-raw.schema.json"))
self.assertIsNotNone(synced_schema_catalog.get("https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.document-put-raw.schema.json"))
self.assertEqual(snapshot_response["documents"][0]["recordKey"], "note:1")
self.assertEqual(typed_snapshot_response.documents[0].record_key, "note:1")
self.assertEqual(typed_snapshot_response.documents[0].schema_id, "mesh.server_note.v1")

View file

@ -34,6 +34,7 @@ import {
type CultNetWireContract,
} from "cultnet-ts";
export * from "./realtime-wire";
export * from "./provider-session";
export * from "./provider-session-wire";
export * from "./provider-rudp-transport";

View file

@ -0,0 +1,191 @@
// The one TypeScript encoding of a CultMesh realtime frame.
//
// Rule for rule, this is `CultMeshRealtimeWireProtocol` in the C# reference
// (`src/GameCult.Mesh/CultMeshRealtimeWireProtocol.cs`), over the frame shape
// `CultMeshRealtimeTransports.cs` defines. QUIC stream framing stays
// transport-owned; this file owns only the typed frame bytes and never touches
// a socket.
//
// Byte parity is proven both ways, cross-process, by two committed vector files
// under `contracts/cultmesh/`: `realtime-frame-vectors.json`, which the C# test
// `CultMeshRealtimeWireProtocolTests` writes under CULTMESH_WRITE_VECTORS=1 and
// this package's test decodes and re-encodes, and
// `realtime-frame-vectors.ts-written.json`, which
// `scripts/write-cultmesh-realtime-frame-vectors.mjs` writes with this codec and
// the same C# test decodes. A fixture authored on one side alone would only
// agree with itself.
import { isNullOrWhiteSpaceCSharp } from "cultnet-ts";
/** `CultMeshRealtimeWireProtocol.ApplicationProtocolName`: the QUIC ALPN. */
export const CULTMESH_REALTIME_ALPN = "cultmesh-state-v1";
/** `ConnectionCloseCode`: ASCII "CULT". A QUIC error code is 62-bit, so bigint. */
export const CULTMESH_REALTIME_CONNECTION_CLOSE_CODE = 0x43554c54n;
/** `StreamAbortCode`: ASCII "STAT". */
export const CULTMESH_REALTIME_STREAM_ABORT_CODE = 0x53544154n;
/** `ReliableStream`: the stream kind carrying `reliable-ordered`. */
export const CULTMESH_REALTIME_RELIABLE_STREAM = 1;
/** `LatestOnlyStream`: the stream kind carrying `latest-only`. */
export const CULTMESH_REALTIME_LATEST_ONLY_STREAM = 2;
/** `MaximumFrameBytes`: the payload ceiling, 64 MiB. */
export const CULTMESH_REALTIME_MAX_PAYLOAD_BYTES = 64 * 1024 * 1024;
/** `MaximumEncodedFrameBytes`: the payload ceiling plus the header and three maximal identities. */
export const CULTMESH_REALTIME_MAX_ENCODED_FRAME_BYTES =
CULTMESH_REALTIME_MAX_PAYLOAD_BYTES + 37 + 3 * 0xffff;
/**
* `CultMeshRealtimeDelivery`. The wire byte is the C# enum's ordinal, so the
* order of these three is the wire contract, not a naming choice.
*/
export type CultMeshRealtimeDelivery = "reliable-ordered" | "latest-only" | "unreliable";
const DELIVERY_BYTES: readonly CultMeshRealtimeDelivery[] = ["reliable-ordered", "latest-only", "unreliable"];
/** `CultMeshRealtimeFrame`. `producerEpoch` and `sequence` are `long` in C#, which exceeds `Number.MAX_SAFE_INTEGER`. */
export interface CultMeshRealtimeFrame {
channelId: string;
schemaId: string;
bodyId: string;
producerEpoch: bigint;
sequence: bigint;
delivery: CultMeshRealtimeDelivery;
payload: Uint8Array;
}
const MAGIC = 0x31545343;
const FIXED_HEADER_BYTES = 37;
const WIRE_VERSION = 1;
const MAX_IDENTITY_BYTES = 0xffff;
const INT64_MIN = -(2n ** 63n);
const INT64_MAX = 2n ** 63n - 1n;
/**
* `CultMeshRealtimeFrame.Validate`. The C# reading of "non-empty" is
* `string.IsNullOrWhiteSpace`, whose whitespace set is not
* `String.prototype.trim`'s, so this borrows the one TypeScript implementation
* of that set from `cultnet-ts` rather than keeping a second reading here.
*
* The range check has no C# counterpart because `long` gives it for free; a
* `bigint` does not, and a `DataView` would otherwise throw a bare `RangeError`
* from inside the encoder.
*/
function validateFrame(frame: CultMeshRealtimeFrame): void {
if (isNullOrWhiteSpaceCSharp(frame.channelId)) throw new Error("Realtime channel identity is required.");
if (isNullOrWhiteSpaceCSharp(frame.schemaId)) throw new Error("Realtime schema identity is required.");
if (isNullOrWhiteSpaceCSharp(frame.bodyId)) throw new Error("Realtime body identity is required.");
if (frame.producerEpoch < 0n || frame.sequence < 0n) {
throw new Error("Realtime epoch and sequence must be non-negative.");
}
if (frame.producerEpoch > INT64_MAX || frame.sequence > INT64_MAX || frame.producerEpoch < INT64_MIN || frame.sequence < INT64_MIN) {
throw new Error("Realtime epoch and sequence must fit a 64-bit signed integer.");
}
}
/** `CultMeshRealtimeWireProtocol.EncodeFrame`. */
export function encodeRealtimeFrame(frame: CultMeshRealtimeFrame): Uint8Array {
validateFrame(frame);
const deliveryByte = DELIVERY_BYTES.indexOf(frame.delivery);
// C# reads an enum, which holds any byte; a TypeScript union does not, so the
// unspellable value is refused here with the message decode gives it.
if (deliveryByte < 0) throw new Error("Realtime frame delivery mode is invalid.");
const encoder = new TextEncoder();
const channel = encoder.encode(frame.channelId);
const schema = encoder.encode(frame.schemaId);
const body = encoder.encode(frame.bodyId);
if (channel.byteLength > MAX_IDENTITY_BYTES || schema.byteLength > MAX_IDENTITY_BYTES || body.byteLength > MAX_IDENTITY_BYTES) {
throw new Error("Realtime frame identity exceeds the QUIC wire limit.");
}
if (frame.payload.byteLength > CULTMESH_REALTIME_MAX_PAYLOAD_BYTES) {
throw new Error("Realtime frame payload exceeds the QUIC wire limit.");
}
const result = new Uint8Array(
FIXED_HEADER_BYTES + channel.byteLength + schema.byteLength + body.byteLength + frame.payload.byteLength,
);
const view = new DataView(result.buffer);
view.setUint32(0, MAGIC, true);
result[4] = deliveryByte;
view.setBigInt64(5, frame.producerEpoch, true);
view.setBigInt64(13, frame.sequence, true);
view.setUint16(21, channel.byteLength, true);
view.setUint16(23, schema.byteLength, true);
view.setUint16(25, body.byteLength, true);
view.setInt32(27, frame.payload.byteLength, true);
view.setInt32(31, FIXED_HEADER_BYTES, true);
view.setUint16(35, WIRE_VERSION, true);
let offset = FIXED_HEADER_BYTES;
result.set(channel, offset); offset += channel.byteLength;
result.set(schema, offset); offset += schema.byteLength;
result.set(body, offset); offset += body.byteLength;
result.set(frame.payload, offset);
return result;
}
/**
* `CultMeshRealtimeWireProtocol.DecodeFrame`, refusal for refusal and in the
* same order. Like the reference, it does not call `validateFrame`: a frame
* whose identities are blank decodes here exactly as it does in C#, and only
* encoding refuses it.
*
* One corner has no C# counterpart. The reference sums the lengths under
* `checked`, so a payload length near `int.MaxValue` throws `OverflowException`
* there rather than its length refusal; JavaScript numbers do not overflow, so
* the sum simply fails to match and the length refusal covers that case too.
* No encoder on either side can produce such a frame.
*/
export function decodeRealtimeFrame(bytes: Uint8Array): CultMeshRealtimeFrame {
if (bytes.byteLength < FIXED_HEADER_BYTES) throw new Error("Realtime frame header is truncated.");
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (view.getUint32(0, true) !== MAGIC) throw new Error("Realtime frame magic is invalid.");
if (view.getUint16(35, true) !== WIRE_VERSION || view.getInt32(31, true) !== FIXED_HEADER_BYTES) {
throw new Error("Realtime frame wire version is unsupported.");
}
const delivery = DELIVERY_BYTES[bytes[4]!];
if (delivery === undefined) throw new Error("Realtime frame delivery mode is invalid.");
const channelLength = view.getUint16(21, true);
const schemaLength = view.getUint16(23, true);
const bodyLength = view.getUint16(25, true);
const payloadLength = view.getInt32(27, true);
const expected = FIXED_HEADER_BYTES + channelLength + schemaLength + bodyLength + payloadLength;
if (payloadLength < 0 || expected !== bytes.byteLength) throw new Error("Realtime frame length is invalid.");
// `Encoding.UTF8.GetString` substitutes U+FFFD for invalid bytes rather than
// throwing, which is what a non-fatal `TextDecoder` does. `ignoreBOM` is not
// cosmetic: by default `TextDecoder` *deletes* a leading U+FEFF, and
// `GetString` keeps it, so an identity starting with a byte-order mark would
// decode to a different string on the two sides. U+FEFF is not whitespace to
// .NET either, so such an identity passes the transport contract and reaches
// the wire.
const decoder = new TextDecoder("utf-8", { ignoreBOM: true });
let offset = FIXED_HEADER_BYTES;
const channelId = decoder.decode(bytes.subarray(offset, offset + channelLength)); offset += channelLength;
const schemaId = decoder.decode(bytes.subarray(offset, offset + schemaLength)); offset += schemaLength;
const bodyId = decoder.decode(bytes.subarray(offset, offset + bodyLength)); offset += bodyLength;
return {
channelId,
schemaId,
bodyId,
producerEpoch: view.getBigInt64(5, true),
sequence: view.getBigInt64(13, true),
delivery,
// Not `bytes.slice(...)`. Node's `Buffer` overrides `slice` with an alias of
// `subarray`, so on a `Buffer` — which is what every socket read hands a Node
// caller — that spelling returns a *view* of the frame, and a decoded payload
// that changes when the frame buffer is reused.
//
// Nor `Uint8Array.prototype.slice.call(bytes, ...)`, which does bypass the
// override and copy, but takes its result constructor from `@@species`: on a
// `Buffer` that is `FastBuffer`, so the payload comes back a `Buffer` from a
// `Buffer` frame and a `Uint8Array` from a `Uint8Array` one. The declared
// type holds either way, but `deepStrictEqual` does not, so the same frame
// would compare equal or not depending on how it reached the decoder.
//
// The constructor copies and is the one type on both inputs.
payload: new Uint8Array(bytes.subarray(offset, offset + payloadLength)),
};
}

View file

@ -2500,16 +2500,20 @@ test("CultMesh TS branded facade exposes schema and shard catalogs", () => {
const builtIns = CultMesh.createBuiltInSchemaCatalog();
assert.equal(
builtIns.get("cultnet.shard_catalog_request.v0")?.schemaVersion,
builtIns.get(
"https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.shard-catalog-request.schema.json",
)?.schemaVersion,
"cultnet.shard_catalog_request.v0",
);
assert.equal(
builtIns.get("cultnet.shard_catalog_response.v0")?.kind,
builtIns.get(
"https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.shard-catalog-response.schema.json",
)?.kind,
"wire_message",
);
assert.equal(
builtIns.get(
"https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json",
"https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.transport-profile.schema.json",
)?.schemaVersion,
"cultnet.transport_profile.v0",
);

View file

@ -0,0 +1,397 @@
// The realtime frame codec, pinned rule by rule against the C# reference.
//
// The bytes themselves are pinned by two committed files under
// `contracts/cultmesh/`. `realtime-frame-vectors.json` is written by
// `CultMeshRealtimeWireProtocolTests.RealtimeFrameVectorsAreSharedWithTypeScript`
// under CULTMESH_WRITE_VECTORS=1: this side decodes it and re-encodes to the
// same bytes. `realtime-frame-vectors.ts-written.json` is written by
// `scripts/write-cultmesh-realtime-frame-vectors.mjs` with this codec and
// decoded by the C# reference, so parity is proven in both directions and in
// two processes. A fixture authored on one side alone would only agree with
// itself, which is what the authority vectors already taught.
import test from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
CULTMESH_REALTIME_ALPN,
CULTMESH_REALTIME_CONNECTION_CLOSE_CODE,
CULTMESH_REALTIME_LATEST_ONLY_STREAM,
CULTMESH_REALTIME_MAX_ENCODED_FRAME_BYTES,
CULTMESH_REALTIME_MAX_PAYLOAD_BYTES,
CULTMESH_REALTIME_RELIABLE_STREAM,
CULTMESH_REALTIME_STREAM_ABORT_CODE,
decodeRealtimeFrame,
encodeRealtimeFrame,
type CultMeshRealtimeDelivery,
type CultMeshRealtimeFrame,
} from "../src/realtime-wire";
const CONTRACTS = join(__dirname, "..", "..", "..", "..", "contracts", "cultmesh");
const CSHARP_VECTORS = join(CONTRACTS, "realtime-frame-vectors.json");
const TS_VECTORS = join(CONTRACTS, "realtime-frame-vectors.ts-written.json");
interface IdentityRule { repeat: string; byteLength: number }
interface RampRule { ramp: number }
interface VectorFields {
channelId: string | IdentityRule;
schemaId: string | IdentityRule;
bodyId: string | IdentityRule;
producerEpoch: number;
sequence: number;
delivery: CultMeshRealtimeDelivery;
payload: string | RampRule;
}
interface Vectors {
frames: {
label: string;
fields: VectorFields;
encoding: string | null;
encodedLength: number;
sha256OfEncoding: string;
}[];
malformed: { label: string; encoding: string; message: string }[];
}
/** The repeat unit written whole until the UTF-8 byte length is exactly `byteLength`. */
function repeatToByteLength(unit: string, byteLength: number): string {
const unitBytes = Buffer.byteLength(unit, "utf8");
assert.equal(byteLength % unitBytes, 0, `'${unit}' is ${unitBytes} UTF-8 bytes and does not divide ${byteLength}`);
return unit.repeat(byteLength / unitBytes);
}
/** The deterministic payload both runtimes build the same way. */
function ramp(length: number): Uint8Array {
const bytes = new Uint8Array(length);
for (let index = 0; index < length; index += 1) bytes[index] = index % 251;
return bytes;
}
function identity(value: string | IdentityRule): string {
return typeof value === "string" ? value : repeatToByteLength(value.repeat, value.byteLength);
}
function payload(value: string | RampRule): Uint8Array {
return typeof value === "string" ? new Uint8Array(Buffer.from(value, "base64")) : ramp(value.ramp);
}
function frameOf(fields: VectorFields): CultMeshRealtimeFrame {
return {
channelId: identity(fields.channelId),
schemaId: identity(fields.schemaId),
bodyId: identity(fields.bodyId),
// The C# `long` fields are written as JSON integers and can exceed the safe
// integer range, so they are read from the raw text, never through
// `JSON.parse`'s number.
producerEpoch: 0n,
sequence: 0n,
delivery: fields.delivery,
payload: payload(fields.payload),
};
}
// `JSON.parse` would round `long.MaxValue` to 9223372036854775808, which is not
// a valid i64 and not what either side encoded. The two i64 fields are lifted
// out of the raw text as bigints before the document is parsed.
function readVectors(path: string): { doc: Vectors; int64s: Map<string, { producerEpoch: bigint; sequence: bigint }> } {
const text = readFileSync(path, "utf8");
const int64s = new Map<string, { producerEpoch: bigint; sequence: bigint }>();
const pattern = /"label": "(?<label>(?:[^"\\]|\\.)*)"[\s\S]*?"producerEpoch": (?<epoch>-?\d+),\s*"sequence": (?<sequence>-?\d+),/g;
for (const match of text.matchAll(pattern)) {
const groups = match.groups!;
int64s.set(JSON.parse(`"${groups["label"]!}"`) as string, {
producerEpoch: BigInt(groups["epoch"]!),
sequence: BigInt(groups["sequence"]!),
});
}
return { doc: JSON.parse(text) as Vectors, int64s };
}
const sha256 = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
for (const [label, path] of [["C# reference", CSHARP_VECTORS], ["TypeScript-written", TS_VECTORS]] as const) {
test(`every ${label} frame vector decodes to its recorded fields and re-encodes to its recorded bytes`, () => {
const { doc, int64s } = readVectors(path);
assert.ok(doc.frames.length >= 8, `${label} carries ${doc.frames.length} frames`);
const seen = new Set<CultMeshRealtimeDelivery>();
for (const vector of doc.frames) {
const int64 = int64s.get(vector.label);
assert.ok(int64, `${vector.label}: no i64 pair was lifted from the text`);
const frame = { ...frameOf(vector.fields), ...int64 };
seen.add(frame.delivery);
// This side encodes the recorded fields to the recorded bytes.
const encoded = encodeRealtimeFrame(frame);
assert.equal(encoded.byteLength, vector.encodedLength, vector.label);
assert.equal(sha256(encoded), vector.sha256OfEncoding, vector.label);
// The two oversized cases carry no literal encoding; the digest above is
// the whole of their parity.
const recorded = vector.encoding === null ? encoded : new Uint8Array(Buffer.from(vector.encoding, "base64"));
assert.deepEqual(recorded, encoded, vector.label);
// And decodes those bytes back to the recorded fields.
const decoded = decodeRealtimeFrame(recorded);
assert.equal(decoded.channelId, frame.channelId, vector.label);
assert.equal(decoded.schemaId, frame.schemaId, vector.label);
assert.equal(decoded.bodyId, frame.bodyId, vector.label);
assert.equal(decoded.producerEpoch, frame.producerEpoch, vector.label);
assert.equal(decoded.sequence, frame.sequence, vector.label);
assert.equal(decoded.delivery, frame.delivery, vector.label);
assert.deepEqual(decoded.payload, frame.payload, vector.label);
}
// One vector per delivery mode, or a mode is unpinned.
assert.deepEqual([...seen].sort(), ["latest-only", "reliable-ordered", "unreliable"]);
});
test(`every ${label} malformed vector is refused with the C# reference's exact message`, () => {
const { doc } = readVectors(path);
assert.ok(doc.malformed.length >= 8, `${label} carries ${doc.malformed.length} malformed frames`);
for (const vector of doc.malformed) {
const bytes = new Uint8Array(Buffer.from(vector.encoding, "base64"));
assert.throws(
() => decodeRealtimeFrame(bytes),
(error: Error) => error.message === vector.message,
`${vector.label}: expected ${JSON.stringify(vector.message)}`,
);
}
// Every refusal the C# decoder can give is exercised by the file.
assert.deepEqual([...new Set(doc.malformed.map(vector => vector.message))].sort(), [
"Realtime frame delivery mode is invalid.",
"Realtime frame header is truncated.",
"Realtime frame length is invalid.",
"Realtime frame magic is invalid.",
"Realtime frame wire version is unsupported.",
]);
});
}
const sample = (overrides: Partial<CultMeshRealtimeFrame> = {}): CultMeshRealtimeFrame => ({
channelId: "aetheria.zone-1",
schemaId: "cultmesh.realtime.v1",
bodyId: "body-7",
producerEpoch: 1n,
sequence: 2n,
delivery: "reliable-ordered",
payload: Uint8Array.from([1, 2, 3]),
...overrides,
});
test("the wire constants are the C# reference's, including the ALPN and the two QUIC error codes", () => {
assert.equal(CULTMESH_REALTIME_ALPN, "cultmesh-state-v1");
assert.equal(CULTMESH_REALTIME_CONNECTION_CLOSE_CODE, 0x43554c54n);
assert.equal(CULTMESH_REALTIME_STREAM_ABORT_CODE, 0x53544154n);
assert.equal(CULTMESH_REALTIME_RELIABLE_STREAM, 1);
assert.equal(CULTMESH_REALTIME_LATEST_ONLY_STREAM, 2);
assert.equal(CULTMESH_REALTIME_MAX_PAYLOAD_BYTES, 64 * 1024 * 1024);
assert.equal(CULTMESH_REALTIME_MAX_ENCODED_FRAME_BYTES, 64 * 1024 * 1024 + 37 + 3 * 65535);
// The ALPN and the close codes are ASCII the C# side spells as numbers.
assert.equal(Buffer.from("CULT", "ascii").readUInt32BE(0), Number(CULTMESH_REALTIME_CONNECTION_CLOSE_CODE));
assert.equal(Buffer.from("STAT", "ascii").readUInt32BE(0), Number(CULTMESH_REALTIME_STREAM_ABORT_CODE));
});
// `CultMeshRealtimeWireProtocol.cs:37-54`, offset by offset. A vector proves the
// whole layout at once; this says which byte carries which field, so a swap of
// two same-width fields names itself.
test("the fixed header is 37 little-endian bytes in the reference's order", () => {
const encoded = encodeRealtimeFrame(sample({
channelId: "ch", schemaId: "sch", bodyId: "bdy",
producerEpoch: 0x0102030405060708n, sequence: 0x1112131415161718n,
delivery: "unreliable", payload: Uint8Array.from([0xaa]),
}));
const view = new DataView(encoded.buffer, encoded.byteOffset, encoded.byteLength);
assert.equal(view.getUint32(0, true), 0x31545343, "magic, little-endian");
assert.deepEqual([...encoded.subarray(0, 4)], [0x43, 0x53, 0x54, 0x31], "magic bytes as written");
assert.equal(encoded[4], 2, "delivery");
assert.equal(view.getBigInt64(5, true), 0x0102030405060708n, "producer epoch");
assert.equal(view.getBigInt64(13, true), 0x1112131415161718n, "sequence");
assert.equal(view.getUint16(21, true), 2, "channel length");
assert.equal(view.getUint16(23, true), 3, "schema length");
assert.equal(view.getUint16(25, true), 3, "body length");
assert.equal(view.getInt32(27, true), 1, "payload length");
assert.equal(view.getInt32(31, true), 37, "header size");
assert.equal(view.getUint16(35, true), 1, "wire version");
assert.equal(Buffer.from(encoded.subarray(37, 37 + 2 + 3 + 3)).toString("utf8"), "chschbdy", "identities in order");
assert.deepEqual([...encoded.subarray(45)], [0xaa], "payload last");
assert.equal(encoded.byteLength, 37 + 2 + 3 + 3 + 1);
});
test("the delivery byte is the C# enum ordinal: 0, 1, 2 in that order", () => {
const bytes: Record<CultMeshRealtimeDelivery, number> = { "reliable-ordered": 0, "latest-only": 1, "unreliable": 2 };
for (const [delivery, byte] of Object.entries(bytes) as [CultMeshRealtimeDelivery, number][]) {
const encoded = encodeRealtimeFrame(sample({ delivery }));
assert.equal(encoded[4], byte, delivery);
assert.equal(decodeRealtimeFrame(encoded).delivery, delivery, delivery);
}
assert.throws(
() => encodeRealtimeFrame(sample({ delivery: "at-most-once" as CultMeshRealtimeDelivery })),
/delivery mode is invalid/,
);
});
test("identities are UTF-8 and their length prefixes count bytes, not characters", () => {
const frame = sample({ channelId: "z\u00f6ne-\u221e", schemaId: "s", bodyId: "b" });
const encoded = encodeRealtimeFrame(frame);
const view = new DataView(encoded.buffer, encoded.byteOffset, encoded.byteLength);
assert.equal(frame.channelId.length, 6);
assert.equal(view.getUint16(21, true), Buffer.byteLength(frame.channelId, "utf8"));
assert.equal(view.getUint16(21, true), 9);
assert.equal(decodeRealtimeFrame(encoded).channelId, frame.channelId);
});
test("an identity of exactly 65535 UTF-8 bytes encodes and one byte more is refused", () => {
const wide = repeatToByteLength("\u00e4\u20ac", 65535);
assert.equal(Buffer.byteLength(wide, "utf8"), 65535);
assert.notEqual(wide.length, 65535);
const encoded = encodeRealtimeFrame(sample({ channelId: wide }));
assert.equal(new DataView(encoded.buffer, encoded.byteOffset).getUint16(21, true), 65535);
assert.equal(decodeRealtimeFrame(encoded).channelId, wide);
for (const field of ["channelId", "schemaId", "bodyId"] as const) {
assert.throws(
() => encodeRealtimeFrame(sample({ [field]: `${wide}a` })),
/identity exceeds the QUIC wire limit/,
field,
);
}
});
test("a payload at the 64 MiB ceiling encodes and one byte more is refused", () => {
const encoded = encodeRealtimeFrame(sample({ payload: ramp(CULTMESH_REALTIME_MAX_PAYLOAD_BYTES) }));
assert.equal(encoded.byteLength, 37 + 15 + 20 + 6 + CULTMESH_REALTIME_MAX_PAYLOAD_BYTES);
assert.equal(decodeRealtimeFrame(encoded).payload.byteLength, CULTMESH_REALTIME_MAX_PAYLOAD_BYTES);
assert.throws(
() => encodeRealtimeFrame(sample({ payload: new Uint8Array(CULTMESH_REALTIME_MAX_PAYLOAD_BYTES + 1) })),
/payload exceeds the QUIC wire limit/,
);
});
// `CultMeshRealtimeTransports.cs:29-39`, message for message. The blank strings
// are C#'s `IsNullOrWhiteSpace` set, not `String.prototype.trim`'s: U+0085 is
// whitespace to .NET and not to JavaScript, and U+FEFF the other way round.
test("encode refuses what the transport contract forbids, in the reference's words", () => {
for (const blank of ["", " ", "\t", "\u0085", "\u3000"]) {
assert.throws(() => encodeRealtimeFrame(sample({ channelId: blank })), /^Error: Realtime channel identity is required\.$/);
assert.throws(() => encodeRealtimeFrame(sample({ schemaId: blank })), /^Error: Realtime schema identity is required\.$/);
assert.throws(() => encodeRealtimeFrame(sample({ bodyId: blank })), /^Error: Realtime body identity is required\.$/);
}
// U+FEFF is not whitespace to .NET, so it is a legal one-character identity.
assert.equal(decodeRealtimeFrame(encodeRealtimeFrame(sample({ channelId: "\ufeff" }))).channelId, "\ufeff");
assert.throws(() => encodeRealtimeFrame(sample({ producerEpoch: -1n })), /epoch and sequence must be non-negative/);
assert.throws(() => encodeRealtimeFrame(sample({ sequence: -1n })), /epoch and sequence must be non-negative/);
// C# gets this bound from `long`; a bigint does not.
assert.throws(() => encodeRealtimeFrame(sample({ sequence: 2n ** 63n })), /must fit a 64-bit signed integer/);
assert.equal(encodeRealtimeFrame(sample({ sequence: 2n ** 63n - 1n })).byteLength, 37 + 15 + 20 + 6 + 3);
});
// `CultMeshRealtimeWireProtocol.cs:60-76`, refusal for refusal and in the same
// order: a frame that breaks two rules names the earlier one.
test("decode refuses every malformed frame the reference refuses, in the reference's order", () => {
const good = encodeRealtimeFrame(sample());
const spoil = (mutate: (bytes: Uint8Array) => void) => { const copy = Uint8Array.from(good); mutate(copy); return copy; };
const cases: readonly (readonly [string, Uint8Array, string])[] = [
["a header one byte short", good.subarray(0, 36), "Realtime frame header is truncated."],
["an empty buffer", new Uint8Array(0), "Realtime frame header is truncated."],
["a wrong magic", spoil(bytes => { bytes[3] = 0x32; }), "Realtime frame magic is invalid."],
["a big-endian magic", spoil(bytes => bytes.set([0x31, 0x54, 0x53, 0x43], 0)), "Realtime frame magic is invalid."],
["wire version 0", spoil(bytes => { bytes[35] = 0; }), "Realtime frame wire version is unsupported."],
["wire version 2", spoil(bytes => { bytes[35] = 2; }), "Realtime frame wire version is unsupported."],
["header size 36", spoil(bytes => { bytes[31] = 36; }), "Realtime frame wire version is unsupported."],
["header size 38", spoil(bytes => { bytes[31] = 38; }), "Realtime frame wire version is unsupported."],
["delivery 3", spoil(bytes => { bytes[4] = 3; }), "Realtime frame delivery mode is invalid."],
["delivery 255", spoil(bytes => { bytes[4] = 255; }), "Realtime frame delivery mode is invalid."],
["a negative payload length", spoil(bytes => { bytes[30] = 0x80; }), "Realtime frame length is invalid."],
["one byte too many", Uint8Array.from([...good, 0]), "Realtime frame length is invalid."],
["one byte too few", good.subarray(0, good.byteLength - 1), "Realtime frame length is invalid."],
["a channel length one too long", spoil(bytes => { bytes[21] += 1; }), "Realtime frame length is invalid."],
];
for (const [label, bytes, message] of cases) {
assert.throws(() => decodeRealtimeFrame(bytes), (error: Error) => error.message === message, label);
}
// Order: a frame whose magic and delivery are both wrong is refused for the
// magic, and one whose version and delivery are both wrong for the version.
assert.throws(
() => decodeRealtimeFrame(spoil(bytes => { bytes[3] = 0x32; bytes[4] = 9; })),
(error: Error) => error.message === "Realtime frame magic is invalid.",
);
assert.throws(
() => decodeRealtimeFrame(spoil(bytes => { bytes[35] = 3; bytes[4] = 9; })),
(error: Error) => error.message === "Realtime frame wire version is unsupported.",
);
// Like the reference, decode does not re-run the transport contract: a frame
// with a blank identity decodes rather than being refused.
const blank = spoil(bytes => bytes.set(new Uint8Array(15).fill(0x20), 37));
assert.equal(decodeRealtimeFrame(blank).channelId, " ".repeat(15));
});
// The `payloadLength < 0` clause in the length refusal is not redundant with the
// sum check beside it, which is what a surviving mutant that dropped it claimed.
// A negative payload length cancels against the identity lengths, so a frame can
// be crafted whose sum lands exactly on its own byte length. Without the clause
// each of these decodes to blank identities and an empty payload where the C#
// reference (`CultMeshRealtimeWireProtocol.cs:74-76`, same clause) refuses.
test("decode refuses a negative payload length that the length sum cancels out", () => {
const good = encodeRealtimeFrame(sample());
// A valid magic, wire version, header size and delivery byte, then the three
// identity lengths and the payload length written by hand.
const crafted = (channelLength: number, payloadLength: number, totalBytes: number) => {
const bytes = new Uint8Array(totalBytes);
bytes.set(good.subarray(0, 37), 0);
const view = new DataView(bytes.buffer);
view.setUint16(21, channelLength, true);
view.setUint16(23, 0, true);
view.setUint16(25, 0, true);
view.setInt32(27, payloadLength, true);
// The sum the decoder computes must land exactly on the frame's own length,
// or the check beside the clause would refuse it and prove nothing.
assert.equal(37 + channelLength + 0 + 0 + payloadLength, totalBytes, "the crafted sum must match");
return bytes;
};
const cases: readonly (readonly [string, Uint8Array])[] = [
["a maximal channel length cancelled by -65535, summing to 37", crafted(0xffff, -0xffff, 37)],
["a channel length of 1 cancelled by -1, summing to 37", crafted(1, -1, 37)],
["a channel length of 2 cancelled by -1, summing to 38", crafted(2, -1, 38)],
];
for (const [label, bytes] of cases) {
assert.throws(
() => decodeRealtimeFrame(bytes),
(error: Error) => error.message === "Realtime frame length is invalid.",
label,
);
}
});
test("decode reads exactly the viewed bytes of an offset view, not the buffer behind it", () => {
const good = encodeRealtimeFrame(sample({ payload: Uint8Array.from([9, 8, 7]) }));
const pool = new Uint8Array(good.byteLength + 64).fill(0xa5);
pool.set(good, 17);
const view = pool.subarray(17, 17 + good.byteLength);
assert.deepEqual(decodeRealtimeFrame(view).payload, Uint8Array.from([9, 8, 7]));
assert.equal(decodeRealtimeFrame(view).channelId, "aetheria.zone-1");
// And the decoded payload does not alias the frame it came from.
const decoded = decodeRealtimeFrame(good);
good[good.byteLength - 1] = 0;
assert.deepEqual(decoded.payload, Uint8Array.from([9, 8, 7]));
});
// The `Uint8Array` case above passes with a plain `bytes.slice(...)`; this one
// does not. Node's `Buffer` overrides `slice` with an alias of `subarray`, and a
// `Buffer` is what a socket read hands a Node caller, so the aliasing bug this
// pins is the one that would actually reach a consumer.
test("the decoded payload copies out of a Node Buffer too, which overrides slice", () => {
const frame = Buffer.from(encodeRealtimeFrame(sample({ payload: Uint8Array.from([9, 8, 7]) })));
const decoded = decodeRealtimeFrame(frame);
frame[frame.byteLength - 1] = 0;
assert.deepEqual(decoded.payload, Uint8Array.from([9, 8, 7]), "payload unchanged by a later write to the frame");
assert.equal(Buffer.isBuffer(decoded.payload), false, "the copy is a plain Uint8Array, not a Buffer");
assert.notEqual(decoded.payload.buffer, frame.buffer, "the copy has its own backing store");
// The three identities need no such check: `TextDecoder` returns strings, which
// hold no reference to the bytes they were decoded from.
assert.equal(decoded.channelId, "aetheria.zone-1");
});
test("the codec touches no socket: no node: import in the source", () => {
const source = readFileSync(join(__dirname, "..", "..", "src", "realtime-wire.ts"), "utf8");
assert.doesNotMatch(source, /from "node:|require\("node:/);
});

View file

@ -55,19 +55,19 @@ from .transport import (
INTEROP_DOCUMENT_TYPE = "cultnet.interop-note"
INTEROP_SCHEMA_VERSION = "cultnet.interop_note.v0"
INTEROP_MUTATION_INTENT_DOCUMENT_TYPE = "cultnet.interop-note-mutation-intent"
INTEROP_MUTATION_INTENT_SCHEMA_ID = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-note-mutation-intent.schema.json"
INTEROP_MUTATION_INTENT_SCHEMA_ID = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-note-mutation-intent.schema.json"
INTEROP_MUTATION_INTENT_SCHEMA_VERSION = "cultnet.interop_note_mutation_intent.v0"
INTEROP_MUTATION_RECEIPT_DOCUMENT_TYPE = "cultnet.interop-note-mutation-receipt"
INTEROP_MUTATION_RECEIPT_SCHEMA_ID = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-note-mutation-receipt.schema.json"
INTEROP_MUTATION_RECEIPT_SCHEMA_ID = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-note-mutation-receipt.schema.json"
INTEROP_MUTATION_RECEIPT_SCHEMA_VERSION = "cultnet.interop_note_mutation_receipt.v0"
INTEROP_FIRE_COMMAND_DOCUMENT_TYPE = "cultnet.interop-fire-weapon-command"
INTEROP_FIRE_COMMAND_SCHEMA_ID = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-fire-weapon-command.schema.json"
INTEROP_FIRE_COMMAND_SCHEMA_ID = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-fire-weapon-command.schema.json"
INTEROP_FIRE_COMMAND_SCHEMA_VERSION = "cultnet.interop_fire_weapon_command.v0"
INTEROP_FIRE_RECEIPT_DOCUMENT_TYPE = "cultnet.interop-fire-weapon-receipt"
INTEROP_FIRE_RECEIPT_SCHEMA_ID = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-fire-weapon-receipt.schema.json"
INTEROP_FIRE_RECEIPT_SCHEMA_ID = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-fire-weapon-receipt.schema.json"
INTEROP_FIRE_RECEIPT_SCHEMA_VERSION = "cultnet.interop_fire_weapon_receipt.v0"
WITNESS_ARTIFACT_BUNDLE_DOCUMENT_TYPE = "cultnet.witness_artifact_bundle"
WITNESS_ARTIFACT_BUNDLE_SCHEMA_ID = "https://github.com/GameCult/cultnet-ts/contracts/cultnet.witness-artifact-bundle.schema.json"
WITNESS_ARTIFACT_BUNDLE_SCHEMA_ID = "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.witness-artifact-bundle.schema.json"
WITNESS_ARTIFACT_BUNDLE_SCHEMA_VERSION = "cultnet.witness_artifact_bundle.v0"
SIMULATION_FACT_SCHEMA_ID = simulation_fact_document.catalog_entry().schema_id
DISCOVERY_PROBE_SCHEMA_VERSION = "cultnet.discovery_probe.v0"

View file

@ -6,7 +6,7 @@ from dataclasses import dataclass, field
from typing import Any
INTEROP_WIRE_CONTRACT = "cultnet.schema.v0"
CULTNET_SCHEMA_BASE = "https://github.com/GameCult/cultnet-ts/contracts"
CULTNET_SCHEMA_BASE = "https://github.com/GameCult/CultLib/contracts/cultnet"
VERSE_CATALOG_REQUEST = "cultmesh.verse_catalog_request.v0"
PEER_EXCHANGE_REQUEST = "cultmesh.peer_exchange_request.v0"
TRANSPORT_PROFILE_SCHEMA_VERSION = "cultnet.transport_profile.v0"

View file

@ -1677,7 +1677,7 @@ class CultNetTests(unittest.TestCase):
self.assertIn("operations", mutation_contract["properties"])
self.assertEqual(
hello_schema["properties"]["transportProfiles"]["items"]["$ref"],
"https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json",
"https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.transport-profile.schema.json",
)
consensus_schema = json.loads(by_version["cultnet.simulation_consensus_candidate.v0"]["schemaJson"])
@ -1686,7 +1686,7 @@ class CultNetTests(unittest.TestCase):
def test_cultnet_schema_catalog_applies_filters_and_responses(self) -> None:
catalog = wire_message_schema_catalog(include_schema_json=True)
descriptor = catalog.get("https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put-raw.schema.json")
descriptor = catalog.get("https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.document-put-raw.schema.json")
self.assertIsNotNone(descriptor)
assert descriptor is not None
self.assertEqual(descriptor.kind, "wire_message")
@ -1902,7 +1902,7 @@ class CultNetTests(unittest.TestCase):
runtime_kind="python",
display_name="Python Interop Test",
agent_id="python-interop-test-agent",
schema_path=str(Path("packages/cultnet-ts/integration/contracts/cultnet.interop-note.schema.json")),
schema_path=str(Path("contracts/cultnet/interop/cultnet.interop-note.schema.json")),
)
binding = state.bindings["note"]
logged_value = {

View file

@ -1,40 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-mutation-contract.schema.json",
"title": "CultNet Document Mutation Contract",
"type": "object",
"required": [
"documentType",
"operations",
"authority"
],
"additionalProperties": false,
"properties": {
"documentType": { "type": "string", "minLength": 1 },
"payloadSchemaVersion": { "type": "string", "minLength": 1 },
"operations": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"enum": ["snapshot", "documentPut", "documentDelete", "intentSubmit", "receiptWatch"]
}
},
"authority": {
"type": "string",
"enum": ["readOnly", "localUser", "coordinator", "runtime", "denied"]
},
"intentDocumentTypes": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"receiptDocumentTypes": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"notes": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
}
}

View file

@ -1,19 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put-raw.schema.json",
"title": "CultNet Raw Document Put Message",
"type": "object",
"required": [
"schemaVersion",
"messageId",
"document"
],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.document_put_raw.v0" },
"messageId": { "type": "string", "minLength": 1 },
"document": {
"$ref": "./cultnet.raw-document-record.schema.json"
}
}
}

View file

@ -1,19 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put.schema.json",
"title": "CultNet Document Put Message",
"type": "object",
"required": [
"schemaVersion",
"messageId",
"document"
],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.document_put.v0" },
"messageId": { "type": "string", "minLength": 1 },
"document": {
"$ref": "./cultnet.document-record.schema.json"
}
}
}

View file

@ -1,26 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-record.schema.json",
"title": "CultNet Document Record",
"type": "object",
"required": [
"schemaId",
"recordKey",
"storedAt",
"payload"
],
"additionalProperties": false,
"properties": {
"schemaId": { "type": "string", "minLength": 1 },
"recordKey": { "type": "string", "minLength": 1 },
"storedAt": { "type": "string", "minLength": 1 },
"payload": {},
"sourceRuntimeId": { "type": "string", "minLength": 1 },
"sourceAgentId": { "type": "string", "minLength": 1 },
"sourceRole": { "type": "string", "minLength": 1 },
"tags": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
}
}

View file

@ -1,15 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.error.schema.json",
"title": "CultNet Error Message",
"type": "object",
"required": [
"schemaVersion",
"error"
],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.error.v0" },
"error": { "type": "string", "minLength": 1 }
}
}

View file

@ -1,41 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.hello.schema.json",
"title": "CultNet Hello Message",
"type": "object",
"required": [
"schemaVersion",
"runtimeId",
"runtimeKind"
],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.hello.v0" },
"runtimeId": { "type": "string", "minLength": 1 },
"runtimeKind": { "type": "string", "minLength": 1 },
"agentId": { "type": "string", "minLength": 1 },
"role": { "type": "string", "minLength": 1 },
"displayName": { "type": "string", "minLength": 1 },
"supportedDocumentTypes": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"supportedMutationContracts": {
"type": "array",
"items": {
"$ref": "./cultnet.document-mutation-contract.schema.json"
}
},
"supportedMessageVersions": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"transportProfiles": {
"type": "array",
"items": {
"$ref": "./cultnet.transport-profile.schema.json"
}
},
"supportsSchemaCatalog": { "type": "boolean" }
}
}

View file

@ -1,19 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.login.schema.json",
"title": "CultNet Login Message",
"type": "object",
"required": [
"schemaVersion",
"nonce",
"auth",
"password"
],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.login.v0" },
"nonce": { "type": "string", "minLength": 1 },
"auth": { "type": "string", "minLength": 1 },
"password": { "type": "string", "minLength": 1 }
}
}

View file

@ -1,32 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.raw-document-record.schema.json",
"title": "CultNet Raw Document Record",
"type": "object",
"required": [
"schemaId",
"recordKey",
"storedAt",
"payloadEncoding",
"payload"
],
"additionalProperties": false,
"properties": {
"schemaId": { "type": "string", "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", "minLength": 1 },
"sourceAgentId": { "type": "string", "minLength": 1 },
"sourceRole": { "type": "string", "minLength": 1 },
"tags": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
}
}

View file

@ -1,21 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.register.schema.json",
"title": "CultNet Register Message",
"type": "object",
"required": [
"schemaVersion",
"nonce",
"email",
"password",
"name"
],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.register.v0" },
"nonce": { "type": "string", "minLength": 1 },
"email": { "type": "string", "minLength": 1 },
"password": { "type": "string", "minLength": 1 },
"name": { "type": "string", "minLength": 1 }
}
}

View file

@ -1,15 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.sample-change-name.schema.json",
"title": "CultNet Sample Change Name Message",
"type": "object",
"required": [
"schemaVersion",
"name"
],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.sample.change_name.v0" },
"name": { "type": "string", "minLength": 1 }
}
}

View file

@ -1,15 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.sample-chat.schema.json",
"title": "CultNet Sample Chat Message",
"type": "object",
"required": [
"schemaVersion",
"text"
],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.sample.chat.v0" },
"text": { "type": "string", "minLength": 1 }
}
}

View file

@ -1,22 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.schema-catalog-response.schema.json",
"title": "CultNet Schema Catalog Response Message",
"type": "object",
"required": [
"schemaVersion",
"messageId",
"schemas"
],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.schema_catalog_response.v0" },
"messageId": { "type": "string", "minLength": 1 },
"schemas": {
"type": "array",
"items": {
"$ref": "./cultnet.schema-descriptor.schema.json"
}
}
}
}

View file

@ -1,39 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.schema-descriptor.schema.json",
"title": "CultNet Schema Descriptor",
"type": "object",
"required": [
"schemaId",
"kind",
"wireContracts",
"contentHash"
],
"additionalProperties": false,
"properties": {
"schemaId": { "type": "string", "minLength": 1 },
"kind": {
"type": "string",
"enum": [
"wire_message",
"document_payload",
"shared_contract"
]
},
"schemaVersion": { "type": "string", "minLength": 1 },
"documentType": { "type": "string", "minLength": 1 },
"title": { "type": "string", "minLength": 1 },
"wireContracts": {
"type": "array",
"items": {
"type": "string",
"enum": [
"cultnet.schema.v0",
"gamecult.networking.v0"
]
}
},
"contentHash": { "type": "string", "minLength": 1 },
"schemaJson": { "type": "string", "minLength": 1 }
}
}

View file

@ -1,19 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "cultnet.shard_catalog_request.v0",
"type": "object",
"required": ["schemaVersion", "messageId"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.shard_catalog_request.v0" },
"messageId": { "type": "string", "minLength": 1 },
"schemaIds": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"recordKeys": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
}
}

View file

@ -1,23 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.snapshot-request.schema.json",
"title": "CultNet Snapshot Request Message",
"type": "object",
"required": [
"schemaVersion",
"messageId"
],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.snapshot_request.v0" },
"messageId": { "type": "string", "minLength": 1 },
"schemaIds": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"recordKeys": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
}
}

View file

@ -1,22 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.snapshot-response.schema.json",
"title": "CultNet Snapshot Response Message",
"type": "object",
"required": [
"schemaVersion",
"messageId",
"documents"
],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.snapshot_response.v0" },
"messageId": { "type": "string", "minLength": 1 },
"documents": {
"type": "array",
"items": {
"$ref": "./cultnet.document-record.schema.json"
}
}
}
}

View file

@ -1,17 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.verify.schema.json",
"title": "CultNet Verify Message",
"type": "object",
"required": [
"schemaVersion",
"nonce",
"session"
],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.verify.v0" },
"nonce": { "type": "string", "minLength": 1 },
"session": { "type": "string", "minLength": 1 }
}
}

View file

@ -55,16 +55,16 @@ use std::time::Instant;
const INTEROP_DOCUMENT_TYPE: &str = "cultnet.interop-note";
const INTEROP_SCHEMA_VERSION: &str = "cultnet.interop_note.v0";
const MUTATION_INTENT_TYPE: &str = "cultnet.interop-note-mutation-intent";
const MUTATION_INTENT_SCHEMA_ID: &str = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-note-mutation-intent.schema.json";
const MUTATION_INTENT_SCHEMA_ID: &str = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-note-mutation-intent.schema.json";
const MUTATION_INTENT_SCHEMA_VERSION: &str = "cultnet.interop_note_mutation_intent.v0";
const MUTATION_RECEIPT_TYPE: &str = "cultnet.interop-note-mutation-receipt";
const MUTATION_RECEIPT_SCHEMA_ID: &str = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-note-mutation-receipt.schema.json";
const MUTATION_RECEIPT_SCHEMA_ID: &str = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-note-mutation-receipt.schema.json";
const MUTATION_RECEIPT_SCHEMA_VERSION: &str = "cultnet.interop_note_mutation_receipt.v0";
const FIRE_COMMAND_TYPE: &str = "cultnet.interop-fire-weapon-command";
const FIRE_COMMAND_SCHEMA_ID: &str = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-fire-weapon-command.schema.json";
const FIRE_COMMAND_SCHEMA_ID: &str = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-fire-weapon-command.schema.json";
const FIRE_COMMAND_SCHEMA_VERSION: &str = "cultnet.interop_fire_weapon_command.v0";
const FIRE_RECEIPT_TYPE: &str = "cultnet.interop-fire-weapon-receipt";
const FIRE_RECEIPT_SCHEMA_ID: &str = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-fire-weapon-receipt.schema.json";
const FIRE_RECEIPT_SCHEMA_ID: &str = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-fire-weapon-receipt.schema.json";
const FIRE_RECEIPT_SCHEMA_VERSION: &str = "cultnet.interop_fire_weapon_receipt.v0";
const DISCOVERY_ANNOUNCE_SCHEMA_VERSION: &str = "cultnet.discovery_announce.v0";
const RUDP_INTEROP_CONNECTION_ID: u32 = 0x43554C54;

View file

@ -194,28 +194,28 @@ pub fn builtin_schema_registry() -> Result<CultNetSchemaRegistry> {
for registration in [
schema_registration(
include_str!("../contracts/cultnet.hello.schema.json"),
include_str!("../../../contracts/cultnet/cultnet.hello.schema.json"),
CultNetSchemaKind::WireMessage,
vec![CultNetWireContract::CultNetSchemaV0],
Some("cultnet.hello.v0"),
None,
)?,
schema_registration(
include_str!("../contracts/cultnet.document-mutation-contract.schema.json"),
include_str!("../../../contracts/cultnet/cultnet.document-mutation-contract.schema.json"),
CultNetSchemaKind::SharedContract,
vec![CultNetWireContract::CultNetSchemaV0],
None,
None,
)?,
schema_registration(
include_str!("../contracts/cultnet.transport-profile.schema.json"),
include_str!("../../../contracts/cultnet/cultnet.transport-profile.schema.json"),
CultNetSchemaKind::SharedContract,
vec![CultNetWireContract::CultNetSchemaV0],
Some("cultnet.transport_profile.v0"),
None,
)?,
schema_registration(
include_str!("../contracts/cultnet.login.schema.json"),
include_str!("../../../contracts/cultnet/cultnet.login.schema.json"),
CultNetSchemaKind::WireMessage,
vec![
CultNetWireContract::CultNetSchemaV0,
@ -225,7 +225,7 @@ pub fn builtin_schema_registry() -> Result<CultNetSchemaRegistry> {
None,
)?,
schema_registration(
include_str!("../contracts/cultnet.register.schema.json"),
include_str!("../../../contracts/cultnet/cultnet.register.schema.json"),
CultNetSchemaKind::WireMessage,
vec![
CultNetWireContract::CultNetSchemaV0,
@ -235,7 +235,7 @@ pub fn builtin_schema_registry() -> Result<CultNetSchemaRegistry> {
None,
)?,
schema_registration(
include_str!("../contracts/cultnet.verify.schema.json"),
include_str!("../../../contracts/cultnet/cultnet.verify.schema.json"),
CultNetSchemaKind::WireMessage,
vec![
CultNetWireContract::CultNetSchemaV0,
@ -245,7 +245,7 @@ pub fn builtin_schema_registry() -> Result<CultNetSchemaRegistry> {
None,
)?,
schema_registration(
include_str!("../contracts/cultnet.login-success.schema.json"),
include_str!("../../../contracts/cultnet/cultnet.login-success.schema.json"),
CultNetSchemaKind::WireMessage,
vec![
CultNetWireContract::CultNetSchemaV0,
@ -255,7 +255,7 @@ pub fn builtin_schema_registry() -> Result<CultNetSchemaRegistry> {
None,
)?,
schema_registration(
include_str!("../contracts/cultnet.error.schema.json"),
include_str!("../../../contracts/cultnet/cultnet.error.schema.json"),
CultNetSchemaKind::WireMessage,
vec![
CultNetWireContract::CultNetSchemaV0,
@ -265,7 +265,7 @@ pub fn builtin_schema_registry() -> Result<CultNetSchemaRegistry> {
None,
)?,
schema_registration(
include_str!("../contracts/cultnet.sample-change-name.schema.json"),
include_str!("../../../contracts/cultnet/cultnet.sample-change-name.schema.json"),
CultNetSchemaKind::WireMessage,
vec![
CultNetWireContract::CultNetSchemaV0,
@ -275,7 +275,7 @@ pub fn builtin_schema_registry() -> Result<CultNetSchemaRegistry> {
None,
)?,
schema_registration(
include_str!("../contracts/cultnet.sample-chat.schema.json"),
include_str!("../../../contracts/cultnet/cultnet.sample-chat.schema.json"),
CultNetSchemaKind::WireMessage,
vec![
CultNetWireContract::CultNetSchemaV0,
@ -285,56 +285,56 @@ pub fn builtin_schema_registry() -> Result<CultNetSchemaRegistry> {
None,
)?,
schema_registration(
include_str!("../contracts/cultnet.document-put.schema.json"),
include_str!("../../../contracts/cultnet/cultnet.document-put.schema.json"),
CultNetSchemaKind::WireMessage,
vec![CultNetWireContract::CultNetSchemaV0],
Some("cultnet.document_put.v0"),
None,
)?,
schema_registration(
include_str!("../contracts/cultnet.document-delete.schema.json"),
include_str!("../../../contracts/cultnet/cultnet.document-delete.schema.json"),
CultNetSchemaKind::WireMessage,
vec![CultNetWireContract::CultNetSchemaV0],
Some("cultnet.document_delete.v0"),
None,
)?,
schema_registration(
include_str!("../contracts/cultnet.raw-document-record.schema.json"),
include_str!("../../../contracts/cultnet/cultnet.raw-document-record.schema.json"),
CultNetSchemaKind::SharedContract,
vec![CultNetWireContract::CultNetSchemaV0],
None,
None,
)?,
schema_registration(
include_str!("../contracts/cultnet.document-put-raw.schema.json"),
include_str!("../../../contracts/cultnet/cultnet.document-put-raw.schema.json"),
CultNetSchemaKind::WireMessage,
vec![CultNetWireContract::CultNetSchemaV0],
Some("cultnet.document_put_raw.v0"),
None,
)?,
schema_registration(
include_str!("../contracts/cultnet.snapshot-request.schema.json"),
include_str!("../../../contracts/cultnet/cultnet.snapshot-request.schema.json"),
CultNetSchemaKind::WireMessage,
vec![CultNetWireContract::CultNetSchemaV0],
Some("cultnet.snapshot_request.v0"),
None,
)?,
schema_registration(
include_str!("../contracts/cultnet.snapshot-response.schema.json"),
include_str!("../../../contracts/cultnet/cultnet.snapshot-response.schema.json"),
CultNetSchemaKind::WireMessage,
vec![CultNetWireContract::CultNetSchemaV0],
Some("cultnet.snapshot_response.v0"),
None,
)?,
schema_registration(
include_str!("../contracts/cultnet.snapshot-response-raw.schema.json"),
include_str!("../../../contracts/cultnet/cultnet.snapshot-response-raw.schema.json"),
CultNetSchemaKind::WireMessage,
vec![CultNetWireContract::CultNetSchemaV0],
Some("cultnet.snapshot_response_raw.v0"),
None,
)?,
schema_registration(
include_str!("../contracts/cultnet.schema-catalog-request.schema.json"),
include_str!("../../../contracts/cultnet/cultnet.schema-catalog-request.schema.json"),
CultNetSchemaKind::WireMessage,
vec![
CultNetWireContract::CultNetSchemaV0,
@ -344,7 +344,7 @@ pub fn builtin_schema_registry() -> Result<CultNetSchemaRegistry> {
None,
)?,
schema_registration(
include_str!("../contracts/cultnet.schema-catalog-response.schema.json"),
include_str!("../../../contracts/cultnet/cultnet.schema-catalog-response.schema.json"),
CultNetSchemaKind::WireMessage,
vec![
CultNetWireContract::CultNetSchemaV0,
@ -354,21 +354,21 @@ pub fn builtin_schema_registry() -> Result<CultNetSchemaRegistry> {
None,
)?,
schema_registration(
include_str!("../contracts/cultnet.shard-catalog-request.schema.json"),
include_str!("../../../contracts/cultnet/cultnet.shard-catalog-request.schema.json"),
CultNetSchemaKind::WireMessage,
vec![CultNetWireContract::CultNetSchemaV0],
Some("cultnet.shard_catalog_request.v0"),
None,
)?,
schema_registration(
include_str!("../contracts/cultnet.shard-catalog-response.schema.json"),
include_str!("../../../contracts/cultnet/cultnet.shard-catalog-response.schema.json"),
CultNetSchemaKind::WireMessage,
vec![CultNetWireContract::CultNetSchemaV0],
Some("cultnet.shard_catalog_response.v0"),
None,
)?,
schema_registration(
include_str!("../contracts/ghostlight.agent-state.schema.json"),
include_str!("../../../contracts/cultnet/ghostlight.agent-state.schema.json"),
CultNetSchemaKind::DocumentPayload,
vec![CultNetWireContract::CultNetSchemaV0],
Some("ghostlight.agent_state.v0"),

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